Compare commits
220
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655a112e74 | ||
|
|
46c395e045 | ||
|
|
fe94e99801 | ||
|
|
0630a32167 | ||
|
|
5371382cb4 | ||
|
|
fd6c5df1a8 | ||
|
|
2d6675a452 | ||
|
|
ef0ae90768 | ||
|
|
2c6fe7c368 | ||
|
|
ed6af50ea5 | ||
|
|
b109c4e5e1 | ||
|
|
ae030d759d | ||
|
|
4ff0d20c10 | ||
|
|
df47ab386c | ||
|
|
af9ac6d979 | ||
|
|
047f347955 | ||
|
|
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 | ||
|
|
641d5659ec | ||
|
|
663986f551 | ||
|
|
32a61ff963 | ||
|
|
bdb1c7ec18 | ||
|
|
51e3731076 | ||
|
|
a5faec0466 | ||
|
|
7c6531eaf7 | ||
|
|
d52b4f1240 | ||
|
|
41cea400a7 | ||
|
|
6e5e0db999 | ||
|
|
e0e5ae68a4 | ||
|
|
1fc11529ed | ||
|
|
b090b3f86b | ||
|
|
a3d3fb3b69 | ||
|
|
4dc26c9394 | ||
|
|
7546cb094f | ||
|
|
797d2678c8 | ||
|
|
78015afb35 | ||
|
|
1c330c697f | ||
|
|
d18e286377 | ||
|
|
f49fde3a06 | ||
|
|
206651f89a | ||
|
|
c0f221b1ca | ||
|
|
09be20a044 | ||
|
|
2e1ba7d2e0 | ||
|
|
1a23016df1 | ||
|
|
ebe3c17618 | ||
|
|
1a96360f6a | ||
|
|
4f5d2126d6 | ||
|
|
6be4601763 | ||
|
|
36ece2fca7 | ||
|
|
dc225bd0b1 | ||
|
|
6acd57d0ec | ||
|
|
596027f210 | ||
|
|
0aa9a52497 | ||
|
|
09ec79c57e | ||
|
|
e8339f4d12 | ||
|
|
4f984cd9c6 | ||
|
|
d1caf0a208 | ||
|
|
8eb25b98fd | ||
|
|
6211b8e768 | ||
|
|
0307f23024 | ||
|
|
3fd30bb9e6 | ||
|
|
6ff00c696a | ||
|
|
c6551e4901 | ||
|
|
b06d7fa3f4 | ||
|
|
16d5b237d2 | ||
|
|
660de5716a | ||
|
|
51fb2805fd | ||
|
|
6ffb24b544 | ||
|
|
4419ef7730 | ||
|
|
991b1a5a0b | ||
|
|
fd77a047f9 | ||
|
|
341428d9ca | ||
|
|
128c53a11d | ||
|
|
7264026d66 | ||
|
|
20690ba652 | ||
|
|
949a5aee61 | ||
|
|
18fb79e971 | ||
|
|
b301a414cb | ||
|
|
92c41bdb0c | ||
|
|
75c3d22b62 | ||
|
|
a6f24e9581 | ||
|
|
a73a409fe4 | ||
|
|
70d19d09d0 | ||
|
|
40ea47b2a1 | ||
|
|
7ed3e287ea | ||
|
|
8e3530a510 | ||
|
|
e5d7407c79 | ||
|
|
377b51f2db | ||
|
|
a09fa89f30 | ||
|
|
7af1e6efa8 | ||
|
|
09b3a1fcdc | ||
|
|
816f53f819 | ||
|
|
bba1fb21e6 | ||
|
|
d4f557631b | ||
|
|
e53161188c | ||
|
|
ff17b9b107 | ||
|
|
63cc06b93c | ||
|
|
8ec3fc877d | ||
|
|
819902f385 | ||
|
|
292564c6e7 | ||
|
|
eef2332823 | ||
|
|
e82d428b05 | ||
|
|
9cbe055791 | ||
|
|
7596049828 | ||
|
|
d3ca006886 | ||
|
|
f91281e991 | ||
|
|
7c5e78db17 | ||
|
|
8e374b3d24 | ||
|
|
c9774e89e0 | ||
|
|
f9938135c6 | ||
|
|
386a27c0b6 | ||
|
|
080a3dc253 | ||
|
|
811ddee3b7 | ||
|
|
4e242c3491 | ||
|
|
54fce0f187 | ||
|
|
93a32217e0 | ||
|
|
95ba80f618 | ||
|
|
d710323bd0 | ||
|
|
38b450cbcf | ||
|
|
6fe49344e2 | ||
|
|
6e01ae6002 | ||
|
|
11e43542cf | ||
|
|
2256a37b72 | ||
|
|
533133486c | ||
|
|
eb19fa4b97 | ||
|
|
5ed850196b | ||
|
|
be1f323a09 | ||
|
|
bdcddadf90 | ||
|
|
4062242063 | ||
|
|
abcc7b6c3a | ||
|
|
9e35bf21a3 | ||
|
|
2a1e0337fd | ||
|
|
dcc15008cd | ||
|
|
dd2e95f8af | ||
|
|
c450e1c13d | ||
|
|
c6935d8f0f | ||
|
|
5d973f76ec | ||
|
|
fd125c5fe1 | ||
|
|
08a42b16dd | ||
|
|
b736789ecb | ||
|
|
f569bc55ea | ||
|
|
9231409c5c | ||
|
|
0d140b4636 | ||
|
|
9e74b34b5d | ||
|
|
47afe117f4 | ||
|
|
4fe49ca8d0 | ||
|
|
8ca7796d04 | ||
|
|
dcab84249f | ||
|
|
e5b18202f3 | ||
|
|
efc9456948 | ||
|
|
c52430554a | ||
|
|
fd7ab06fb1 | ||
|
|
434b73d834 | ||
|
|
985d79d3c0 | ||
|
|
004dce5472 | ||
|
|
0b31fba663 | ||
|
|
6958b2a6e2 | ||
|
|
fd4194503c | ||
|
|
a1800a8e88 | ||
|
|
03e0ee2f95 | ||
|
|
9adf0c0803 | ||
|
|
e9d03987f9 | ||
|
|
b0e3cdd3d0 | ||
|
|
2e3fc475cf | ||
|
|
1f89fce21b | ||
|
|
512b742c46 | ||
|
|
02be4b2a55 | ||
|
|
e036d280c0 | ||
|
|
ac81023ea0 | ||
|
|
d76a4cbf4d | ||
|
|
fbda2d91af | ||
|
|
f59ee4d2d6 | ||
|
|
0bf8e71b52 | ||
|
|
34d6870e6a | ||
|
|
1a1b11c5a3 | ||
|
|
85d7ef21eb | ||
|
|
a4d7225036 | ||
|
|
8dc2e9d748 | ||
|
|
8cc15fde3d | ||
|
|
ddb395901b | ||
|
|
c33385be6c | ||
|
|
e95609ce69 | ||
|
|
345709a306 | ||
|
|
4b59d6fb82 | ||
|
|
5ca657c104 | ||
|
|
bbaf1cbd97 | ||
|
|
f838c8cb98 | ||
|
|
43767c725f | ||
|
|
b26794e21a | ||
|
|
7dc14da4af | ||
|
|
3d90388b5b | ||
|
|
8c08c2e748 |
@@ -0,0 +1,78 @@
|
||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||
# `certs/server.key` still ship while this file reads as solved. Only
|
||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||
# into .gitignore, where `**/` is wrong.
|
||||
#
|
||||
# Matching is case-sensitive, so secrets use character ranges rather
|
||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||
#
|
||||
# Extend with this repo's own host-built artifacts, written anchored:
|
||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||
# deletes the package directory from the context.
|
||||
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
# Each submodule keeps a config with the same exposure in its git directory
|
||||
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||
# its own .git directory when it keeps one.
|
||||
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||
# `**/.git/modules/**/config` also matches that segment's directory
|
||||
# under .git/modules/. Go's version stamping then fails the build;
|
||||
# nothing leaks. Name such a submodule without that segment:
|
||||
# `git submodule add --name`.
|
||||
**/.git/config
|
||||
**/.git/modules/**/config
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||
# `services/api/.claude/` and must add its own anchored entry.
|
||||
.claude
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Re-include a committed template with a negation if the
|
||||
# build needs one: `!docs/example.env`.
|
||||
**/*.[eE][nN][vV]
|
||||
**/.[eE][nN][vV].*
|
||||
**/.[eE][nN][vV][rR][cC]
|
||||
|
||||
# Private keys and the bundles carrying them. Public certificates
|
||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||
**/*.[pP][eE][mM]
|
||||
**/*.[kK][eE][yY]
|
||||
**/*.[pP]12
|
||||
**/*.[pP][fF][xX]
|
||||
**/[iI][dD]_[rR][sS][aA]
|
||||
**/[iI][dD]_[dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Editor state: never a build input, and it churns COPY.
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/*~
|
||||
**/*.bak
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# This repo's host-built artifacts: the binary script/build writes, test
|
||||
# binaries and coverage output.
|
||||
/secret
|
||||
/*.test
|
||||
/coverage.out
|
||||
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,9 @@
|
||||
name: check
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: script/cibuild
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# OS
|
||||
.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/
|
||||
|
||||
# Secrets. Unanchored like every entry above, so each matches at every
|
||||
# depth. Matching is case-sensitive on Linux, so names use character
|
||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Only the templates `example.env` and `sample.env` are
|
||||
# re-included below. A repository that commits any other template adds
|
||||
# its own negation after these lines, for example `!.env.example`.
|
||||
*.[eE][nN][vV]
|
||||
.[eE][nN][vV].*
|
||||
.[eE][nN][vV][rR][cC]
|
||||
!example.env
|
||||
!sample.env
|
||||
|
||||
# Private keys and the bundles carrying them.
|
||||
*.[pP][eE][mM]
|
||||
*.[kK][eE][yY]
|
||||
*.[pP]12
|
||||
*.[pP][fF][xX]
|
||||
[iI][dD]_[rR][sS][aA]
|
||||
[iI][dD]_[dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# Go. /secret is the built binary, anchored so that it does not also match
|
||||
# the internal/secret/ package directory.
|
||||
*.log
|
||||
*.out
|
||||
*.test
|
||||
/secret
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
version: "2"
|
||||
|
||||
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||
# linters.settings, not top-level linters-settings) so that the
|
||||
# thresholds below are actually applied by golangci-lint >= v2.
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||
# left to `default: all`, because it carries the module policy below.
|
||||
- gomodguard_v2
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||
- godot # Requires comments to end with periods
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
# Deprecated: the warning is attached to the old name, so it is
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
depguard:
|
||||
# Test-support code must not be compiled into the shipped binary. A
|
||||
# test-support package exists to hand a test privileges the program
|
||||
# itself must never have, so a file that is not a test must not import
|
||||
# one. Test files, and the files inside a package whose directory name
|
||||
# ends in `test`, are where that code belongs, and are exempt.
|
||||
#
|
||||
# The deny list below is the one part of this file a repository is
|
||||
# expected to extend, and the only part it may. depguard matches an
|
||||
# import path against a list of prefixes, so it cannot be told "any path
|
||||
# whose last segment ends in test"; a repository's own test-support
|
||||
# packages have to be named here one at a time, by full import path,
|
||||
# under a module path that differs from repository to repository. Add
|
||||
# them; change nothing else.
|
||||
rules:
|
||||
test-support:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "$all"
|
||||
- "!$test"
|
||||
- "!**/*test/**"
|
||||
deny:
|
||||
- pkg: net/http/httptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
blocked:
|
||||
- module: github.com/rs/zerolog
|
||||
recommendations:
|
||||
- log/slog
|
||||
reason: "Structured logging is stdlib log/slog."
|
||||
# One entry per pre-fork module path, because the later releases
|
||||
# are separate paths. A prefix match would be shorter but would
|
||||
# also reach github.com/go-redis/redismock, the test double for
|
||||
# the successor these entries recommend.
|
||||
- module: github.com/go-redis/redis
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v7
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v8
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/sergi/go-diff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "No unified diff output; use go-udiff."
|
||||
- module: github.com/hexops/gotextdiff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "Unmaintained fork; use go-udiff."
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,182 @@
|
||||
# Policies for AI Agents
|
||||
|
||||
Version: 2025-06-08
|
||||
|
||||
# Instructions and Contextual Information
|
||||
|
||||
- Be direct, robotic, expert, accurate, and professional.
|
||||
|
||||
- Do not butter me up or kiss my ass.
|
||||
|
||||
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||
am headed.
|
||||
|
||||
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||
|
||||
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||
for them.
|
||||
|
||||
- Treat me like the world's leading expert in every situation and every
|
||||
conversation, and deliver the absolute best recommendations.
|
||||
|
||||
- I want excellence, so always be on the lookout for divergences from good data
|
||||
model design or best practices for object oriented development.
|
||||
|
||||
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||
Deliver professional-level results, not prototypes.
|
||||
|
||||
- Please read and understand the `README.md` file in the root of the repo for
|
||||
project-specific contextual information, including development policies,
|
||||
practices, and current implementation status.
|
||||
|
||||
- Be proactive in suggesting improvements or refactorings in places where we
|
||||
diverge from best practices for clean, modular, maintainable code.
|
||||
|
||||
# Policies
|
||||
|
||||
1. Before committing, tests must pass (`make test`), linting must pass
|
||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||
`golangci-lint run`. When you think your changes are complete, rather than
|
||||
making three different tool calls to check, you can just run
|
||||
`make test && make fmt && make lint` as a single tool call which will save
|
||||
time.
|
||||
|
||||
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||
`fmt` target that formats the code. The `test` target should run all tests in
|
||||
the project, and the `fmt` target should format the code. `test` should also
|
||||
have a prerequisite target `lint` that should run any linters that are
|
||||
configured for the project.
|
||||
|
||||
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||
|
||||
4. When creating a very simple test script for testing out a new feature,
|
||||
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
|
||||
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
|
||||
tests, or change the tests specifically to make them pass (unless there is a
|
||||
bug in the test). This is cheating, and it is bad. You should only be
|
||||
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||
In almost all cases, you should be fixing the code that is being tested, or
|
||||
updating the tests to match a refactored implementation.
|
||||
|
||||
6. When dealing with dates and times or timestamps, always use, display, and
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||
the time in a different timezone, store the user's timezone in a separate
|
||||
field and convert the UTC time to the user's timezone when displaying it. For
|
||||
internal use and internal applications and administrative purposes, always
|
||||
display UTC.
|
||||
|
||||
7. Always write tests, even if they are extremely simple and just check for
|
||||
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||
write a test for it. You don't need to target complete coverage, but you
|
||||
should at least test any new functionality you add. If you are fixing a bug,
|
||||
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||
|
||||
8. When implementing new features, be aware of potential side-effects (such as
|
||||
state files on disk, data in the database, etc.) and ensure that it is
|
||||
possible to mock or stub these side-effects in tests.
|
||||
|
||||
9. Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If stdout is not a terminal, output the
|
||||
structured logs in jsonl format.
|
||||
|
||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||
anywhere more than once. Always define a constant (usually at the top of the
|
||||
file) and give it a descriptive name, then use that constant in the code
|
||||
instead of the bare string or number.
|
||||
|
||||
11. You do not need to summarize your changes in the chat after making them.
|
||||
Making the changes and committing them is sufficient. If anything out of the
|
||||
ordinary happened, please explain it, but in the normal case where you found
|
||||
and fixed the bug, or implemented the feature, there is no need for the
|
||||
end-of-change summary.
|
||||
|
||||
12. Do not create additional files in the root directory of the project without
|
||||
asking permission first. Configuration files, documentation, and build files
|
||||
are acceptable in the root, but source code and other files should be
|
||||
organized in appropriate subdirectories.
|
||||
|
||||
13. Commit messages carry no author or co-author attribution for the coding
|
||||
agent. The agent is an inanimate tool and the owner is the sole author of
|
||||
the code it writes; such a line is advertising, not attribution.
|
||||
|
||||
14. Never run part of the test suite: always run the whole thing with
|
||||
`make test`.
|
||||
|
||||
15. Do not stop working on a task until you have reached the definition of done
|
||||
it gives. Do all of the work, not part or most of it.
|
||||
|
||||
16. After each commit, push to the remote.
|
||||
|
||||
## 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.
|
||||
|
||||
**Good (modern Python 3.9+):**
|
||||
|
||||
```python
|
||||
def process_items(items: list[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
|
||||
**Avoid (triggers UP006):**
|
||||
|
||||
```python
|
||||
from typing import List, Dict
|
||||
|
||||
def process_items(items: List[str]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
|
||||
For optional types, use the `|` operator instead of `Union`:
|
||||
|
||||
```python
|
||||
# Good
|
||||
def get_value(key: str) -> str | 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:
|
||||
- Standard library imports
|
||||
- Third-party imports
|
||||
- Local application imports
|
||||
|
||||
Each group should be separated by a blank line.
|
||||
|
||||
## Go-Specific Guidelines
|
||||
|
||||
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.
|
||||
|
||||
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
||||
|
||||
4. **Never modify linter config** (`.golangci.yml`) to suppress findings, and do
|
||||
not modify it at all unless specifically instructed. Fix the code.
|
||||
|
||||
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.
|
||||
|
||||
7. **A program's `main.go` is `./cmd/<program_name>/main.go`** and only imports
|
||||
and calls `<program_name>.CLIEntry()`; the implementation is in
|
||||
`./internal/<program_name>/`. This keeps several programs in one repository
|
||||
from cluttering the root directory.
|
||||
|
||||
8. **Log with `log/slog`.**
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# Lint phase. The linter is invoked directly rather than through `make
|
||||
# lint` or `script/lint`, which are themselves a docker build.
|
||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go vet ./...
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
# The same checks on the code as a macOS build compiles it, which a Linux
|
||||
# build never compiles. Cgo is off, because compiling cgo code for macOS
|
||||
# needs Apple's SDK headers.
|
||||
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.24.13-trixie, 2026-02-04
|
||||
FROM golang@sha256:5835f052b784aa39f2fe9070def3568605c8bc3fcd810f10402066348b61e716 AS test
|
||||
ENV CGO_ENABLED=1
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Go's build cache goes in a cache mount, not the image layer, which would
|
||||
# take seconds longer to export. --no-cache, on every build in script/,
|
||||
# starts the mount empty; -count=1 keeps a build without it from taking
|
||||
# test results from there.
|
||||
RUN --mount=type=cache,id=sneak/secret/go-build-test,target=/root/.cache/go-build \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies are
|
||||
# what make BuildKit build them first, so this stage cannot run unless
|
||||
# lint and test passed.
|
||||
# golang 1.24.13-alpine, 2026-03-10
|
||||
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
# script/build compiles with cgo, so it needs a C compiler too.
|
||||
RUN apk add --no-cache gcc musl-dev make git
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
make build VERSION="${VERSION:-dev}"
|
||||
|
||||
# Runtime stage, and the last one
|
||||
# alpine 3.23, 2026-03-10
|
||||
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
|
||||
RUN apk add --no-cache ca-certificates gnupg
|
||||
|
||||
RUN adduser -D -s /bin/sh secret
|
||||
|
||||
COPY --from=builder /src/secret /usr/local/bin/secret
|
||||
RUN chmod +x /usr/local/bin/secret
|
||||
|
||||
USER secret
|
||||
WORKDIR /home/secret
|
||||
|
||||
ENTRYPOINT ["secret"]
|
||||
@@ -1,93 +1,50 @@
|
||||
# Makefile for Secret Manager - Simple Go CLI Tool
|
||||
export CGO_ENABLED=1
|
||||
|
||||
# Configuration
|
||||
BINARY_NAME = secret
|
||||
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
|
||||
docker-run clean install hooks
|
||||
|
||||
default: build
|
||||
default: check
|
||||
|
||||
# Simple build (no code signing needed)
|
||||
build: clean
|
||||
@echo "Building secret manager..."
|
||||
go build -o $(BINARY_NAME) cmd/secret/main.go
|
||||
@echo "Build complete: ./$(BINARY_NAME)"
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
# Build with verbose output
|
||||
build-verbose: clean
|
||||
@echo "Building with verbose output..."
|
||||
go build -v -o $(BINARY_NAME) cmd/secret/main.go
|
||||
@echo "Build complete: ./$(BINARY_NAME)"
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
# Vet the code
|
||||
vet:
|
||||
@echo "Running go vet..."
|
||||
go vet ./...
|
||||
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
|
||||
build:
|
||||
@script/build
|
||||
|
||||
# Test with linting and vetting
|
||||
test: vet lint
|
||||
@echo "Running go tests..."
|
||||
go test -v ./...
|
||||
test:
|
||||
@script/test
|
||||
|
||||
# Run comprehensive test script
|
||||
test-comprehensive: build
|
||||
@echo "Running comprehensive test script..."
|
||||
@chmod +x test_secret_manager.sh
|
||||
@./test_secret_manager.sh
|
||||
fmt:
|
||||
@script/fmt
|
||||
|
||||
# Run all tests (unit tests + comprehensive tests)
|
||||
test-all: test test-comprehensive
|
||||
|
||||
# Lint the code
|
||||
# Vets and lints the Linux build, then the macOS build
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
golangci-lint run --timeout 5m
|
||||
@script/lint
|
||||
|
||||
# Check all code quality (build + vet + lint + unit tests)
|
||||
check: build vet lint test
|
||||
check:
|
||||
@script/check
|
||||
|
||||
# Build Docker container
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
# Run Docker container interactively
|
||||
docker-run:
|
||||
docker run --rm -it "$$(./script/projectname)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -f ./$(BINARY_NAME)
|
||||
rm -f ./secret
|
||||
|
||||
# Install to /usr/local/bin
|
||||
install: build
|
||||
@echo "Installing to /usr/local/bin..."
|
||||
sudo cp $(BINARY_NAME) /usr/local/bin/
|
||||
@echo "Installed to /usr/local/bin/$(BINARY_NAME)"
|
||||
cp ./secret $(HOME)/bin/secret
|
||||
|
||||
# Uninstall from /usr/local/bin
|
||||
uninstall:
|
||||
@echo "Removing from /usr/local/bin..."
|
||||
sudo rm -f /usr/local/bin/$(BINARY_NAME)
|
||||
@echo "Uninstalled $(BINARY_NAME)"
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
# Test keychain functionality
|
||||
test-keychain:
|
||||
@echo "Testing keychain functionality..."
|
||||
@./$(BINARY_NAME) --help > /dev/null 2>&1 && echo "Binary runs successfully" || echo "Binary failed to run"
|
||||
|
||||
# Help target
|
||||
help:
|
||||
@echo "Secret Manager - Simple Go CLI Tool"
|
||||
@echo "===================================="
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the secret manager (default)"
|
||||
@echo " build-verbose - Build with verbose output"
|
||||
@echo " vet - Run go vet"
|
||||
@echo " lint - Run linter only"
|
||||
@echo " test - Run unit tests with vet and lint"
|
||||
@echo " test-comprehensive - Run comprehensive test script"
|
||||
@echo " test-all - Run both unit tests and comprehensive tests"
|
||||
@echo " check - Run all code quality checks"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " install - Install to /usr/local/bin"
|
||||
@echo " uninstall - Remove from /usr/local/bin"
|
||||
@echo " test-keychain - Test basic functionality"
|
||||
@echo " help - Show this help"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make build && ./secret --help"
|
||||
@echo " make test-all # Run all tests"
|
||||
@echo " make check # Run all quality checks"
|
||||
|
||||
.PHONY: default build build-verbose vet test test-comprehensive test-all lint check clean install uninstall test-keychain help
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
@@ -1,226 +1,419 @@
|
||||
# Secret - Hierarchical Secret Manager
|
||||
# secret - Local Secret Manager
|
||||
|
||||
Secret is a modern, secure command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
|
||||
## Description
|
||||
|
||||
## Core Architecture
|
||||
`secret` is a WTFPL-licensed Go command-line local secret manager by
|
||||
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture
|
||||
for storing and managing sensitive data. It supports multiple vaults, various
|
||||
unlock mechanisms, and provides secure storage using the `age` encryption
|
||||
library.
|
||||
|
||||
## 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
|
||||
|
||||
Secret implements a sophisticated three-layer key architecture:
|
||||
Secret implements a three-layer key architecture:
|
||||
|
||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
|
||||
2. **Unlock Keys**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
|
||||
3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values
|
||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
|
||||
foundation for all encryption
|
||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
|
||||
multiple authentication methods
|
||||
3. **Version-specific Keys**: Per-version keys that encrypt individual secret
|
||||
values
|
||||
|
||||
### Version Management
|
||||
|
||||
Each secret maintains a history of versions, with each version having:
|
||||
|
||||
- Its own encryption key pair
|
||||
- Metadata including creation time and validity period, encrypted to the
|
||||
version's key pair
|
||||
- Immutable value storage
|
||||
|
||||
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
|
||||
|
||||
Vaults provide logical separation of secrets, each with its own long-term key and unlock key set. This allows for complete isolation between different contexts (work, personal, projects).
|
||||
|
||||
## Installation
|
||||
|
||||
Build from source:
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd secret
|
||||
go build -o secret ./cmd/secret
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
Vaults provide logical separation of secrets, each with its own long-term key
|
||||
and unlocker set. This allows for complete isolation between different contexts
|
||||
(work, personal, projects).
|
||||
|
||||
## 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
|
||||
|
||||
#### `secret init`
|
||||
Initializes the secret manager with a default vault. Prompts for a BIP39 mnemonic phrase and creates the initial directory structure.
|
||||
|
||||
Initializes the secret manager with a default vault. Prompts for a BIP39
|
||||
mnemonic phrase and creates the initial directory structure.
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
||||
|
||||
### Vault Management
|
||||
|
||||
#### `secret vault list [--json]`
|
||||
Lists all available vaults.
|
||||
#### `secret vault list [--json]` / `secret vault ls`
|
||||
|
||||
Lists all available vaults. The current vault is marked.
|
||||
|
||||
#### `secret vault create <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>`
|
||||
|
||||
Switches to the specified vault for subsequent operations.
|
||||
|
||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a vault and all its secrets. It first asks for
|
||||
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.
|
||||
|
||||
- `--force, -f`: Remove without asking, also a vault that contains secrets
|
||||
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||
|
||||
### Secret Management
|
||||
|
||||
#### `secret add <secret-name> [--force]`
|
||||
|
||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||
|
||||
- `--force, -f`: Overwrite existing secret
|
||||
|
||||
**Secret Name Format:** `[a-z0-9\.\-\_\/]+`
|
||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
|
||||
allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
|
||||
contain `//`, or have `..` as a path segment.
|
||||
|
||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||
|
||||
#### `secret get <secret-name>`
|
||||
#### `secret get <secret-name> [--version <version>]`
|
||||
|
||||
Retrieves and outputs a secret value to stdout.
|
||||
|
||||
- `--version, -v`: Get a specific version (default: current)
|
||||
|
||||
#### `secret list [filter] [--json]` / `secret ls`
|
||||
|
||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||
|
||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||
|
||||
**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
|
||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently
|
||||
deleted
|
||||
|
||||
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||
|
||||
Moves or renames a secret within the current vault.
|
||||
|
||||
- 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
|
||||
|
||||
### Version Management
|
||||
|
||||
#### `secret version list <secret-name>` / `secret version ls`
|
||||
|
||||
Lists all versions of a secret showing creation time, status, and validity
|
||||
period.
|
||||
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
|
||||
Promotes a specific version to current by rewriting the secret's `current` file
|
||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
|
||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
||||
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
|
||||
- Cannot remove the current version (must promote another version first)
|
||||
|
||||
### Key Generation
|
||||
|
||||
#### `secret generate mnemonic`
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
|
||||
stdout. The warning to keep it safe goes to stderr.
|
||||
|
||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||
|
||||
Generates and stores a random secret.
|
||||
|
||||
- `--length, -l`: Length of generated secret (default: 16)
|
||||
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
||||
- `--force, -f`: Overwrite existing secret
|
||||
|
||||
### Unlock Key Management
|
||||
### Unlocker Management
|
||||
|
||||
#### `secret keys list [--json]`
|
||||
Lists all unlock keys in the current vault with their metadata.
|
||||
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
||||
|
||||
#### `secret keys add <type> [options]`
|
||||
Creates a new unlock key of the specified type:
|
||||
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]`
|
||||
|
||||
Creates a new unlocker of the specified type:
|
||||
|
||||
**Types:**
|
||||
- `passphrase`: Traditional passphrase-protected unlock key
|
||||
- `keychain`: macOS Keychain-protected unlock key (macOS only)
|
||||
|
||||
- `passphrase`: Traditional passphrase-protected unlocker
|
||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||
|
||||
**Options:**
|
||||
- `--keyid <id>`: GPG key ID (required for PGP type)
|
||||
|
||||
#### `secret keys rm <key-id>`
|
||||
Removes an unlock key.
|
||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
|
||||
specified)
|
||||
|
||||
#### `secret key select <key-id>`
|
||||
Selects an unlock key as the current default for operations.
|
||||
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||
which is removed only once the new one is the current unlocker.
|
||||
|
||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
||||
naming the unlocker and its vault and saying whether it is the vault's last
|
||||
unlocker; for the last one it says how many secrets the vault holds and warns
|
||||
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.
|
||||
Removing the current unlocker makes the first of the vault's other unlockers
|
||||
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
|
||||
them, the current one, and says which; when there is none, the vault is left
|
||||
without a current unlocker. `secret unlocker select` refuses an unlocker whose
|
||||
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
|
||||
that does not exist and the vault has another that `secret unlocker select`
|
||||
accepts, opening the vault fails with an error that says to select one with
|
||||
`secret unlocker select`.
|
||||
|
||||
- `--force, -f`: Remove without asking, even the last unlocker
|
||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||
vault data will be PERMANENTLY INACCESSIBLE
|
||||
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
|
||||
losing access to all secrets forever
|
||||
|
||||
#### `secret unlocker select <unlocker-id>`
|
||||
|
||||
Selects an unlocker as the current default for operations.
|
||||
|
||||
### Import Operations
|
||||
|
||||
#### `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]`
|
||||
|
||||
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||
|
||||
### Encryption Operations
|
||||
|
||||
#### `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]`
|
||||
|
||||
Decrypts data using an Age key stored as a secret.
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
### 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/
|
||||
│ ├── default/
|
||||
│ │ ├── unlock-keys.d/
|
||||
│ │ │ ├── passphrase/ # Passphrase unlock key
|
||||
│ │ │ ├── keychain/ # Keychain unlock key (macOS)
|
||||
│ │ │ └── pgp/ # PGP unlock key
|
||||
│ │ ├── unlockers.d/
|
||||
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
|
||||
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
|
||||
│ │ ├── secrets.d/
|
||||
│ │ │ ├── api%key/ # Secret: api/key
|
||||
│ │ │ │ ├── versions/
|
||||
│ │ │ │ │ ├── 20231215.001/ # Version directory
|
||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||
│ │ │ │ └── current # Current version's name: 20231216.001
|
||||
│ │ │ └── database%password/ # Secret: database/password
|
||||
│ │ └── current-unlock-key -> ../unlock-keys.d/passphrase
|
||||
│ │ │ ├── versions/
|
||||
│ │ │ └── current # Current version's name: 20231215.001
|
||||
│ │ ├── vault-metadata.json # Vault metadata
|
||||
│ │ ├── pub.age # Long-term public key
|
||||
│ │ └── current-unlocker # Current unlocker's directory name
|
||||
│ └── work/
|
||||
│ ├── unlock-keys.d/
|
||||
│ ├── unlockers.d/
|
||||
│ ├── secrets.d/
|
||||
│ └── current-unlock-key
|
||||
├── currentvault -> vaults.d/default
|
||||
└── configuration.json
|
||||
│ ├── vault-metadata.json
|
||||
│ ├── pub.age
|
||||
│ └── current-unlocker
|
||||
├── 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
|
||||
|
||||
#### Long-term Keys
|
||||
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
|
||||
#### 1: Long-term Keys
|
||||
|
||||
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
|
||||
deterministic (HD) key derivation
|
||||
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlock keys
|
||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by
|
||||
unlockers
|
||||
|
||||
#### Unlock Keys
|
||||
Unlock keys provide different authentication methods to access the long-term keys:
|
||||
#### 2: Unlockers
|
||||
|
||||
1. **Passphrase Keys**:
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
Unlockers provide different authentication methods to access the long-term keys:
|
||||
|
||||
2. **Keychain Keys** (macOS only):
|
||||
- Uses macOS Keychain for secure storage
|
||||
- Provides seamless authentication on macOS systems
|
||||
- Age private key encrypted with random passphrase stored in Keychain
|
||||
1. **Passphrase Unlockers**:
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
|
||||
3. **PGP Keys**:
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
2. **PGP Unlockers**:
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
|
||||
Each vault maintains its own set of unlock keys and one long-term key. The long-term key is encrypted to each unlock key, allowing any authorized unlock key 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.
|
||||
|
||||
#### Secret-specific Keys
|
||||
- Each secret has its own encryption key pair
|
||||
#### 3: Secret-specific Keys
|
||||
|
||||
- Each secret version has its own encryption key pair
|
||||
- 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
|
||||
|
||||
- `SB_SECRET_STATE_DIR`: Custom state directory location
|
||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase (avoids interactive prompt)
|
||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
||||
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlock keys
|
||||
- `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.
|
||||
|
||||
The prompt asks on the terminal the command runs in, not on stdin, so it also
|
||||
asks when stdin is a pipe, as in `echo value | secret add name --force` or
|
||||
`cat file | secret encrypt key`. With no controlling terminal, as under cron or
|
||||
in a CI job, the command fails and names the variable to set.
|
||||
|
||||
## Security Features
|
||||
|
||||
### 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
|
||||
- No plaintext secrets stored on disk
|
||||
|
||||
### Access Control
|
||||
|
||||
- Multiple authentication methods supported
|
||||
- Hierarchical key architecture provides defense in depth
|
||||
- Vault isolation prevents cross-contamination
|
||||
|
||||
### Forward Secrecy
|
||||
- Per-secret encryption keys limit exposure if compromised
|
||||
- Long-term keys protected by multiple unlock key layers
|
||||
|
||||
### Platform Integration
|
||||
- macOS Keychain integration for seamless authentication
|
||||
- GPG integration for existing key management workflows
|
||||
- Per-version encryption keys limit exposure if compromised
|
||||
- Each version is independently encrypted
|
||||
- Historical versions remain encrypted with their original keys
|
||||
|
||||
### Hardware Integration
|
||||
|
||||
- Hardware token support via PGP/GPG integration
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```bash
|
||||
# Initialize with a new mnemonic
|
||||
secret generate mnemonic # Copy the output
|
||||
@@ -235,9 +428,13 @@ echo "ssh-private-key-content" | secret add ssh/servers/web01
|
||||
secret list
|
||||
secret get database/prod/password
|
||||
secret get services/api/key
|
||||
|
||||
# Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
|
||||
secret remove ssh/servers/web01
|
||||
```
|
||||
|
||||
### Multi-vault Setup
|
||||
|
||||
```bash
|
||||
# Create separate vaults for different contexts
|
||||
secret vault create work
|
||||
@@ -246,7 +443,7 @@ secret vault create personal
|
||||
# Work with work vault
|
||||
secret vault select work
|
||||
echo "work-db-pass" | secret add database/password
|
||||
secret keys add passphrase # Add passphrase authentication
|
||||
secret unlocker add passphrase # Add passphrase authentication
|
||||
|
||||
# Switch to personal vault
|
||||
secret vault select personal
|
||||
@@ -254,23 +451,43 @@ echo "personal-email-pass" | secret add email/password
|
||||
|
||||
# List all vaults
|
||||
secret vault list
|
||||
|
||||
# Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
|
||||
secret vault remove personal --force
|
||||
```
|
||||
|
||||
### Advanced Authentication
|
||||
|
||||
```bash
|
||||
# Add multiple unlock methods
|
||||
secret keys add passphrase # Password-based
|
||||
secret keys add keychain # macOS Keychain (macOS only)
|
||||
secret keys add pgp --keyid ABCD1234 # GPG key
|
||||
secret unlocker add passphrase # Password-based
|
||||
secret unlocker add pgp --keyid ABCD1234 # GPG key
|
||||
|
||||
# List unlock keys
|
||||
secret keys list
|
||||
# List unlockers
|
||||
secret unlocker list
|
||||
|
||||
# Select a specific unlock key
|
||||
secret key select <key-id>
|
||||
# Select a specific unlocker
|
||||
secret unlocker select <unlocker-id>
|
||||
|
||||
# Remove an unlocker ⚠️ 🛑 (asks first!)
|
||||
secret unlocker remove <unlocker-id>
|
||||
```
|
||||
|
||||
### Version Management
|
||||
|
||||
```bash
|
||||
# List all versions of a secret
|
||||
secret version list database/prod/password
|
||||
|
||||
# Promote an older version to current
|
||||
secret version promote database/prod/password 20231215.001
|
||||
|
||||
# Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
|
||||
secret version remove database/prod/password 20231214.001
|
||||
```
|
||||
|
||||
### Encryption/Decryption with Age Keys
|
||||
|
||||
```bash
|
||||
# Generate an Age key and store it as a secret
|
||||
secret generate secret encryption/mykey
|
||||
@@ -285,61 +502,141 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
## Technical Details
|
||||
|
||||
### Cryptographic Primitives
|
||||
|
||||
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
||||
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
|
||||
- **Authentication**: Poly1305 MAC
|
||||
- **Hashing**: Double SHA-256 for public key identification
|
||||
|
||||
### File Formats
|
||||
- **Age Files**: Standard Age encryption format (.age extension)
|
||||
- **Metadata**: JSON format with timestamps and type information
|
||||
- **Configuration**: JSON configuration files
|
||||
|
||||
- **age Files**: Standard age encryption format (.age extension), except
|
||||
`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
|
||||
|
||||
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||
mnemonic, and thus a unique key pair
|
||||
- **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
|
||||
- **macOS**: Full support including Keychain integration
|
||||
- **Linux**: Full support (excluding Keychain features)
|
||||
- **Windows**: Basic support (filesystem operations only)
|
||||
|
||||
- **macOS**: Full support
|
||||
- **Linux**: Full support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
- Protects against unauthorized access to secret values
|
||||
- Provides defense against compromise of individual components
|
||||
- Supports platform-specific authentication where available
|
||||
- Supports hardware-backed authentication where available
|
||||
|
||||
### Best Practices
|
||||
1. Use strong, unique passphrases for unlock keys
|
||||
2. Enable platform-specific authentication (Keychain) when available
|
||||
3. Regularly audit unlock keys and remove unused ones
|
||||
|
||||
1. Use strong, unique passphrases for unlockers
|
||||
2. Enable hardware authentication (hardware tokens) when available
|
||||
3. Regularly audit unlockers and remove unused ones
|
||||
4. Keep mnemonic phrases securely backed up offline
|
||||
5. Use separate vaults for different security contexts
|
||||
|
||||
### Limitations
|
||||
- Requires access to unlock keys for secret retrieval
|
||||
|
||||
- Requires access to unlockers for secret retrieval
|
||||
- Mnemonic phrases must be securely stored and backed up
|
||||
- Platform-specific features limited to supported platforms
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
go build -o secret ./cmd/secret # Build binary
|
||||
go test ./... # Run tests
|
||||
go vet ./... # Run static analysis
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make lint # Run linter
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes comprehensive tests:
|
||||
|
||||
```bash
|
||||
./test_secret_manager.sh # Full integration test suite
|
||||
go test ./... # Unit tests
|
||||
make test # Run all tests
|
||||
```
|
||||
|
||||
## Entrypoints
|
||||
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call them. We
|
||||
provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (Go, Go module download, and
|
||||
node, yarn and prettier for formatting markdown), idempotently; prettier is
|
||||
pinned by hash in `package.json` and `yarn.lock`; golangci-lint is not
|
||||
installed, it runs in docker
|
||||
- `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`
|
||||
- `script/projectname` — output the project name (`secret`); used by other
|
||||
scripts such as `script/docker`
|
||||
- `script/build` — build the `secret` binary into the repo root, stamping the
|
||||
version (`VERSION` from the environment, else `git describe`) and the git
|
||||
commit
|
||||
- `script/test` — build the `test` phase of the `Dockerfile`, which runs the
|
||||
test suite with the race detector, a 90-second timeout and coverage; on
|
||||
failure it reruns the tests verbosely for the details and fails even when the
|
||||
rerun passes
|
||||
- `script/lint` — build the `lint` phase of the `Dockerfile`, which runs
|
||||
`go vet` and `golangci-lint`, then both again on the code as a macOS build
|
||||
compiles it (`GOOS=darwin`, with cgo off), which a Linux build never compiles
|
||||
- `script/fmt` — format all Go code with `go fmt` and every markdown file with
|
||||
prettier (4-space tabs, `proseWrap: always`) (writes)
|
||||
- `script/fmt-check` — check the same formatting without writing, on the host
|
||||
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
|
||||
- `script/docker` — build the Docker image tagged with the project name; the
|
||||
build runs the `lint` and `test` phases first
|
||||
- `script/cibuild` — CI entrypoint: runs `script/bootstrap`, `script/check`,
|
||||
then builds the image as `script/docker` does
|
||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
|
||||
`script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Authentication Methods**: Supports passphrase-based, keychain-based (macOS), and PGP-based unlock keys
|
||||
- **Multiple Authentication Methods**: Supports passphrase and PGP unlockers
|
||||
- **Vault Isolation**: Complete separation between different vaults
|
||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||
|
||||
## TODO
|
||||
|
||||
Open work is tracked on the
|
||||
[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.
|
||||
|
||||
## 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)
|
||||
|
||||
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-04
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
|
||||
- Repos follow the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
pattern: the implementation of each Makefile target lives in an executable
|
||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||
for development after a fresh clone: runs `bootstrap`, then
|
||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||
pristine checkout with nothing installed the run dies there, after the
|
||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||
source nvm for the pinned node version before invoking it, exactly as
|
||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||
docker and git then gets through `script/check`. Four further scripts are our
|
||||
own extensions to the standard: `script/check` runs `script/test`,
|
||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||
`script/projectname` (literally that filename) simply outputs the project's
|
||||
name. Scripts that need the name call `script/projectname` — e.g.
|
||||
`script/docker` assembles its image tag from it — so those scripts stay
|
||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||
the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||
image cannot be built unless they pass. For non-server repos the final stage
|
||||
brings up a development environment; for server repos it is the runtime image.
|
||||
The gate phases and the build stage start from their pinned base images and
|
||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||
repo's own `Dockerfile` does for its yarn packages. The development
|
||||
environment stage installs development prerequisites by running
|
||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||
|
||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||
and nothing else:
|
||||
|
||||
```sh
|
||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||
```
|
||||
|
||||
**A stage that is not the last one in the file is built only when the final
|
||||
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||
two gates are always invoked by name here, and why the final stage carries a
|
||||
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||
and tested nothing.
|
||||
|
||||
**Every `docker build` in `script/` is tagged**, here and in
|
||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||
image behind on every invocation, on every developer host and every CI
|
||||
runner; a tagged one replaces the previous image.
|
||||
|
||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||
themselves a `docker build` and would recurse into a daemon that does not
|
||||
exist in a build step. Formatting is the exception and stays on the host:
|
||||
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||
read-only twin.
|
||||
|
||||
**No lint verdict may come from a host invocation of the linter.** On a
|
||||
shared host golangci-lint reads a result cache keyed on file content rather
|
||||
than location, so a second checkout of the same content is served the first
|
||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||
a digest-pinned binary, so neither is reachable.
|
||||
|
||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||
the check `RUN` is served from cache, nothing executes, and the build still
|
||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||
not evidence that anything ran: a sub-second build reporting success is a
|
||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||
and friends destroy a build cache shared with every other build on the host.
|
||||
When a check is added or changed, prove it works by planting a defect it must
|
||||
catch and watching the run fail on it, then revert the defect. A green run
|
||||
alone shows neither that the check ran nor that it covers what it should.
|
||||
|
||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
hash), so lint failures surface in seconds rather than after a full compile,
|
||||
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
# Lint phase
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
# unless lint and test passed.
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
RUN apk add --no-cache git
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage, and the last one
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||
both Go and the linter), so nothing needs installing.
|
||||
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||
and a stage nothing depends on is not built at all, so without these two
|
||||
lines a red gate would not fail the build.
|
||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||
same two copies. A plain `docker build .` builds the last stage's chain
|
||||
and nothing else.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
- If the project requires CGO or system libraries for linting, install them
|
||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||
lists in the same `RUN`, so the layer does not keep them:
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||
submodule keeping its own `.git` directory. `git describe` does not need
|
||||
them, and each can hold a credential: a password in a remote URL, or the
|
||||
token the CI checkout step stores there. A submodule whose name has a
|
||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||
then fails the build: give it a name without that segment
|
||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||
takes the version from the `VERSION` build argument when one is given,
|
||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||
tagged commit; on a later commit, the tag, the number of commits since it
|
||||
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||
tag is reachable. The stage that compiles also marks its working directory
|
||||
safe for git (`git config --system --add safe.directory /src`): a context
|
||||
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||
checkout owned by another user, so the version would come out empty.
|
||||
`ARG VERSION` has no default, and the build fails if the context carries
|
||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||
refuses an empty build argument drops that refusal and keeps the argument.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||
successful run means every check passed; a bare `docker build .` does not
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build is uncached and so runs the gate phases a second time. That is the
|
||||
price of the rule above, and it is worth paying: the image that ships is built
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||
checked by review: to try a change to it, add the feature branch to that list
|
||||
and push, then remove the branch from the list again before merging. Keep any
|
||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||
from the feature branch publishes nothing.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||
that shims to it.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||
20 and 60 seconds is still green, but the overage must be filed as an
|
||||
improvement bug against that repo. Add a 90-second timeout to the test
|
||||
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||
one.
|
||||
|
||||
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||
on success (just package/suite summaries) while providing full diagnostic
|
||||
detail on failure (every test case, every assertion). The command lives in the
|
||||
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||
Makefile form below is the same pattern for any repo-local invocation:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@<test-command> || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
<test-command-with-v>; exit 1; }
|
||||
```
|
||||
|
||||
Go example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
```
|
||||
|
||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||
cache, so neither run can report a stored pass in place of running the
|
||||
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||
and no recompilation.
|
||||
|
||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||
run again on unchanged code it prints that result, marked `(cached)`,
|
||||
without running them. That matters on a developer's machine, where this
|
||||
target runs and the directory lasts from one run to the next. The `test`
|
||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||
result for this repo's tests and nothing before its `go test` step runs a
|
||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||
step run on an unchanged tree.
|
||||
|
||||
Python example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@python -m pytest || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
python -m pytest -v; exit 1; }
|
||||
```
|
||||
|
||||
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||
already proved the tests are broken, so the build must not pass even if a
|
||||
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||
for diagnostic output.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||
semantics, in which an unanchored pattern already matches at every depth; they
|
||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||
|
||||
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||
across unmodified leaves secrets in the build context.** Docker matches with
|
||||
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||
therefore excludes only the copies at the repository root, while `config/.env`
|
||||
and `certs/server.key` still reach the context and can land in an image layer
|
||||
— which is more dangerous than a short file with no secret patterns at all,
|
||||
because it reads as solved and stops anyone looking. Give every
|
||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||
also catches something the build needs, re-include it with a negation
|
||||
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||
other file it covers. Fetch the standard `.dockerignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||
it with the repo's own artifacts.
|
||||
|
||||
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||
additional checkout of the repo — so under `COPY . .` the build context
|
||||
inflates by a multiple of the repo and another session's unreviewed work can
|
||||
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||
prefix, because the prefixed form would also delete any nested directory of
|
||||
that name from the build. Anchoring carries a known gap that the canonical
|
||||
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||
file and not the tracker: the directory is created in the agent's working
|
||||
directory, so a repo running agents in subdirectories still ships
|
||||
`services/api/.claude/` and must add its own anchored entry there.
|
||||
|
||||
- **A plain `docker build .` of a clone stamps the version that
|
||||
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||
the version they compute on the host; it takes precedence. They do this
|
||||
byte-identically across repos:
|
||||
|
||||
```sh
|
||||
# Own line: a failing command substitution inside an argument does not
|
||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
docker build --no-cache \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$(script/projectname)" .
|
||||
```
|
||||
|
||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||
fallback is applied — a live check that fires on a build from an export with
|
||||
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||
the scripts stay byte-identical. One consequence for CI: the standard
|
||||
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||
|
||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||
a probe image that does `COPY . .`, and list what actually landed
|
||||
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||
transfers only the delta from the previous build.
|
||||
|
||||
- **No build artifacts in version control.** Code-derived data (compiled
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||
should generate these at build time. Notable exception: Go protobuf generated
|
||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||
downloads code but does not execute code generation.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||
_NEVER_ be modified by an agent: fetch it from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||
because it cannot be written once for every repo: the `deny` list of the
|
||||
`test-support` depguard rule, where a repo names its own test-support packages
|
||||
by full import path. A repo adds entries there and changes nothing else, and a
|
||||
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||
image
|
||||
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||
directive must not name a newer Go minor version than the one golangci-lint
|
||||
was built with, or golangci-lint refuses to lint it: this release lints
|
||||
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||
the two prompted the change: the canonical copy can name linters that an older
|
||||
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||
`default: all` switches on until the canonical copy disables them.
|
||||
|
||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||
disagree about what the tool even is. The canonical form:
|
||||
- compares the installed version against the pin over the **whole** version
|
||||
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||
running `2.12.2-rc1` and skips the install;
|
||||
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||
mismatch, so the failure direction is a redundant install and never a
|
||||
skipped one;
|
||||
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||
then through `PATH`, not through the directory the installer wrote to —
|
||||
and fails naming the resolved path, since an install that a shadowing
|
||||
binary hides succeeds while changing nothing any caller sees;
|
||||
- is actually called, and prints the version on both success paths: a
|
||||
function defined and never invoked has the same exit status and the same
|
||||
empty output as one that worked.
|
||||
|
||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||
|
||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- **HTTP/web services must be hardened for production internet exposure before
|
||||
tagging 1.0.** This means full compliance with security best practices
|
||||
including, without limitation, all of the following:
|
||||
- **Security headers** on every response:
|
||||
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||
and `includeSubDomains`.
|
||||
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||
unavoidable, and document the reason.
|
||||
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||
- `Permissions-Policy` restricting access to browser features the
|
||||
application does not use (camera, microphone, geolocation, etc.).
|
||||
- **Request and response limits:**
|
||||
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||
unbounded input.
|
||||
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||
against slowloris attacks.
|
||||
- `WriteTimeout` on the `http.Server`.
|
||||
- `IdleTimeout` on the `http.Server`.
|
||||
- Per-handler execution time limits via `context.WithTimeout` or
|
||||
chi/stdlib `middleware.Timeout`.
|
||||
- **Authentication and session security:**
|
||||
- Rate limiting on password-based authentication endpoints. API keys are
|
||||
high-entropy and not susceptible to brute force, so they are exempt.
|
||||
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||
authenticated via `Authorization` header (Bearer token, API key) are
|
||||
exempt because the browser does not attach these automatically.
|
||||
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||
MD5, or SHA.
|
||||
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||
`Strict`) attributes.
|
||||
- **Reverse proxy awareness:**
|
||||
- True client IP detection when behind a reverse proxy
|
||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||
forwarded headers only from a configured set of trusted proxy
|
||||
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||
- **CORS:**
|
||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||
only for public, unauthenticated read-only APIs.
|
||||
- **Error handling:**
|
||||
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||
or other implementation details to the client. Return generic error
|
||||
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||
- **TLS:**
|
||||
- Services never terminate TLS directly. They are always deployed behind
|
||||
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||
set by the application so that the browser enforces HTTPS end-to-end.
|
||||
|
||||
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||
hardening measure exists for HTTP services and is not listed here, it is
|
||||
still expected. When in doubt, harden.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard (with that link), then documents each provided `script/`
|
||||
entrypoint and its purpose.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||
and language-specific config). Everything else goes in a subdirectory.
|
||||
Canonical subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||
`cmd/`
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`)
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
|
||||
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||
is never committed under a file or directory named after one agent tool, such
|
||||
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||
@@ -1,197 +1,609 @@
|
||||
# TODO for 1.0 Release
|
||||
|
||||
This document outlines the bugs, issues, and improvements that need to be addressed before the 1.0 release of the secret manager.
|
||||
|
||||
## Critical (Blockers for Release)
|
||||
|
||||
### Error Handling and User Experience
|
||||
|
||||
|
||||
- [ ] **1. Inappropriate Cobra usage printing**: Commands currently print usage information for all errors, including internal program failures. Usage should only be printed when the user provides incorrect arguments or invalid commands, not when the program encounters internal errors (like file system issues, crypto failures, etc.).
|
||||
|
||||
- [ ] **2. Inconsistent error messages**: Error messages need standardization and should be user-friendly. Many errors currently expose internal implementation details.
|
||||
|
||||
- [ ] **3. Missing validation for vault names**: Vault names should be validated against a safe character set to prevent filesystem issues.
|
||||
|
||||
- [ ] **4. No graceful handling of corrupted state**: If key files are corrupted or missing, the tool should provide clear error messages and recovery suggestions.
|
||||
|
||||
### Core Functionality Bugs
|
||||
|
||||
- [ ] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
|
||||
|
||||
- [ ] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
|
||||
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/`
|
||||
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
|
||||
|
||||
- [ ] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
|
||||
|
||||
- [ ] **8. Missing current unlock key initialization**: When creating vaults, no default unlock key is selected, which can cause operations to fail.
|
||||
|
||||
- [ ] **9. Race conditions in file operations**: Multiple concurrent operations could corrupt the vault state due to lack of file locking.
|
||||
|
||||
### Security Issues
|
||||
|
||||
- [ ] **10. Insecure temporary file handling**: Temporary files containing sensitive data may not be properly cleaned up or secured.
|
||||
|
||||
- [ ] **11. Missing secure memory clearing**: Sensitive data in memory (passphrases, keys) should be cleared after use.
|
||||
|
||||
- [ ] **12. Weak default permissions**: Some files may be created with overly permissive default permissions.
|
||||
|
||||
## Important (Should be fixed before release)
|
||||
|
||||
### User Interface Improvements
|
||||
|
||||
- [ ] **13. Add confirmation prompts for destructive operations**: Operations like `keys rm` and vault deletion should require confirmation.
|
||||
|
||||
- [ ] **14. Improve progress indicators**: Long operations (key generation, encryption) should show progress.
|
||||
|
||||
- [ ] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
|
||||
|
||||
- [ ] **16. Add `--help` examples**: Command help should include practical examples for each operation.
|
||||
|
||||
### Command Implementation Gaps
|
||||
|
||||
- [ ] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
|
||||
|
||||
- [ ] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
|
||||
|
||||
- [ ] **19. Missing vault deletion command**: No way to delete vaults that are no longer needed.
|
||||
|
||||
- [ ] **20. No secret deletion command**: Missing `secret rm <secret-name>` functionality.
|
||||
|
||||
- [ ] **21. Missing secret history/versioning**: No way to see previous versions of secrets or restore old values.
|
||||
|
||||
### Configuration and Environment
|
||||
|
||||
- [ ] **22. Global configuration not fully implemented**: The `configuration.json` file structure exists but isn't used consistently.
|
||||
|
||||
- [ ] **23. Missing environment variable validation**: Environment variables should be validated for format and security.
|
||||
|
||||
- [ ] **24. No configuration file validation**: JSON configuration files should be validated against schemas.
|
||||
|
||||
### PGP Integration Issues
|
||||
|
||||
- [ ] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
|
||||
|
||||
- [ ] **26. Missing GPG agent integration**: Should detect and use existing GPG agent when available.
|
||||
|
||||
- [ ] **27. No validation of GPG key existence**: Should verify the specified GPG key exists before creating PGP unlock keys.
|
||||
|
||||
### Cross-Platform Issues
|
||||
|
||||
- [ ] **28. macOS Keychain error handling**: Better error messages when biometric authentication fails or isn't available.
|
||||
|
||||
- [ ] **29. Windows path handling**: File paths may not work correctly on Windows systems.
|
||||
|
||||
- [ ] **30. XDG compliance on Linux**: Should respect `XDG_CONFIG_HOME` and other XDG environment variables.
|
||||
|
||||
## Trivial (Nice to have)
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **31. Add more comprehensive unit tests**: Current test coverage could be improved, especially for error conditions.
|
||||
|
||||
- [ ] **32. Reduce code duplication**: Several functions have similar patterns that could be refactored.
|
||||
|
||||
- [ ] **33. Improve function documentation**: Many functions lack proper Go documentation comments.
|
||||
|
||||
- [ ] **34. Add static analysis**: Integrate tools like `staticcheck`, `golangci-lint` with more linters.
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
- [ ] **35. Cache unlock key operations**: Avoid re-reading unlock key metadata on every operation.
|
||||
|
||||
- [ ] **36. Optimize file I/O**: Batch file operations where possible to reduce syscalls.
|
||||
|
||||
- [ ] **37. Add connection pooling for HSM operations**: For hardware security module operations.
|
||||
|
||||
### User Experience Enhancements
|
||||
|
||||
- [ ] **38. Add shell completion**: Bash/Zsh completion for commands and secret names.
|
||||
|
||||
- [ ] **39. Colored output**: Use colors to improve readability of lists and error messages.
|
||||
|
||||
- [ ] **40. Add `--quiet` flag**: Option to suppress non-essential output.
|
||||
|
||||
- [ ] **41. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
|
||||
|
||||
### Additional Features
|
||||
|
||||
- [ ] **42. Secret templates**: Predefined templates for common secret types (database URLs, API keys, etc.).
|
||||
|
||||
- [ ] **43. Bulk operations**: Import/export multiple secrets at once.
|
||||
|
||||
- [ ] **44. Secret sharing**: Secure sharing of secrets between vaults or users.
|
||||
|
||||
- [ ] **45. Audit logging**: Log all secret access and modifications.
|
||||
|
||||
- [ ] **46. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] **47. Man pages**: Generate and install proper Unix man pages.
|
||||
|
||||
- [ ] **48. API documentation**: Document the internal API for potential library use.
|
||||
|
||||
- [ ] **49. Migration guide**: Document how to migrate from other secret managers.
|
||||
|
||||
- [ ] **50. Security audit documentation**: Document security assumptions and threat model.
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### Code Structure
|
||||
|
||||
- [ ] **51. Consistent interface implementation**: Ensure all unlock key types properly implement the UnlockKey interface.
|
||||
|
||||
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
|
||||
|
||||
- [ ] **53. Improved error types**: Create specific error types instead of using generic `fmt.Errorf`.
|
||||
|
||||
### Testing Infrastructure
|
||||
|
||||
- [ ] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
|
||||
|
||||
- [ ] **55. Integration test isolation**: Tests should not affect each other or the host system.
|
||||
|
||||
- [ ] **56. Performance benchmarks**: Add benchmarks for crypto operations and file I/O.
|
||||
|
||||
## Technical Debt
|
||||
|
||||
- [ ] **57. Remove unused code**: Clean up any dead code or unused imports.
|
||||
|
||||
- [ ] **58. Standardize JSON schemas**: Create proper JSON schemas for all configuration files.
|
||||
|
||||
- [ ] **59. Improve error propagation**: Many functions swallow important context in error messages.
|
||||
|
||||
- [ ] **60. Consistent naming conventions**: Some variables and functions use inconsistent naming.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- [ ] **61. Add pre-commit hooks**: Ensure code quality and formatting before commits.
|
||||
|
||||
- [ ] **62. Continuous integration**: Set up CI/CD pipeline with automated testing.
|
||||
|
||||
- [ ] **63. Release automation**: Automate the build and release process.
|
||||
|
||||
- [ ] **64. Dependency management**: Regular updates and security scanning of dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Priority Assessment
|
||||
|
||||
**Critical items** (1-12) block the 1.0 release and must be fixed for basic functionality and security.
|
||||
|
||||
**Important items** (13-30) should be addressed for a polished user experience but don't block the release.
|
||||
|
||||
**Trivial items** (31-50) are enhancements that can be addressed in future releases.
|
||||
|
||||
**Architecture and Infrastructure** (51-64) are longer-term improvements for maintainability and development workflow.
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
- Critical (1-12): 2-3 weeks
|
||||
- Important (13-30): 3-4 weeks
|
||||
- Trivial (31-50): Ongoing post-1.0
|
||||
- Architecture/Infrastructure (51-64): Ongoing post-1.0
|
||||
|
||||
Total estimated time to 1.0: 5-7 weeks with focused development effort.
|
||||
# Workflow
|
||||
|
||||
- branch from `next`
|
||||
- do the Next Step: the next open issue in the `1.0.0` milestone
|
||||
- log it at the top of Completed Steps
|
||||
- commit (`TODO.md` changes in the same commit as the work)
|
||||
- push, and open a PR against `next`
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
|
||||
authoritative.
|
||||
|
||||
# Next Step
|
||||
|
||||
Take the next open issue in the `1.0.0` milestone:
|
||||
https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
||||
prompt, gives the error `wrong passphrase for unlocker <id>`
|
||||
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
||||
identity matched any of the recipients", followed by advice to give the vault
|
||||
a new unlocker from its mnemonic, which would have replaced an unlocker that
|
||||
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
|
||||
to use the mnemonic. A `priv.age` that is not an age file still gets that
|
||||
advice.
|
||||
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
|
||||
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
|
||||
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
|
||||
for the passphrase at a terminal instead of failing
|
||||
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
|
||||
controlling terminal gets the error that names the variable to set. On SIGINT
|
||||
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
|
||||
since that is where the prompt turns echo off. `secret init` and
|
||||
`secret vault create` no longer write a newline to stderr after the mnemonic
|
||||
is typed, since the prompt ends that line on the terminal.
|
||||
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
||||
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
||||
stderr when no output writer is set, so `secret vault list --json | jq`
|
||||
received nothing. "No versions found" from `secret version list` moved to
|
||||
stdout too. Messages such as "Created vault", and the warning after the
|
||||
mnemonic, stay on stderr.
|
||||
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
||||
open through the unlockers it still has
|
||||
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
||||
naming the removed unlocker, so every command that opened the vault failed,
|
||||
with advice to use the mnemonic. `secret unlocker remove` of the current
|
||||
unlocker now makes the first of the vault's other unlockers that
|
||||
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
|
||||
current before removing it, and says so; when there is none, it also removes
|
||||
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
|
||||
made current. When `current-unlocker` names an unlocker that does not exist
|
||||
and the vault has another that `secret unlocker select` accepts, the error
|
||||
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
|
||||
without the advice to use the mnemonic.
|
||||
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
|
||||
passphrase or mnemonic, leaves the secret as it was
|
||||
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
|
||||
written before the vault was unlocked to set `notAfter` on the version it
|
||||
replaces, so each failed add left one more version that `secret version list`
|
||||
showed. The vault is now unlocked, and that version's metadata read, before
|
||||
anything is written. `generate secret --force` and `import --force` take the
|
||||
same path.
|
||||
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
||||
same long-term key as its words separated by single spaces
|
||||
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||
text exactly as typed, so a vault made with a double or trailing space did not
|
||||
open with the same words typed normally. `agehd.DeriveEntropy`, which every
|
||||
use of the mnemonic goes through, now joins the words with single spaces and
|
||||
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
|
||||
- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code
|
||||
and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were
|
||||
research: neither works without macOS code signing and entitlements, which
|
||||
`secret` does not have. The last commit that has them is `ef0ae90`, tagged
|
||||
`v1.0.0-rc1`. `secret unlocker add` takes only `passphrase` and `pgp` on every
|
||||
platform, `internal/macse` and those unlockers' files in `internal/secret` are
|
||||
deleted, and `github.com/keybase/go-keychain` left `go.mod` and `go.sum`.
|
||||
`secret` still builds and runs on macOS.
|
||||
- 2026-10-07: The part of `github.com/tyler-smith/go-bip39` v1.1.0 that `secret`
|
||||
uses is copied into `internal/bip39`, with upstream's `LICENSE` beside it,
|
||||
because its repository no longer exists
|
||||
(https://git.eeqj.de/sneak/secret/issues/122). Every import moved there, and
|
||||
the module left `go.mod` and `go.sum`. No derived key or mnemonic changes.
|
||||
- 2026-10-07: The canonical files are re-vendored from `sneak/prompts` commit
|
||||
`dd4027b` (https://git.eeqj.de/sneak/secret/issues/121), with golangci-lint
|
||||
v2.14.0 in the lint phase. Lint and test are phases of the `Dockerfile`, and
|
||||
`script/lint` and `script/test` each build one with `--no-cache`;
|
||||
`Dockerfile.lint` and `script/lint-darwin` are gone, and the lint phase runs
|
||||
`go vet` and checks the macOS build too. The tests run on the Debian Go image
|
||||
with cgo and the race detector, with the policy's 90-second timeout.
|
||||
`script/cibuild` bootstraps, runs `script/check` and builds the image;
|
||||
`CHECK_EPOCH` and the memlock ulimit are gone. `--no-cache` starts Go's build
|
||||
cache mount empty, so `make test` compiles everything on every run. The rules
|
||||
in `CLAUDE.md` that `AGENTS.md` lacked are in `AGENTS.md`, and `CLAUDE.md` is
|
||||
deleted.
|
||||
- 2026-10-06: `script/test` runs the tests with the race detector, a 30-second
|
||||
timeout per package and coverage, as `REPO_POLICIES.md` requires
|
||||
(https://git.eeqj.de/sneak/secret/issues/32). When they fail, it reruns them
|
||||
verbosely for the details and then fails anyway, so a test that fails once and
|
||||
passes on the retry no longer gives a green build. `go vet` still runs first,
|
||||
and every `go test` keeps `-count=1`. The tests that gave the `secret` binary
|
||||
a minute now give it 10 seconds, and the PGP unlocker test's 30-second timer
|
||||
is 10 seconds, so a test that hangs fails with its own message before the
|
||||
package's 30-second timeout ends every test in it.
|
||||
- 2026-10-06: `make test` in `script/cibuild` no longer compiles the standard
|
||||
library and every dependency from nothing on every build
|
||||
(https://git.eeqj.de/sneak/secret/issues/124). The `Dockerfile` runs it and
|
||||
`make build` with Go's build cache in a BuildKit cache mount, which docker
|
||||
keeps between builds, so each compiles only what changed since the last build.
|
||||
The mount has an id of its own, so other repositories' builds do not share it.
|
||||
`script/test` passes `-count=1`, so every test runs on every build and no
|
||||
result comes from Go's test cache. A build with an empty cache, such as the
|
||||
first after docker's build cache is cleared, compiles everything in
|
||||
`make test` as before.
|
||||
- 2026-10-06: `TestRemoveIgnoresTerminalOnStdout` and
|
||||
`TestRemoveAsksAtTerminalOnStdin` no longer wait until Go's test timeout
|
||||
(https://git.eeqj.de/sneak/secret/issues/126). On Linux, `pty.Open` of
|
||||
`github.com/creack/pty` v1.1.24 passed the address of a local variable to the
|
||||
`ioctl` system call as a plain number, through a function call; when Go moved
|
||||
the goroutine's stack in between, the kernel wrote the terminal's number to
|
||||
the old place, and `pty.Open` opened `/dev/pts/0` instead of the terminal it
|
||||
had created. `secret rm` then wrote to that other terminal, and the test read
|
||||
a terminal no program had open, which never ends. `go.mod` now requires the
|
||||
commit on that library's main branch that passes a pointer instead; no release
|
||||
has it yet. Both tests stop reading the terminal when their one-minute context
|
||||
ends and fail saying so.
|
||||
- 2026-10-06: The tests run quickly with the race detector on
|
||||
(https://git.eeqj.de/sneak/secret/issues/120). Most of their time went to
|
||||
deriving keys from passphrases with scrypt, which is slow on purpose. The new
|
||||
`secret.ScryptWorkFactor`, when not zero, replaces age's scrypt work factor
|
||||
when a passphrase encrypts; the tests of `internal/secret`, `internal/vault`
|
||||
and `internal/cli` set it to 1 before any test runs, and the program never
|
||||
sets it; `TestGetCommandOutputsToStdout` checks that the passphrase unlocker
|
||||
the built binary's `secret init` writes names age's work factor, 18.
|
||||
`TestRemovalAsksWithoutHoldingLock` and `TestFailedCommandReleasesLock` no
|
||||
longer run in parallel with other tests: each waits at most 10 seconds for the
|
||||
in-memory lock that every test in the package shares, and other tests'
|
||||
commands held it longer. `TestConcurrentAddsKeepEveryVersion`, which times
|
||||
nothing, and `TestGetCommandOutputsToStdout`, which no longer sets an
|
||||
environment variable its commands do not read, now run in parallel. The
|
||||
`script/cibuild` comment no longer says that tests are skipped without its
|
||||
memlock ulimit.
|
||||
- 2026-10-05: No test stores a secret larger than 1 MiB
|
||||
(https://git.eeqj.de/sneak/secret/issues/52). The size tests for `secret add`,
|
||||
`secret import` and the stdin buffer no longer try 2 MB, 10 MB, 99 MB, 100 MB
|
||||
or 101 MB secrets, and nothing tests that a secret over the 100 MB limit is
|
||||
rejected; the limit itself is unchanged. With nothing large left, the size
|
||||
tests no longer skip a case for want of locked memory.
|
||||
- 2026-10-05: The Go module path is `sneak.berlin/go/secret`, as
|
||||
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret`
|
||||
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the
|
||||
`-X` flags in `script/build` that stamp the version and commit shown by
|
||||
`secret info`, and the examples in `pkg/agehd/README.md` and
|
||||
`pkg/bip85/README.md`. `go mod tidy` now lists `github.com/dustin/go-humanize`
|
||||
and `github.com/fatih/color`, which `internal/cli` imports, as direct
|
||||
requirements. Code that imported the old path must switch to the new one.
|
||||
- 2026-10-04: `make fmt` formats every markdown file with prettier (4-space
|
||||
tabs, `proseWrap: always`) as well as the Go code, and `make fmt-check` checks
|
||||
both, as the model scripts in the `prompts` repo do
|
||||
(https://git.eeqj.de/sneak/secret/issues/110). Prettier is pinned by hash in
|
||||
`package.json` and `yarn.lock`, and `script/bootstrap` installs node, yarn and
|
||||
prettier. The `Dockerfile` lint stage copies node and yarn from a node image
|
||||
pinned by hash and runs `script/bootstrap`, so its `make fmt-check` fails the
|
||||
build on an unformatted markdown file. Every markdown file was formatted once,
|
||||
wording unchanged.
|
||||
- 2026-10-04: A mnemonic that cannot be read, in `secret init` and
|
||||
`secret vault create`, gives an error that names the mnemonic only
|
||||
(https://git.eeqj.de/sneak/secret/issues/115). It is read with
|
||||
`secret.ReadMnemonic`, whose every error wraps the new
|
||||
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
|
||||
message said "failed to read mnemonic: failed to read passphrase:" and advised
|
||||
setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
|
||||
mnemonic: stdin is not a terminal (piped input or script). Please set the
|
||||
SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
|
||||
messages no longer repeat "cannot read passphrase" after "failed to read
|
||||
passphrase:", and empty input gives "nothing was entered".
|
||||
- 2026-10-04: A failure returns the same error value whichever command hits it
|
||||
(https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
|
||||
its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
|
||||
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
|
||||
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
|
||||
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
|
||||
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
|
||||
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
|
||||
`secret` package's error for it, not an `internal/cli` copy; on macOS, the
|
||||
check that the system is macOS is gone, as it could never fail.
|
||||
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
|
||||
mnemonic, as `init` and `vault create` do. `secret generate secret` gives
|
||||
`errLengthTooSmall` for a length below 1 wherever it is checked, and
|
||||
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
|
||||
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
|
||||
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
|
||||
`secret` already returned under another name. Messages are unchanged, except
|
||||
that `secret decrypt` of a missing secret says "not found", as `secret get`
|
||||
does, not "does not exist"; `vault import` of an invalid mnemonic says
|
||||
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
|
||||
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
|
||||
import says
|
||||
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
|
||||
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
|
||||
which supplies the words "failed to read passphrase" that its callers used to
|
||||
add themselves; so two passphrases that differ now give only "passphrases do
|
||||
not match", the words now follow "failed to read mnemonic:" and "failed to
|
||||
read passphrase confirmation:", and a terminal read error no longer repeats
|
||||
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
|
||||
found by gpg's status line for "No public key"; before, the message repeated
|
||||
"failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
|
||||
keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
|
||||
build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
|
||||
and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
|
||||
always give 86 Base64 or 80 Base85 characters, the most a password length may
|
||||
ask for. Tests that matched these errors' text use `errors.Is`.
|
||||
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
|
||||
matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
|
||||
Every exported error that can be returned has a test that the function returns
|
||||
it, and errors wrapping a cause are checked through the wrapping. Checks that
|
||||
still match text, because the error has no exported value the test can name,
|
||||
are listed on the issue.
|
||||
- 2026-10-04: When a vault cannot be opened through its current unlocker,
|
||||
because a file the unlocker needs is missing or damaged, its keychain item or
|
||||
Secure Enclave key is gone, or the passphrase is wrong, the error now ends by
|
||||
naming the vault, saying that it still opens with its mnemonic, and that
|
||||
`secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC` set to it,
|
||||
gives the vault a new unlocker; for a vault that is not the current one, as in
|
||||
`secret move` between vaults, it says to run `secret vault select` first
|
||||
(https://git.eeqj.de/sneak/secret/issues/47). Before, it ended with the bare
|
||||
cause. The advice is given only when the vault metadata records the key the
|
||||
mnemonic derives, so not for a vault created without a mnemonic, and not when
|
||||
the passphrase could not be read at all. `secret vault import` is not named:
|
||||
it refuses a vault that has a long-term key. `secret encrypt` and
|
||||
`secret decrypt` now read the key secret through `vault.GetSecret`, as
|
||||
`secret get` does, so they give the same advice; `Secret.GetValue`, the other
|
||||
way to get the long-term key, is removed. When a secret's `current` file
|
||||
cannot be read, the error says that `secret version list` lists its versions
|
||||
and `secret version promote` makes one current. The causes stay wrapped.
|
||||
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`, so
|
||||
no two unlockers of a vault share one
|
||||
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
|
||||
Enclave unlocker's ID was its creation time to the minute and the host name,
|
||||
and a passphrase unlocker's the time to the minute, so two created within a
|
||||
minute shared an ID, and `unlocker select`, `unlocker remove` and the
|
||||
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID was
|
||||
`pgp-` and its key's fingerprint; a second PGP unlocker for a key is still
|
||||
refused, now by comparing the fingerprint in the other unlockers' metadata.
|
||||
`unlocker list` and the shell completion of `unlocker select` and
|
||||
`unlocker remove` take each ID from the directory the unlocker was read from,
|
||||
no longer by matching metadata, so two unlockers with the same metadata are
|
||||
listed apart; an unlocker of an unknown type is listed under its directory
|
||||
name, and completion now offers Secure Enclave unlockers too. The keychain and
|
||||
Secure Enclave code was type-checked by `script/lint-darwin`, never run; a
|
||||
test on Linux lists, completes, selects and removes each of two passphrase
|
||||
unlockers with the same metadata by its own ID.
|
||||
- 2026-10-04: README's Storage Architecture, `secret version promote`, Technical
|
||||
Details and Testing text matches the code
|
||||
(https://git.eeqj.de/sneak/secret/issues/102). `current` and `currentvault`
|
||||
are plain files holding a name, not symbolic links; a version's metadata is
|
||||
the encrypted `metadata.age`; the state directory is `berlin.sneak.pkg.secret`
|
||||
in the user's configuration directory, not `~/.local/share/secret`, and holds
|
||||
the `lock` file. Also corrected: the code sets up no Touch ID for the keychain
|
||||
or Secure Enclave unlocker, and the Secure Enclave only decrypts; per-version
|
||||
keys give no forward secrecy; `pub.age` is not age-encrypted; vault metadata
|
||||
holds no vault name. Testing lists only `make test`.
|
||||
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
|
||||
not at all (https://git.eeqj.de/sneak/secret/issues/105). `vault.CreateVault`
|
||||
now takes the unlocker passphrase too, writes the vault directory with its
|
||||
metadata, long-term public key and passphrase unlocker, `longterm.age`
|
||||
included, into a temporary directory, renames that into `vaults.d` once it is
|
||||
complete, and only then makes the vault current. Before, either command killed
|
||||
after the passphrase prompt but before the unlocker was written left a vault
|
||||
with no unlocker, which `vault create` had already made current and which
|
||||
neither command would create again. Killed part-way now, it leaves no vault,
|
||||
and the next command that takes the lock deletes the temporary directory; or,
|
||||
killed between the rename and making the vault current, a complete vault that
|
||||
is not current, which `secret vault select` makes current.
|
||||
- 2026-10-04: A failed `secret unlocker add keychain` or
|
||||
`secret unlocker add secure-enclave` no longer leaves its keychain item or
|
||||
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
|
||||
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
||||
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
|
||||
key again if encrypting with it or writing the unlocker then fails.
|
||||
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates it,
|
||||
and fails with an error naming the key's label if it cannot; it deletes the
|
||||
key again if getting its public key then fails. The Objective-C was only read,
|
||||
never compiled or run, and so was `macse_darwin.go`, which is cgo only.
|
||||
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
|
||||
among them, before it stores the item in the keychain, and deletes the item
|
||||
again if moving the unlocker into place then fails. A failure to delete is
|
||||
reported along with the first error. The tests of this run only on macOS: the
|
||||
Secure Enclave one in a build with cgo on a Mac with a Secure Enclave, the
|
||||
keychain one in a build with cgo.
|
||||
- 2026-10-04: What a command killed part-way left under a `.tmp-` name
|
||||
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories of
|
||||
`secret.TempDirFor` and the temporary files of `secret.WriteFileAtomic`,
|
||||
encrypted keys included, is deleted by the next command that takes the state
|
||||
directory lock. Before, it stayed until deleted by hand. A command writes
|
||||
`finished` into the lock file just before it releases the lock; the next one
|
||||
to take the lock searches only when it does not find that, so after a command
|
||||
that finished nothing is searched, however many secrets and versions there
|
||||
are. The search looks in the state directory, each vault, each secret and each
|
||||
version, the only directories those helpers make them in. A command that only
|
||||
reads takes no lock and deletes nothing. A failure to delete is warned about
|
||||
and the command goes on. An unlocker directory with no metadata file was
|
||||
already removed by `secret unlocker remove` given its directory name; a test
|
||||
now shows it.
|
||||
- 2026-10-04: An age identity's private key goes into a locked buffer through
|
||||
`secret.IdentityToLockedBuffer` everywhere
|
||||
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key when a
|
||||
passphrase, PGP, keychain or Secure Enclave unlocker is created, the new
|
||||
unlocker's own key, a new secret version's key, and the key `secret encrypt`
|
||||
generates. Before, each place converted the string age returns to bytes and
|
||||
left the string in ordinary memory. The function moves the string's own bytes
|
||||
into the buffer, which overwrites them; the copies age makes while writing the
|
||||
string remain, as its comment says. The 1.0 memory-security entry below no
|
||||
longer lists these places, `internal/cli/crypto.go` among them, nor
|
||||
`version.go:155`, which was `internal/secret/version.go`, not
|
||||
`internal/cli/version.go`.
|
||||
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
|
||||
`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 no longer
|
||||
panics: `GetID()` warns with the unlocker's directory and returns
|
||||
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
|
||||
metadata file cannot be checked for, read or parsed instead of failing, so
|
||||
`secret unlocker list` still lists the others; the listing's ID lookup no
|
||||
longer warns about that directory again.
|
||||
- 2026-10-03: `secret mv` rejects a move whose destination is the source
|
||||
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
|
||||
defaults to the source name) before changing anything; before, `--force`
|
||||
removed the destination first and so deleted the secret. Every vault name
|
||||
given with `vault:` must be one of the existing vaults by exact name, so
|
||||
`work:x work/:x` is rejected instead of being taken for a move between two
|
||||
vaults. A move within a named vault no longer makes that vault the current
|
||||
one, whether it succeeds or fails.
|
||||
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on
|
||||
`lock` in the state directory; a mutex on the in-memory test filesystem), so
|
||||
concurrent commands no longer lose versions or race on the current pointers.
|
||||
Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
|
||||
rename), so no file is ever half-written and `current`, `currentvault` and
|
||||
`current-unlocker` never go missing. New versions, new secrets and cross-vault
|
||||
copies are built in a temporary directory and renamed into place, and removals
|
||||
rename out of the way first, so a version or secret is never half-added and
|
||||
never half-removed.
|
||||
- 2026-10-03: The checks run before changing a vault now stop with an error
|
||||
naming the path and cause when they cannot read what they inspect, instead of
|
||||
reading the failure as "nothing there": the duplicate check before
|
||||
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
|
||||
the secret count that guards removing the last unlocker and removing a vault,
|
||||
and the existing long-term key check before `vault import`.
|
||||
- 2026-10-03: `version rm`, `version promote` and `get --version` 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 (`secret.VersionExists`),
|
||||
and touch nothing otherwise. An empty `--version` is rejected instead of
|
||||
meaning the current version. Before, `secret version rm x ../../..` deleted
|
||||
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
|
||||
version.
|
||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
|
||||
code after its deferred `memguard.Purge()` has run, and only `main` calls
|
||||
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
|
||||
buffer before exiting; when the process is in the terminal's foreground
|
||||
process group it first restores the terminal settings from startup, so an
|
||||
interrupted passphrase prompt no longer leaves echo off.
|
||||
- 2026-10-03: Every command that builds a path from a secret name checks the
|
||||
name first with `vault.ValidateSecretName` and touches nothing when it is
|
||||
invalid: `rm`, `mv` (both names, within a vault and between vaults, before
|
||||
switching the current vault), `import`, `version list`/`promote`/`rm`,
|
||||
`encrypt` and `decrypt`. The error and `README.md` state the naming rule.
|
||||
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
|
||||
in it.
|
||||
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
|
||||
it is generated into a locked buffer, and the keychain JSON is written and
|
||||
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
|
||||
Linux) without `encoding/json` holding it; the JSON field names are unchanged.
|
||||
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
|
||||
that needs more locked memory than the process can lock, and run every case
|
||||
under `script/cibuild`. The image stamps the `VERSION` build argument, else
|
||||
`git describe --tags --always`, into `Version`, and fails if `.git` is present
|
||||
but yields no version; `make build` stamps `git describe` too, not a fixed
|
||||
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
|
||||
canonical copy.
|
||||
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
||||
`.golangci.yml` (all linters enabled minus the standard disable list, `lll`
|
||||
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
|
||||
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
|
||||
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
|
||||
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
|
||||
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
|
||||
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
|
||||
`pkg/bip85` were reshaped so every composed error message is byte-identical to
|
||||
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||
skips an unreadable `unlockers.d` entry with a warning instead of 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, 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 protection,
|
||||
plus review fixes (stub panics, derivation index, tests, README) on branch
|
||||
secure-enclave-unlocker.
|
||||
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
|
||||
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
|
||||
metadata, allow uppercase secret names, fix hardcoded derivation index,
|
||||
validate names in GetSecretVersion against path traversal, return errors
|
||||
instead of panicking, add Warn() on silent anomalies.
|
||||
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths
|
||||
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
|
||||
removed.
|
||||
- Per-secret keypair architecture, vault package refactor, versioning with
|
||||
--version, comprehensive test suite with in-memory filesystem.
|
||||
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
|
||||
- Renamed SEP unlocker to Keychain, reorganized import commands.
|
||||
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
|
||||
|
||||
# Future Steps
|
||||
|
||||
- Implement version-number shell completion for the second arg of
|
||||
`secret version promote` and `secret version rm` (`internal/cli/version.go`;
|
||||
was an in-code TODO removed for godox).
|
||||
- Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
|
||||
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
|
||||
- CI does not compile, lint or test the files built only with cgo on macOS,
|
||||
since compiling them needs Apple's SDK:
|
||||
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
|
||||
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
|
||||
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
|
||||
run on them, so it would likely find more there than the line lengths. No
|
||||
macOS test runs in CI. A macOS runner would cover all of it (asked on
|
||||
https://git.eeqj.de/sneak/secret/issues/50).
|
||||
- 1.0 critical security blockers (from repo TODO.md):
|
||||
- Memory security: age writes an identity's private key out as a string in
|
||||
ordinary memory, and the copies it makes on the way stay there
|
||||
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
||||
- Medium priority:
|
||||
- Standardize error messages; stop leaking internals.
|
||||
- Split oversized CLI functions.
|
||||
- Cleanups: read statedir from environment or default instead of passing it
|
||||
around.
|
||||
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
||||
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
||||
consistency, vault export/import, batch operations, search, secret metadata
|
||||
(descriptions, tags).
|
||||
|
||||
+8
-1
@@ -1,5 +1,12 @@
|
||||
// Package main is the entry point for the secret CLI application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
CLIEntry()
|
||||
os.Exit(cli.Entry())
|
||||
}
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
module git.eeqj.de/sneak/secret
|
||||
module sneak.berlin/go/secret
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
filippo.io/age v1.2.1
|
||||
github.com/awnumar/memguard v0.22.5
|
||||
github.com/btcsuite/btcd v0.24.2
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/afero v1.14.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/awnumar/memcall v0.2.0 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c // indirect
|
||||
github.com/facebookincubator/sks v0.0.0-20250508161834-9be892919529 // indirect
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/google/btree v1.0.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.2 // indirect
|
||||
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
|
||||
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
|
||||
github.com/google/go-attestation v0.5.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tspi v0.3.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2018 Tyler Smith and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,285 @@
|
||||
// Package bip39 is the Golang implementation of the BIP39 spec.
|
||||
//
|
||||
// The official BIP39 spec can be found at
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
|
||||
//
|
||||
// It is a copy of github.com/tyler-smith/go-bip39 v1.1.0, trimmed to what
|
||||
// secret uses.
|
||||
//
|
||||
//nolint:mnd // the numbers are BIP-39's own, written as upstream writes them
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidMnemonic is returned when trying to use a malformed mnemonic.
|
||||
ErrInvalidMnemonic = errors.New("invalid mnenomic")
|
||||
|
||||
// ErrEntropyLengthInvalid is returned when trying to use an entropy set with
|
||||
// an invalid size.
|
||||
ErrEntropyLengthInvalid = errors.New(
|
||||
"entropy length must be [128, 256] and a multiple of 32",
|
||||
)
|
||||
|
||||
// ErrChecksumIncorrect is returned when entropy has the incorrect checksum.
|
||||
ErrChecksumIncorrect = errors.New("checksum incorrect")
|
||||
)
|
||||
|
||||
// NewEntropy will create random entropy bytes
|
||||
// so long as the requested size bitSize is an appropriate size.
|
||||
//
|
||||
// bitSize has to be a multiple 32 and be within the inclusive range of {128, 256}
|
||||
func NewEntropy(bitSize int) ([]byte, error) {
|
||||
err := validateEntropyBitSize(bitSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entropy := make([]byte, bitSize/8)
|
||||
_, err = rand.Read(entropy)
|
||||
|
||||
return entropy, err
|
||||
}
|
||||
|
||||
// EntropyFromMnemonic takes a mnemonic generated by this library,
|
||||
// and returns the input entropy used to generate the given mnemonic.
|
||||
// An error is returned if the given mnemonic is invalid.
|
||||
func EntropyFromMnemonic(mnemonic string) ([]byte, error) {
|
||||
mnemonicSlice, isValid := splitMnemonicWords(mnemonic)
|
||||
if !isValid {
|
||||
return nil, ErrInvalidMnemonic
|
||||
}
|
||||
|
||||
// Some bitwise operands for working with big.Ints
|
||||
shift11BitsMask := big.NewInt(2048)
|
||||
bigOne := big.NewInt(1)
|
||||
|
||||
// used to isolate the checksum bits from the entropy+checksum byte array
|
||||
wordLengthChecksumMasksMapping := map[int]*big.Int{
|
||||
12: big.NewInt(15),
|
||||
15: big.NewInt(31),
|
||||
18: big.NewInt(63),
|
||||
21: big.NewInt(127),
|
||||
24: big.NewInt(255),
|
||||
}
|
||||
// used to use only the desired x of 8 available checksum bits.
|
||||
// 256 bit (word length 24) requires all 8 bits of the checksum,
|
||||
// and thus no shifting is needed for it (we would get a divByZero crash if we did)
|
||||
wordLengthChecksumShiftMapping := map[int]*big.Int{
|
||||
12: big.NewInt(16),
|
||||
15: big.NewInt(8),
|
||||
18: big.NewInt(4),
|
||||
21: big.NewInt(2),
|
||||
}
|
||||
|
||||
// wordMap is a reverse lookup map for the word list
|
||||
wordMap := map[string]int{}
|
||||
for i, v := range English() {
|
||||
wordMap[v] = i
|
||||
}
|
||||
|
||||
// Decode the words into a big.Int.
|
||||
b := big.NewInt(0)
|
||||
|
||||
for _, v := range mnemonicSlice {
|
||||
index, found := wordMap[v]
|
||||
if !found {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: word `%v` not found in reverse map", ErrInvalidMnemonic, v,
|
||||
)
|
||||
}
|
||||
|
||||
var wordBytes [2]byte
|
||||
|
||||
//nolint:gosec // the index of a word in the list is below 2048
|
||||
binary.BigEndian.PutUint16(wordBytes[:], uint16(index))
|
||||
|
||||
b = b.Mul(b, shift11BitsMask)
|
||||
b = b.Or(b, big.NewInt(0).SetBytes(wordBytes[:]))
|
||||
}
|
||||
|
||||
// Build and add the checksum to the big.Int.
|
||||
checksum := big.NewInt(0)
|
||||
checksumMask := wordLengthChecksumMasksMapping[len(mnemonicSlice)]
|
||||
checksum = checksum.And(b, checksumMask)
|
||||
|
||||
b.Div(b, big.NewInt(0).Add(checksumMask, bigOne))
|
||||
|
||||
// The entropy is the underlying bytes of the big.Int. Any upper bytes of
|
||||
// all 0's are not returned so we pad the beginning of the slice with empty
|
||||
// bytes if necessary.
|
||||
entropy := b.Bytes()
|
||||
entropy = padByteSlice(entropy, len(mnemonicSlice)/3*4)
|
||||
|
||||
// Generate the checksum and compare with the one we got from the mneomnic.
|
||||
entropyChecksumBytes := computeChecksum(entropy)
|
||||
entropyChecksum := big.NewInt(int64(entropyChecksumBytes[0]))
|
||||
|
||||
if l := len(mnemonicSlice); l != 24 {
|
||||
checksumShift := wordLengthChecksumShiftMapping[l]
|
||||
entropyChecksum.Div(entropyChecksum, checksumShift)
|
||||
}
|
||||
|
||||
if checksum.Cmp(entropyChecksum) != 0 {
|
||||
return nil, ErrChecksumIncorrect
|
||||
}
|
||||
|
||||
return entropy, nil
|
||||
}
|
||||
|
||||
// NewMnemonic will return a string consisting of the mnemonic words for
|
||||
// the given entropy.
|
||||
// If the provide entropy is invalid, an error will be returned.
|
||||
func NewMnemonic(entropy []byte) (string, error) {
|
||||
// Compute some lengths for convenience.
|
||||
entropyBitLength := len(entropy) * 8
|
||||
checksumBitLength := entropyBitLength / 32
|
||||
sentenceLength := (entropyBitLength + checksumBitLength) / 11
|
||||
|
||||
// Validate that the requested size is supported.
|
||||
err := validateEntropyBitSize(entropyBitLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Some bitwise operands for working with big.Ints
|
||||
last11BitsMask := big.NewInt(2047)
|
||||
shift11BitsMask := big.NewInt(2048)
|
||||
|
||||
// wordList is the set of words to use
|
||||
wordList := English()
|
||||
|
||||
// Add checksum to entropy.
|
||||
entropy = addChecksum(entropy)
|
||||
|
||||
// Break entropy up into sentenceLength chunks of 11 bits.
|
||||
// For each word AND mask the rightmost 11 bits and find the word at that index.
|
||||
// Then bitshift entropy 11 bits right and repeat.
|
||||
// Add to the last empty slot so we can work with LSBs instead of MSB.
|
||||
|
||||
// Entropy as an int so we can bitmask without worrying about bytes slices.
|
||||
entropyInt := new(big.Int).SetBytes(entropy)
|
||||
|
||||
// Slice to hold words in.
|
||||
words := make([]string, sentenceLength)
|
||||
|
||||
// Throw away big.Int for AND masking.
|
||||
word := big.NewInt(0)
|
||||
|
||||
for i := sentenceLength - 1; i >= 0; i-- {
|
||||
// Get 11 right most bits and bitshift 11 to the right for next time.
|
||||
word.And(entropyInt, last11BitsMask)
|
||||
entropyInt.Div(entropyInt, shift11BitsMask)
|
||||
|
||||
// Get the bytes representing the 11 bits as a 2 byte slice.
|
||||
wordBytes := padByteSlice(word.Bytes(), 2)
|
||||
|
||||
// Convert bytes to an index and add that word to the list.
|
||||
words[i] = wordList[binary.BigEndian.Uint16(wordBytes)]
|
||||
}
|
||||
|
||||
return strings.Join(words, " "), nil
|
||||
}
|
||||
|
||||
// NewSeed creates a hashed seed output given a provided string and password.
|
||||
// No checking is performed to validate that the string provided is a valid mnemonic.
|
||||
func NewSeed(mnemonic string, password string) []byte {
|
||||
return pbkdf2.Key([]byte(mnemonic), []byte("mnemonic"+password), 2048, 64, sha512.New)
|
||||
}
|
||||
|
||||
// IsMnemonicValid attempts to verify that the provided mnemonic is valid.
|
||||
// Validity is determined by both the number of words being appropriate,
|
||||
// and that all the words in the mnemonic are present in the word list.
|
||||
func IsMnemonicValid(mnemonic string) bool {
|
||||
_, err := EntropyFromMnemonic(mnemonic)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Appends to data the first (len(data) / 32)bits of the result of sha256(data)
|
||||
// Currently only supports data up to 32 bytes
|
||||
func addChecksum(data []byte) []byte {
|
||||
// Some bitwise operands for working with big.Ints
|
||||
bigOne := big.NewInt(1)
|
||||
bigTwo := big.NewInt(2)
|
||||
|
||||
// Get first byte of sha256
|
||||
hash := computeChecksum(data)
|
||||
firstChecksumByte := hash[0]
|
||||
|
||||
// len() is in bytes so we divide by 4
|
||||
checksumBitLength := uint(len(data) / 4)
|
||||
|
||||
// For each bit of check sum we want we shift the data one the left
|
||||
// and then set the (new) right most bit equal to checksum bit at that index
|
||||
// staring from the left
|
||||
dataBigInt := new(big.Int).SetBytes(data)
|
||||
for i := range checksumBitLength {
|
||||
// Bitshift 1 left
|
||||
dataBigInt.Mul(dataBigInt, bigTwo)
|
||||
|
||||
// Set rightmost bit if leftmost checksum bit is set
|
||||
if firstChecksumByte&(1<<(7-i)) > 0 {
|
||||
dataBigInt.Or(dataBigInt, bigOne)
|
||||
}
|
||||
}
|
||||
|
||||
return dataBigInt.Bytes()
|
||||
}
|
||||
|
||||
func computeChecksum(data []byte) []byte {
|
||||
hasher := sha256.New()
|
||||
hasher.Write(data)
|
||||
|
||||
return hasher.Sum(nil)
|
||||
}
|
||||
|
||||
// validateEntropyBitSize ensures that entropy is the correct size for being a
|
||||
// mnemonic.
|
||||
func validateEntropyBitSize(bitSize int) error {
|
||||
if (bitSize%32) != 0 || bitSize < 128 || bitSize > 256 {
|
||||
return ErrEntropyLengthInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// padByteSlice returns a byte slice of the given size with contents of the
|
||||
// given slice left padded and any empty spaces filled with 0's.
|
||||
func padByteSlice(slice []byte, length int) []byte {
|
||||
offset := length - len(slice)
|
||||
if offset <= 0 {
|
||||
return slice
|
||||
}
|
||||
|
||||
newSlice := make([]byte, length)
|
||||
copy(newSlice[offset:], slice)
|
||||
|
||||
return newSlice
|
||||
}
|
||||
|
||||
func splitMnemonicWords(mnemonic string) ([]string, bool) {
|
||||
// Create a list of all the words in the mnemonic sentence
|
||||
words := strings.Fields(mnemonic)
|
||||
|
||||
// Get num of words
|
||||
numOfWords := len(words)
|
||||
|
||||
// The number of words should be 12, 15, 18, 21 or 24
|
||||
if numOfWords%3 != 0 || numOfWords < 12 || numOfWords > 24 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return words, true
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type vector struct {
|
||||
entropy string
|
||||
mnemonic string
|
||||
seed string
|
||||
}
|
||||
|
||||
func TestNewMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, vector := range testVectors() {
|
||||
entropy, err := hex.DecodeString(vector.entropy)
|
||||
assertNil(t, err)
|
||||
|
||||
mnemonic, err := NewMnemonic(entropy)
|
||||
assertNil(t, err)
|
||||
assertEqualString(t, vector.mnemonic, mnemonic)
|
||||
|
||||
seed := NewSeed(mnemonic, "TREZOR")
|
||||
assertEqualString(t, vector.seed, hex.EncodeToString(seed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMnemonicInvalidEntropy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewMnemonic([]byte{})
|
||||
assertNotNil(t, err)
|
||||
}
|
||||
|
||||
func TestIsMnemonicValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, vector := range badMnemonicSentences() {
|
||||
assertFalse(t, IsMnemonicValid(vector.mnemonic))
|
||||
}
|
||||
|
||||
for _, vector := range testVectors() {
|
||||
assertTrue(t, IsMnemonicValid(vector.mnemonic))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEntropy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Good tests.
|
||||
for i := 128; i <= 256; i += 32 {
|
||||
_, err := NewEntropy(i)
|
||||
assertNil(t, err)
|
||||
}
|
||||
// Bad Values
|
||||
for i := range 257 {
|
||||
if i%8 != 0 {
|
||||
_, err := NewEntropy(i)
|
||||
assertNotNil(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadByteSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertEqualByteSlices(t, []byte{0}, padByteSlice([]byte{}, 1))
|
||||
assertEqualByteSlices(t, []byte{0, 1}, padByteSlice([]byte{1}, 2))
|
||||
assertEqualByteSlices(t, []byte{1, 1}, padByteSlice([]byte{1, 1}, 2))
|
||||
assertEqualByteSlices(t, []byte{1, 1, 1}, padByteSlice([]byte{1, 1, 1}, 2))
|
||||
}
|
||||
|
||||
//nolint:funlen // the test vectors, kept as upstream wrote them
|
||||
func TestMnemonicToByteArrayForZeroLeadingSeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ms := []string{
|
||||
"00000000000000000000000000000000",
|
||||
"00a84c51041d49acca66e6160c1fa999",
|
||||
"00ca45df1673c76537a2020bfed1dafd",
|
||||
"0019d5871c7b81fd83d474ef1c1e1dae",
|
||||
"00dcb021afb35ffcdd1d032d2056fc86",
|
||||
"0062be7bd09a27288b6cf0eb565ec739",
|
||||
"00dc705b5efa0adf25b9734226ba60d4",
|
||||
"0017747418d54c6003fa64fade83374b",
|
||||
"000d44d3ee7c3dfa45e608c65384431b",
|
||||
"008241c1ef976b0323061affe5bf24b9",
|
||||
"00a6aec77e4d16bea80b50a34991aaba",
|
||||
"0011527b8c6ddecb9d0c20beccdeb58d",
|
||||
"001c938c503c8f5a2bba2248ff621546",
|
||||
"0002f90aaf7a8327698f0031b6317c36",
|
||||
"00bff43071ed7e07f77b14f615993bac",
|
||||
"00da143e00ef17fc63b6fb22dcc2c326",
|
||||
"00ffc6764fb32a354cab1a3ddefb015d",
|
||||
"0062ef47e0985e8953f24760b7598cdd",
|
||||
"003bf9765064f71d304908d906c065f5",
|
||||
"00993851503471439d154b3613947474",
|
||||
"007ad0ffe9eae753a483a76af06dfa67",
|
||||
"00091824db9ec19e663bee51d64c83cc",
|
||||
"00f48ac621f7e3cb39b2012ac3121543",
|
||||
"0072917415cdca24dfa66c4a92c885b4",
|
||||
"0027ced2b279ea8a91d29364487cdbf4",
|
||||
"00b9c0d37fb10ba272e55842ad812583",
|
||||
"004b3d0d2b9285946c687a5350479c8c",
|
||||
"00c7c12a37d3a7f8c1532b17c89b724c",
|
||||
"00f400c5545f06ae17ad00f3041e4e26",
|
||||
"001e290be10df4d209f247ac5878662b",
|
||||
"00bf0f74568e582a7dd1ee64f792ec8b",
|
||||
"00d2e43ecde6b72b847db1539ed89e23",
|
||||
"00cecba6678505bb7bfec8ed307251f6",
|
||||
"000aeed1a9edcbb4bc88f610d3ce84eb",
|
||||
"00d06206aadfc25c2b21805d283f15ae",
|
||||
"00a31789a2ab2d54f8fadd5331010287",
|
||||
"003493c5f520e8d5c0483e895a121dc9",
|
||||
"004706112800b76001ece2e268bc830e",
|
||||
"00ab31e28bb5305be56e38337dbfa486",
|
||||
"006872fe85df6b0fa945248e6f9379d1",
|
||||
"00717e5e375da6934e3cfdf57edaf3bd",
|
||||
"007f1b46e7b9c4c76e77c434b9bccd6b",
|
||||
"00dc93735aa35def3b9a2ff676560205",
|
||||
"002cd5dcd881a49c7b87714c6a570a76",
|
||||
"0013b5af9e13fac87e0c505686cfb6bf",
|
||||
"007ab1ec9526b0bc04b64ae65fd42631",
|
||||
"00abb4e11d8385c1cca905a6a65e9144",
|
||||
"00574fc62a0501ad8afada2e246708c3",
|
||||
"005207e0a815bb2da6b4c35ec1f2bf52",
|
||||
"00f3460f136fb9700080099cbd62bc18",
|
||||
"007a591f204c03ca7b93981237112526",
|
||||
"00cfe0befd428f8e5f83a5bfc801472e",
|
||||
"00987551ac7a879bf0c09b8bc474d9af",
|
||||
"00cadd3ce3d78e49fbc933a85682df3f",
|
||||
"00bfbf2e346c855ccc360d03281455a1",
|
||||
"004cdf55d429d028f715544ce22d4f31",
|
||||
"0075c84a7d15e0ac85e1e41025eed23b",
|
||||
"00807dddd61f71725d336cab844d2cb5",
|
||||
"00422f21b77fe20e367467ed98c18410",
|
||||
"00b44d0ac622907119c626c850a462fd",
|
||||
"00363f5e7f22fc49f3cd662a28956563",
|
||||
"000fe5837e68397bbf58db9f221bdc4e",
|
||||
"0056af33835c888ef0c22599686445d3",
|
||||
"00790a8647fd3dfb38b7e2b6f578f2c6",
|
||||
"00da8d9009675cb7beec930e263014fb",
|
||||
"00d4b384540a5bb54aa760edaa4fb2fe",
|
||||
"00be9b1479ed680fdd5d91a41eb926d0",
|
||||
"009182347502af97077c40a6e74b4b5c",
|
||||
"00f5c90ee1c67fa77fd821f8e9fab4f1",
|
||||
"005568f9a2dd6b0c0cc2f5ba3d9cac38",
|
||||
"008b481f8678577d9cf6aa3f6cd6056b",
|
||||
"00c4323ece5e4fe3b6cd4c5c932931af",
|
||||
"009791f7550c3798c5a214cb2d0ea773",
|
||||
"008a7baab22481f0ad8167dd9f90d55c",
|
||||
"00f0e601519aafdc8ff94975e64c946d",
|
||||
"0083b61e0daa9219df59d697c270cd31",
|
||||
}
|
||||
|
||||
for _, m := range ms {
|
||||
seed, _ := hex.DecodeString(m)
|
||||
|
||||
mnemonic, err := NewMnemonic(seed)
|
||||
if err != nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
|
||||
_, err = EntropyFromMnemonic(mnemonic)
|
||||
if err != nil {
|
||||
t.Errorf("Failed for %x - %v", seed, mnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestEntropyFromMnemonic128(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 128)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic160(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 160)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic192(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 192)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic224(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 224)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic256(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 256)
|
||||
}
|
||||
|
||||
//nolint:dupword,lll // the test vector, kept as upstream wrote it
|
||||
func TestEntropyFromMnemonicInvalidChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := EntropyFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon yellow")
|
||||
assertEqual(t, ErrChecksumIncorrect, err)
|
||||
}
|
||||
|
||||
//nolint:dupword // the test vectors, kept as upstream wrote them
|
||||
func TestEntropyFromMnemonicInvalidMnemonicSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, mnemonic := range []string{
|
||||
"a a a a a a a a a a a a a a a a a a a a a a a a a", // Too many words
|
||||
"a", // Too few
|
||||
"a a a a a a a a a a a a a a", // Not multiple of 3
|
||||
} {
|
||||
_, err := EntropyFromMnemonic(mnemonic)
|
||||
assertEqual(t, ErrInvalidMnemonic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testEntropyFromMnemonic(t *testing.T, bitSize int) {
|
||||
t.Helper()
|
||||
|
||||
for range 512 {
|
||||
expectedEntropy, err := NewEntropy(bitSize)
|
||||
assertNil(t, err)
|
||||
assertTrue(t, len(expectedEntropy) != 0)
|
||||
|
||||
mnemonic, err := NewMnemonic(expectedEntropy)
|
||||
assertNil(t, err)
|
||||
assertTrue(t, len(mnemonic) != 0)
|
||||
|
||||
actualEntropy, err := EntropyFromMnemonic(mnemonic)
|
||||
assertNil(t, err)
|
||||
assertEqualByteSlices(t, expectedEntropy, actualEntropy)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:dupword,funlen,lll // the BIP-39 test vectors, kept as upstream wrote them
|
||||
func testVectors() []vector {
|
||||
return []vector{
|
||||
{
|
||||
entropy: "00000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
seed: "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||
seed: "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607",
|
||||
},
|
||||
{
|
||||
entropy: "80808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
|
||||
seed: "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||
seed: "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069",
|
||||
},
|
||||
{
|
||||
entropy: "000000000000000000000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent",
|
||||
seed: "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
|
||||
seed: "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd",
|
||||
},
|
||||
{
|
||||
entropy: "808080808080808080808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always",
|
||||
seed: "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when",
|
||||
seed: "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528",
|
||||
},
|
||||
{
|
||||
entropy: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
|
||||
seed: "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
|
||||
seed: "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87",
|
||||
},
|
||||
{
|
||||
entropy: "8080808080808080808080808080808080808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
|
||||
seed: "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
|
||||
seed: "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
|
||||
},
|
||||
{
|
||||
entropy: "77c2b00716cec7213839159e404db50d",
|
||||
mnemonic: "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge",
|
||||
seed: "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff",
|
||||
},
|
||||
{
|
||||
entropy: "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b",
|
||||
mnemonic: "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap",
|
||||
seed: "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5",
|
||||
},
|
||||
{
|
||||
entropy: "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982",
|
||||
mnemonic: "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic",
|
||||
seed: "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67",
|
||||
},
|
||||
{
|
||||
entropy: "0460ef47585604c5660618db2e6a7e7f",
|
||||
mnemonic: "afford alter spike radar gate glance object seek swamp infant panel yellow",
|
||||
seed: "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4",
|
||||
},
|
||||
{
|
||||
entropy: "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f",
|
||||
mnemonic: "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left",
|
||||
seed: "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba",
|
||||
},
|
||||
{
|
||||
entropy: "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416",
|
||||
mnemonic: "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste",
|
||||
seed: "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449",
|
||||
},
|
||||
{
|
||||
entropy: "eaebabb2383351fd31d703840b32e9e2",
|
||||
mnemonic: "turtle front uncle idea crush write shrug there lottery flower risk shell",
|
||||
seed: "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c",
|
||||
},
|
||||
{
|
||||
entropy: "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78",
|
||||
mnemonic: "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment",
|
||||
seed: "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79",
|
||||
},
|
||||
{
|
||||
entropy: "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef",
|
||||
mnemonic: "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top",
|
||||
seed: "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c",
|
||||
},
|
||||
{
|
||||
entropy: "18ab19a9f54a9274f03e5209a2ac8a91",
|
||||
mnemonic: "board flee heavy tunnel powder denial science ski answer betray cargo cat",
|
||||
seed: "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8",
|
||||
},
|
||||
{
|
||||
entropy: "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4",
|
||||
mnemonic: "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief",
|
||||
seed: "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9",
|
||||
},
|
||||
{
|
||||
entropy: "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419",
|
||||
mnemonic: "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut",
|
||||
seed: "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:dupword,lll // the test vectors, kept as upstream wrote them
|
||||
func badMnemonicSentences() []vector {
|
||||
return []vector{
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow yellow"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice caged above"},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo, wrong"},
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will will will"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always."},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo why"},
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art art"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thanks year wave worth useful legal winner thank year wave sausage worth title"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letters advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo voted"},
|
||||
{mnemonic: "jello better achieve collect unaware mountain thought cargo oxygen act hood bridge"},
|
||||
{mnemonic: "renew, stay, biology, evidence, goat, welcome, casual, join, adapt, armor, shuffle, fault, little, machine, walk, stumble, urge, swap"},
|
||||
{mnemonic: "dignity pass list indicate nasty"},
|
||||
|
||||
// From issue 32
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon letter"},
|
||||
}
|
||||
}
|
||||
|
||||
func assertNil(t *testing.T, object any) {
|
||||
t.Helper()
|
||||
|
||||
if object != nil {
|
||||
t.Errorf("Expected nil, got %v", object)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotNil(t *testing.T, object any) {
|
||||
t.Helper()
|
||||
|
||||
if object == nil {
|
||||
t.Error("Expected not nil")
|
||||
}
|
||||
}
|
||||
|
||||
func assertTrue(t *testing.T, a bool) {
|
||||
t.Helper()
|
||||
|
||||
if !a {
|
||||
t.Error("Expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertFalse(t *testing.T, a bool) {
|
||||
t.Helper()
|
||||
|
||||
if a {
|
||||
t.Error("Expected false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a, b any) {
|
||||
t.Helper()
|
||||
|
||||
if a != b {
|
||||
t.Errorf("Objects not equal, expected `%s` and got `%s`", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqualString(t *testing.T, a, b string) {
|
||||
t.Helper()
|
||||
|
||||
if a != b {
|
||||
t.Errorf("Strings not equal, expected `%s` and got `%s`", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqualByteSlices(t *testing.T, a, b []byte) {
|
||||
t.Helper()
|
||||
|
||||
if len(a) != len(b) {
|
||||
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"hash/crc32"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnglishChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Ensure word list is correct
|
||||
// $ wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt
|
||||
// $ crc32 english.txt
|
||||
// c1dbd296
|
||||
checksum := crc32.ChecksumIEEE([]byte(english))
|
||||
if checksum != 0xc1dbd296 {
|
||||
t.Error("english checksum invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package bip39_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
)
|
||||
|
||||
//nolint:lll // the test vector and its output, kept as upstream wrote them
|
||||
func ExampleNewMnemonic() {
|
||||
// the entropy can be any byte slice, generated how pleased,
|
||||
// as long its bit size is a multiple of 32 and is within
|
||||
// the inclusive range of {128,256}
|
||||
entropy, _ := hex.DecodeString("066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad")
|
||||
|
||||
// generate a mnemomic
|
||||
mnemomic, _ := bip39.NewMnemonic(entropy)
|
||||
fmt.Println(mnemomic)
|
||||
// output:
|
||||
// all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform
|
||||
}
|
||||
|
||||
//nolint:lll // the test vector and its output, kept as upstream wrote them
|
||||
func ExampleNewSeed() {
|
||||
seed := bip39.NewSeed("all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", "TREZOR")
|
||||
fmt.Println(hex.EncodeToString(seed))
|
||||
// output:
|
||||
// 26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package cli implements the command-line interface for the secret application.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// Instance encapsulates all CLI functionality and state
|
||||
type Instance struct {
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
cmd *cobra.Command
|
||||
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
|
||||
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
|
||||
// none.
|
||||
Mnemonic *memguard.LockedBuffer
|
||||
UnlockPassphrase *memguard.LockedBuffer
|
||||
// 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
|
||||
func NewCLIInstance() (*Instance, error) {
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||
}
|
||||
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithFs creates a new CLI instance with the given
|
||||
// filesystem (for testing)
|
||||
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||
}
|
||||
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state
|
||||
// directory (for testing)
|
||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||
return &Instance{
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFilesystem sets the filesystem for this CLI instance (for testing)
|
||||
func (cli *Instance) SetFilesystem(fs afero.Fs) {
|
||||
cli.fs = fs
|
||||
}
|
||||
|
||||
// SetStateDir sets the state directory for this CLI instance (for testing)
|
||||
func (cli *Instance) SetStateDir(stateDir string) {
|
||||
cli.stateDir = stateDir
|
||||
}
|
||||
|
||||
// GetStateDir returns the state directory for this CLI instance
|
||||
func (cli *Instance) GetStateDir() string {
|
||||
return cli.stateDir
|
||||
}
|
||||
|
||||
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
|
||||
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
|
||||
// either calls it once, before anything else, and passes the buffers on
|
||||
// from there: each variable is unset as soon as it is read, so that the
|
||||
// processes this one starts, gpg among them, do not inherit it, and a
|
||||
// second read would find nothing. The returned function destroys both
|
||||
// buffers.
|
||||
func (cli *Instance) readSecretEnv() func() {
|
||||
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
|
||||
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
|
||||
|
||||
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
return func() {
|
||||
if mnemonic != nil {
|
||||
mnemonic.Destroy()
|
||||
}
|
||||
|
||||
if passphrase != nil {
|
||||
passphrase.Destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readAndUnsetEnv returns the value of the environment variable name in a
|
||||
// locked buffer, or nil when it is unset or empty, and unsets the variable.
|
||||
// Unsetting does not erase the value: it stays in this process's memory,
|
||||
// and in /proc/<pid>/environ, which shows the environment the process
|
||||
// started with. The caller must destroy the returned buffer.
|
||||
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
|
||||
value := os.Getenv(name)
|
||||
_ = os.Unsetenv(name)
|
||||
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return memguard.NewBufferFromBytes([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
func TestCLIInstanceStateDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test the CLI instance state directory functionality
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test state directory
|
||||
testStateDir := "/test-state-dir"
|
||||
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
got := instance.GetStateDir()
|
||||
if got != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // reads process environment to determine the state dir
|
||||
func TestCLIInstanceWithFs(t *testing.T) {
|
||||
// Test creating CLI instance with custom filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
instance, err := cli.NewCLIInstanceWithFs(fs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
// The state directory should be determined automatically
|
||||
stateDir := instance.GetStateDir()
|
||||
if stateDir == "" {
|
||||
t.Error("Expected non-empty state directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir(t *testing.T) {
|
||||
// Test the determineStateDir function from the secret package
|
||||
|
||||
// Test with environment variable set
|
||||
testEnvDir := "/test-env-dir"
|
||||
t.Setenv(secret.EnvStateDir, testEnvDir)
|
||||
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stateDir != testEnvDir {
|
||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||
}
|
||||
|
||||
// Test with custom config dir
|
||||
_ = os.Unsetenv(secret.EnvStateDir)
|
||||
customConfigDir := "/custom-config"
|
||||
|
||||
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||
if stateDir != expectedDir {
|
||||
t.Errorf("Expected state directory %q with custom config, got %q",
|
||||
expectedDir, stateDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// errUnsupportedShell is returned for unknown shell completion targets
|
||||
var errUnsupportedShell = errors.New("unsupported shell type")
|
||||
|
||||
func newCompletionCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate completion script",
|
||||
Long: `To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(secret completion bash)
|
||||
# To load completions for each session, execute once:
|
||||
# Linux:
|
||||
$ secret completion bash > /etc/bash_completion.d/secret
|
||||
# macOS:
|
||||
$ secret completion bash > $(brew --prefix)/etc/bash_completion.d/secret
|
||||
|
||||
Zsh:
|
||||
# If shell completion is not already enabled in your environment,
|
||||
# you will need to enable it. You can execute the following once:
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ secret completion zsh > "${fpath[1]}/_secret"
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
$ secret completion fish | source
|
||||
# To load completions for each session, execute once:
|
||||
$ secret completion fish > ~/.config/fish/completions/secret.fish
|
||||
|
||||
PowerShell:
|
||||
PS> secret completion powershell | Out-String | Invoke-Expression
|
||||
# To load completions for every new session, run:
|
||||
PS> secret completion powershell > secret.ps1
|
||||
# and source this file from your PowerShell profile.
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
return cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
return cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
return cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// getSecretNamesCompletionFunc returns a completion function that provides
|
||||
// secret names
|
||||
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Get list of secrets
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Filter secrets based on what user has typed
|
||||
var completions []string
|
||||
|
||||
for _, secret := range secrets {
|
||||
if strings.HasPrefix(secret, toComplete) {
|
||||
completions = append(completions, secret)
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||
// unlocker IDs, the names of the unlockers' directories in unlockers.d
|
||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
unlockerMetadata, err := vlt.ListUnlockers()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var completions []string
|
||||
|
||||
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||
if strings.HasPrefix(id, toComplete) {
|
||||
completions = append(completions, id)
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// getVaultNamesCompletionFunc returns a completion function that provides
|
||||
// vault names
|
||||
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var completions []string
|
||||
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v)
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||
// 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(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// Complete secret names for the specified vault
|
||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||
vaultName := parts[0]
|
||||
secretPrefix := parts[1]
|
||||
|
||||
if vault.ValidateVaultName(vaultName) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, secretPrefix) {
|
||||
completions = append(completions, vaultName+":"+secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
// completeUnqualifiedVaultSecrets completes vault names (with a ":"
|
||||
// suffix) and secrets from the current vault
|
||||
func completeUnqualifiedVaultSecrets(
|
||||
fs afero.Fs, stateDir, toComplete string,
|
||||
) []string {
|
||||
var completions []string
|
||||
|
||||
// Complete vault names with ":" suffix
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err == nil {
|
||||
for _, v := range vaults {
|
||||
if strings.HasPrefix(v, toComplete) {
|
||||
completions = append(completions, v+":")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also complete secrets from current vault (for within-vault moves)
|
||||
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err == nil {
|
||||
secrets, err := currentVlt.ListSecrets()
|
||||
if err == nil {
|
||||
for _, secretName := range secrets {
|
||||
if strings.HasPrefix(secretName, toComplete) {
|
||||
completions = append(completions, secretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
// getVaultSecretCompletionFunc returns a completion function for the
|
||||
// vault:secret format. It completes vault names with ":" suffix, and
|
||||
// after ":" it completes secrets from that vault.
|
||||
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
return func(
|
||||
_ *cobra.Command, _ []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Check if we're completing after a vault: prefix
|
||||
if strings.Contains(toComplete, ":") {
|
||||
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
|
||||
cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// Sentinel errors for asking the user to confirm a removal
|
||||
var (
|
||||
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
|
||||
"nobody to ask for confirmation; pass --force to remove without asking")
|
||||
errNotConfirmed = errors.New("cancelled; nothing was removed")
|
||||
errChangedWhileAsking = errors.New("what was to be removed changed " +
|
||||
"while waiting for the answer; nothing was removed")
|
||||
)
|
||||
|
||||
// askThenLock asks the user to confirm a removal, unless force is set, and
|
||||
// then takes the state directory lock and returns the function that
|
||||
// releases it. find makes the command's checks, keeps what it found for
|
||||
// the caller to remove, and returns the question that names it. find runs
|
||||
// before the question, which is asked without the lock so that no other
|
||||
// command waits while the user answers, and runs again once the lock is
|
||||
// taken. That run is the last, so the caller removes what find found under
|
||||
// the lock. If its question then differs from the one the user answered,
|
||||
// something changed in between, and askThenLock fails.
|
||||
func (cli *Instance) askThenLock(
|
||||
cmd *cobra.Command, force bool, find func() (string, error),
|
||||
) (func(), error) {
|
||||
asked := ""
|
||||
|
||||
if !force {
|
||||
question, err := find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = cli.confirm(cmd, question)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
asked = question
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
question, err := find()
|
||||
if err == nil && !force && question != asked {
|
||||
err = errChangedWhileAsking
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
release()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// confirm asks question and returns nil only when the user answers y or
|
||||
// yes; any other answer, a bare Enter included, cancels. When stdin is not
|
||||
// a terminal it asks nothing and fails at once: nobody is there to answer,
|
||||
// and waiting for an answer would hang a script. Stdin decides, not
|
||||
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
|
||||
// still asks. The question goes to stderr.
|
||||
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
|
||||
answers := cli.terminal
|
||||
if answers == nil {
|
||||
answers = cmd.InOrStdin()
|
||||
|
||||
if !isTerminal(answers) {
|
||||
return errNoTerminal
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
|
||||
|
||||
answer, err := bufio.NewReader(answers).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("failed to read the answer: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(answer)) {
|
||||
case "y", "yes":
|
||||
return nil
|
||||
default:
|
||||
return errNotConfirmed
|
||||
}
|
||||
}
|
||||
|
||||
// isTerminal reports whether r is a terminal.
|
||||
func isTerminal(r io.Reader) bool {
|
||||
file, ok := r.(*os.File)
|
||||
|
||||
return ok && term.IsTerminal(int(file.Fd()))
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// confirmTestSecret is the secret the tests remove, or remove a
|
||||
// version of, in the vault "work".
|
||||
confirmTestSecret = "test/secret"
|
||||
|
||||
// lastUnlockerRemoval names the case that removes the only unlocker.
|
||||
lastUnlockerRemoval = "unlocker rm, the last one"
|
||||
)
|
||||
|
||||
// removal is one removal command, set up on its own state directory.
|
||||
type removal struct {
|
||||
fs afero.Fs
|
||||
run func(cli *Instance, cmd *cobra.Command, force bool) error
|
||||
// removed is the directory the command removes.
|
||||
removed string
|
||||
// question is the question the command asks.
|
||||
question string
|
||||
}
|
||||
|
||||
// newConfirmTestVaults returns an in-memory state directory with the
|
||||
// vaults "other" and "work", the current one. "work" holds two versions of
|
||||
// confirmTestSecret and the given number of PGP unlockers. It returns the
|
||||
// directory of "work" and the older version.
|
||||
func newConfirmTestVaults(
|
||||
t *testing.T, unlockers int,
|
||||
) (*afero.MemMapFs, string, string) {
|
||||
t.Helper()
|
||||
|
||||
fs := &afero.MemMapFs{}
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("older"), false)
|
||||
addTestSecret(t, vlt, []byte("newer"), true)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
versions, err := secret.ListVersions(fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
for i := range unlockers {
|
||||
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
|
||||
fmt.Sprintf("pgp-%d", i),
|
||||
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
|
||||
listTestGPGKeyID+string(rune('A'+i)))
|
||||
}
|
||||
|
||||
// ListVersions lists the newest version first.
|
||||
return fs, vaultDir, versions[1]
|
||||
}
|
||||
|
||||
// newRemoval sets up the removal the command names.
|
||||
func newRemoval(t *testing.T, command string) removal {
|
||||
t.Helper()
|
||||
|
||||
unlockers := 2
|
||||
if command == lastUnlockerRemoval {
|
||||
unlockers = 1
|
||||
}
|
||||
|
||||
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
||||
// The first unlocker's directory name, written by newConfirmTestVaults
|
||||
unlockerID := "pgp-0"
|
||||
|
||||
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||
return cli.UnlockersRemove(unlockerID, force, cmd)
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "rm":
|
||||
return removal{
|
||||
fs: fs,
|
||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||
return cli.RemoveSecret(cmd, confirmTestSecret, force)
|
||||
},
|
||||
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
|
||||
question: "Permanently remove secret 'test/secret' and its 2 " +
|
||||
"version(s) from vault 'work'?",
|
||||
}
|
||||
case "version rm":
|
||||
return removal{
|
||||
fs: fs,
|
||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
|
||||
},
|
||||
removed: filepath.Join(
|
||||
workDir, "secrets.d", "test%secret", "versions", older),
|
||||
question: "Permanently remove version " + older +
|
||||
" of secret 'test/secret' from vault 'work'?",
|
||||
}
|
||||
case "vault rm":
|
||||
return removal{
|
||||
fs: fs,
|
||||
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||
return cli.RemoveVault(cmd, "work", force)
|
||||
},
|
||||
removed: workDir,
|
||||
question: "Permanently remove vault 'work' and its 1 secret(s)?",
|
||||
}
|
||||
case "unlocker rm":
|
||||
return removal{
|
||||
fs: fs,
|
||||
run: removeFirstUnlocker,
|
||||
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||
question: "Permanently remove unlocker '" + unlockerID +
|
||||
"' from vault 'work'? It is not the vault's last unlocker.",
|
||||
}
|
||||
case lastUnlockerRemoval:
|
||||
return removal{
|
||||
fs: fs,
|
||||
run: removeFirstUnlocker,
|
||||
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||
question: "Permanently remove unlocker '" + unlockerID +
|
||||
"', the last unlocker of vault 'work', which holds 1 " +
|
||||
"secret(s)? Without an unlocker the vault opens only " +
|
||||
"with its mnemonic.",
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no removal %q", command)
|
||||
|
||||
return removal{}
|
||||
}
|
||||
|
||||
// removalCommands lists the commands newRemoval sets up.
|
||||
func removalCommands() []string {
|
||||
return []string{
|
||||
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
|
||||
}
|
||||
}
|
||||
|
||||
// newConfirmTestCommand returns a command whose output is discarded and
|
||||
// whose stderr, where the question goes, is the returned buffer.
|
||||
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
|
||||
return cmd, &stderr
|
||||
}
|
||||
|
||||
// requireExists asserts whether the directory dir exists.
|
||||
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
|
||||
t.Helper()
|
||||
|
||||
exists, err := afero.DirExists(fs, dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, exists, dir)
|
||||
}
|
||||
|
||||
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
|
||||
// any case, around which spaces do not matter.
|
||||
func TestConfirmAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for answer, want := range map[string]error{
|
||||
"y\n": nil,
|
||||
"Y\n": nil,
|
||||
"yes\n": nil,
|
||||
" YES \n": nil,
|
||||
"y": nil,
|
||||
"\n": errNotConfirmed,
|
||||
"": errNotConfirmed,
|
||||
"n\n": errNotConfirmed,
|
||||
"yy\n": errNotConfirmed,
|
||||
"no\ny\n": errNotConfirmed,
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cli := &Instance{terminal: strings.NewReader(answer)}
|
||||
cmd, stderr := newConfirmTestCommand()
|
||||
|
||||
err := cli.confirm(cmd, "Remove it?")
|
||||
|
||||
require.ErrorIs(t, err, want)
|
||||
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
|
||||
// and removes what it names when the user answers y.
|
||||
func TestRemovalAnsweredYesRemoves(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range removalCommands() {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRemoval(t, command)
|
||||
requireExists(t, r.fs, r.removed, true)
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||
cli.terminal = strings.NewReader("y\n")
|
||||
cmd, stderr := newConfirmTestCommand()
|
||||
|
||||
require.NoError(t, r.run(cli, cmd, false))
|
||||
|
||||
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
||||
requireExists(t, r.fs, r.removed, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovalDeclinedLeavesEverything checks that each removal changes
|
||||
// nothing when the user answers anything but y or yes, a bare Enter
|
||||
// included.
|
||||
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range removalCommands() {
|
||||
for _, answer := range []string{"\n", "n\n", ""} {
|
||||
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRemoval(t, command)
|
||||
before := stateDirModTimes(t, r.fs)
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||
cli.terminal = strings.NewReader(answer)
|
||||
cmd, stderr := newConfirmTestCommand()
|
||||
|
||||
err := r.run(cli, cmd, false)
|
||||
|
||||
require.ErrorIs(t, err, errNotConfirmed)
|
||||
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
||||
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovalForcedAsksNothing checks that each removal with --force
|
||||
// removes what it would have named without asking, and without reading
|
||||
// its input, which is not a terminal.
|
||||
func TestRemovalForcedAsksNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range removalCommands() {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRemoval(t, command)
|
||||
|
||||
input := strings.NewReader("n\n")
|
||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||
cmd, stderr := newConfirmTestCommand()
|
||||
cmd.SetIn(input)
|
||||
|
||||
require.NoError(t, r.run(cli, cmd, true))
|
||||
|
||||
assert.Empty(t, stderr.String(), "asked with --force")
|
||||
assert.Equal(t, 2, input.Len(), "read its input with --force")
|
||||
requireExists(t, r.fs, r.removed, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
|
||||
// --force, whose input is not a terminal, fails at once telling the user
|
||||
// to pass --force, and changes nothing. The input is a pipe that nobody
|
||||
// writes to or closes, so reading it would block for good.
|
||||
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, command := range removalCommands() {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRemoval(t, command)
|
||||
before := stateDirModTimes(t, r.fs)
|
||||
|
||||
input, inputWriter, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = inputWriter.Close()
|
||||
_ = input.Close()
|
||||
})
|
||||
|
||||
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||
cmd, stderr := newConfirmTestCommand()
|
||||
cmd.SetIn(input)
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() { done <- r.run(cli, cmd, false) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, errNoTerminal)
|
||||
assert.Contains(t, err.Error(), "pass --force")
|
||||
case <-time.After(lockWait):
|
||||
// Closing the pipe ends the read, and frees the lock if
|
||||
// the command holds it.
|
||||
_ = inputWriter.Close()
|
||||
|
||||
t.Fatal("waited for an answer on input that is not a terminal")
|
||||
}
|
||||
|
||||
assert.Empty(t, stderr.String(), "asked without a terminal")
|
||||
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
|
||||
// for its answer, another command can take the state directory lock and
|
||||
// change the secret, and that the removal then removes nothing, since the
|
||||
// secret is no longer what the question named.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
|
||||
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,416 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// 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 no 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, run in a new session, which has no 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"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
||||
"Error: failed to read mnemonic: there is no terminal to ask on. "+
|
||||
"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)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// Sentinel errors for encrypt/decrypt operations
|
||||
var (
|
||||
errNotAgeSecretKey = errors.New(
|
||||
"does not contain a valid age secret key")
|
||||
)
|
||||
|
||||
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
||||
func newCryptoCmd(
|
||||
use, short, long string,
|
||||
run func(cli *Instance, secretName, inputFile, outputFile string) error,
|
||||
) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return run(cli, args[0], inputFile, outputFile)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
return newCryptoCmd(
|
||||
"encrypt <secret-name>",
|
||||
"Encrypt data using an age secret key stored in a secret",
|
||||
"Encrypt data using an age secret key. If the secret doesn't "+
|
||||
"exist, a new age key is generated and stored.",
|
||||
(*Instance).Encrypt,
|
||||
)
|
||||
}
|
||||
|
||||
func newDecryptCmd() *cobra.Command {
|
||||
return newCryptoCmd(
|
||||
"decrypt <secret-name>",
|
||||
"Decrypt data using an age secret key stored in a secret",
|
||||
"Decrypt data using an age secret key stored in the specified secret.",
|
||||
(*Instance).Decrypt,
|
||||
)
|
||||
}
|
||||
|
||||
// storeNewEncryptionKey generates an age secret key and stores it as the
|
||||
// named secret, holding the state directory lock while it does. It fails
|
||||
// with vault.ErrSecretExists if another command stored the secret first.
|
||||
// The caller must destroy the returned buffer.
|
||||
func (cli *Instance) storeNewEncryptionKey(
|
||||
vlt *vault.Vault, secretName string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
secureBuffer := secret.IdentityToLockedBuffer(identity)
|
||||
|
||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||
if err != nil {
|
||||
secureBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
||||
// for the named secret, generating and storing a new key if the secret
|
||||
// does not exist. The caller must destroy the returned buffer. Only storing
|
||||
// a new key takes the state directory lock, so that reading an existing key
|
||||
// works on a read-only state directory and keeps no other command waiting
|
||||
// at the passphrase prompt, and Encrypt streams its input and output
|
||||
// unlocked.
|
||||
func (cli *Instance) resolveEncryptionKey(
|
||||
vlt *vault.Vault, secretName string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
key, err := cli.storeNewEncryptionKey(vlt, secretName)
|
||||
if !errors.Is(err, vault.ErrSecretExists) {
|
||||
return key, err
|
||||
}
|
||||
// Another command stored the key since the check above: read it
|
||||
}
|
||||
|
||||
// Secret exists, get the age secret key from it
|
||||
secretBuffer, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
secretBuffer.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
}
|
||||
|
||||
return secretBuffer, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using an age secret key stored in a secret
|
||||
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
err := vault.ValidateSecretName(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Get or create the age secret key for this secret
|
||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyBuffer.Destroy()
|
||||
|
||||
// Parse the secret key
|
||||
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
|
||||
// Get recipient from identity
|
||||
recipient := identity.Recipient()
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
encryptor, err := age.Encrypt(output, recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(encryptor, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
err = encryptor.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using an age secret key stored in a secret
|
||||
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
err := vault.ValidateSecretName(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
|
||||
exists, err := secretObj.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Get the age secret key from the secret
|
||||
secretBuffer, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||
}
|
||||
|
||||
// Parse the age secret key to get the identity
|
||||
identity, err := age.ParseX25519Identity(secretBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
|
||||
// Set up input reader
|
||||
var input io.Reader = os.Stdin
|
||||
|
||||
if inputFile != "" {
|
||||
file, err := cli.fs.Open(inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open input file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
output := cli.cmd.OutOrStdout()
|
||||
|
||||
if outputFile != "" {
|
||||
file, err := cli.fs.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
decryptor, err := age.Decrypt(input, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(output, decryptor)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
||||
// attempting to parse it
|
||||
func isValidAgeSecretKey(key string) bool {
|
||||
_, err := age.ParseX25519Identity(key)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// Entry must return its exit code rather than exit, so that its deferred
|
||||
// memguard purge runs on the success and the error path alike.
|
||||
//
|
||||
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
|
||||
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
|
||||
savedArgs := os.Args
|
||||
|
||||
t.Cleanup(func() { os.Args = savedArgs })
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
exitCode int
|
||||
}{
|
||||
{args: []string{"secret", "--help"}, exitCode: 0},
|
||||
{args: []string{"secret", "no-such-command"}, exitCode: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
buf := memguard.NewBufferFromBytes([]byte("key material"))
|
||||
os.Args = tt.args
|
||||
|
||||
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
|
||||
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl-C while `secret add` waits for the value on stdin must end the
|
||||
// process through memguard's signal handler, which wipes every buffer and
|
||||
// exits with status 1, not through Go's default handling, which kills the
|
||||
// process with the buffers intact.
|
||||
func TestInterruptExitsThroughMemguard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const waitingForValue = "Reading secret value from stdin"
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
wd, err := filepath.Abs("../..")
|
||||
require.NoError(t, err)
|
||||
|
||||
secretPath := filepath.Join(wd, "secret")
|
||||
env := []string{
|
||||
secret.EnvStateDir + "=" + t.TempDir(),
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=/usr/bin:/bin",
|
||||
// The debug log on stderr shows when add starts waiting for the value.
|
||||
"GODEBUG=berlin.sneak.pkg.secret",
|
||||
}
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
||||
initCmd.Env = env
|
||||
|
||||
output, err := initCmd.CombinedOutput()
|
||||
require.NoError(t, err, "init should succeed: %s", output)
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
|
||||
addCmd.Env = env
|
||||
|
||||
// Held open and never written, so add keeps waiting for the value.
|
||||
stdin, err := addCmd.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = stdin.Close() }()
|
||||
|
||||
stderr, err := addCmd.StderrPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, addCmd.Start())
|
||||
|
||||
waiting := false
|
||||
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for !waiting && scanner.Scan() {
|
||||
waiting = strings.Contains(scanner.Text(), waitingForValue)
|
||||
}
|
||||
|
||||
require.True(t, waiting, "add never logged %q", waitingForValue)
|
||||
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
|
||||
|
||||
err = addCmd.Wait()
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestMissingSecretOrVaultErrors checks that a command that finds no such
|
||||
// secret or vault returns the vault package's error for it, as `secret get`
|
||||
// does, and leaves the vaults unchanged. "default" is the current vault, and
|
||||
// both vaults hold the secret "x".
|
||||
func TestMissingSecretOrVaultErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||
|
||||
tests := []struct {
|
||||
command string
|
||||
want error
|
||||
run func(c *cli.Instance) error
|
||||
}{
|
||||
{
|
||||
"rm --force nosuch", vault.ErrSecretNotFound,
|
||||
func(c *cli.Instance) error {
|
||||
return c.RemoveSecret(&cobra.Command{}, "nosuch", true)
|
||||
},
|
||||
},
|
||||
{
|
||||
"version rm --force nosuch", vault.ErrSecretNotFound,
|
||||
func(c *cli.Instance) error {
|
||||
return c.RemoveVersion(&cobra.Command{}, "nosuch", "20260101.001", true)
|
||||
},
|
||||
},
|
||||
{
|
||||
"mv --force work:nosuch default", vault.ErrSecretNotFound,
|
||||
func(c *cli.Instance) error {
|
||||
return c.MoveSecret(&cobra.Command{}, "work:nosuch", "default", true)
|
||||
},
|
||||
},
|
||||
{
|
||||
"decrypt nosuch", vault.ErrSecretNotFound,
|
||||
func(c *cli.Instance) error { return c.Decrypt("nosuch", "", "") },
|
||||
},
|
||||
{
|
||||
"vault rm --force nosuch", vault.ErrVaultNotFound,
|
||||
func(c *cli.Instance) error {
|
||||
return c.RemoveVault(&cobra.Command{}, "nosuch", true)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requireRejectedAndUnchanged(t, before, tt.want, tt.run)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSecretLength = 16
|
||||
mnemonicEntropyBits = 128
|
||||
)
|
||||
|
||||
// Sentinel errors for secret generation
|
||||
var (
|
||||
errLengthTooSmall = errors.New("length must be at least 1")
|
||||
errUnsupportedSecretType = errors.New("unsupported type")
|
||||
)
|
||||
|
||||
func newGenerateCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate random data",
|
||||
Long: `Generate various types of random data including mnemonics and secrets.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newGenerateMnemonicCmd())
|
||||
cmd.AddCommand(newGenerateSecretCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGenerateMnemonicCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "mnemonic",
|
||||
Short: "Generate a random BIP39 mnemonic phrase",
|
||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||
`mnemonic phrase that can be used with 'secret init' ` +
|
||||
`or 'secret import'.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.GenerateMnemonic(cmd)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGenerateSecretCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret <name>",
|
||||
Short: "Generate a random secret and store it in the vault",
|
||||
Long: `Generate a cryptographically secure random secret and ` +
|
||||
`store it in the current vault under the given name.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
length, _ := cmd.Flags().GetInt("length")
|
||||
secretType, _ := cmd.Flags().GetString("type")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntP("length", "l", defaultSecretLength,
|
||||
"Length of the generated secret (default 16)")
|
||||
cmd.Flags().StringP("type", "t", "base58",
|
||||
"Type of secret to generate (base58, alnum)")
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// GenerateMnemonic generates a random BIP39 mnemonic phrase
|
||||
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
// Generate 128 bits of entropy for a 12-word mnemonic
|
||||
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate entropy: %w", err)
|
||||
}
|
||||
|
||||
// Create mnemonic from entropy
|
||||
mnemonic, err := bip39.NewMnemonic(entropy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate mnemonic: %w", err)
|
||||
}
|
||||
|
||||
// Output mnemonic to stdout
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||
|
||||
// Output helpful information to stderr
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "⚠️ IMPORTANT: Save this mnemonic phrase securely!")
|
||||
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
|
||||
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
|
||||
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||
fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecret generates a random secret and stores it in the vault
|
||||
func (cli *Instance) GenerateSecret(
|
||||
cmd *cobra.Command,
|
||||
secretName string,
|
||||
length int,
|
||||
secretType string,
|
||||
force bool,
|
||||
) error {
|
||||
if length < 1 {
|
||||
return errLengthTooSmall
|
||||
}
|
||||
|
||||
var (
|
||||
secretValue string
|
||||
err error
|
||||
)
|
||||
|
||||
switch secretType {
|
||||
case "base58":
|
||||
secretValue, err = generateRandomBase58(length)
|
||||
case "alnum":
|
||||
secretValue, err = generateRandomAlnum(length)
|
||||
case "mnemonic":
|
||||
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
|
||||
errUnsupportedSecretType)
|
||||
default:
|
||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||
errUnsupportedSecretType, secretType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate random secret: %w", err)
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Store the secret in the vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Protect the generated secret immediately
|
||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(secretName, secretBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n",
|
||||
length, secretType, secretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRandomBase58 generates a random base58 string of the specified length
|
||||
func generateRandomBase58(length int) (string, error) {
|
||||
const base58Chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
return generateRandomString(length, base58Chars)
|
||||
}
|
||||
|
||||
// generateRandomAlnum generates a random alphanumeric string of the specified length
|
||||
func generateRandomAlnum(length int) (string, error) {
|
||||
const alnumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
return generateRandomString(length, alnumChars)
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string of the specified length
|
||||
// using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length < 1 {
|
||||
return "", errLengthTooSmall
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := range length {
|
||||
randomIndex, err := rand.Int(rand.Reader, charsetLen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||
}
|
||||
|
||||
result[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
|
||||
return string(result), nil
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// Version info - these are set at build time
|
||||
var (
|
||||
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
||||
)
|
||||
|
||||
// InfoOutput represents the system information for JSON output
|
||||
type InfoOutput struct {
|
||||
Version string `json:"version"`
|
||||
GitCommit string `json:"gitCommit"`
|
||||
Author string `json:"author"`
|
||||
License string `json:"license"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
DataDirectory string `json:"dataDirectory"`
|
||||
CurrentVault string `json:"currentVault"`
|
||||
NumVaults int `json:"numVaults"`
|
||||
NumSecrets int `json:"numSecrets"`
|
||||
TotalSize int64 `json:"totalSizeBytes"`
|
||||
OldestSecret time.Time `json:"oldestSecret"`
|
||||
LatestSecret time.Time `json:"latestSecret"`
|
||||
}
|
||||
|
||||
// newInfoCmd returns the info command
|
||||
func newInfoCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
var jsonOutput bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Display system information",
|
||||
Long: "Display information about the secret system including " +
|
||||
"version, vault statistics, and storage usage",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cli.Info(cmd, jsonOutput)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Info displays system information
|
||||
func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
||||
info := InfoOutput{
|
||||
Version: Version,
|
||||
GitCommit: GitCommit,
|
||||
Author: "Jeffrey Paul <sneak@sneak.berlin>",
|
||||
License: "WTFPL",
|
||||
GoVersion: runtime.Version(),
|
||||
DataDirectory: cli.stateDir,
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
info.CurrentVault = currentVault.Name
|
||||
}
|
||||
|
||||
// Count vaults
|
||||
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||
|
||||
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||
if err == nil {
|
||||
for _, entry := range vaultEntries {
|
||||
if entry.IsDir() {
|
||||
info.NumVaults++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gather statistics from all vaults
|
||||
if info.NumVaults > 0 {
|
||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
|
||||
cli.fs, vaultsDir)
|
||||
info.NumSecrets = totalSecrets
|
||||
info.TotalSize = totalSize
|
||||
|
||||
if !oldestTime.IsZero() {
|
||||
info.OldestSecret = oldestTime
|
||||
}
|
||||
|
||||
if !latestTime.IsZero() {
|
||||
info.LatestSecret = latestTime
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(cmd.OutOrStdout())
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
return encoder.Encode(info)
|
||||
}
|
||||
|
||||
// Pretty print with colors and emoji
|
||||
return prettyPrintInfo(cmd.OutOrStdout(), info)
|
||||
}
|
||||
|
||||
// prettyPrintInfo formats and prints the info in a pretty format
|
||||
func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
||||
const separatorLength = 40
|
||||
|
||||
bold := color.New(color.Bold)
|
||||
green := color.New(color.FgGreen)
|
||||
cyan := color.New(color.FgCyan)
|
||||
yellow := color.New(color.FgYellow)
|
||||
magenta := color.New(color.FgMagenta)
|
||||
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = bold.Fprintln(w, "🔐 Secret System Information")
|
||||
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||
|
||||
_, _ = fmt.Fprintf(w, "📦 Version: %s\n", green.Sprint(info.Version))
|
||||
_, _ = fmt.Fprintf(w, "🔧 Git Commit: %s\n", cyan.Sprint(info.GitCommit))
|
||||
_, _ = fmt.Fprintf(w, "👤 Author: %s\n", cyan.Sprint(info.Author))
|
||||
_, _ = fmt.Fprintf(w, "📜 License: %s\n", cyan.Sprint(info.License))
|
||||
_, _ = fmt.Fprintf(w, "🐹 Go Version: %s\n", cyan.Sprint(info.GoVersion))
|
||||
_, _ = fmt.Fprintf(w, "📁 Data Directory: %s\n", yellow.Sprint(info.DataDirectory))
|
||||
|
||||
if info.CurrentVault != "" {
|
||||
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", magenta.Sprint(info.CurrentVault))
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "🗄️ Current Vault: %s\n", color.RedString("(none)"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||
|
||||
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
||||
|
||||
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
||||
|
||||
if info.TotalSize >= 0 {
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
|
||||
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
||||
}
|
||||
|
||||
if !info.OldestSecret.IsZero() {
|
||||
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n",
|
||||
info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
if !info.LatestSecret.IsZero() {
|
||||
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n",
|
||||
info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// vaultStats accumulates statistics while walking vault directories
|
||||
type vaultStats struct {
|
||||
totalSecrets int
|
||||
totalSize int64
|
||||
oldestTime time.Time
|
||||
latestTime time.Time
|
||||
}
|
||||
|
||||
// addVersion accumulates size and timestamp info for one version directory
|
||||
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||
// Add size of encrypted data
|
||||
dataPath := filepath.Join(versionPath, "data.age")
|
||||
|
||||
stat, err := fs.Stat(dataPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Add size of metadata
|
||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||
|
||||
stat, err = fs.Stat(metaPath)
|
||||
if err == nil {
|
||||
s.totalSize += stat.Size()
|
||||
}
|
||||
|
||||
// Track timestamps
|
||||
stat, err = fs.Stat(versionPath)
|
||||
if err == nil {
|
||||
modTime := stat.ModTime()
|
||||
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||
s.oldestTime = modTime
|
||||
}
|
||||
|
||||
if s.latestTime.IsZero() || modTime.After(s.latestTime) {
|
||||
s.latestTime = modTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addSecret accumulates stats for one secret directory
|
||||
func (s *vaultStats) addSecret(fs afero.Fs, secretsPath, secretName string) {
|
||||
s.totalSecrets++
|
||||
secretPath := filepath.Join(secretsPath, secretName)
|
||||
|
||||
// Get size and timestamps from all versions
|
||||
versionsPath := filepath.Join(secretPath, "versions")
|
||||
|
||||
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read versions directory for secret",
|
||||
"secret", secretName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, versionEntry := range versionEntries {
|
||||
if !versionEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
// addVault accumulates stats for one vault directory
|
||||
func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
|
||||
vaultPath := filepath.Join(vaultsDir, vaultName)
|
||||
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||
|
||||
// Count secrets in this vault
|
||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||
if err != nil {
|
||||
secret.Warn("Could not read secrets directory for vault",
|
||||
"vault", vaultName, "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, secretEntry := range secretEntries {
|
||||
if !secretEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addSecret(fs, secretsPath, secretEntry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// gatherVaultStats collects statistics from all vaults, returning the
|
||||
// total secret count, total size, and oldest/latest secret timestamps
|
||||
func gatherVaultStats(
|
||||
fs afero.Fs,
|
||||
vaultsDir string,
|
||||
) (int, int64, time.Time, time.Time, error) {
|
||||
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return 0, 0, time.Time{}, time.Time{}, err
|
||||
}
|
||||
|
||||
var stats vaultStats
|
||||
|
||||
for _, vaultEntry := range vaultEntries {
|
||||
if !vaultEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.addVault(fs, vaultsDir, vaultEntry.Name())
|
||||
}
|
||||
|
||||
return stats.totalSecrets, stats.totalSize,
|
||||
stats.oldestTime, stats.latestTime, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||
var errPassphraseMismatch = errors.New("passphrases do not match")
|
||||
|
||||
// NewInitCmd creates the init command
|
||||
func NewInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize the secrets manager",
|
||||
Long: `Create the necessary directory structure for storing ` +
|
||||
`secrets and generate encryption keys.`,
|
||||
RunE: RunInit,
|
||||
}
|
||||
}
|
||||
|
||||
// RunInit is the exported function that handles the init command
|
||||
func RunInit(cmd *cobra.Command, _ []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.Init(cmd)
|
||||
}
|
||||
|
||||
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
|
||||
// or reads it interactively. The returned cleanup function must be deferred
|
||||
// by the caller.
|
||||
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
||||
if cli.Mnemonic != nil {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
|
||||
return cli.Mnemonic, func() {}, nil
|
||||
}
|
||||
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic", "error", err)
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// Init initializes the secret manager, holding the state directory lock
|
||||
// while initialize runs
|
||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.initialize(cmd)
|
||||
}
|
||||
|
||||
// initialize creates the state directory, the default vault and its first
|
||||
// unlocker
|
||||
func (cli *Instance) initialize(cmd *cobra.Command) error {
|
||||
secret.Debug("Starting secret manager initialization")
|
||||
|
||||
// Create state directory
|
||||
stateDir := cli.GetStateDir()
|
||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||
|
||||
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create state directory", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
cmd.Printf("Initialized secrets manager at: %s\n", stateDir)
|
||||
}
|
||||
|
||||
// Prompt for mnemonic
|
||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupMnemonic()
|
||||
|
||||
mnemonicStr := mnemonic.String()
|
||||
if mnemonicStr == "" {
|
||||
secret.Debug("Empty mnemonic provided")
|
||||
|
||||
return errMnemonicEmpty
|
||||
}
|
||||
|
||||
// Validate the mnemonic using BIP39
|
||||
secret.DebugWith("Validating BIP39 mnemonic",
|
||||
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
||||
errInvalidMnemonicPhrase)
|
||||
}
|
||||
|
||||
// Ask for the unlocker passphrase before creating the vault, so that
|
||||
// stopping at the prompt leaves no vault without an unlocker behind
|
||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupPassphrase()
|
||||
|
||||
// Create the default vault with its passphrase unlocker
|
||||
secret.Debug("Creating default vault")
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
|
||||
mnemonic, passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create default vault", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
unlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
cmd.Printf("\nDefault vault created and configured\n")
|
||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||
cmd.Println("\nYour secret manager is ready to use!")
|
||||
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
||||
cmd.Println("unlockers are not required for secret operations.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
||||
// This version adds confirmation (read twice) for creating new unlockers
|
||||
// Returns a LockedBuffer containing the passphrase
|
||||
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Get the first passphrase
|
||||
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read confirmation passphrase
|
||||
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||
if err != nil {
|
||||
passphraseBuffer1.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
||||
}
|
||||
|
||||
// Compare passphrases
|
||||
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
||||
passphraseBuffer1.Destroy()
|
||||
passphraseBuffer2.Destroy()
|
||||
|
||||
return nil, errPassphraseMismatch
|
||||
}
|
||||
|
||||
// Clean up the second buffer, we'll return the first
|
||||
passphraseBuffer2.Destroy()
|
||||
|
||||
// Return the first buffer (caller is responsible for destroying it)
|
||||
return passphraseBuffer1, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
|
||||
// "other" has no long-term key, as vault import needs.
|
||||
func TestInvalidMnemonicError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
command string
|
||||
run func(c *Instance) error
|
||||
}{
|
||||
{"secret init", func(c *Instance) error { return c.Init(c.cmd) }},
|
||||
{"secret vault create work", func(c *Instance) error {
|
||||
return c.CreateVault(c.cmd, "work")
|
||||
}},
|
||||
{"secret vault import other", func(c *Instance) error {
|
||||
return c.VaultImport(c.cmd, "other")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, _ := newTestInstance(fs)
|
||||
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
||||
t.Cleanup(instance.Mnemonic.Destroy)
|
||||
|
||||
require.ErrorIs(t, tt.run(instance), errInvalidMnemonicPhrase)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSecretErrors checks that `secret generate secret` gives one
|
||||
// error for a length below 1 and one for a type it cannot generate.
|
||||
func TestGenerateSecretErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
instance, cmd := newTestInstance(afero.NewMemMapFs())
|
||||
|
||||
err := instance.GenerateSecret(cmd, "x", 0, "base58", false)
|
||||
require.ErrorIs(t, err, errLengthTooSmall)
|
||||
|
||||
_, err = generateRandomString(0, "ab")
|
||||
require.ErrorIs(t, err, errLengthTooSmall)
|
||||
|
||||
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "mnemonic", false)
|
||||
require.ErrorIs(t, err, errUnsupportedSecretType)
|
||||
|
||||
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "hex", false)
|
||||
require.ErrorIs(t, err, errUnsupportedSecretType)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestLeftoversRemovedByNextChangingCommand is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
|
||||
// killed part-way leaves in each directory where secret.TempDirFor and
|
||||
// secret.WriteFileAtomic make temporary entries: a temporary directory
|
||||
// holding a vault, secret, unlocker or version being added or removed, and
|
||||
// a temporary file beside a file being replaced. `secret list` must leave
|
||||
// them all, and the next command that takes the state directory lock, here
|
||||
// `secret vault select` of the vault already current, must delete exactly
|
||||
// them: a vault named like a temporary directory stays. The copy has no
|
||||
// lock file yet, so that command, as after a killed one, finds no mark that
|
||||
// the last holder of the lock finished.
|
||||
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
|
||||
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
|
||||
|
||||
before := snapshotStateDir(t, fs)
|
||||
|
||||
vaultDir := testStateDir + "/vaults.d/default"
|
||||
secretDir := vaultDir + "/secrets.d/x"
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
for _, dir := range []string{
|
||||
testStateDir + "/.tmp-1/default",
|
||||
vaultDir + "/.tmp-2/x",
|
||||
secretDir + "/.tmp-3/" + testVersion,
|
||||
} {
|
||||
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
||||
require.NoError(t, afero.WriteFile(fs, dir+"/value.age",
|
||||
[]byte("encrypted"), secret.FilePerms))
|
||||
}
|
||||
|
||||
for _, file := range []string{
|
||||
testStateDir + "/.currentvault.tmp-4",
|
||||
vaultDir + "/.current-unlocker.tmp-5",
|
||||
secretDir + "/.current.tmp-6",
|
||||
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
|
||||
} {
|
||||
require.NoError(t, afero.WriteFile(fs, file,
|
||||
[]byte("partial"), secret.FilePerms))
|
||||
}
|
||||
|
||||
planted := snapshotStateDir(t, fs)
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(io.Discard)
|
||||
|
||||
require.NoError(t, c.ListSecrets(cmd, false, false, ""))
|
||||
require.Equal(t, planted, snapshotStateDir(t, fs))
|
||||
|
||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//nolint:testpackage // sets the unexported fields of Instance
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// lockWait is how long a test waits for something that must happen
|
||||
// once the lock is free.
|
||||
lockWait = 10 * time.Second
|
||||
|
||||
// testPassphrase protects the passphrase unlockers the tests create.
|
||||
testPassphrase = "test-passphrase"
|
||||
|
||||
// testInput is a file outside the state directory that commands read.
|
||||
testInput = "/input"
|
||||
)
|
||||
|
||||
// lockInBackground starts taking the state directory lock and returns a
|
||||
// channel that delivers the function releasing it once it has been taken.
|
||||
func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
|
||||
t.Helper()
|
||||
|
||||
taken := make(chan func(), 1)
|
||||
|
||||
go func() {
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
if assert.NoError(t, err) {
|
||||
taken <- release
|
||||
}
|
||||
}()
|
||||
|
||||
return taken
|
||||
}
|
||||
|
||||
// addAtOnce runs one add of the secret name per value, all at once, and
|
||||
// returns their errors. Each add is given mnemonic, which a forced add
|
||||
// needs.
|
||||
func addAtOnce(
|
||||
fs afero.Fs, stateDir, name string, force bool, values []string,
|
||||
mnemonic *memguard.LockedBuffer,
|
||||
) []error {
|
||||
errs := make(chan error, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
go func() {
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = mnemonic
|
||||
cli.cmd = &cobra.Command{}
|
||||
cli.cmd.SetIn(strings.NewReader(value))
|
||||
|
||||
errs <- cli.AddSecret(name, force)
|
||||
}()
|
||||
}
|
||||
|
||||
results := make([]error, 0, len(values))
|
||||
for range values {
|
||||
results = append(results, <-errs)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// numbered returns count distinct values starting with prefix.
|
||||
func numbered(prefix string, count int) []string {
|
||||
values := make([]string, 0, count)
|
||||
for i := range count {
|
||||
values = append(values, prefix+"-"+strconv.Itoa(i))
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
|
||||
// the in-memory and on the real filesystem. Without the state directory
|
||||
// lock, adds of a new secret all find it absent and replace each other, and
|
||||
// forced adds read the same highest version number and overwrite each
|
||||
// other's version. With it they behave as if run one after another.
|
||||
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
const adds = 8
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
}{
|
||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
||||
{"real", afero.NewOsFs(), t.TempDir()},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// One add creates the secret; the others find that it exists
|
||||
created := 0
|
||||
|
||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
||||
numbered("create", adds), mnemonic) {
|
||||
if err == nil {
|
||||
created++
|
||||
} else {
|
||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, 1, created, "exactly one add creates the secret")
|
||||
|
||||
// Every forced add stores a version of its own
|
||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
||||
numbered("force", adds), mnemonic) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = mnemonic
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
versions, err := secret.ListVersions(tc.fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "shared"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, adds+1, "one version per successful add")
|
||||
|
||||
values := make(map[string]bool, len(versions))
|
||||
|
||||
for _, version := range versions {
|
||||
value, err := vlt.GetSecretVersion("shared", version)
|
||||
require.NoError(t, err)
|
||||
|
||||
values[string(value.Bytes())] = true
|
||||
value.Destroy()
|
||||
}
|
||||
|
||||
assert.Len(t, values, adds+1, "every add stored its own value")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readNotifier passes reads through to Reader and closes reading at the
|
||||
// first one.
|
||||
type readNotifier struct {
|
||||
io.Reader
|
||||
|
||||
reading chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *readNotifier) Read(p []byte) (int, error) {
|
||||
r.once.Do(func() { close(r.reading) })
|
||||
|
||||
return r.Reader.Read(p)
|
||||
}
|
||||
|
||||
// TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in
|
||||
// one process, starting encrypt once add is reading its input. Had add
|
||||
// taken the state directory lock before reading, it would hold the lock
|
||||
// while waiting for encrypt's output, and encrypt would wait for the lock
|
||||
// to store its key: neither would finish.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
||||
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
// If the test gives up, this makes add's read fail, so that both
|
||||
// commands return and release the lock the other tests use
|
||||
t.Cleanup(func() { _ = pipeReader.Close() })
|
||||
|
||||
const commands = 2
|
||||
|
||||
input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})}
|
||||
results := make(chan error, commands)
|
||||
|
||||
go func() {
|
||||
add := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
add.cmd = &cobra.Command{}
|
||||
add.cmd.SetIn(input)
|
||||
|
||||
results <- add.AddSecret("encrypted", false)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-input.reading
|
||||
|
||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
encrypt.cmd = &cobra.Command{}
|
||||
encrypt.cmd.SetOut(pipeWriter)
|
||||
|
||||
err := encrypt.Encrypt("key", testInput, "")
|
||||
// Ends add's input, as the end of the pipe does
|
||||
_ = pipeWriter.CloseWithError(err)
|
||||
|
||||
results <- err
|
||||
}()
|
||||
|
||||
timeout := time.After(lockWait)
|
||||
|
||||
for range commands {
|
||||
select {
|
||||
case err := <-results:
|
||||
require.NoError(t, err)
|
||||
case <-timeout:
|
||||
t.Fatal("secret encrypt piped into secret add never finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedCommandReleasesLock checks that a command failing after it
|
||||
// took the state directory lock leaves the lock free for the next command.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestFailedCommandReleasesLock(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
// Fails once it holds the lock: there is no current vault. Without
|
||||
// --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)
|
||||
|
||||
select {
|
||||
case release := <-lockInBackground(t, fs):
|
||||
release()
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("the failed command left the state directory locked")
|
||||
}
|
||||
}
|
||||
|
||||
// stateDirModTimes returns the modification time of every file and
|
||||
// directory under the test state directory. Any change a command makes, even
|
||||
// rewriting a file with the same content, changes it.
|
||||
func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
||||
t.Helper()
|
||||
|
||||
modTimes := make(map[string]int64)
|
||||
|
||||
err := afero.Walk(fs, testStateDir,
|
||||
func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
modTimes[path] = info.ModTime().UnixNano()
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return modTimes
|
||||
}
|
||||
|
||||
// setupEveryCommand makes what each command in
|
||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
||||
// versions of "test/secret", the vault "other" without a long-term key, for
|
||||
// vault import, and the file testInput. There is no vault "default", which
|
||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
||||
// ID.
|
||||
func setupEveryCommand(
|
||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
||||
) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherDir, err := other.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", 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)
|
||||
|
||||
unlockerID := ""
|
||||
|
||||
if withUnlocker {
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
unlockerID = unlocker.GetID()
|
||||
}
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
||||
|
||||
// Newest first
|
||||
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,
|
||||
// while holding the state directory lock. The command must neither finish
|
||||
// nor change anything before it waits for the lock, and must succeed once
|
||||
// the lock is released.
|
||||
func requireWaitsForLock(
|
||||
t *testing.T,
|
||||
withUnlocker bool,
|
||||
run func(cli *Instance, olderVersion, unlockerID string) error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Released at most once, and also if the test fails while holding it,
|
||||
// so that later tests can take it
|
||||
release = sync.OnceFunc(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.Mnemonic = testMnemonicBuffer(t)
|
||||
cli.UnlockPassphrase = unlockPassphrase
|
||||
cli.cmd = &cobra.Command{}
|
||||
cli.cmd.SetIn(strings.NewReader("value"))
|
||||
cli.cmd.SetOut(io.Discard)
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
||||
|
||||
timeout := time.After(lockWait)
|
||||
|
||||
for !waitingForLock() {
|
||||
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),
|
||||
"changed the state directory before waiting for the lock")
|
||||
|
||||
release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("did not finish once the lock was released")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangingCommandsWaitForLock checks that each command that changes the
|
||||
// state directory waits for its lock.
|
||||
//
|
||||
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||
func TestChangingCommandsWaitForLock(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
withUnlocker bool
|
||||
run func(cli *Instance, olderVersion, unlockerID string) error
|
||||
}{
|
||||
{"add", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.AddSecret("added", false)
|
||||
}},
|
||||
{"import", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.ImportSecret(cli.cmd, "imported", testInput, false)
|
||||
}},
|
||||
{"generate secret", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.GenerateSecret(cli.cmd, "generated", 16, "base58", false)
|
||||
}},
|
||||
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.Encrypt("key", testInput, "")
|
||||
}},
|
||||
{"rm --force", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.RemoveSecret(cli.cmd, "test/secret", true)
|
||||
}},
|
||||
{"move", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
||||
}},
|
||||
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
||||
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
||||
}},
|
||||
{"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
|
||||
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
|
||||
}},
|
||||
{"vault create", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.CreateVault(cli.cmd, "created")
|
||||
}},
|
||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.SelectVault(cli.cmd, "other")
|
||||
}},
|
||||
{"vault import", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.VaultImport(cli.cmd, "other")
|
||||
}},
|
||||
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.RemoveVault(cli.cmd, "other", true)
|
||||
}},
|
||||
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||
}},
|
||||
{"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
|
||||
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
||||
}},
|
||||
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
||||
return cli.UnlockerSelect(unlockerID)
|
||||
}},
|
||||
{"init", false, func(cli *Instance, _, _ string) error {
|
||||
return cli.Init(cli.cmd)
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requireWaitsForLock(t, tc.withUnlocker, tc.run)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
||||
// key that already exists, which only reads the state directory, finishes
|
||||
// while another command holds the state directory lock.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
||||
|
||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
encrypt.Mnemonic = mnemonic
|
||||
encrypt.cmd = &cobra.Command{}
|
||||
encrypt.cmd.SetOut(io.Discard)
|
||||
|
||||
// Stores the key
|
||||
require.NoError(t, encrypt.Encrypt("key", testInput, ""))
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
// Also frees a waiting encrypt if the test fails, so that it releases
|
||||
// the lock the other tests use
|
||||
defer release()
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() { done <- encrypt.Encrypt("key", testInput, "") }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("secret encrypt with an existing key waited for the lock")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptStreamsUnlocked checks that secret encrypt has released the
|
||||
// state directory lock by the time it writes its output. Holding it while
|
||||
// streaming would stall every other changing command for as long as the
|
||||
// stream lasts, and forever when the other end of the pipe is one of them.
|
||||
//
|
||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
||||
|
||||
outputReader, outputWriter := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
encrypt.cmd = &cobra.Command{}
|
||||
encrypt.cmd.SetOut(outputWriter)
|
||||
|
||||
err := encrypt.Encrypt("key", testInput, "")
|
||||
_ = outputWriter.CloseWithError(err)
|
||||
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// The first byte of output: encrypt is streaming now, and blocked
|
||||
// writing until it is read
|
||||
_, err = io.ReadFull(outputReader, make([]byte, 1))
|
||||
require.NoError(t, err)
|
||||
|
||||
taken := lockInBackground(t, fs)
|
||||
|
||||
select {
|
||||
case release := <-taken:
|
||||
release()
|
||||
case <-time.After(lockWait):
|
||||
// Let encrypt finish, so that it releases the lock, then free it
|
||||
// again for the tests that follow
|
||||
_, _ = io.Copy(io.Discard, outputReader)
|
||||
|
||||
(<-taken)()
|
||||
t.Fatal("secret encrypt held the lock while streaming")
|
||||
}
|
||||
|
||||
_, err = io.Copy(io.Discard, outputReader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-done)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
|
||||
// onto itself deleted it, also when "work" was spelled two ways, and a failed
|
||||
// move within "work" left "work" the current vault. "default" is the current
|
||||
// vault in every case, and each case runs on its own copy of the state
|
||||
// directory.
|
||||
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
||||
|
||||
const (
|
||||
ontoItself = "secret 'x' cannot be moved onto itself"
|
||||
workX = "work:x"
|
||||
)
|
||||
|
||||
// internal/cli declares these errors itself and does not export them, so
|
||||
// only their text can be compared.
|
||||
tests := []struct {
|
||||
command string
|
||||
source, dest string
|
||||
force bool
|
||||
wantErr string
|
||||
}{
|
||||
{"mv x x", "x", "x", false, ontoItself},
|
||||
{"mv --force x x", "x", "x", true, ontoItself},
|
||||
{"mv --force work:x work:", workX, "work:", true, ontoItself},
|
||||
// An empty destination name defaults to the source name.
|
||||
{`mv --force work:x ""`, workX, "", true, ontoItself},
|
||||
// "work" is a vault name, so the destination is work:x.
|
||||
{"mv --force work:x work", workX, "work", true, ontoItself},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newFsFromSnapshot(t, before)
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
||||
|
||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||
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
|
||||
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
|
||||
// leaves "default" the current vault.
|
||||
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
after := snapshotStateDir(t, fs)
|
||||
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
|
||||
|
||||
require.Equal(t, "default", after[testStateDir+"/currentvault"])
|
||||
require.Contains(t, after, workSecrets+"y/")
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/creack/pty"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
|
||||
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
|
||||
// the passphrase failed instead of asking for it. Each command here must ask
|
||||
// on the terminal and succeed once the passphrase is typed there.
|
||||
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("v1"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
key := secret.IdentityToLockedBuffer(identity)
|
||||
defer key.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("mykey", key, false))
|
||||
|
||||
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
|
||||
|
||||
vlt.UnlockPassphrase = passphrase
|
||||
|
||||
added, err := vlt.GetSecret("s1")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer added.Destroy()
|
||||
|
||||
assert.Equal(t, "v2", added.String())
|
||||
|
||||
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
|
||||
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
|
||||
assert.Equal(t, "a document\n", decrypted)
|
||||
}
|
||||
|
||||
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
|
||||
// terminal: the terminal is its controlling terminal and none of stdin,
|
||||
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
|
||||
// ends its line there, so stderr must not get a newline for it as well, a
|
||||
// blank line in the log. pty.Open is described above
|
||||
// TestRemoveIgnoresTerminalOnStdout.
|
||||
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stderr strings.Builder
|
||||
|
||||
cmd.Stderr = &stderr
|
||||
cmd.ExtraFiles = []*os.File{tty}
|
||||
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret init did not ask on the terminal: %s",
|
||||
shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testMnemonic + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
err = cmd.Wait()
|
||||
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
|
||||
|
||||
assert.True(t, strings.HasPrefix(stderr.String(),
|
||||
"Initialized secrets manager at: "+stateDir+"\n\n"+
|
||||
"Default vault created and configured\n"),
|
||||
"stderr: %q", stderr.String())
|
||||
}
|
||||
|
||||
// runAtTerminal runs the secret binary with args and input on stdin, in a
|
||||
// new session whose controlling terminal is a new terminal, which is also
|
||||
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
||||
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
|
||||
// for the passphrase prompt on the terminal, types the passphrase there,
|
||||
// and returns what the command wrote to stdout once it has succeeded.
|
||||
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stdout strings.Builder
|
||||
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = tty
|
||||
// Ctty is a descriptor in the new process: 2, its stderr.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
|
||||
args, shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testPassphrase + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
|
||||
|
||||
return stdout.String()
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// testStateDir is the in-memory state directory of the test vaults.
|
||||
testStateDir = "/test/state"
|
||||
|
||||
// testPassphrase protects the passphrase unlocker of each test vault.
|
||||
testPassphrase = "test-passphrase"
|
||||
|
||||
// testVersion is a version name in the format the vault uses.
|
||||
testVersion = "20260101.001"
|
||||
|
||||
// missingFile is an import source that does not exist, so an import
|
||||
// that opened it before checking the name would fail with another error.
|
||||
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"
|
||||
// and "default", the current one. Each holds the secret "x" and a
|
||||
// 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
|
||||
func newTwoVaultFs(t *testing.T) afero.Fs {
|
||||
t.Helper()
|
||||
|
||||
twoVaultsOnce.Do(func() {
|
||||
fs := afero.NewMemMapFs()
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
for _, name := range []string{"work", "default"} {
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = vlt.CreatePassphraseUnlocker(
|
||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
twoVaults = snapshotStateDir(t, 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
|
||||
// contents, and every directory, written with a trailing "/", to "". Two
|
||||
// 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 {
|
||||
t.Helper()
|
||||
|
||||
tree := map[string]string{}
|
||||
|
||||
err := afero.Walk(fs, testStateDir, func(
|
||||
path string, info os.FileInfo, err error,
|
||||
) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if path == testStateDir+"/lock" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
tree[path+"/"] = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tree[path] = string(content)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
|
||||
// directories and files recorded by snapshotStateDir.
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// In sorted order every directory comes before its contents.
|
||||
for _, path := range slices.Sorted(maps.Keys(tree)) {
|
||||
dir, isDir := strings.CutSuffix(path, "/")
|
||||
if isDir {
|
||||
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
// requireRejectedAndUnchanged runs a command on a copy of the state
|
||||
// directory recorded in before. It requires the error want, so that a later
|
||||
// check rejecting the argument does not count, and everything under the
|
||||
// state directory as it was: the error alone proves nothing, since it could
|
||||
// come after the vault had already been deleted.
|
||||
func requireRejectedAndUnchanged(
|
||||
t *testing.T, before map[string]string, want error,
|
||||
run func(c *cli.Instance) error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
fs := newFsFromSnapshot(t, before)
|
||||
|
||||
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
||||
|
||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||
require.ErrorIs(t, err, want)
|
||||
}
|
||||
|
||||
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
||||
// 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.
|
||||
// Removals, moves and imports use --force, so that only the name check
|
||||
// stands in the way.
|
||||
//
|
||||
//nolint:paralleltest // the cases share cmd
|
||||
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
||||
// Creating a passphrase unlocker is slow by design, so the vaults are
|
||||
// created once and each case runs on its own copy of them.
|
||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||
|
||||
vaultDir := testStateDir + "/vaults.d/default"
|
||||
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
||||
require.Contains(t, before, vaultDir+"/current-unlocker")
|
||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
tests := []struct {
|
||||
command string
|
||||
run func(c *cli.Instance) error
|
||||
}{
|
||||
{"rm --force ..", func(c *cli.Instance) error {
|
||||
return c.RemoveSecret(cmd, "..", true)
|
||||
}},
|
||||
{"rm --force .", func(c *cli.Instance) error {
|
||||
return c.RemoveSecret(cmd, ".", true)
|
||||
}},
|
||||
{`rm --force ""`, func(c *cli.Instance) error {
|
||||
return c.RemoveSecret(cmd, "", true)
|
||||
}},
|
||||
{"rm --force ../../etc", func(c *cli.Instance) error {
|
||||
return c.RemoveSecret(cmd, "../../etc", true)
|
||||
}},
|
||||
{"mv --force .. x", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "..", "x", true)
|
||||
}},
|
||||
{"mv --force x ..", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "x", "..", true)
|
||||
}},
|
||||
{`mv --force x ""`, func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "x", "", true)
|
||||
}},
|
||||
// "work" is not the current vault: a move within it must not
|
||||
// select it when a name is rejected.
|
||||
{"mv --force work:.. work:x", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
||||
}},
|
||||
{"mv --force work:x work:..", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
||||
}},
|
||||
{"mv --force default:.. work", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "default:..", "work", true)
|
||||
}},
|
||||
{"mv --force default:.. work:y", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
||||
}},
|
||||
{"mv --force default:x work:..", func(c *cli.Instance) error {
|
||||
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
||||
}},
|
||||
{"import --force ..", func(c *cli.Instance) error {
|
||||
return c.ImportSecret(cmd, "..", missingFile, true)
|
||||
}},
|
||||
{"import --force .", func(c *cli.Instance) error {
|
||||
return c.ImportSecret(cmd, ".", missingFile, true)
|
||||
}},
|
||||
{"import --force ../../etc", func(c *cli.Instance) error {
|
||||
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
||||
}},
|
||||
{"version list ..", func(c *cli.Instance) error {
|
||||
return c.ListVersions(cmd, "..")
|
||||
}},
|
||||
{"version promote ..", func(c *cli.Instance) error {
|
||||
return c.PromoteVersion(cmd, "..", testVersion)
|
||||
}},
|
||||
{"version rm --force ..", func(c *cli.Instance) error {
|
||||
return c.RemoveVersion(cmd, "..", testVersion, true)
|
||||
}},
|
||||
{"encrypt ..", func(c *cli.Instance) error {
|
||||
return c.Encrypt("..", "", "")
|
||||
}},
|
||||
{"decrypt ..", func(c *cli.Instance) error {
|
||||
return c.Decrypt("..", "", "")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
requireRejectedAndUnchanged(t, before, vault.ErrInvalidSecretName, tt.run)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/67, where
|
||||
// `secret version rm x ../../..` deleted the whole vault,
|
||||
// `secret version rm x ..` the secret x, and `secret version rm x .` or
|
||||
// `secret version rm x ""` every version of x. A version argument is
|
||||
// accepted only if it is one of the versions `secret version list` lists.
|
||||
//
|
||||
//nolint:paralleltest // the cases share cmd
|
||||
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
commands := []struct {
|
||||
command string
|
||||
run func(c *cli.Instance, version string) error
|
||||
}{
|
||||
{"version rm --force x", func(c *cli.Instance, version string) error {
|
||||
return c.RemoveVersion(cmd, "x", version, true)
|
||||
}},
|
||||
{"version promote x", func(c *cli.Instance, version string) error {
|
||||
return c.PromoteVersion(cmd, "x", version)
|
||||
}},
|
||||
{"get x --version", func(c *cli.Instance, version string) error {
|
||||
return c.GetSecretWithVersion(cmd, "x", version)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range commands {
|
||||
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
||||
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
||||
requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound,
|
||||
func(c *cli.Instance) error { return tt.run(c, version) })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
||||
// `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 // 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) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// A second version of "x" becomes the current one.
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// ListVersions lists the newest version first.
|
||||
oldDir := secretDir + "/versions/" + versions[1] + "/"
|
||||
before := snapshotStateDir(t, fs)
|
||||
require.Contains(t, before, oldDir)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expected: the state as before without everything under oldDir.
|
||||
want := map[string]string{}
|
||||
|
||||
for path, content := range before {
|
||||
if !strings.HasPrefix(path, oldDir) {
|
||||
want[path] = content
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, want, snapshotStateDir(t, fs))
|
||||
}
|
||||
|
||||
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
||||
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
||||
// in the current vault and changes nothing else.
|
||||
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||
fs := newFsFromSnapshot(t, before)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
err := c.MoveSecret(&cobra.Command{}, "x", "work", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expected: the state as before, with everything under the current
|
||||
// vault's secrets.d/x/ now under secrets.d/work/.
|
||||
oldDir := testStateDir + "/vaults.d/default/secrets.d/x/"
|
||||
newDir := testStateDir + "/vaults.d/default/secrets.d/work/"
|
||||
want := map[string]string{}
|
||||
|
||||
for path, content := range before {
|
||||
rest, found := strings.CutPrefix(path, oldDir)
|
||||
if found {
|
||||
path = newDir + rest
|
||||
}
|
||||
|
||||
want[path] = content
|
||||
}
|
||||
|
||||
require.Contains(t, want, newDir)
|
||||
require.Equal(t, want, snapshotStateDir(t, fs))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// Entry runs the secret CLI and returns the process exit code. It wipes
|
||||
// every memguard buffer before it returns, so the caller must do nothing
|
||||
// but exit with the code.
|
||||
func Entry() int {
|
||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
||||
// and exits with status 1. The passphrase prompt turns echo off on the
|
||||
// controlling terminal until the read finishes, so a signal there would
|
||||
// leave echo off. Only a process in the terminal's foreground process
|
||||
// group may reset it: one in the background that tries is stopped
|
||||
// instead of exiting.
|
||||
terminal, terminalErr := os.Open(secret.TerminalPath)
|
||||
|
||||
var terminalState *term.State
|
||||
|
||||
if terminalErr == nil {
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
terminalState, terminalErr = term.GetState(int(terminal.Fd()))
|
||||
}
|
||||
|
||||
memguard.CatchSignal(func(os.Signal) {
|
||||
if terminalErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
foreground, err := unix.IoctlGetInt(int(terminal.Fd()), unix.TIOCGPGRP)
|
||||
if err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(int(terminal.Fd()), terminalState)
|
||||
}
|
||||
}, os.Interrupt, unix.SIGTERM)
|
||||
|
||||
defer memguard.Purge()
|
||||
|
||||
err := newRootCmd().Execute()
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
secret.Debug("newRootCmd starting")
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "secret",
|
||||
Short: "A simple secrets manager",
|
||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||
`information securely.`,
|
||||
// Cobra prints the error a command returns; Entry does not.
|
||||
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")
|
||||
// Add subcommands
|
||||
cmd.AddCommand(NewInitCmd())
|
||||
cmd.AddCommand(newGenerateCmd())
|
||||
cmd.AddCommand(newVaultCmd())
|
||||
cmd.AddCommand(newAddCmd())
|
||||
cmd.AddCommand(newGetCmd())
|
||||
cmd.AddCommand(newListCmd())
|
||||
cmd.AddCommand(newRemoveCmd())
|
||||
cmd.AddCommand(newMoveCmd())
|
||||
cmd.AddCommand(newUnlockerCmd())
|
||||
cmd.AddCommand(newImportCmd())
|
||||
cmd.AddCommand(newEncryptCmd())
|
||||
cmd.AddCommand(newDecryptCmd())
|
||||
cmd.AddCommand(newVersionCmd())
|
||||
cmd.AddCommand(newInfoCmd())
|
||||
cmd.AddCommand(newCompletionCmd())
|
||||
|
||||
secret.Debug("newRootCmd completed")
|
||||
|
||||
return cmd
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// testVaultName is the vault name used by the size tests.
|
||||
const testVaultName = "test-vault"
|
||||
|
||||
// newSizeTestVault creates an in-memory vault unlocked with the test
|
||||
// mnemonic and returns the filesystem and vault.
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault
|
||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
return fs, vlt
|
||||
}
|
||||
|
||||
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
||||
// verifies that it is stored.
|
||||
func runAddSecretSizeCase(t *testing.T, size int) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add newline that will be stripped
|
||||
testDataWithNewline := make([]byte, 0, len(testData)+1)
|
||||
testDataWithNewline = append(testDataWithNewline, testData...)
|
||||
testDataWithNewline = append(testDataWithNewline, '\n')
|
||||
|
||||
// Create command with fake stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(testDataWithNewline))
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("test-secret-%d", size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||
"Retrieved secret should match original (without newline)")
|
||||
}
|
||||
|
||||
// runImportSecretSizeCase imports a secret file of the given size and
|
||||
// verifies that it is stored.
|
||||
func runImportSecretSizeCase(t *testing.T, size int) {
|
||||
t.Helper()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Generate test data of specified size
|
||||
testData := make([]byte, size)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write test data to file
|
||||
testFile := fmt.Sprintf("/test/secret-%d.bin", size)
|
||||
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create command
|
||||
cmd := &cobra.Command{}
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
|
||||
// Test importing the secret
|
||||
secretName := fmt.Sprintf("imported-secret-%d", size)
|
||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||
"Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||
//
|
||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||
func TestAddSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{
|
||||
name: "1KB secret",
|
||||
size: 1024,
|
||||
},
|
||||
{
|
||||
name: "10KB secret",
|
||||
size: 10 * 1024,
|
||||
},
|
||||
{
|
||||
name: "100KB secret",
|
||||
size: 100 * 1024,
|
||||
},
|
||||
{
|
||||
name: "1MB secret",
|
||||
size: 1024 * 1024,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runAddSecretSizeCase(t, tt.size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||
//
|
||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||
func TestImportSecretVariousSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{
|
||||
name: "1KB file",
|
||||
size: 1024,
|
||||
},
|
||||
{
|
||||
name: "10KB file",
|
||||
size: 10 * 1024,
|
||||
},
|
||||
{
|
||||
name: "100KB file",
|
||||
size: 100 * 1024,
|
||||
},
|
||||
{
|
||||
name: "1MB file",
|
||||
size: 1024 * 1024,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runImportSecretSizeCase(t, tt.size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||
//
|
||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
sizes := []int{
|
||||
1, // Single byte
|
||||
100, // Small
|
||||
4095, // Just under initial 4KB
|
||||
4096, // Exactly 4KB
|
||||
4097, // Just over 4KB
|
||||
8191, // Just under 8KB (first double)
|
||||
8192, // Exactly 8KB
|
||||
8193, // Just over 8KB
|
||||
12288, // 12KB (should trigger second double)
|
||||
16384, // 16KB
|
||||
32768, // 32KB (after more doublings)
|
||||
65536, // 64KB
|
||||
131072, // 128KB
|
||||
524288, // 512KB
|
||||
1048576, // 1MB
|
||||
}
|
||||
|
||||
for _, size := range sizes {
|
||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Create test data of exactly the specified size
|
||||
// Use a pattern that's easy to verify
|
||||
testData := make([]byte, size)
|
||||
for i := range testData {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Create command with fake stdin (no newline)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(bytes.NewReader(testData))
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
secretName := fmt.Sprintf("buffer-test-%d", size)
|
||||
err = cli.AddSecret(secretName, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||
"Retrieved secret should match original exactly")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs, vlt := newSizeTestVault(t)
|
||||
|
||||
// Create a custom reader that simulates slow streaming input
|
||||
// This will help verify our buffer handling works correctly with partial reads
|
||||
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||
streamingStdin := &slowReader{
|
||||
data: testData,
|
||||
chunkSize: 1000, // Read 1KB at a time
|
||||
}
|
||||
|
||||
// Create command with slow reader as stdin
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetIn(streamingStdin)
|
||||
|
||||
// Create CLI instance
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cli.fs = fs
|
||||
cli.stateDir = testStateDir
|
||||
cli.cmd = cmd
|
||||
|
||||
// Test adding the secret
|
||||
err = cli.AddSecret("streaming-test", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the secret was stored correctly
|
||||
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||
"Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// slowReader simulates a reader that returns data in small chunks
|
||||
type slowReader struct {
|
||||
data []byte
|
||||
offset int
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *slowReader) Read(p []byte) (int, error) {
|
||||
if r.offset >= len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// Read at most chunkSize bytes, bounded by the remaining data and
|
||||
// the destination buffer
|
||||
remaining := len(r.data) - r.offset
|
||||
toRead := min(r.chunkSize, remaining, len(p))
|
||||
|
||||
n := copy(p, r.data[r.offset:r.offset+toRead])
|
||||
r.offset += n
|
||||
|
||||
if r.offset >= len(r.data) {
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
||||
// value to stdout, not stderr
|
||||
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for our vault; each command is given it
|
||||
// in its environment
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Find the secret binary path
|
||||
wd, err := filepath.Abs("../..")
|
||||
require.NoError(t, err, "should get working directory")
|
||||
|
||||
secretPath := filepath.Join(wd, "secret")
|
||||
testPassphrase := "test-passphrase"
|
||||
|
||||
// Initialize vault
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "init should succeed: %s", string(output))
|
||||
|
||||
// The binary, unlike these tests, encrypts the passphrase unlocker's key
|
||||
// at age's scrypt work factor, 18. age writes the work factor last on the
|
||||
// second line of priv.age: "-> scrypt <salt> <work factor>".
|
||||
vaultDir := filepath.Join(tempDir, "vaults.d", "default")
|
||||
unlockerName := readFile(t, filepath.Join(vaultDir, "current-unlocker"))
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
||||
privAge := readFile(t, filepath.Join(unlockerDir, "priv.age"))
|
||||
header := strings.SplitN(string(privAge), "\n", 3)
|
||||
require.Len(t, header, 3, "priv.age should start with an age header")
|
||||
assert.Regexp(t, `^-> scrypt \S+ 18$`, header[1],
|
||||
"the passphrase unlocker should be encrypted at scrypt work factor 18")
|
||||
|
||||
// Add a secret
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
cmd.Stdin = strings.NewReader("test-secret-value")
|
||||
|
||||
output, err = cmd.CombinedOutput()
|
||||
require.NoError(t, err, "add should succeed: %s", string(output))
|
||||
|
||||
// Test that 'secret get' outputs to stdout, not stderr
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
"PATH=" + "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = cmd.Run()
|
||||
require.NoError(t, err, "get should succeed")
|
||||
|
||||
// The secret value should be in stdout
|
||||
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()),
|
||||
"secret value should be in stdout")
|
||||
|
||||
// Nothing should be in stderr
|
||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||
}
|
||||
|
||||
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
|
||||
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
|
||||
// output to stdout, so that it can be piped
|
||||
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
secretPath := secretBinaryPath(t)
|
||||
|
||||
// run runs the secret binary and returns what it wrote to stdout and to
|
||||
// stderr
|
||||
run := func(args ...string) (string, string) {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
stdout, stderr := run("generate", "mnemonic")
|
||||
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
|
||||
"stdout should hold the mnemonic, not %q", stdout)
|
||||
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
|
||||
"the warning should be on stderr")
|
||||
|
||||
run("init")
|
||||
|
||||
stdout, _ = run("vault", "list", "--json")
|
||||
|
||||
var vaults map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
|
||||
"stdout should hold the vaults as JSON, not %q", stdout)
|
||||
assert.Equal(t, "default", vaults["currentVault"])
|
||||
|
||||
stdout, _ = run("unlocker", "list", "--json")
|
||||
|
||||
var unlockers map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
|
||||
"stdout should hold the unlockers as JSON, not %q", stdout)
|
||||
assert.Len(t, unlockers["unlockers"], 1)
|
||||
|
||||
stdout, _ = run("vault", "list")
|
||||
assert.Contains(t, stdout, "default (current)")
|
||||
|
||||
stdout, _ = run("unlocker", "list")
|
||||
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||
func ExecuteCommandInProcess(
|
||||
args []string, stdin string, env map[string]string,
|
||||
) (string, error) {
|
||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||
|
||||
// Save current environment
|
||||
savedEnv := make(map[string]string)
|
||||
for k := range env {
|
||||
savedEnv[k] = os.Getenv(k)
|
||||
}
|
||||
|
||||
// Set test environment
|
||||
for k, v := range env {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
|
||||
// Create root command
|
||||
rootCmd := newRootCmd()
|
||||
|
||||
// Capture output
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
|
||||
// Set stdin if provided
|
||||
if stdin != "" {
|
||||
rootCmd.SetIn(strings.NewReader(stdin))
|
||||
}
|
||||
|
||||
// Set args
|
||||
rootCmd.SetArgs(args)
|
||||
|
||||
// Execute command
|
||||
err := rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
secret.Debug("Command execution completed",
|
||||
"error", err, "outputLength", len(output), "output", output)
|
||||
|
||||
// Add debug info for troubleshooting
|
||||
if len(output) == 0 && err == nil {
|
||||
secret.Debug("Warning: Command executed successfully but produced no output",
|
||||
"args", args)
|
||||
}
|
||||
|
||||
// Restore environment
|
||||
for k, v := range savedEnv {
|
||||
if v == "" {
|
||||
_ = os.Unsetenv(k)
|
||||
} else {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return output, err
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // executes the CLI in-process against shared state
|
||||
func TestOutputCapture(t *testing.T) {
|
||||
// Test vault list command which we fixed
|
||||
output, err := cli.ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
||||
t.Logf("vault list output: %q", output)
|
||||
|
||||
// Test help command
|
||||
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, output, "help output should not be empty")
|
||||
t.Logf("help output length: %d", len(output))
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
// Unlock Failure Tests
|
||||
//
|
||||
// When a vault cannot be opened through its current unlocker, because a
|
||||
// file the unlocker needs is missing, 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. A wrong passphrase gives an error that says so, without that advice.
|
||||
// When the current unlocker does not exist and the vault has another that
|
||||
// `secret unlocker select` accepts, the error says to select one instead.
|
||||
// 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"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// mnemonicAdvice ends the error when the current vault "default", which
|
||||
// its mnemonic opens, cannot be opened through its current unlocker.
|
||||
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
|
||||
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
|
||||
"to the mnemonic to give it a new unlocker"
|
||||
|
||||
// versionAdvice ends the error when a secret's current file cannot be
|
||||
// read.
|
||||
versionAdvice = "; this file only names the current version: " +
|
||||
"'secret version list' lists the secret's versions, and " +
|
||||
"'secret version promote' makes one of them current"
|
||||
|
||||
// unlockTestVaultDir is the directory of the vault "default" of
|
||||
// newTwoVaultFs, the current vault, whose secret "x" is "value".
|
||||
unlockTestVaultDir = testStateDir + "/vaults.d/default"
|
||||
)
|
||||
|
||||
// currentUnlockerDir returns the directory of the current unlocker of the
|
||||
// vault in vaultDir on fs.
|
||||
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
|
||||
t.Helper()
|
||||
|
||||
unlockerName, err := afero.ReadFile(fs,
|
||||
filepath.Join(vaultDir, "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
|
||||
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
||||
}
|
||||
|
||||
// newUnlockTestCLI returns the directory of the current unlocker of the
|
||||
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
|
||||
// instance on fs that has the unlock passphrase, as from the environment,
|
||||
// but not the mnemonic.
|
||||
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
|
||||
t.Helper()
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
|
||||
}
|
||||
|
||||
// discardCmd returns a command whose output is discarded.
|
||||
func discardCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(io.Discard)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getSecret returns what `secret get name` prints.
|
||||
func getSecret(t *testing.T, c *cli.Instance, name string) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
require.NoError(t, c.GetSecret(cmd, name))
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
|
||||
// file that opening the vault through its current unlocker needs is
|
||||
// missing: it keeps the cause, which names the file, and ends with the
|
||||
// advice that the mnemonic still opens the vault. The test then follows
|
||||
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
|
||||
// the vault a new unlocker, which opens it.
|
||||
func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
file string // the file removed
|
||||
inVaultDir bool // the file is the vault's, not the unlocker's
|
||||
want string // the message before the cause
|
||||
}{
|
||||
{
|
||||
file: "current-unlocker",
|
||||
inVaultDir: true,
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to get current unlocker: " +
|
||||
"failed to read current unlocker: ",
|
||||
},
|
||||
{
|
||||
file: "priv.age",
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to get unlocker identity: " +
|
||||
"failed to read unlocker private key: ",
|
||||
},
|
||||
{
|
||||
file: "longterm.age",
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to read encrypted long-term private key: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.file, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
path := filepath.Join(unlockerDir, tt.file)
|
||||
|
||||
if tt.inVaultDir {
|
||||
path = filepath.Join(unlockTestVaultDir, tt.file)
|
||||
}
|
||||
|
||||
require.NoError(t, fs.Remove(path))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Equal(t, path, cause.Path)
|
||||
|
||||
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
|
||||
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||
|
||||
c.Mnemonic = nil
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
|
||||
// a passphrase that does not decrypt the passphrase unlocker: it says that
|
||||
// the passphrase is wrong, without the advice to use the mnemonic, since the
|
||||
// unlocker still opens the vault with the right passphrase. The test then
|
||||
// gives the right passphrase.
|
||||
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
assert.NotContains(t, err.Error(), mnemonicAdvice)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"wrong passphrase for unlocker "+filepath.Base(unlockerDir))
|
||||
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
|
||||
// passphrase unlocker's priv.age is not an age file: the passphrase is not
|
||||
// blamed, and the error ends with the advice that the mnemonic still opens
|
||||
// the vault.
|
||||
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
|
||||
[]byte("damaged"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
require.ErrorContains(t, err, 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"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesSelect checks the error of `secret get`
|
||||
// when current-unlocker names an unlocker that does not exist and the vault
|
||||
// has another: it names the missing unlocker and says to select another
|
||||
// one, without the advice to use the mnemonic. The test then follows that
|
||||
// advice.
|
||||
func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "current-unlocker"),
|
||||
[]byte("missing"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"unlocker with ID missing not found; the vault 'default' has other "+
|
||||
"unlockers: run 'secret unlocker select' with the ID of one of them "+
|
||||
"to make it the current unlocker")
|
||||
|
||||
require.NoError(t, c.UnlockerSelect(filepath.Base(unlockerDir)))
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
|
||||
// when the unlocker that current-unlocker names does not exist and the
|
||||
// vault's only other unlocker is the directory "odd", whose metadata names a
|
||||
// type that `secret unlocker select` refuses: the error ends with the advice
|
||||
// that the mnemonic still opens the vault, not with advice to select another
|
||||
// unlocker. The test then follows that advice.
|
||||
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, fs.RemoveAll(unlockerDir))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
|
||||
"unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"failed to read unlocker metadata: "+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"))
|
||||
}
|
||||
|
||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||
// binary without SB_UNLOCK_PASSPHRASE and in a new session, which has no
|
||||
// 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"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
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: there is no terminal to ask on. 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())
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// Unlocker type names shared across the CLI
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypePGP = "pgp"
|
||||
|
||||
cmdUseList = "list"
|
||||
)
|
||||
|
||||
// Sentinel errors for unlocker operations
|
||||
var (
|
||||
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
|
||||
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
||||
errKeyIDOnlyForPGP = errors.New(
|
||||
"--keyid flag is only valid for PGP unlockers")
|
||||
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
||||
// composes "GPG key <id> is already added as an unlocker".
|
||||
errGPGKeyAlreadyUnlocker = errors.New(
|
||||
"is already added as an unlocker")
|
||||
)
|
||||
|
||||
// UnlockerInfo represents unlocker information for display
|
||||
type UnlockerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
// Table formatting constants
|
||||
const (
|
||||
unlockerIDWidth = 40
|
||||
unlockerTypeWidth = 12
|
||||
unlockerDateWidth = 20
|
||||
unlockerFlagsWidth = 20
|
||||
)
|
||||
|
||||
// getDefaultGPGKey returns the default GPG key ID if available
|
||||
func getDefaultGPGKey() (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// First try to get the configured default key using gpgconf
|
||||
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
return fields[9], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no default key is configured, get the first secret key
|
||||
cmd = exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons")
|
||||
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||
}
|
||||
|
||||
// Parse output to find the first usable secret key
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
// sec line indicates a secret key
|
||||
if strings.HasPrefix(line, "sec:") {
|
||||
fields := strings.Split(line, ":")
|
||||
// Field 5 contains the key ID
|
||||
if len(fields) > 4 && fields[4] != "" {
|
||||
return fields[4], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", errNoGPGSecretKeys
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "unlocker",
|
||||
Short: "Manage unlockers",
|
||||
Long: `Create, list, and remove unlockers for the current vault.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newUnlockerListCmd())
|
||||
cmd.AddCommand(newUnlockerAddCmd())
|
||||
cmd.AddCommand(newUnlockerRemoveCmd())
|
||||
cmd.AddCommand(newUnlockerSelectCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlockerListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List unlockers in the current vault",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
|
||||
return cli.UnlockersList(jsonOutput)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// unlockerAddHelp returns the supported unlocker types list and their
|
||||
// descriptions
|
||||
func unlockerAddHelp() (string, string) {
|
||||
supportedTypes := "passphrase, pgp"
|
||||
typeDescriptions := "Available unlocker types:\n" +
|
||||
"\n" +
|
||||
" passphrase - Traditional password-based encryption\n" +
|
||||
" Prompts for a passphrase that will be used to " +
|
||||
"encrypt/decrypt the vault's master key.\n" +
|
||||
" The passphrase is never stored in plaintext.\n" +
|
||||
"\n" +
|
||||
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
|
||||
" Uses your existing GPG key to encrypt/decrypt " +
|
||||
"the vault's master key.\n" +
|
||||
" Requires gpg to be installed and configured " +
|
||||
"with at least one secret key.\n" +
|
||||
" Use --keyid to specify a particular key, " +
|
||||
"otherwise uses your default GPG key."
|
||||
|
||||
return supportedTypes, typeDescriptions
|
||||
}
|
||||
|
||||
func newUnlockerAddCmd() *cobra.Command {
|
||||
supportedTypes, typeDescriptions := unlockerAddHelp()
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <type>",
|
||||
Short: "Add a new unlocker",
|
||||
Long: "Add a new unlocker to the current vault.\n" +
|
||||
"\n" +
|
||||
typeDescriptions + "\n" +
|
||||
"\n" +
|
||||
"Each vault can have multiple unlockers, allowing different " +
|
||||
"authentication methods\n" +
|
||||
"to access the same vault. This provides flexibility and " +
|
||||
"backup access options.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgs: strings.Split(supportedTypes, ", "),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
unlockerType := args[0]
|
||||
|
||||
// Validate unlocker type
|
||||
validTypes := strings.Split(supportedTypes, ", ")
|
||||
if !slices.Contains(validTypes, unlockerType) {
|
||||
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
|
||||
"Run 'secret unlocker add --help' for detailed descriptions",
|
||||
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||
}
|
||||
|
||||
// Check if --keyid was used with non-PGP type
|
||||
if unlockerType != unlockerTypePGP && cmd.Flags().Changed("keyid") {
|
||||
return errKeyIDOnlyForPGP
|
||||
}
|
||||
|
||||
return cli.UnlockersAdd(unlockerType, cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("keyid", "",
|
||||
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlockerRemoveCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <unlocker-id>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove an unlocker",
|
||||
Long: `Remove an unlocker from the current vault. Asks for ` +
|
||||
`confirmation first, saying whether it is the vault's last ` +
|
||||
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
||||
`is given. Removing the current unlocker makes another of the ` +
|
||||
`vault's unlockers current, if it has one that ` +
|
||||
`'secret unlocker select' accepts. Warning: Without ` +
|
||||
`unlockers and without your mnemonic, vault data will be ` +
|
||||
`permanently inaccessible.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.UnlockersRemove(args[0], force, cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Remove without asking for confirmation, even the last unlocker")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUnlockerSelectCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "select <unlocker-id>",
|
||||
Short: "Select an unlocker as current",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.UnlockerSelect(args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockersList lists unlockers in the current vault, each under its ID,
|
||||
// the name of its directory in unlockers.d
|
||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the current unlocker ID
|
||||
var currentUnlockerID string
|
||||
|
||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err == nil {
|
||||
currentUnlockerID = currentUnlocker.GetID()
|
||||
}
|
||||
|
||||
unlockerMetadata, err := vlt.ListUnlockers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var unlockers []UnlockerInfo
|
||||
|
||||
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||
metadata := unlockerMetadata[unlockerID]
|
||||
|
||||
unlockers = append(unlockers, UnlockerInfo{
|
||||
ID: unlockerID,
|
||||
Type: metadata.Type,
|
||||
CreatedAt: metadata.CreatedAt,
|
||||
Flags: metadata.Flags,
|
||||
IsCurrent: unlockerID == currentUnlockerID,
|
||||
})
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
return cli.printUnlockersJSON(unlockers, currentUnlockerID)
|
||||
}
|
||||
|
||||
return cli.printUnlockersTable(unlockers)
|
||||
}
|
||||
|
||||
// printUnlockersJSON prints unlockers in JSON format
|
||||
func (cli *Instance) printUnlockersJSON(
|
||||
unlockers []UnlockerInfo, currentUnlockerID string,
|
||||
) error {
|
||||
output := map[string]any{
|
||||
"unlockers": unlockers,
|
||||
"currentUnlockerID": currentUnlockerID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printUnlockersTable prints unlockers in a formatted table
|
||||
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
out := cli.cmd.OutOrStdout()
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
||||
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
||||
|
||||
for _, unlocker := range unlockers {
|
||||
flags := ""
|
||||
if len(unlocker.Flags) > 0 {
|
||||
flags = strings.Join(unlocker.Flags, ",")
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
if unlocker.IsCurrent {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
unlocker.Type,
|
||||
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
flags)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersAdd adds a new unlocker
|
||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
switch unlockerType {
|
||||
case unlockerTypePassphrase:
|
||||
return cli.addPassphraseUnlocker(cmd)
|
||||
case unlockerTypePGP:
|
||||
return cli.addPGPUnlocker(cmd)
|
||||
default:
|
||||
supportedTypes := "passphrase, pgp"
|
||||
|
||||
return fmt.Errorf("%w: %s (supported: %s)",
|
||||
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||
// printing a warning if selection fails
|
||||
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||
err := vlt.SelectUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||
} else {
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
}
|
||||
}
|
||||
|
||||
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
|
||||
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the
|
||||
// long-term key
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// The new unlocker gets the passphrase from the environment, which also
|
||||
// unlocks the current passphrase unlocker, else the one entered here
|
||||
passphraseBuffer := cli.UnlockPassphrase
|
||||
if passphraseBuffer == nil {
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
}
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
// CreatePassphraseUnlocker has already made it the current unlocker
|
||||
cmd.Printf("Automatically selected as current unlocker\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPGPUnlocker creates a PGP unlocker in the current vault
|
||||
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||
// Get GPG key ID from flag, environment, or default key
|
||||
var gpgKeyID string
|
||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||
gpgKeyID = flagKeyID
|
||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||
gpgKeyID = envKeyID
|
||||
} else {
|
||||
// Try to get the default GPG key
|
||||
defaultKeyID, err := getDefaultGPGKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||
}
|
||||
|
||||
gpgKeyID = defaultKeyID
|
||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||
}
|
||||
|
||||
// Check if this key is already added as an unlocker
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Check if this GPG key is already added
|
||||
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
||||
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)
|
||||
}
|
||||
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
|
||||
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||
|
||||
// Auto-select the newly created unlocker
|
||||
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockersRemove removes an unlocker from the current vault, after asking
|
||||
// the user to confirm unless force is set.
|
||||
func (cli *Instance) UnlockersRemove(
|
||||
unlockerID string, force bool, cmd *cobra.Command,
|
||||
) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return unlockerToRemove{}, err
|
||||
}
|
||||
|
||||
exists, err := vlt.HasUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return unlockerToRemove{}, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
|
||||
unlockerID, vault.ErrUnlockerNotFound)
|
||||
}
|
||||
|
||||
// Get list of unlockers. It leaves out a directory whose metadata file
|
||||
// is missing or cannot be checked for, read or parsed.
|
||||
unlockers, err := vlt.ListUnlockers()
|
||||
if err != nil {
|
||||
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()),
|
||||
}
|
||||
|
||||
if len(unlockers) == 1 {
|
||||
_, found.last = unlockers[unlockerID]
|
||||
}
|
||||
|
||||
// unlockerID may instead name a directory left out of the list. If its
|
||||
// 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!")
|
||||
}
|
||||
|
||||
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||
|
||||
if newCurrentID != "" {
|
||||
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
|
||||
}
|
||||
|
||||
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
|
||||
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return vlt.SelectUnlocker(unlockerID)
|
||||
}
|
||||
|
||||
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
||||
// for the GPG key with the given fingerprint. It returns an error, and no
|
||||
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
||||
// the caller must then not create the unlocker. It reads unlockers.d itself
|
||||
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
|
||||
// `unlocker list` but not this check: the skipped unlocker may be the
|
||||
// duplicate. A directory whose metadata file is missing or corrupt is not
|
||||
// a working unlocker and is passed over.
|
||||
func (cli *Instance) pgpUnlockerExists(
|
||||
vlt *vault.Vault, fingerprint string,
|
||||
) (bool, error) {
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
||||
)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(unlockersDir, entry.Name())
|
||||
|
||||
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 false, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// TestUnlockerAddKnowsOnlyPassphraseAndPGP is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/132: `secret unlocker add` takes
|
||||
// only passphrase and pgp, on every platform. keychain and secure-enclave
|
||||
// fail as unknown types, and neither --help nor shell completion offers
|
||||
// them.
|
||||
//
|
||||
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
||||
func TestUnlockerAddKnowsOnlyPassphraseAndPGP(t *testing.T) {
|
||||
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
||||
removedTypes := []string{"keychain", "secure-enclave"}
|
||||
|
||||
for _, unlockerType := range removedTypes {
|
||||
_, err := ExecuteCommandInProcess(
|
||||
[]string{"unlocker", "add", unlockerType}, "", env)
|
||||
require.ErrorIs(t, err, errInvalidUnlockerType)
|
||||
|
||||
instance, cmd := newTestInstance(newListTestVault(t, 1))
|
||||
require.ErrorIs(t, instance.UnlockersAdd(unlockerType, cmd),
|
||||
errInvalidUnlockerType)
|
||||
}
|
||||
|
||||
// --help prints the command's Long text, and shell completion offers
|
||||
// its ValidArgs.
|
||||
addCmd := newUnlockerAddCmd()
|
||||
help := addCmd.Long
|
||||
completions := strings.Join(addCmd.ValidArgs, " ")
|
||||
|
||||
for _, offered := range []string{help, completions} {
|
||||
assert.Contains(t, offered, unlockerTypePassphrase)
|
||||
assert.Contains(t, offered, unlockerTypePGP)
|
||||
|
||||
for _, unlockerType := range removedTypes {
|
||||
assert.NotContains(t, offered, unlockerType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// 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,78 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
||||
// side by side whose metadata is the same, creation time included, as
|
||||
// copying an unlocker directory leaves them. It asserts that `unlocker
|
||||
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
||||
// give each its own ID, and that each is selected and removed by its ID
|
||||
// alone.
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Unlocker List Tests
|
||||
//
|
||||
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
||||
// cannot be read or used:
|
||||
//
|
||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||
// metadata does not stop the others from being listed.
|
||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||
// file cannot be checked for or read is left out, and the other is
|
||||
// still listed.
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
// listTestStateDir is the state directory of the synthetic vault used
|
||||
// by the unlocker listing tests.
|
||||
listTestStateDir = "/state"
|
||||
|
||||
// listTestVaultName is the name of that synthetic vault.
|
||||
listTestVaultName = "default"
|
||||
|
||||
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
|
||||
// in the PGP unlockers' metadata.
|
||||
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||
|
||||
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
||||
// directory names under unlockers.d, and so the unlockers' IDs.
|
||||
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||
|
||||
// listTestUnlockersDirName is the directory holding the unlockers.
|
||||
listTestUnlockersDirName = "unlockers.d"
|
||||
|
||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||
listTestMetadataFileName = "unlocker-metadata.json"
|
||||
|
||||
// listTestDirPerm and listTestFilePerm are the fixture permissions.
|
||||
listTestDirPerm = 0o700
|
||||
listTestFilePerm = 0o600
|
||||
)
|
||||
|
||||
// errUnlockersDirUnreadable is returned by the test filesystem in place of
|
||||
// a successful open of unlockers.d.
|
||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||
|
||||
// unlockersDirFailFs fails every open of unlockers.d, as when the
|
||||
// directory cannot be read.
|
||||
type unlockersDirFailFs struct {
|
||||
afero.Fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||
if filepath.Base(name) == listTestUnlockersDirName {
|
||||
return nil, errUnlockersDirUnreadable
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// errMetadataUnreadable is returned by the test filesystem in place of a
|
||||
// successful open of one unlocker's metadata file.
|
||||
var errMetadataUnreadable = errors.New("input/output error")
|
||||
|
||||
// metadataReadFailFs fails every open of the file at unreadablePath. The
|
||||
// file still exists, so checking for it succeeds and only reading it fails.
|
||||
type metadataReadFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
unreadablePath string
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
|
||||
if name == f.unreadablePath {
|
||||
return nil, errMetadataUnreadable
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// errMetadataUncheckable is returned by the test filesystem in place of a
|
||||
// successful check for one unlocker's metadata file.
|
||||
var errMetadataUncheckable = errors.New("permission denied")
|
||||
|
||||
// metadataStatFailFs fails every check for whether the file at
|
||||
// uncheckablePath exists, as when its unlocker directory cannot be entered.
|
||||
type metadataStatFailFs struct {
|
||||
afero.Fs
|
||||
|
||||
uncheckablePath string
|
||||
}
|
||||
|
||||
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
||||
if name == f.uncheckablePath {
|
||||
return nil, errMetadataUncheckable
|
||||
}
|
||||
|
||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||
return f.Fs.Stat(name)
|
||||
}
|
||||
|
||||
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
|
||||
// metadata recording the GPG key ID keyID.
|
||||
func writePGPUnlocker(
|
||||
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||
createdAt time.Time, keyID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
metadata := secret.PGPUnlockerMetadata{
|
||||
UnlockerMetadata: secret.UnlockerMetadata{
|
||||
Type: unlockerTypePGP,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
GPGKeyID: keyID,
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
|
||||
dir := filepath.Join(unlockersDir, dirName)
|
||||
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
|
||||
listTestFilePerm,
|
||||
))
|
||||
}
|
||||
|
||||
// newListTestVault builds a synthetic vault on a MemMapFs containing the
|
||||
// given number of PGP unlockers, with the first one selected as current.
|
||||
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
|
||||
t.Helper()
|
||||
|
||||
base := &afero.MemMapFs{}
|
||||
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
|
||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||
|
||||
require.NoError(t, afero.WriteFile(
|
||||
base, filepath.Join(listTestStateDir, "currentvault"),
|
||||
[]byte(listTestVaultName), listTestFilePerm,
|
||||
))
|
||||
|
||||
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
|
||||
names = names[:unlockerCount]
|
||||
|
||||
for i, name := range names {
|
||||
writePGPUnlocker(t, base, unlockersDir, name,
|
||||
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
|
||||
listTestGPGKeyID+string(rune('A'+i)),
|
||||
)
|
||||
}
|
||||
|
||||
require.NoError(t, afero.WriteFile(
|
||||
base, filepath.Join(vaultDir, "current-unlocker"),
|
||||
[]byte(names[0]), listTestFilePerm,
|
||||
))
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
// listUnlockersJSON runs UnlockersList in JSON mode against the given
|
||||
// filesystem and decodes the emitted unlocker rows.
|
||||
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
|
||||
require.NoError(t, instance.UnlockersList(true))
|
||||
|
||||
var decoded struct {
|
||||
Unlockers []UnlockerInfo `json:"unlockers"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
||||
|
||||
return decoded.Unlockers
|
||||
}
|
||||
|
||||
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||
// fully readable unlockers.d every entry is listed with its real ID.
|
||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := newListTestVault(t, 2)
|
||||
|
||||
unlockers := listUnlockersJSON(t, base)
|
||||
|
||||
require.Len(t, unlockers, 2)
|
||||
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
|
||||
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
|
||||
assert.True(t, unlockers[0].IsCurrent)
|
||||
assert.False(t, unlockers[1].IsCurrent)
|
||||
}
|
||||
|
||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
healthyID := listTestUnlockerDirOne
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "not JSON",
|
||||
metadata: "not json",
|
||||
wantIDs: []string{healthyID},
|
||||
},
|
||||
{
|
||||
name: "GPG key ID of the wrong type",
|
||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||
},
|
||||
{
|
||||
name: "GPG key ID missing",
|
||||
metadata: `{"type": "pgp"}`,
|
||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
metadata: `{"type": "unknown"}`,
|
||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newListTestVault(t, 2)
|
||||
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
|
||||
listTestVaultName, listTestUnlockersDirName,
|
||||
listTestUnlockerDirTwo, listTestMetadataFileName)
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
|
||||
))
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
require.Len(t, unlockers, len(tt.wantIDs))
|
||||
|
||||
for i, wantID := range tt.wantIDs {
|
||||
assert.Equal(t, wantID, unlockers[i].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
|
||||
// metadata file cannot be checked for or cannot be read is left out of the
|
||||
// listing, and the other unlocker is still listed with its real ID. The
|
||||
// failing one sorts first, so finding the other's ID has to step past it
|
||||
// as well.
|
||||
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failingPath := filepath.Join(listTestStateDir, "vaults.d",
|
||||
listTestVaultName, listTestUnlockersDirName,
|
||||
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()
|
||||
|
||||
fs := tt.wrap(newListTestVault(t, 2))
|
||||
|
||||
unlockers := listUnlockersJSON(t, fs)
|
||||
|
||||
require.Len(t, unlockers, 1,
|
||||
"only the unlocker with usable metadata may be listed")
|
||||
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
|
||||
"the listed row must carry the real unlocker ID")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestUnlockerRemoveCurrent is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
|
||||
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
|
||||
// makes the passphrase unlocker current and says so, and the vault then
|
||||
// opens through it. Removing the passphrase unlocker too removes
|
||||
// current-unlocker.
|
||||
//
|
||||
// The second case also gives the vault the unlocker directory "odd", whose
|
||||
// metadata names a type that `secret unlocker select` refuses, and which
|
||||
// sorts before the passphrase unlocker: neither removal makes it current.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||
func TestUnlockerRemoveCurrent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
withOdd bool
|
||||
}{
|
||||
{name: "two unlockers", withOdd: false},
|
||||
{name: "and the directory odd", withOdd: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vlt, err := vault.CreateVault(fs, listTestStateDir,
|
||||
listTestVaultName, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.withOdd {
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
|
||||
"odd", "unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
}
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
|
||||
pgpUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
|
||||
|
||||
var output bytes.Buffer
|
||||
|
||||
cmd.SetOut(&output)
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
|
||||
assert.Contains(t, output.String(),
|
||||
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
|
||||
|
||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||
reopened.UnlockPassphrase = passphrase
|
||||
|
||||
_, err = reopened.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
|
||||
|
||||
exists, err := afero.Exists(fs, filepath.Join(
|
||||
testVaultDir(listTestVaultName), "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists,
|
||||
"current-unlocker is left after the passphrase unlocker")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// usageHeading starts the usage text cobra prints after an error.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Sentinel errors for vault operations
|
||||
var (
|
||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||
errVaultHasLongTermKey = errors.New(
|
||||
"already has a long-term key configured")
|
||||
errMnemonicEnvNotSet = errors.New(
|
||||
"SB_SECRET_MNEMONIC environment variable not set")
|
||||
errPassphraseEnvNotSet = errors.New(
|
||||
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||
)
|
||||
|
||||
func newVaultCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "vault",
|
||||
Short: "Manage vaults",
|
||||
Long: `Create, list, and select vaults for organizing secrets.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newVaultListCmd())
|
||||
cmd.AddCommand(newVaultCreateCmd())
|
||||
cmd.AddCommand(newVaultSelectCmd())
|
||||
cmd.AddCommand(newVaultImportCmd())
|
||||
cmd.AddCommand(newVaultRemoveCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVaultListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.ListVaults(cmd, jsonOutput)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVaultCreateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new vault",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.CreateVault(cmd, args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultSelectCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "select <name>",
|
||||
Short: "Select a vault as current",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.SelectVault(cmd, args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultImportCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
Use: "import <vault-name>",
|
||||
Short: "Import a mnemonic into a vault",
|
||||
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||
`(default if not specified).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
vaultName := "default"
|
||||
if len(args) > 0 {
|
||||
vaultName = args[0]
|
||||
}
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.VaultImport(cmd, vaultName)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVaultRemoveCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a vault",
|
||||
Long: `Remove a vault and all its secrets. Asks for ` +
|
||||
`confirmation first, naming how many secrets the vault ` +
|
||||
`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),
|
||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
return cli.RemoveVault(cmd, args[0], force)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false,
|
||||
"Remove without asking for confirmation, even a vault that contains secrets")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ListVaults lists all available vaults
|
||||
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"vaults": vaults,
|
||||
"currentVault": currentVault,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out, string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
_, _ = fmt.Fprintln(out, "Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
_, _ = fmt.Fprintln(out, " (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
for _, vaultName := range vaults {
|
||||
if vaultName == currentVault {
|
||||
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePassphrase returns the unlock passphrase from the environment,
|
||||
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
|
||||
// returned cleanup function must be deferred by the caller.
|
||||
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
|
||||
if cli.UnlockPassphrase != nil {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
return cli.UnlockPassphrase, func() {}, nil
|
||||
}
|
||||
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupMnemonic()
|
||||
|
||||
mnemonicStr := mnemonic.String()
|
||||
if mnemonicStr == "" {
|
||||
return errMnemonicEmpty
|
||||
}
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonicStr)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||
return errInvalidMnemonicPhrase
|
||||
}
|
||||
|
||||
// Ask for the unlocker passphrase before creating the vault, so that
|
||||
// stopping at the prompt leaves no vault without an unlocker behind
|
||||
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupPassphrase()
|
||||
|
||||
// Create the vault with its passphrase unlocker
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
|
||||
mnemonic, passphraseBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
unlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectVault selects a vault as the current one
|
||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
err = vault.SelectVault(cli.fs, cli.stateDir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Selected vault '%s' as current\n", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||
// and returns the vault directory, public key path, and validated mnemonic
|
||||
func (cli *Instance) vaultImportPreflight(
|
||||
vlt *vault.Vault, vaultName string,
|
||||
) (string, string, string, error) {
|
||||
// Check if vault exists
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, vault.ErrVaultNotFound)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := vaultDir + "/pub.age"
|
||||
|
||||
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, errVaultHasLongTermKey)
|
||||
}
|
||||
|
||||
// Get mnemonic from environment
|
||||
if cli.Mnemonic == nil {
|
||||
return "", "", "", errMnemonicEnvNotSet
|
||||
}
|
||||
|
||||
mnemonic := cli.Mnemonic.String()
|
||||
|
||||
// Validate the mnemonic
|
||||
mnemonicWords := strings.Fields(mnemonic)
|
||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return "", "", "", errInvalidMnemonicPhrase
|
||||
}
|
||||
|
||||
return vaultDir, pubKeyPath, mnemonic, nil
|
||||
}
|
||||
|
||||
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||
func updateVaultImportMetadata(
|
||||
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||
publicKeyHash, familyHash string,
|
||||
) error {
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
||||
// directory lock while importMnemonic runs
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
return cli.importMnemonic(cmd, vaultName)
|
||||
}
|
||||
|
||||
// importMnemonic gives the vault a long-term key derived from the mnemonic
|
||||
// and a passphrase unlocker
|
||||
func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error {
|
||||
secret.Debug("Importing mnemonic into vault",
|
||||
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||
|
||||
// Get the specific vault by name
|
||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||
|
||||
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||
|
||||
// Derive long-term key from mnemonic with the appropriate index
|
||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key",
|
||||
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate public key hash from the actual derivation index being used
|
||||
// This is used to verify that the derived key matches what was stored
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Calculate family hash from index 0 (same for all vaults with this
|
||||
// mnemonic). This is used to identify which vaults belong to the same
|
||||
// mnemonic family.
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
err = updateVaultImportMetadata(
|
||||
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get passphrase from environment variable
|
||||
passphraseBuffer := cli.UnlockPassphrase
|
||||
if passphraseBuffer == nil {
|
||||
return errPassphraseEnvNotSet
|
||||
}
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
|
||||
cmd.Printf("Long-term public key: %s\n", ltPublicKey)
|
||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countVaultSecrets returns the number of secrets in the vault directory
|
||||
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
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 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read secrets directory %s: %w",
|
||||
secretsDir, err)
|
||||
}
|
||||
|
||||
return len(entries), nil
|
||||
}
|
||||
|
||||
// switchAwayFromVault selects another vault as current before removal
|
||||
func (cli *Instance) switchAwayFromVault(
|
||||
cmd *cobra.Command, vaults []string, name string,
|
||||
) error {
|
||||
// Find another vault to switch to
|
||||
var newVault string
|
||||
|
||||
for _, v := range vaults {
|
||||
if v != name {
|
||||
newVault = v
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to the new vault
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||
}
|
||||
|
||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// If removing current vault, switch to another vault first
|
||||
if found.isCurrent {
|
||||
err = cli.switchAwayFromVault(cmd, found.vaults, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the vault directory
|
||||
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Removed vault '%s'\n", name)
|
||||
|
||||
if found.secrets > 0 {
|
||||
cmd.Printf("Warning: Vault contained secrets that have been " +
|
||||
"permanently deleted\n")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
tabWriterPadding = 2
|
||||
)
|
||||
|
||||
// Sentinel errors for version operations
|
||||
var (
|
||||
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
||||
)
|
||||
|
||||
// newVersionCmd returns the version management command
|
||||
func newVersionCmd() *cobra.Command {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
return VersionCommands(cli)
|
||||
}
|
||||
|
||||
// VersionCommands returns the version management commands
|
||||
func VersionCommands(cli *Instance) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Manage secret versions",
|
||||
Long: "Commands for managing secret versions including listing, " +
|
||||
"promoting, and retrieving specific versions",
|
||||
}
|
||||
|
||||
// List versions command
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list <secret-name>",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List all versions of a secret",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
destroySecrets := cli.readSecretEnv()
|
||||
defer destroySecrets()
|
||||
|
||||
return cli.ListVersions(cmd, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
// Promote version command
|
||||
promoteCmd := &cobra.Command{
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete secret name for first arg
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
// Version number completion for the second arg is not implemented
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cli.PromoteVersion(cmd, args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
// Remove version command
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <secret-name> <version>",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "Remove a specific version of a secret",
|
||||
Long: "Remove a specific version of a secret. Cannot remove the " +
|
||||
"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
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
) ([]string, cobra.ShellCompDirective) {
|
||||
// Complete secret name for first arg
|
||||
if len(args) == 0 {
|
||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||
}
|
||||
// Version number completion for the second arg is not implemented
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
|
||||
return versionCmd
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
secret.Debug("ListVersions called", "secret_name", secretName)
|
||||
|
||||
err := vault.ValidateSecretName(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current vault", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the encoded secret name
|
||||
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secret not found", "secret_name", secretName)
|
||||
|
||||
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// List all versions
|
||||
versions, err := secret.ListVersions(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to list versions", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to list versions: %w", err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err)
|
||||
|
||||
currentVersion = ""
|
||||
}
|
||||
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Get long-term key for decrypting metadata
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Create table writer
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||
|
||||
// Load and display each version's metadata
|
||||
for _, version := range versions {
|
||||
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
||||
}
|
||||
|
||||
_ = w.Flush()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printVersionRow loads one version's metadata and writes its table row
|
||||
func printVersionRow(
|
||||
w io.Writer, vlt *vault.Vault,
|
||||
secretName, version, currentVersion string,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) {
|
||||
sv := secret.NewVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
err := sv.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Warn("Failed to load version metadata",
|
||||
"version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
||||
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
||||
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
version, createdAt, status, notBefore, notAfter)
|
||||
}
|
||||
|
||||
// formatVersionTime formats an optional version timestamp, "-" when unset
|
||||
func formatVersionTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// PromoteVersion promotes a specific version to current
|
||||
func (cli *Instance) PromoteVersion(
|
||||
cmd *cobra.Command, secretName string, version string,
|
||||
) error {
|
||||
err := vault.ValidateSecretName(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the encoded secret name
|
||||
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||
|
||||
// Check if version exists
|
||||
exists, err := secret.VersionExists(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("version '%s' %w '%s'",
|
||||
version, vault.ErrVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink using the proper function
|
||||
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update current version: %w", err)
|
||||
}
|
||||
|
||||
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveVersion removes a specific version of a secret, after asking the
|
||||
// user to confirm unless force is set.
|
||||
func (cli *Instance) RemoveVersion(
|
||||
cmd *cobra.Command, secretName string, version string, force bool,
|
||||
) error {
|
||||
err := vault.ValidateSecretName(secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return versionToRemove{}, err
|
||||
}
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return versionToRemove{}, err
|
||||
}
|
||||
|
||||
// Get the encoded secret name
|
||||
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
return versionToRemove{},
|
||||
fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return versionToRemove{},
|
||||
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
exists, err = secret.VersionExists(cli.fs, secretDir, version)
|
||||
if err != nil {
|
||||
return versionToRemove{},
|
||||
fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
|
||||
version, vault.ErrVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
return versionToRemove{},
|
||||
fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Don't allow removing the current version
|
||||
if version == currentVersion {
|
||||
return versionToRemove{}, fmt.Errorf(
|
||||
"cannot remove the current version '%s'; %w",
|
||||
version, errCannotRemoveCurrentVersion)
|
||||
}
|
||||
|
||||
return versionToRemove{
|
||||
dir: filepath.Join(secretDir, "versions", version),
|
||||
question: fmt.Sprintf("Permanently remove version %s of secret "+
|
||||
"'%s' from vault '%s'?", version, secretName, vlt.GetName()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Version CLI Command Tests
|
||||
//
|
||||
// Tests for version-related CLI commands:
|
||||
//
|
||||
// - TestListVersionsCommand: Tests `secret version list` command output
|
||||
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
|
||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
|
||||
// - TestVersionCommandStructure: Tests command structure and help text
|
||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||
//
|
||||
// Test Utilities:
|
||||
// - setupTestVault(): CLI test helper for vault initialization
|
||||
// - Uses consistent test mnemonic for reproducible testing
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
const (
|
||||
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// testStateDir is the in-memory state directory used by CLI tests.
|
||||
testStateDir = "/test/state"
|
||||
)
|
||||
|
||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||
// destroyed when the test ends.
|
||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||
t.Helper()
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
t.Cleanup(mnemonic.Destroy)
|
||||
|
||||
return mnemonic
|
||||
}
|
||||
|
||||
// Helper function to add a version of the "test/secret" secret to the
|
||||
// vault with proper buffer protection
|
||||
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||
t.Helper()
|
||||
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
|
||||
err := vlt.AddSecret("test/secret", buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to set up a vault with long-term key in testStateDir
|
||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
err = vault.SelectVault(fs, testStateDir, "default")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestListVersionsCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = cli.Mnemonic
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
// List versions
|
||||
err = cli.ListVersions(cmd, "test/secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read output
|
||||
outputStr := buf.String()
|
||||
|
||||
// Verify output contains version headers
|
||||
assert.Contains(t, outputStr, "VERSION")
|
||||
assert.Contains(t, outputStr, "CREATED")
|
||||
assert.Contains(t, outputStr, "STATUS")
|
||||
assert.Contains(t, outputStr, "NOT_BEFORE")
|
||||
assert.Contains(t, outputStr, "NOT_AFTER")
|
||||
|
||||
// Should have current status for latest version
|
||||
assert.Contains(t, outputStr, "current")
|
||||
|
||||
// Should have two version entries
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
versionLines := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||
versionLines++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, versionLines)
|
||||
}
|
||||
|
||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
// Try to list versions of non-existent secret
|
||||
err := cli.ListVersions(cmd, "nonexistent/secret")
|
||||
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
func TestPromoteVersionCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Current should be version-2
|
||||
value, err := vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
||||
|
||||
// Promote first version
|
||||
firstVersion := versions[1] // Older version
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
err = cli.PromoteVersion(cmd, "test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read output
|
||||
outputStr := buf.String()
|
||||
|
||||
// Verify success message
|
||||
assert.Contains(t, outputStr, "Promoted version")
|
||||
assert.Contains(t, outputStr, firstVersion)
|
||||
|
||||
// Verify current is now version-1
|
||||
promoted, err := vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer promoted.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
||||
}
|
||||
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecret(t, vlt, []byte("value"), false)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
// Try to promote non-existent version
|
||||
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
||||
require.ErrorIs(t, err, vault.ErrVersionNotFound)
|
||||
}
|
||||
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = cli.Mnemonic
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
// Test getting the current version
|
||||
err = cli.GetSecret(cmd, "test/secret")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version-2", buf.String())
|
||||
|
||||
// Test getting specific version
|
||||
buf.Reset()
|
||||
|
||||
firstVersion := versions[1] // Older version
|
||||
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
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
|
||||
func TestVersionCommandStructure(t *testing.T) {
|
||||
// Test that version commands are properly structured
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := VersionCommands(cli)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
assert.Equal(t, "Manage secret versions", cmd.Short)
|
||||
|
||||
// Check subcommands
|
||||
listCmd := cmd.Commands()[0]
|
||||
assert.Equal(t, "list <secret-name>", listCmd.Use)
|
||||
assert.Equal(t, "List all versions of a secret", listCmd.Short)
|
||||
|
||||
promoteCmd := cmd.Commands()[1]
|
||||
assert.Equal(t, "promote <secret-name> <version>", promoteCmd.Use)
|
||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||
}
|
||||
|
||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Create a secret directory without versions (edge case)
|
||||
vaultDir := stateDir + "/vaults.d/default"
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
|
||||
// List versions - should show "No versions found"
|
||||
err = cli.ListVersions(cmd, "test/secret")
|
||||
|
||||
// Should succeed even with no versions
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
// 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
|
||||
// creates with mode 0600 in the same directory (a rename is only atomic
|
||||
// within one filesystem), is synced to disk, and is renamed over path. The
|
||||
// temporary file is removed if any step fails.
|
||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
||||
"."+filepath.Base(path)+tempNamePart+"*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||
}
|
||||
|
||||
_, err = tmp.Write(data)
|
||||
if err == nil {
|
||||
err = tmp.Sync()
|
||||
}
|
||||
|
||||
closeErr := tmp.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = fs.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_ = fs.Remove(tmp.Name())
|
||||
|
||||
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TempDirFor creates an empty temporary directory in which to build the
|
||||
// directory target before renaming it into place, or into which to move
|
||||
// target before deleting it. It is made in target's grandparent: on the
|
||||
// same filesystem, so the rename is atomic, and outside target's parent,
|
||||
// the directory that is listed to find vaults, secrets, versions and
|
||||
// unlockers, so one left behind by a crash is never taken for one of them.
|
||||
// Its name leaves out target's, which may already be as long as a file name
|
||||
// can be.
|
||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to create temporary directory for %s: %w", target, err)
|
||||
}
|
||||
|
||||
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
|
||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
||||
func RemoveDirAtomic(fs afero.Fs, dir string) error {
|
||||
tmp, err := TempDirFor(fs, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = fs.Rename(dir, filepath.Join(tmp, filepath.Base(dir)))
|
||||
if err != nil {
|
||||
_ = fs.Remove(tmp)
|
||||
|
||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||
}
|
||||
|
||||
err = fs.RemoveAll(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", dir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
var errInjected = errors.New("injected failure")
|
||||
|
||||
// The kinds of change hookFs passes to before.
|
||||
const (
|
||||
opCreate = "create"
|
||||
opOpen = "open"
|
||||
opSync = "sync"
|
||||
opMkdir = "mkdir"
|
||||
opRemove = "remove"
|
||||
opRename = "rename"
|
||||
)
|
||||
|
||||
// currentFile is the file in a secret's directory that names its current
|
||||
// version.
|
||||
const currentFile = "current"
|
||||
|
||||
// unlockerMetadataFile is the file a new unlocker writes last.
|
||||
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.
|
||||
//
|
||||
//nolint:gosec // G101: test data, not a real credential
|
||||
const unlockerPassphrase = "unlocker passphrase"
|
||||
|
||||
// hookFs passes every call through to Fs, but first calls before for each
|
||||
// call that changes the filesystem, and for each Sync of a file opened
|
||||
// through it, with the path it changes (the new path, for Rename). A test
|
||||
// uses before to inspect the tree at every point where a crash could stop
|
||||
// the code under test, or returns an error from it to make that call fail.
|
||||
// If opened is set, OpenFile also tells it the mode it opens each file with.
|
||||
type hookFs struct {
|
||||
afero.Fs
|
||||
|
||||
before func(op, path string) error
|
||||
opened func(path string, perm os.FileMode)
|
||||
}
|
||||
|
||||
// hookFile is a file opened through hookFs.
|
||||
type hookFile struct {
|
||||
afero.File
|
||||
|
||||
before func(op, path string) error
|
||||
}
|
||||
|
||||
func (f hookFile) Sync() error {
|
||||
err := f.before(opSync, f.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.File.Sync()
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) Create(name string) (afero.File, error) {
|
||||
err := h.before(opCreate, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := h.Fs.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hookFile{File: file, before: h.before}, nil
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
err := h.before(opOpen, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.opened != nil {
|
||||
h.opened(name, perm)
|
||||
}
|
||||
|
||||
file, err := h.Fs.OpenFile(name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hookFile{File: file, before: h.before}, nil
|
||||
}
|
||||
|
||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
||||
err := h.before(opMkdir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Mkdir(name, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
||||
err := h.before(opMkdir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) Remove(name string) error {
|
||||
err := h.before(opRemove, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Remove(name)
|
||||
}
|
||||
|
||||
func (h hookFs) RemoveAll(path string) error {
|
||||
err := h.before(opRemove, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (h hookFs) Rename(oldname, newname string) error {
|
||||
err := h.before(opRename, newname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Fs.Rename(oldname, newname)
|
||||
}
|
||||
|
||||
// testFilesystem is a filesystem to run a test on, with a directory in it
|
||||
// to work in.
|
||||
type testFilesystem struct {
|
||||
name string
|
||||
open func(t *testing.T) (afero.Fs, string)
|
||||
}
|
||||
|
||||
// testFilesystems are the in-memory filesystem that most tests use and the
|
||||
// real one: every rename-based guarantee is checked on both.
|
||||
//
|
||||
//nolint:gochecknoglobals // read-only table shared by the tests below
|
||||
var testFilesystems = []testFilesystem{
|
||||
{"memory", func(*testing.T) (afero.Fs, string) {
|
||||
return afero.NewMemMapFs(), "/test"
|
||||
}},
|
||||
{"real", func(t *testing.T) (afero.Fs, string) {
|
||||
t.Helper()
|
||||
|
||||
return afero.NewOsFs(), t.TempDir()
|
||||
}},
|
||||
}
|
||||
|
||||
// dirNames lists the names in dir.
|
||||
func dirNames(t *testing.T, fs afero.Fs, dir string) []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())
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// dirFiles returns the contents of the files in dir, by name.
|
||||
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
files := map[string]string{}
|
||||
|
||||
for _, name := range dirNames(t, fs, dir) {
|
||||
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
|
||||
require.NoError(t, err)
|
||||
|
||||
files[name] = string(data)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
||||
// and returns it.
|
||||
func writeLongTermKey(
|
||||
t *testing.T, fs afero.Fs, stateDir string,
|
||||
) *age.X25519Identity {
|
||||
t.Helper()
|
||||
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.MkdirAll(vaultDir, 0o700))
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600))
|
||||
|
||||
return ltIdentity
|
||||
}
|
||||
|
||||
// newVaultWithSecret creates the vault name under stateDir from the test
|
||||
// mnemonic, with a secret "shared" in it that holds value.
|
||||
func newVaultWithSecret(
|
||||
t *testing.T, fs afero.Fs, stateDir, name, value string,
|
||||
) *vault.Vault {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
||||
defer buffer.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("shared", buffer, false))
|
||||
|
||||
return vlt
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicReplacesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs, dir := tfs.open(t)
|
||||
path := filepath.Join(dir, currentFile)
|
||||
|
||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("old")))
|
||||
require.NoError(t, secret.WriteFileAtomic(fs, path, []byte("new")))
|
||||
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new", string(data))
|
||||
|
||||
info, err := fs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, secret.FilePerms, info.Mode().Perm())
|
||||
|
||||
// No temporary file is left next to it
|
||||
assert.Equal(t, []string{currentFile}, dirNames(t, fs, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicFailureKeepsOldFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, dir := tfs.open(t)
|
||||
path := filepath.Join(dir, currentFile)
|
||||
require.NoError(t, secret.WriteFileAtomic(base, path, []byte("old")))
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
||||
if op == opRename {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
err := secret.WriteFileAtomic(fs, path, []byte("new"))
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
data, err := afero.ReadFile(base, path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "old", string(data))
|
||||
|
||||
// The temporary file is removed again
|
||||
assert.Equal(t, []string{currentFile}, dirNames(t, base, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveDirAtomic checks that RemoveDirAtomic deletes nothing where the
|
||||
// directory stands, which a crash could stop half-way, and that it leaves
|
||||
// nothing behind.
|
||||
func TestRemoveDirAtomic(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, "secrets.d")
|
||||
target := filepath.Join(listed, "doomed")
|
||||
|
||||
require.NoError(t, base.MkdirAll(filepath.Join(target, "versions"), 0o700))
|
||||
require.NoError(t, secret.WriteFileAtomic(base,
|
||||
filepath.Join(target, currentFile), []byte("20231216.001")))
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRemove && strings.HasPrefix(path, target) {
|
||||
t.Errorf("deleted %s where it stands", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs, target))
|
||||
|
||||
// Gone, and no temporary directory is left in the directory
|
||||
// that is listed or in the one above it
|
||||
assert.Empty(t, dirNames(t, base, listed))
|
||||
assert.Equal(t, []string{"secrets.d"}, dirNames(t, base, dir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongestNames adds a secret to a vault and removes the vault, both
|
||||
// named with 255 bytes, the most a file name may have, on the real
|
||||
// filesystem: the temporary directories they use must fit that limit too.
|
||||
func TestLongestNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const longestName = 255
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
name := strings.Repeat("a", longestName)
|
||||
|
||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret(name, value, false))
|
||||
|
||||
got, err := vlt.GetSecret(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer got.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("long"), got.Bytes())
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs, vaultDir))
|
||||
assert.NoDirExists(t, vaultDir)
|
||||
}
|
||||
|
||||
// TestForcedCopyKeepsDestinationUntilReplaced copies a secret over one in
|
||||
// another vault, as a forced move between vaults does, and makes the last
|
||||
// step that completes the copy fail. The secret it was to replace must
|
||||
// still be there unchanged: it may go only once its replacement is whole.
|
||||
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
||||
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
||||
|
||||
// The copy is complete once its current file is written
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(path) == currentFile {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
err := vault.NewVault(fs, stateDir, "dest").
|
||||
CopySecretAllVersions(src, "shared", "shared", true)
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
value, err := dest.GetSecret("shared")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("old"), value.Bytes())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempDirsStayOutOfListings adds a version, adds a secret, copies a
|
||||
// secret over another and removes one, and checks that none of them makes a
|
||||
// directory directly in secrets.d or in a versions directory. Those are
|
||||
// listed to find secrets and versions, so a temporary directory made there
|
||||
// would be listed while half-built, and one left by a crash would stay.
|
||||
func TestTempDirsStayOutOfListings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
newVaultWithSecret(t, base, stateDir, "default", "first")
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
parent := filepath.Base(filepath.Dir(path))
|
||||
if op == opMkdir && (parent == "secrets.d" || parent == "versions") {
|
||||
t.Errorf("made %s where it is listed", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
vlt := vault.NewVault(fs, stateDir, "default")
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("second"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("shared", value, true))
|
||||
require.NoError(t, vlt.AddSecret("other", value, false))
|
||||
require.NoError(t, vlt.CopySecretAllVersions(vlt, "shared", "other", true))
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, secret.RemoveDirAtomic(fs,
|
||||
filepath.Join(vaultDir, "secrets.d", "shared")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionSaveIsWholeOrAbsent checks, before every change Save makes and
|
||||
// once after it returns, that the version directory either does not exist
|
||||
// or holds all of its files: a crash at any point leaves no version that
|
||||
// cannot be decrypted.
|
||||
func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
ltIdentity := writeLongTermKey(t, base, stateDir)
|
||||
|
||||
var versionDir string
|
||||
|
||||
checkVersionDir := func(string, string) error {
|
||||
exists, err := afero.DirExists(base, versionDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
if exists {
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"pub.age", "value.age", privKeyFile, "metadata.age"},
|
||||
dirNames(t, base, versionDir),
|
||||
"version directory visible before it was complete")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fs := hookFs{Fs: base, before: checkVersionDir}
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
versionDir = sv.Directory
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("whole or nothing"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, sv.Save(value))
|
||||
require.NoError(t, checkVersionDir("", ""))
|
||||
|
||||
got, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer got.Destroy()
|
||||
|
||||
assert.Equal(t, "whole or nothing", got.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVersionSaveFailureLeavesNothing makes the write of the encrypted
|
||||
// private key fail, after the value has been written, and checks that
|
||||
// neither the version nor its temporary directory is left behind.
|
||||
func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, stateDir := tfs.open(t)
|
||||
writeLongTermKey(t, base, stateDir)
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(path) == privKeyFile {
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
vault := &MockVersionVault{Name: testVaultName, fs: fs, stateDir: stateDir}
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("never stored"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.ErrorIs(t, sv.Save(value), errInjected)
|
||||
|
||||
// The secret directory holds only the empty versions directory
|
||||
versionsDir := filepath.Dir(sv.Directory)
|
||||
assert.Equal(t, []string{"versions"},
|
||||
dirNames(t, base, filepath.Dir(versionsDir)))
|
||||
assert.Empty(t, dirNames(t, base, versionsDir))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCurrentFilesNeverMissing selects the current version, vault and
|
||||
// unlocker again and checks, before each change this makes, that the file
|
||||
// naming the current one exists: a reader or a crash never finds it
|
||||
// missing.
|
||||
func TestCurrentFilesNeverMissing(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 := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
// Created as the current unlocker
|
||||
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "shared")
|
||||
version, err := secret.GetCurrentVersion(base, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
reselect func(fs afero.Fs) error
|
||||
}{
|
||||
{filepath.Join(secretDir, currentFile), func(fs afero.Fs) error {
|
||||
return secret.SetCurrentVersion(fs, secretDir, version)
|
||||
}},
|
||||
{filepath.Join(stateDir, "currentvault"), func(fs afero.Fs) error {
|
||||
return vault.SelectVault(fs, stateDir, testVaultName)
|
||||
}},
|
||||
{filepath.Join(vaultDir, "current-unlocker"), func(fs afero.Fs) error {
|
||||
return vault.NewVault(fs, stateDir, testVaultName).
|
||||
SelectUnlocker(unlocker.GetID())
|
||||
}},
|
||||
} {
|
||||
fs := hookFs{Fs: base, before: func(string, string) error {
|
||||
exists, err := afero.Exists(base, tc.path)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "%s is missing", filepath.Base(tc.path))
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
require.NoError(t, tc.reselect(fs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFileAtomicTempFile checks that WriteFileAtomic creates its
|
||||
// temporary file with mode 0600, rather than wider and narrowed later, so
|
||||
// that no other user can ever read it, and syncs it before renaming it into
|
||||
// place, so that a crash cannot leave the file named but its data lost.
|
||||
func TestWriteFileAtomicTempFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tfs := range testFilesystems {
|
||||
t.Run(tfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base, dir := tfs.open(t)
|
||||
|
||||
var modes []os.FileMode
|
||||
|
||||
synced := false
|
||||
fs := hookFs{
|
||||
Fs: base,
|
||||
before: func(op, _ string) error {
|
||||
switch op {
|
||||
case opSync:
|
||||
synced = true
|
||||
case opRename:
|
||||
assert.True(t, synced, "renamed before syncing")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
opened: func(_ string, perm os.FileMode) {
|
||||
modes = append(modes, perm)
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, secret.WriteFileAtomic(fs,
|
||||
filepath.Join(dir, currentFile), []byte("new")))
|
||||
assert.Equal(t, []os.FileMode{secret.FilePerms}, modes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPassphraseUnlockerGetsKeyFirst creates a passphrase unlocker in a
|
||||
// vault whose long-term key cannot be had: it must fail without writing
|
||||
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
||||
// it would replace.
|
||||
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// No mnemonic, and no current unlocker to get the key from
|
||||
base := afero.NewMemMapFs()
|
||||
_, 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 before getting the long-term key", path)
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
||||
CreatePassphraseUnlocker(passphrase)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
|
||||
// creating a passphrase unlocker makes, that the unlocker's directory either
|
||||
// does not exist or holds all of its files: a crash or a failure at any point
|
||||
// leaves no partial unlocker.
|
||||
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
// Package secret provides core types and constants for the secret application.
|
||||
package secret
|
||||
|
||||
import "os"
|
||||
|
||||
const (
|
||||
// AppID is the unique identifier for this application
|
||||
AppID = "berlin.sneak.pkg.secret"
|
||||
|
||||
// EnvStateDir is the environment variable for specifying the state directory
|
||||
EnvStateDir = "SB_SECRET_STATE_DIR"
|
||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||
//nolint:gosec // G101: env var name, not a credential
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
|
||||
// TerminalPath opens the controlling terminal of the process that opens
|
||||
// it, where the passphrase and mnemonic prompts ask.
|
||||
TerminalPath = "/dev/tty"
|
||||
|
||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||
// new unlocker's directory. 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
|
||||
const (
|
||||
// DirPerms is the permission used for directories (read-write-execute for owner only)
|
||||
DirPerms os.FileMode = 0o700
|
||||
|
||||
// FilePerms is the permission used for sensitive files (read-write for owner only)
|
||||
FilePerms os.FileMode = 0o600
|
||||
)
|
||||
+174
-53
@@ -2,35 +2,92 @@ package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"syscall"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// encryptToRecipient encrypts data to a recipient using age
|
||||
func encryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errNoTerminal = errors.New("there is no terminal to ask on")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
// ScryptWorkFactor is, when not zero, the scrypt work factor that
|
||||
// EncryptWithPassphrase uses instead of age's, 18: log2 of scrypt's cost
|
||||
// parameter N. Deriving a key with age's takes about a second and 256 MiB, on
|
||||
// purpose, since so does every guess at the passphrase. Only tests set it,
|
||||
// lower, before any test runs, so that the passphrase unlockers they create
|
||||
// cost nothing; the program leaves it zero. Decryption takes the work factor
|
||||
// from the encrypted data, so it needs no setting.
|
||||
//
|
||||
//nolint:gochecknoglobals // set by the tests of the packages that use this one
|
||||
var ScryptWorkFactor int
|
||||
|
||||
// EncryptToRecipient encrypts data to a recipient using age
|
||||
// The data parameter should be a LockedBuffer for secure memory handling
|
||||
func EncryptToRecipient(
|
||||
data *memguard.LockedBuffer, recipient age.Recipient,
|
||||
) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
Debug("Creating age encryptor")
|
||||
|
||||
w, err := age.Encrypt(&buf, recipient)
|
||||
if err != nil {
|
||||
Debug("Failed to create encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
Debug("Created age encryptor successfully")
|
||||
Debug("Writing data to encryptor")
|
||||
|
||||
_, err = w.Write(data.Bytes())
|
||||
if err != nil {
|
||||
Debug("Failed to write data to encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
Debug("Closing encryptor")
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
Debug("Failed to close encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
Debug("Closed encryptor successfully")
|
||||
|
||||
result := buf.Bytes()
|
||||
Debug("EncryptToRecipient completed successfully", "result_length", len(result))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decryptWithIdentity decrypts data with an identity using age
|
||||
func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
||||
// DecryptWithIdentity decrypts data with an identity using age
|
||||
func DecryptWithIdentity(
|
||||
data []byte, identity age.Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||
@@ -41,72 +98,136 @@ func decryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
// Create a secure buffer for the decrypted data
|
||||
resultBuffer := memguard.NewBufferFromBytes(result)
|
||||
|
||||
// Zero out the original slice to prevent plaintext from lingering
|
||||
// in unprotected memory
|
||||
for i := range result {
|
||||
result[i] = 0
|
||||
}
|
||||
|
||||
return resultBuffer, nil
|
||||
}
|
||||
|
||||
// encryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||
func encryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
|
||||
recipient, err := age.NewScryptRecipient(passphrase)
|
||||
// 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
|
||||
// scrypt-based encryption. Both data and passphrase parameters should
|
||||
// be LockedBuffers for secure memory handling
|
||||
func EncryptWithPassphrase(
|
||||
data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
|
||||
) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
if passphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
// Create recipient directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||
}
|
||||
|
||||
return encryptToRecipient(data, recipient)
|
||||
if ScryptWorkFactor != 0 {
|
||||
recipient.SetWorkFactor(ScryptWorkFactor)
|
||||
}
|
||||
|
||||
return EncryptToRecipient(data, recipient)
|
||||
}
|
||||
|
||||
// decryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||
func decryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
|
||||
identity, err := age.NewScryptIdentity(passphrase)
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's
|
||||
// scrypt-based decryption. The passphrase parameter should be a
|
||||
// LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(
|
||||
encryptedData []byte, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if passphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
// Create identity directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||
}
|
||||
|
||||
return decryptWithIdentity(encryptedData, identity)
|
||||
return DecryptWithIdentity(encryptedData, identity)
|
||||
}
|
||||
|
||||
// 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
|
||||
func readPassphrase(prompt string) (string, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(int(syscall.Stdin)) {
|
||||
// Not a terminal - fall back to regular input
|
||||
fmt.Print(prompt)
|
||||
var passphrase string
|
||||
_, err := fmt.Scanln(&passphrase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
return passphrase, nil
|
||||
}
|
||||
|
||||
// Terminal input - use secure password reading
|
||||
fmt.Print(prompt)
|
||||
passphrase, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
fmt.Println() // Print newline since ReadPassword doesn't echo
|
||||
|
||||
if len(passphrase) == 0 {
|
||||
return "", fmt.Errorf("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
return string(passphrase), nil
|
||||
// Returns a LockedBuffer containing the passphrase for secure memory handling.
|
||||
// Every error it returns wraps ErrPassphraseNotRead.
|
||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
|
||||
}
|
||||
|
||||
// decryptSecretWithLongTermKey is a helper that parses a long-term private key and uses it to decrypt secret data
|
||||
func decryptSecretWithLongTermKey(ltPrivKeyData []byte, encryptedData []byte) ([]byte, error) {
|
||||
// Parse long-term private key
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
// 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 asks on the controlling terminal and reads the answer
|
||||
// there without echoing it, whatever stdin is: in
|
||||
// `echo value | secret add name --force`, stdin is a pipe. 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) {
|
||||
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
Debug("Failed to open the controlling terminal", "error", err)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %w. Please set the %s environment variable or run interactively",
|
||||
notRead, errNoTerminal, envVar)
|
||||
}
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
_, _ = fmt.Fprint(terminal, prompt)
|
||||
|
||||
// Decrypt secret data using long-term key
|
||||
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
|
||||
input, err := term.ReadPassword(int(terminal.Fd()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
||||
}
|
||||
|
||||
// The newline typed after the input, which ReadPassword did not echo
|
||||
_, _ = fmt.Fprintln(terminal)
|
||||
|
||||
if len(input) == 0 {
|
||||
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
||||
}
|
||||
|
||||
// Create a secure buffer and copy the input
|
||||
secureBuffer := memguard.NewBufferFromBytes(input)
|
||||
|
||||
// Clear the original input slice
|
||||
for i := range input {
|
||||
input[i] = 0
|
||||
}
|
||||
|
||||
return decryptedData, nil
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
|
||||
// private key, and that the identity still gives that key afterwards: the
|
||||
// helper overwrites the string age returned, so age must not keep it.
|
||||
func TestIdentityToLockedBuffer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
buffer := secret.IdentityToLockedBuffer(identity)
|
||||
defer buffer.Destroy()
|
||||
|
||||
parsed, err := age.ParseX25519Identity(buffer.String())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
|
||||
|
||||
assert.Equal(t, identity.String(), buffer.String())
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
debugEnabled bool //nolint:gochecknoglobals // package debug state
|
||||
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
|
||||
)
|
||||
|
||||
//nolint:gochecknoinits // debug logging must be ready before any package use
|
||||
func init() {
|
||||
InitDebugLogging()
|
||||
}
|
||||
|
||||
// InitDebugLogging initializes the debug logging system based on the
|
||||
// current GODEBUG environment variable
|
||||
func InitDebugLogging() {
|
||||
godebug := os.Getenv("GODEBUG")
|
||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||
|
||||
if !debugEnabled {
|
||||
// Create a no-op logger that discards all output
|
||||
debugLogger = slog.New(slog.DiscardHandler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disable stderr buffering for immediate debug output when
|
||||
// debugging is enabled
|
||||
//nolint:dogsled // syscall.Syscall returns three values, none needed
|
||||
_, _, _ = syscall.Syscall(
|
||||
syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
|
||||
// Check if STDERR is a TTY
|
||||
isTTY := term.IsTerminal(syscall.Stderr)
|
||||
|
||||
var handler slog.Handler
|
||||
if isTTY {
|
||||
// TTY output: colorized structured format
|
||||
handler = newColorizedHandler(os.Stderr)
|
||||
} else {
|
||||
// Non-TTY output: JSON Lines format
|
||||
handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})
|
||||
}
|
||||
|
||||
debugLogger = slog.New(handler)
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true if debug logging is enabled
|
||||
func IsDebugEnabled() bool {
|
||||
return debugEnabled
|
||||
}
|
||||
|
||||
// Warn logs a warning message to stderr unconditionally (visible
|
||||
// without --verbose or debug flags)
|
||||
func Warn(msg string, args ...any) {
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("WARNING: " + msg)
|
||||
|
||||
for i := 0; i+1 < len(args); i += 2 {
|
||||
fmt.Fprintf(&output, " %s=%v", args[i], args[i+1])
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional attributes
|
||||
func Debug(msg string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(msg, args...)
|
||||
}
|
||||
|
||||
// Debugf logs a formatted debug message with optional attributes
|
||||
func Debugf(format string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// DebugWith logs a debug message with structured attributes
|
||||
func DebugWith(msg string, attrs ...slog.Attr) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
||||
}
|
||||
|
||||
// colorizedHandler implements a TTY-friendly structured log handler
|
||||
type colorizedHandler struct {
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
func newColorizedHandler(output io.Writer) slog.Handler {
|
||||
return &colorizedHandler{output: output}
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
// Explicitly check that debug is enabled AND the level is DEBUG or higher
|
||||
// This ensures we don't default to INFO level when debug is enabled
|
||||
return debugEnabled && level >= slog.LevelDebug
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
if !debugEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Format: [DEBUG] message {key=value, key2=value2}
|
||||
output := fmt.Sprintf("\033[36m[DEBUG]\033[0m \033[1m%s\033[0m", record.Message)
|
||||
|
||||
if record.NumAttrs() > 0 {
|
||||
output += " \033[33m{"
|
||||
first := true
|
||||
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
if !first {
|
||||
output += ", "
|
||||
}
|
||||
|
||||
first = false
|
||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
output += "}\033[0m"
|
||||
}
|
||||
|
||||
output += "\n"
|
||||
_, err := h.output.Write([]byte(output))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) WithAttrs(_ []slog.Attr) slog.Handler {
|
||||
// For simplicity, return the same handler
|
||||
// In a more complex implementation, we'd create a new handler with the attrs
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *colorizedHandler) WithGroup(_ string) slog.Handler {
|
||||
// For simplicity, return the same handler
|
||||
// In a more complex implementation, we'd create a new handler with the group
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//nolint:testpackage // white-box test of unexported debug internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func TestDebugLogging(t *testing.T) {
|
||||
// Test cleanup handled by t.Setenv
|
||||
defer InitDebugLogging() // Re-initialize debug system after test
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
godebug string
|
||||
expectEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "debug enabled",
|
||||
godebug: "berlin.sneak.pkg.secret",
|
||||
expectEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "debug enabled with other flags",
|
||||
godebug: "other=1,berlin.sneak.pkg.secret,another=value",
|
||||
expectEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "debug disabled",
|
||||
godebug: "other=1",
|
||||
expectEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "debug disabled empty",
|
||||
godebug: "",
|
||||
expectEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set GODEBUG
|
||||
if tt.godebug != "" {
|
||||
t.Setenv("GODEBUG", tt.godebug)
|
||||
}
|
||||
|
||||
// Re-initialize debug system
|
||||
InitDebugLogging()
|
||||
|
||||
// Test if debug is enabled
|
||||
enabled := IsDebugEnabled()
|
||||
if enabled != tt.expectEnabled {
|
||||
t.Errorf("IsDebugEnabled() = %v, want %v", enabled, tt.expectEnabled)
|
||||
}
|
||||
|
||||
// If debug should be enabled, test that debug output works
|
||||
if tt.expectEnabled {
|
||||
// Capture debug output by redirecting the colorized handler
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Override the debug logger for testing
|
||||
oldLogger := debugLogger
|
||||
if term.IsTerminal(syscall.Stderr) {
|
||||
// TTY: use colorized handler with our buffer
|
||||
debugLogger = slog.New(newColorizedHandler(&buf))
|
||||
} else {
|
||||
// Non-TTY: use JSON handler with our buffer
|
||||
debugLogger = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
}
|
||||
|
||||
// Test debug output
|
||||
Debug("test message", "key", "value")
|
||||
|
||||
// Restore original logger
|
||||
debugLogger = oldLogger
|
||||
|
||||
// Check that output was generated
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "test message") {
|
||||
t.Errorf("Debug output does not contain expected message. Got: %s", output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // exercises process-global debug logger state
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// Enable debug for testing
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
|
||||
defer InitDebugLogging() // Re-initialize after test
|
||||
|
||||
InitDebugLogging()
|
||||
|
||||
if !IsDebugEnabled() {
|
||||
t.Log("Debug not enabled, but continuing with debug function tests anyway")
|
||||
}
|
||||
|
||||
// Test that debug functions don't panic and can be called
|
||||
t.Run("Debug", func(_ *testing.T) {
|
||||
Debug("test debug message")
|
||||
Debug("test with args", "key", "value", "number", 42)
|
||||
})
|
||||
|
||||
t.Run("Debugf", func(_ *testing.T) {
|
||||
Debugf("formatted message: %s %d", "test", 123)
|
||||
})
|
||||
|
||||
t.Run("DebugWith", func(_ *testing.T) {
|
||||
DebugWith("structured message",
|
||||
slog.String("string_key", "string_value"),
|
||||
slog.Int("int_key", 42),
|
||||
slog.Bool("bool_key", true),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DetermineStateDir determines the state directory based on environment
|
||||
// variables and OS.
|
||||
// It returns an error if no usable directory can be determined.
|
||||
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||
// Check for environment variable first
|
||||
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
|
||||
return envStateDir, nil
|
||||
}
|
||||
|
||||
// Use custom config dir if provided
|
||||
if customConfigDir != "" {
|
||||
return filepath.Join(customConfigDir, AppID), nil
|
||||
}
|
||||
|
||||
// Use os.UserConfigDir() which handles platform-specific directories:
|
||||
// - On Unix systems, it returns $XDG_CONFIG_HOME or $HOME/.config
|
||||
// - On Darwin, it returns $HOME/Library/Application Support
|
||||
// - On Windows, it returns %AppData%
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
// Fallback to a reasonable default if we can't determine user config dir
|
||||
homeDir, homeErr := os.UserHomeDir()
|
||||
if homeErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"unable to determine state directory: config dir: %w, home dir: %w",
|
||||
err, homeErr)
|
||||
}
|
||||
|
||||
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
||||
Warn("Could not determine user config directory, falling back to default",
|
||||
"fallback", fallbackDir, "error", err)
|
||||
|
||||
return fallbackDir, nil
|
||||
}
|
||||
|
||||
return filepath.Join(configDir, AppID), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
// Clear all env vars that could provide a home/config directory.
|
||||
// On Darwin, os.UserHomeDir may still succeed via the password
|
||||
// database, so we also test via an explicit empty-customConfigDir
|
||||
// path to exercise the fallback branch.
|
||||
t.Setenv(secret.EnvStateDir, "")
|
||||
t.Setenv("HOME", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
result, err := secret.DetermineStateDir("")
|
||||
// On systems where both lookups fail, we must get an error.
|
||||
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
||||
// result should still be valid (non-empty, not root-relative).
|
||||
if err != nil {
|
||||
// Good — the error case is handled.
|
||||
return
|
||||
}
|
||||
|
||||
if result == "/.config/"+secret.AppID || result == "" {
|
||||
t.Errorf(
|
||||
"DetermineStateDir returned dangerous/empty path %q without error",
|
||||
result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||
t.Setenv(secret.EnvStateDir, "/custom/state")
|
||||
|
||||
result, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != "/custom/state" {
|
||||
t.Errorf("expected /custom/state, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
||||
t.Setenv(secret.EnvStateDir, "")
|
||||
|
||||
result, err := secret.DetermineStateDir("/my/config")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := "/my/config/" + secret.AppID
|
||||
if result != expected {
|
||||
t.Errorf("expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// VaultMetadata contains information about a vault
|
||||
type VaultMetadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DerivationIndex uint32 `json:"derivationIndex"`
|
||||
// Double SHA256 hash of the actual long-term public key
|
||||
PublicKeyHash string `json:"publicKeyHash,omitempty"`
|
||||
// Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
|
||||
MnemonicFamilyHash string `json:"mnemonicFamilyHash,omitempty"`
|
||||
}
|
||||
|
||||
// UnlockerMetadata contains information about an unlocker
|
||||
type UnlockerMetadata struct {
|
||||
Type string `json:"type"` // passphrase or pgp
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// Metadata contains information about a secret
|
||||
type Metadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Configuration represents the global configuration
|
||||
type Configuration struct {
|
||||
DefaultVault string `json:"defaultVault,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// testMnemonic is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||
// destroyed when the test ends.
|
||||
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||
t.Helper()
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
t.Cleanup(mnemonic.Destroy)
|
||||
|
||||
return mnemonic
|
||||
}
|
||||
|
||||
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||
func writeTestPublicKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestPrivateKey encrypts the private key with the passphrase,
|
||||
// writes it, and verifies it exists.
|
||||
func writeTestPrivateKey(
|
||||
t *testing.T,
|
||||
fs afero.Fs,
|
||||
unlockerDir string,
|
||||
agePrivateKey string,
|
||||
testPassphrase string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(
|
||||
privKeyBuffer, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestLongTermKey encrypts the derived long-term key to the
|
||||
// unlocker's recipient, writes it, and verifies it exists.
|
||||
func writeTestLongTermKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Derive a long-term identity from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker's recipient
|
||||
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestPassphraseUnlocker creates a temp unlocker directory and a
|
||||
// passphrase unlocker with a fresh age identity for testing.
|
||||
func newTestPassphraseUnlocker(
|
||||
t *testing.T, fs afero.Fs,
|
||||
) (*secret.PassphraseUnlocker, *age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create the directory structure in a temp dir
|
||||
unlockerDir := filepath.Join(t.TempDir(), "unlocker")
|
||||
|
||||
err := os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||
}
|
||||
|
||||
// Set up test metadata
|
||||
metadata := secret.UnlockerMetadata{
|
||||
Type: "passphrase",
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{},
|
||||
}
|
||||
|
||||
// Create passphrase unlocker
|
||||
unlocker := secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
||||
|
||||
// Generate a test age identity
|
||||
ageIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate age identity: %v", err)
|
||||
}
|
||||
|
||||
return unlocker, ageIdentity, unlockerDir
|
||||
}
|
||||
|
||||
//nolint:paralleltest // subtests share real-FS state, order matters
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test data
|
||||
testPassphrase := "test-passphrase-123"
|
||||
|
||||
unlocker, ageIdentity, unlockerDir := newTestPassphraseUnlocker(t, fs)
|
||||
agePrivateKey := ageIdentity.String()
|
||||
agePublicKey := ageIdentity.Recipient().String()
|
||||
|
||||
// Test writing public key
|
||||
t.Run("WritePublicKey", func(t *testing.T) {
|
||||
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
// Test encrypting private key with passphrase
|
||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
||||
})
|
||||
|
||||
// Test writing long-term key
|
||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
unlocker.Passphrase = passphrase
|
||||
|
||||
// Test getting identity with the passphrase the unlocker was given,
|
||||
// twice: using it must leave it intact for the next use
|
||||
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
|
||||
for range 2 {
|
||||
identity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get identity with passphrase: %v", err)
|
||||
}
|
||||
|
||||
// Verify the identity matches what we expect
|
||||
expectedPubKey := ageIdentity.Recipient().String()
|
||||
|
||||
actualPubKey := identity.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
unlocker.Passphrase = nil
|
||||
|
||||
// Test getting identity from prompt (this would require mocking the
|
||||
// prompt). For real integration tests, we'd need a way to mock the
|
||||
// passphrase input. Here we just verify the error is what we expect
|
||||
// when no passphrase is available.
|
||||
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
|
||||
// This should fail since we're not in an interactive terminal
|
||||
_, err := unlocker.GetIdentity()
|
||||
if err == nil {
|
||||
t.Errorf("Should have failed to get identity without a passphrase")
|
||||
}
|
||||
})
|
||||
|
||||
// Test removing the unlocker
|
||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||
err := unlocker.Remove()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to remove unlocker: %v", err)
|
||||
}
|
||||
|
||||
// Verify the directory is gone
|
||||
exists, err := afero.DirExists(fs, unlockerDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
t.Errorf("Unlocker directory should not exist after removal")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// PassphraseUnlockKey represents a passphrase-protected unlock key
|
||||
type PassphraseUnlockKey struct {
|
||||
Directory string
|
||||
Metadata UnlockKeyMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys
|
||||
func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting passphrase unlock key identity",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.String("key_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Read encrypted private key of unlock key
|
||||
unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
|
||||
Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath)
|
||||
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockKeyPrivPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase unlock key private key", "error", err, "path", unlockKeyPrivPath)
|
||||
return nil, fmt.Errorf("failed to read unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted passphrase unlock key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
|
||||
)
|
||||
|
||||
// Get passphrase for decrypting the unlock key
|
||||
var passphraseStr string
|
||||
if envPassphrase := os.Getenv(EnvUnlockPassphrase); envPassphrase != "" {
|
||||
Debug("Using passphrase from environment variable", "key_id", p.GetID())
|
||||
passphraseStr = envPassphrase
|
||||
} else {
|
||||
Debug("Prompting for passphrase", "key_id", p.GetID())
|
||||
// Prompt for passphrase
|
||||
passphraseStr, err = readPassphrase("Enter passphrase to unlock vault: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
|
||||
|
||||
// Decrypt the unlock key private key with passphrase
|
||||
privKeyData, err := decryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted unlock key private key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("decrypted_length", len(privKeyData)),
|
||||
)
|
||||
|
||||
// Parse the decrypted private key
|
||||
Debug("Parsing decrypted unlock key identity", "key_id", p.GetID())
|
||||
identity, err := age.ParseX25519Identity(string(privKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse unlock key private key", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to parse unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully parsed passphrase unlock key identity",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.String("public_key", identity.Recipient().String()),
|
||||
)
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// GetType implements UnlockKey interface
|
||||
func (p *PassphraseUnlockKey) GetType() string {
|
||||
return "passphrase"
|
||||
}
|
||||
|
||||
// GetMetadata implements UnlockKey interface
|
||||
func (p *PassphraseUnlockKey) GetMetadata() UnlockKeyMetadata {
|
||||
return p.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements UnlockKey interface
|
||||
func (p *PassphraseUnlockKey) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements UnlockKey interface
|
||||
func (p *PassphraseUnlockKey) GetID() string {
|
||||
return p.Metadata.ID
|
||||
}
|
||||
|
||||
// ID implements UnlockKey interface - generates ID from creation timestamp
|
||||
func (p *PassphraseUnlockKey) ID() string {
|
||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||
createdAt := p.Metadata.CreatedAt
|
||||
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
||||
}
|
||||
|
||||
// Remove implements UnlockKey interface - removes the passphrase unlock key
|
||||
func (p *PassphraseUnlockKey) Remove() error {
|
||||
// For passphrase keys, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlock key directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPassphraseUnlockKey creates a new PassphraseUnlockKey instance
|
||||
func NewPassphraseUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PassphraseUnlockKey {
|
||||
return &PassphraseUnlockKey{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePassphraseKey creates a new passphrase-protected unlock key
|
||||
func CreatePassphraseKey(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlockKey, error) {
|
||||
// Get current vault
|
||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
return currentVault.CreatePassphraseKey(passphrase)
|
||||
}
|
||||
|
||||
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
|
||||
func (p *PassphraseUnlockKey) DecryptLongTermKey() ([]byte, error) {
|
||||
DebugWith("Decrypting long-term key with passphrase unlock key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.String("key_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Get our unlock key identity
|
||||
unlockIdentity, err := p.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get passphrase unlock identity for long-term decryption", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
|
||||
}
|
||||
|
||||
// Read encrypted long-term private key
|
||||
encryptedLtPrivKeyPath := filepath.Join(p.Directory, "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(p.fs, 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)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted long-term private key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using our unlock key
|
||||
Debug("Decrypting long-term private key with passphrase unlock key", "key_id", p.GetID())
|
||||
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term private key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
||||
)
|
||||
|
||||
return ltPrivKeyData, nil
|
||||
}
|
||||
|
||||
// DecryptSecret decrypts a secret using this passphrase unlock key's long-term key management
|
||||
func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
|
||||
DebugWith("Decrypting secret with passphrase unlock key",
|
||||
slog.String("secret_name", secret.Name),
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.String("key_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Get encrypted secret data
|
||||
encryptedData, err := secret.GetEncryptedData()
|
||||
if err != nil {
|
||||
Debug("Failed to get encrypted secret data for passphrase decryption", "error", err, "secret_name", secret.Name)
|
||||
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Retrieved encrypted secret data for passphrase decryption",
|
||||
slog.String("secret_name", secret.Name),
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using our unlock key
|
||||
ltPrivKeyData, err := p.DecryptLongTermKey()
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Use helper to parse long-term key and decrypt secret
|
||||
decryptedData, err := decryptSecretWithLongTermKey(ltPrivKeyData, encryptedData)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret with passphrase unlock key",
|
||||
slog.String("secret_name", secret.Name),
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("decrypted_length", len(decryptedData)),
|
||||
)
|
||||
|
||||
return decryptedData, nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"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")
|
||||
|
||||
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
|
||||
// passphrase does not decrypt the unlocker's private key.
|
||||
var ErrWrongPassphrase = errors.New("wrong passphrase")
|
||||
|
||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||
type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||
}
|
||||
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
||||
func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting passphrase unlocker identity",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.String("unlocker_type", p.GetType()),
|
||||
)
|
||||
|
||||
passphraseBuffer, err := p.getPassphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
// Read encrypted private key of unlocker
|
||||
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
|
||||
Debug("Reading encrypted passphrase unlocker", "path", unlockerPrivPath)
|
||||
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase unlocker private key",
|
||||
"error", err, "path", unlockerPrivPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted passphrase unlocker",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
|
||||
)
|
||||
|
||||
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
||||
|
||||
// Decrypt the unlocker private key with passphrase
|
||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt unlocker private key",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
// age says that no identity matched when the passphrase is wrong
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
if errors.As(err, &noMatch) {
|
||||
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||
}
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Successfully decrypted unlocker private key",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.Int("decrypted_length", privKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Parse the decrypted private key
|
||||
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
||||
|
||||
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully parsed passphrase unlocker identity",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.String("public_key", identity.Recipient().String()),
|
||||
)
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// GetType implements Unlocker interface
|
||||
func (p *PassphraseUnlocker) GetType() string {
|
||||
return "passphrase"
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface
|
||||
func (p *PassphraseUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return p.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements Unlocker interface
|
||||
func (p *PassphraseUnlocker) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (p *PassphraseUnlocker) GetID() string {
|
||||
return filepath.Base(p.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||
func (p *PassphraseUnlocker) Remove() error {
|
||||
// Clean up the passphrase from memory if it exists
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
p.Passphrase.Destroy()
|
||||
}
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
|
||||
// the passphrase. The caller must destroy the returned buffer.
|
||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
// Not NewBufferFromBytes, which would wipe p.Passphrase
|
||||
passphrase := memguard.NewBuffer(p.Passphrase.Size())
|
||||
passphrase.Copy(p.Passphrase.Bytes())
|
||||
|
||||
return passphrase, nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in memory, prompting user")
|
||||
// Prompt for passphrase
|
||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
// The passphrase must be provided as a LockedBuffer for security
|
||||
func CreatePassphraseUnlocker(
|
||||
fs afero.Fs,
|
||||
stateDir string,
|
||||
passphrase *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
// Get current vault
|
||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
return currentVault.CreatePassphraseUnlocker(passphrase)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// pgpUnlockerType is the type of a PGP unlocker.
|
||||
const pgpUnlockerType = "pgp"
|
||||
|
||||
var errNilDataBuffer = errors.New("data buffer is nil")
|
||||
|
||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||
t.Helper()
|
||||
|
||||
// Create GPG config file for non-interactive operation
|
||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||
gpgConfContent := `batch
|
||||
no-tty
|
||||
pinentry-mode loopback
|
||||
`
|
||||
|
||||
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||
}
|
||||
|
||||
// Create a test-specific GPG implementation
|
||||
origEncryptFunc := secret.GPGEncryptFunc
|
||||
origDecryptFunc := secret.GPGDecryptFunc
|
||||
|
||||
// Set custom GPG functions for this test
|
||||
secret.GPGEncryptFunc = func(
|
||||
data *memguard.LockedBuffer, keyID string,
|
||||
) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
||||
"--homedir", gnupgHomeDir,
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pinentry-mode", "loopback",
|
||||
"--passphrase", passphrase,
|
||||
"--trust-model", "always",
|
||||
"--armor",
|
||||
"--encrypt",
|
||||
"-r", keyID)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
||||
"--homedir", gnupgHomeDir,
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pinentry-mode", "loopback",
|
||||
"--passphrase", passphrase,
|
||||
"--quiet",
|
||||
"--decrypt")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewReader(encryptedData)
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Create a secure buffer for the decrypted data
|
||||
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// Restore original functions after test
|
||||
t.Cleanup(func() {
|
||||
secret.GPGEncryptFunc = origEncryptFunc
|
||||
secret.GPGDecryptFunc = origDecryptFunc
|
||||
})
|
||||
}
|
||||
|
||||
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
||||
func runGPGWithPassphrase(
|
||||
ctx context.Context,
|
||||
gnupgHome, passphrase string, args []string, input io.Reader,
|
||||
) ([]byte, error) {
|
||||
cmdArgs := append([]string{
|
||||
"--homedir=" + gnupgHome,
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pinentry-mode", "loopback",
|
||||
"--passphrase", passphrase,
|
||||
}, args...)
|
||||
|
||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
|
||||
cmd.Stdin = input
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG command failed: %w\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
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) {
|
||||
// Check if gpg is available
|
||||
_, err := exec.LookPath("gpg")
|
||||
if err != nil {
|
||||
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
||||
// Continue anyway to test what we can
|
||||
}
|
||||
|
||||
// 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-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
|
||||
|
||||
// Create a temporary GNUPGHOME
|
||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||
|
||||
err = os.MkdirAll(gnupgHomeDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||
}
|
||||
|
||||
// Set new GNUPGHOME
|
||||
t.Setenv("GNUPGHOME", gnupgHomeDir)
|
||||
|
||||
// Test passphrase for GPG key
|
||||
testPassphrase := "test123"
|
||||
|
||||
// Setup non-interactive GPG with custom functions
|
||||
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
||||
|
||||
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
|
||||
|
||||
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
||||
t.Setenv("GPG_AGENT_INFO", "")
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Set test environment variables
|
||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||
|
||||
// Set up vault structure for testing
|
||||
stateDir := tempDir
|
||||
vaultName := "test-vault"
|
||||
|
||||
// Test creation of a PGP unlock key through a vault
|
||||
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
||||
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
||||
})
|
||||
|
||||
// Set up key directory for individual tests
|
||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||
|
||||
err = os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||
}
|
||||
|
||||
// Set up test metadata
|
||||
metadata := secret.UnlockerMetadata{
|
||||
Type: pgpUnlockerType,
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{"gpg", "encrypted"},
|
||||
}
|
||||
|
||||
// Create a PGP unlocker for the remaining tests
|
||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||
|
||||
// Test getting identity from PGP unlocker
|
||||
t.Run("GetIdentity", func(t *testing.T) {
|
||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
||||
})
|
||||
|
||||
// Test removing the unlocker
|
||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||
testRemovePGPUnlocker(t, fs, unlocker, 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(10*time.Second, func() {
|
||||
t.Fatalf("Test timed out after 10 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"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 (
|
||||
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
||||
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
||||
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
|
||||
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
|
||||
var (
|
||||
// GPGEncryptFunc is the function used for GPG encryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGEncryptFunc func(
|
||||
data *memguard.LockedBuffer, keyID string,
|
||||
) ([]byte, error) = gpgEncryptDefault
|
||||
|
||||
// GPGDecryptFunc is the function used for GPG decryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGDecryptFunc func(
|
||||
encryptedData []byte,
|
||||
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
|
||||
// gpgKeyIDRegex validates GPG key IDs
|
||||
// Allows either:
|
||||
// 1. Email addresses (user@domain.tld format)
|
||||
// 2. Short key IDs (8 hex characters)
|
||||
// 3. Long key IDs (16 hex characters)
|
||||
// 4. Full fingerprints (40 hex characters)
|
||||
gpgKeyIDRegex = regexp.MustCompile(
|
||||
`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
|
||||
`^[A-Fa-f0-9]{8}$|` +
|
||||
`^[A-Fa-f0-9]{16}$|` +
|
||||
`^[A-Fa-f0-9]{40}$`,
|
||||
)
|
||||
)
|
||||
|
||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||
type PGPUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// GPG key ID used for encryption
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
|
||||
// PGPUnlocker represents a PGP-protected unlocker
|
||||
type PGPUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
||||
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting PGP unlocker identity",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.String("unlocker_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Step 1: Read the encrypted age private key from filesystem
|
||||
agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg")
|
||||
Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath)
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read PGP-encrypted age private key",
|
||||
"error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read PGP-encrypted age private key",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
|
||||
)
|
||||
|
||||
// Step 2: Decrypt the age private key using GPG
|
||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||
|
||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with GPG",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
||||
}
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Successfully decrypted age private key with GPG",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Step 3: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully parsed PGP age identity",
|
||||
slog.String("unlocker_id", p.GetID()),
|
||||
slog.String("public_key", ageIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ageIdentity, nil
|
||||
}
|
||||
|
||||
// GetType implements Unlocker interface
|
||||
func (p *PGPUnlocker) GetType() string {
|
||||
return "pgp"
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface
|
||||
func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return p.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements Unlocker interface
|
||||
func (p *PGPUnlocker) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (p *PGPUnlocker) GetID() string {
|
||||
return filepath.Base(p.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||
func (p *PGPUnlocker) Remove() error {
|
||||
// For PGP unlockers, we just need to remove the directory
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||
// based on hostname and time
|
||||
func generatePGPUnlockerName() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
|
||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
||||
}
|
||||
|
||||
// pgpUnlockerDir returns the current vault and the directory in it for a
|
||||
// new PGP unlocker, named after the host and the time.
|
||||
//
|
||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||
func pgpUnlockerDir(
|
||||
fs afero.Fs, stateDir string,
|
||||
) (VaultInterface, string, error) {
|
||||
// Get current vault
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate the unlocker name based on hostname and time
|
||||
unlockerName, err := generatePGPUnlockerName()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
}
|
||||
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
||||
}
|
||||
|
||||
// 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(
|
||||
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*PGPUnlocker, error) {
|
||||
err := checkGPGAvailable()
|
||||
if err != nil {
|
||||
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
|
||||
ageIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
||||
// keypair's private key to the GPG key
|
||||
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
||||
vault, ageIdentity, gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{"gpg", "encrypted"},
|
||||
},
|
||||
GPGKeyID: fingerprint,
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Write the unlocker's files, the metadata last
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
||||
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
func validateGPGKeyID(keyID string) error {
|
||||
if keyID == "" {
|
||||
return errGPGKeyIDEmpty
|
||||
}
|
||||
|
||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
context.Background(),
|
||||
"gpg", "--status-fd", "1",
|
||||
"--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
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)
|
||||
}
|
||||
|
||||
// Parse the output to extract the fingerprint
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
return fields[9], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
||||
}
|
||||
|
||||
// checkGPGAvailable verifies that GPG is available
|
||||
func checkGPGAvailable() error {
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
||||
err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||
context.Background(),
|
||||
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(data.String())
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG encryption failed: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Create a secure buffer for the decrypted data
|
||||
outputBuffer := memguard.NewBufferFromBytes(output)
|
||||
|
||||
return outputBuffer, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// 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()))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
errGetEncryptedDataDeprecated = errors.New(
|
||||
"GetEncryptedData is deprecated - use version-specific methods")
|
||||
errGetCurrentVaultNotRegistered = errors.New(
|
||||
"GetCurrentVault function not registered")
|
||||
)
|
||||
|
||||
// VaultInterface defines the interface that vault implementations must satisfy
|
||||
type VaultInterface interface {
|
||||
GetDirectory() (string, error)
|
||||
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
|
||||
GetName() string
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
||||
// mnemonic to derive the long-term key from, and the passphrase for a
|
||||
// current passphrase unlocker; nil for none.
|
||||
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
||||
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
||||
CreatePassphraseUnlocker(
|
||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
}
|
||||
|
||||
// Secret represents a secret in a vault
|
||||
type Secret struct {
|
||||
Name string
|
||||
Directory string
|
||||
Metadata Metadata
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
// NewSecret creates a new Secret instance
|
||||
func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
DebugWith("Creating new secret instance",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", vault.GetName()),
|
||||
)
|
||||
|
||||
// Convert slashes to percent signs for storage directory name
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
DebugWith("Secret storage details",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("storage_name", storageName),
|
||||
slog.String("secret_dir", secretDir),
|
||||
)
|
||||
|
||||
return &Secret{
|
||||
Name: name,
|
||||
Directory: secretDir,
|
||||
vault: vault,
|
||||
Metadata: Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
s.Metadata = Metadata{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
func (s *Secret) GetMetadata() Metadata {
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
|
||||
return nil, errGetEncryptedDataDeprecated
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
func (s *Secret) Exists() (bool, error) {
|
||||
DebugWith("Checking if secret exists",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
// Check if the secret directory exists and has a current symlink
|
||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to check secret directory existence",
|
||||
"error", err, "secret_dir", s.Directory)
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
Debug("Secret directory does not exist", "secret_dir", s.Directory)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if current symlink exists
|
||||
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("No current version found", "error", err, "secret_name", s.Name)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
DebugWith("Secret existence check result",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Bool("exists", true),
|
||||
)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
// This function is a wrapper around the actual implementation in the vault package
|
||||
// and exists to break the import cycle.
|
||||
//
|
||||
//nolint:ireturn // must return the interface to break the import cycle
|
||||
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
// This is a forward declaration. The actual implementation is provided
|
||||
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
||||
if getCurrentVaultFunc == nil {
|
||||
return nil, errGetCurrentVaultNotRegistered
|
||||
}
|
||||
|
||||
return getCurrentVaultFunc(fs, stateDir)
|
||||
}
|
||||
|
||||
// getCurrentVaultFunc is a function variable that will be set by the vault package
|
||||
// to implement the actual GetCurrentVault functionality
|
||||
//
|
||||
//nolint:gochecknoglobals // Required to break import cycle
|
||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||
|
||||
// RegisterGetCurrentVaultFunc allows the vault package to register its
|
||||
// implementation of GetCurrentVault to break the import cycle
|
||||
func RegisterGetCurrentVaultFunc(
|
||||
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
|
||||
) {
|
||||
getCurrentVaultFunc = fn
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
var (
|
||||
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
|
||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
)
|
||||
|
||||
// TestMain makes passphrase encryption in the tests cheap; see
|
||||
// ScryptWorkFactor.
|
||||
func TestMain(m *testing.M) {
|
||||
ScryptWorkFactor = 1
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// MockVault is a test implementation of the VaultInterface
|
||||
type MockVault struct {
|
||||
name string
|
||||
fs afero.Fs
|
||||
directory string
|
||||
derivationIndex uint32
|
||||
mnemonic *memguard.LockedBuffer
|
||||
}
|
||||
|
||||
func (m *MockVault) GetDirectory() (string, error) {
|
||||
return m.directory, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
|
||||
// Create secret directory with proper storage name conversion
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
|
||||
err := m.fs.MkdirAll(secretDir, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create version directory with proper path
|
||||
versionName := "20240101.001" // Use a fixed version name for testing
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
|
||||
err = m.fs.MkdirAll(versionDir, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the vault's long-term public key
|
||||
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
||||
|
||||
// Derive long-term key using the vault's derivation index
|
||||
if m.mnemonic == nil {
|
||||
return errMnemonicNotSet
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write long-term public key if it doesn't exist
|
||||
_, err = m.fs.Stat(ltPubKeyPath)
|
||||
if os.IsNotExist(err) {
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
|
||||
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.writeVersionFiles(versionDir, value, ltIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current file pointing to the version (just the version name)
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
|
||||
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||
m.mnemonic = mnemonic
|
||||
}
|
||||
|
||||
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
// writeVersionFiles generates a version keypair and writes the version
|
||||
// key and value files for the mock vault.
|
||||
func (m *MockVault) writeVersionFiles(
|
||||
versionDir string,
|
||||
value *memguard.LockedBuffer,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
// Generate version-specific keypair
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write version public key
|
||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(
|
||||
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt value to version's public key (value is already a LockedBuffer)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted value
|
||||
valuePath := filepath.Join(versionDir, "value.age")
|
||||
|
||||
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt version private key to long-term public key
|
||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(
|
||||
versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted version private key
|
||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
||||
|
||||
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
||||
}
|
||||
|
||||
// setupMockVaultDirs creates the vault directory structure, long-term
|
||||
// public key, and current vault pointer for tests.
|
||||
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||
t.Helper()
|
||||
|
||||
// Create vault directory structure
|
||||
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Generate a long-term keypair for the vault using the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonicValue, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(
|
||||
fs,
|
||||
ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()),
|
||||
0o600,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
||||
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set current vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySecretFiles checks that AddSecret created the expected version
|
||||
// files for the secret.
|
||||
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
// Check current file exists and points at a version
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
|
||||
valueExists, err := afero.Exists(fs, filepath.Join(versionDir, "value.age"))
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // subtests share one vault, order matters
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
|
||||
setupMockVaultDirs(t, fs, baseDir, vaultDir)
|
||||
|
||||
// Create vault instance using the mock vault
|
||||
vault := &MockVault{
|
||||
name: "test-vault",
|
||||
fs: fs,
|
||||
directory: vaultDir,
|
||||
derivationIndex: 0,
|
||||
mnemonic: mnemonic,
|
||||
}
|
||||
|
||||
// Test data
|
||||
secretName := "test-secret"
|
||||
secretValue := []byte("this is a test secret value")
|
||||
|
||||
// Create a secure buffer for the test value
|
||||
valueBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Test AddSecret
|
||||
t.Run("AddSecret", func(t *testing.T) {
|
||||
err := vault.AddSecret(secretName, valueBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddSecret failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that all expected files were created
|
||||
verifySecretFiles(t, fs, vaultDir, secretName)
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
|
||||
// Create a Secret object to test with
|
||||
secret := NewSecret(vault, secretName)
|
||||
|
||||
// Test GetValue (this will need to be modified since we're using a mock vault)
|
||||
t.Run("GetSecret", func(t *testing.T) {
|
||||
// This test is simplified since we're not implementing the full encryption/decryption
|
||||
// in the mock. We just verify the Secret object is created correctly.
|
||||
if secret.Name != secretName {
|
||||
t.Fatalf("Secret name doesn't match. Expected: %s, Got: %s", secretName, secret.Name)
|
||||
}
|
||||
|
||||
if secret.vault != vault {
|
||||
t.Fatalf("Secret vault reference doesn't match expected vault")
|
||||
}
|
||||
|
||||
t.Logf("Successfully created Secret object with correct properties")
|
||||
})
|
||||
|
||||
// Test Exists
|
||||
t.Run("SecretExists", func(t *testing.T) {
|
||||
exists, err := secret.Exists()
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking if secret exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Secret should exist but Exists() returned false")
|
||||
}
|
||||
|
||||
t.Logf("Secret.Exists() works correctly")
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
// UnlockKey interface defines the methods all unlock key types must implement
|
||||
type UnlockKey interface {
|
||||
GetIdentity() (*age.X25519Identity, error)
|
||||
GetType() string
|
||||
GetMetadata() UnlockKeyMetadata
|
||||
GetDirectory() string
|
||||
GetID() string
|
||||
ID() string // Generate ID from the key's public key
|
||||
Remove() error // Remove the unlock key and any associated resources
|
||||
|
||||
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
|
||||
DecryptLongTermKey() ([]byte, error)
|
||||
|
||||
// DecryptSecret decrypts a secret using this unlock key's long-term key management
|
||||
DecryptSecret(secret *Secret) ([]byte, error)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"filippo.io/age"
|
||||
)
|
||||
|
||||
// Unlocker interface defines the methods all unlocker types must implement
|
||||
type Unlocker interface {
|
||||
GetIdentity() (*age.X25519Identity, error)
|
||||
GetType() string
|
||||
GetMetadata() UnlockerMetadata
|
||||
GetDirectory() string
|
||||
GetID() string // The name of the unlocker's directory, unique in its vault
|
||||
Remove() error // Remove the unlocker and any associated resources
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateGPGKeyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
keyID string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"valid email address", "test@example.com", false},
|
||||
{"valid email with dots and hyphens", "test.user-name@example-domain.co.uk", false},
|
||||
{"valid email with plus", "test+tag@example.com", false},
|
||||
{"valid short key ID (8 hex chars)", "ABCDEF12", false},
|
||||
{"valid long key ID (16 hex chars)", "ABCDEF1234567890", false},
|
||||
{
|
||||
"valid fingerprint (40 hex chars)",
|
||||
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
||||
},
|
||||
{
|
||||
"valid lowercase hex fingerprint",
|
||||
"abcdef1234567890abcdef1234567890abcdef12", false,
|
||||
},
|
||||
{"valid mixed case hex", "AbCdEf1234567890", false},
|
||||
|
||||
// Invalid cases
|
||||
{"empty key ID", "", true},
|
||||
{"key ID with spaces", "test user@example.com", true},
|
||||
{"key ID with semicolon (command injection)", "test@example.com; rm -rf /", true},
|
||||
{
|
||||
"key ID with pipe (command injection)",
|
||||
"test@example.com | cat /etc/passwd", true,
|
||||
},
|
||||
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
|
||||
{
|
||||
"key ID with dollar sign (command injection)",
|
||||
"test@example.com$(whoami)", true,
|
||||
},
|
||||
{"key ID with quotes", "test\"@example.com", true},
|
||||
{"key ID with single quotes", "test'@example.com", true},
|
||||
{"key ID with backslash", "test\\@example.com", true},
|
||||
{"key ID with newline", "test@example.com\nrm -rf /", true},
|
||||
{"key ID with carriage return", "test@example.com\rrm -rf /", true},
|
||||
{"hex with invalid length (7 chars)", "ABCDEF1", true},
|
||||
{"hex with invalid length (9 chars)", "ABCDEF123", true},
|
||||
{"hex with non-hex characters", "ABCDEFGH", true},
|
||||
{"mixed format (email with hex)", "test@ABCDEF12", true},
|
||||
{"key ID with ampersand", "test@example.com & echo test", true},
|
||||
{"key ID with redirect", "test@example.com > /tmp/test", true},
|
||||
{"key ID with null byte", "test@example.com\x00", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateGPGKeyID(tt.keyID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
versionNameParts = 2
|
||||
maxVersionsPerDay = 999
|
||||
)
|
||||
|
||||
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
||||
|
||||
// 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
|
||||
type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
|
||||
}
|
||||
|
||||
// Version represents a version of a secret
|
||||
type Version struct {
|
||||
SecretName string
|
||||
Version string
|
||||
Directory string
|
||||
Metadata VersionMetadata
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
// NewVersion creates a new Version instance
|
||||
func NewVersion(vault VaultInterface, secretName string, version string) *Version {
|
||||
DebugWith("Creating new secret version instance",
|
||||
slog.String("secret_name", secretName),
|
||||
slog.String("version", version),
|
||||
slog.String("vault_name", vault.GetName()),
|
||||
)
|
||||
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
storageName := strings.ReplaceAll(secretName, "/", "%")
|
||||
versionDir := filepath.Join(vaultDir, "secrets.d", storageName, "versions", version)
|
||||
|
||||
DebugWith("Secret version storage details",
|
||||
slog.String("secret_name", secretName),
|
||||
slog.String("version", version),
|
||||
slog.String("version_dir", versionDir),
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
return &Version{
|
||||
SecretName: secretName,
|
||||
Version: version,
|
||||
Directory: versionDir,
|
||||
vault: vault,
|
||||
Metadata: VersionMetadata{
|
||||
ID: ulid.Make().String(),
|
||||
CreatedAt: &now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateVersionName generates a new version name in YYYYMMDD.NNN format
|
||||
func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
today := time.Now().Format("20060102")
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Ensure versions directory exists
|
||||
err := fs.MkdirAll(versionsDir, DirPerms)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
// Find the highest serial number for today
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read versions directory: %w", err)
|
||||
}
|
||||
|
||||
maxSerial := 0
|
||||
prefix := today + "."
|
||||
|
||||
for _, entry := range entries {
|
||||
// Skip non-directories and those without correct prefix
|
||||
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract serial number
|
||||
parts := strings.Split(entry.Name(), ".")
|
||||
if len(parts) != versionNameParts {
|
||||
continue
|
||||
}
|
||||
|
||||
var serial int
|
||||
|
||||
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
|
||||
if err != nil {
|
||||
Warn("Skipping malformed version directory name",
|
||||
"name", entry.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if serial > maxSerial {
|
||||
maxSerial = serial
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new version name
|
||||
newSerial := maxSerial + 1
|
||||
if newSerial > maxVersionsPerDay {
|
||||
return "", errMaxVersionsPerDay
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
}
|
||||
|
||||
// Save saves the version metadata and value. The files are written into a
|
||||
// temporary directory that is renamed to sv.Directory once all of them are
|
||||
// complete, so the version directory is either whole or absent, even if the
|
||||
// process dies part-way.
|
||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
if value == nil {
|
||||
return ErrNilValueBuffer
|
||||
}
|
||||
|
||||
DebugWith("Saving secret version",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
slog.Int("value_length", value.Size()),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create the versions directory the finished version is renamed into
|
||||
err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms)
|
||||
if err != nil {
|
||||
Debug("Failed to create versions directory", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
tmpDir, err := TempDirFor(fs, sv.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Once the rename below has moved it into place, this finds nothing.
|
||||
defer func() { _ = fs.RemoveAll(tmpDir) }()
|
||||
|
||||
// Generate a new keypair for this version
|
||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||
}
|
||||
|
||||
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
||||
defer versionPrivateKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = fs.Rename(tmpDir, sv.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to move version into place: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version",
|
||||
"version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMetadata loads and decrypts the version metadata
|
||||
func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
DebugWith("Loading version metadata",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key",
|
||||
"error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted metadata
|
||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
|
||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version metadata",
|
||||
"error", err, "path", encryptedMetadataPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Decrypt metadata using version key
|
||||
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
||||
}
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
// Step 6: Unmarshal metadata
|
||||
var metadata VersionMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
|
||||
if err != nil {
|
||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
sv.Metadata = metadata
|
||||
|
||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the version value
|
||||
func (sv *Version) GetValue(
|
||||
ltIdentity *age.X25519Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting version value",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
)
|
||||
|
||||
// Debug: Log the directory and long-term key info
|
||||
Debug("SecretVersion GetValue debug info",
|
||||
"secret_name", sv.SecretName,
|
||||
"version", sv.Version,
|
||||
"directory", sv.Directory,
|
||||
"lt_identity_public_key", ltIdentity.Recipient().String())
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key",
|
||||
"error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully read encrypted version private key",
|
||||
"path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||
|
||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
Debug("Successfully decrypted version private key",
|
||||
"version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version value",
|
||||
"error", err, "path", encryptedValuePath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully read encrypted value",
|
||||
"path", encryptedValuePath, "size", len(encryptedValue))
|
||||
|
||||
// Step 5: Decrypt value using version key
|
||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||
|
||||
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully retrieved version value",
|
||||
"version", sv.Version,
|
||||
"value_length", valueBuffer.Size(),
|
||||
"is_empty", valueBuffer.Size() == 0)
|
||||
|
||||
return valueBuffer, nil
|
||||
}
|
||||
|
||||
// writePublicKeyAndValue stores the version's public key and the value
|
||||
// encrypted to it in dir.
|
||||
func (sv *Version) writePublicKeyAndValue(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionIdentity *age.X25519Identity,
|
||||
value *memguard.LockedBuffer,
|
||||
) error {
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
pubKeyPath := filepath.Join(dir, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
|
||||
err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey))
|
||||
if err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join(dir, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
|
||||
err = WriteFileAtomic(fs, valuePath, encryptedValue)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEncryptedPrivateKey encrypts the version's private key to the
|
||||
// vault's long-term public key and stores it in dir.
|
||||
func (sv *Version) writeEncryptedPrivateKey(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
||||
) error {
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Encrypting version private key to long-term public key",
|
||||
"version", sv.Version)
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key",
|
||||
"error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(dir, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
|
||||
err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version private key",
|
||||
"error", err, "path", privKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEncryptedMetadata encrypts the version metadata to the version's
|
||||
// public key and stores it in dir.
|
||||
func (sv *Version) writeEncryptedMetadata(
|
||||
fs afero.Fs,
|
||||
dir string,
|
||||
versionIdentity *age.X25519Identity,
|
||||
) error {
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(
|
||||
metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(dir, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
|
||||
err = WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version metadata",
|
||||
"error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Check if versions directory exists
|
||||
exists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// List all version directories
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read versions directory: %w", err)
|
||||
}
|
||||
|
||||
var versions []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
versions = append(versions, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions in reverse chronological order
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// VersionExists reports whether version is one of the versions ListVersions
|
||||
// lists for the secret in secretDir. It only compares names, so a version
|
||||
// the user typed can be checked with it before any path is built from it.
|
||||
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return slices.Contains(versions, version), nil
|
||||
}
|
||||
|
||||
// GetCurrentVersion returns the version that the "current" file points to
|
||||
// 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) {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
|
||||
fileData, err := afero.ReadFile(fs, currentPath)
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// SetCurrentVersion updates the "current" file to point to a specific version
|
||||
// The file contains just the version name (e.g., "20231215.001"). It is
|
||||
// replaced in one rename, so once written it always exists.
|
||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
|
||||
err := WriteFileAtomic(fs, currentPath, []byte(version))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current version file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Version Support Test Suite Documentation
|
||||
//
|
||||
// This file contains core unit tests for version functionality:
|
||||
//
|
||||
// - TestGenerateVersionName: Tests version name generation with date and serial format
|
||||
// - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit
|
||||
// - TestNewVersion: Tests secret version object creation
|
||||
// - TestSecretVersionSave: Tests saving a version with encryption
|
||||
// - TestSecretVersionLoadMetadata: Tests loading and decrypting version metadata
|
||||
// - TestSecretVersionGetValue: Tests retrieving and decrypting version values
|
||||
// - TestListVersions: Tests listing versions in reverse chronological order
|
||||
// - TestGetCurrentVersion: Tests retrieving the current version via symlink
|
||||
// - TestSetCurrentVersion: Tests updating the current version symlink
|
||||
// - TestVersionMetadataTimestamps: Tests timestamp pointer consistency
|
||||
//
|
||||
// Key Test Scenarios:
|
||||
// - Version Creation: First version gets notBefore = epoch + 1 second
|
||||
// - Subsequent versions update previous version's notAfter timestamp
|
||||
// - New version's notBefore equals previous version's notAfter
|
||||
// - Version names follow YYYYMMDD.NNN format
|
||||
// - Maximum 999 versions per day enforced
|
||||
//
|
||||
// Version Retrieval:
|
||||
// - Get current version via symlink
|
||||
// - Get specific version by name
|
||||
// - Empty version parameter returns current
|
||||
// - Non-existent versions return appropriate errors
|
||||
//
|
||||
// Data Integrity:
|
||||
// - Each version has independent encryption keys
|
||||
// - Metadata encryption protects version history
|
||||
// - Long-term key required for all operations
|
||||
// - Concurrent reads handled safely
|
||||
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecretDir = "/test/secret"
|
||||
testVaultName = "test"
|
||||
testVaultStateDir = "/test"
|
||||
)
|
||||
|
||||
var errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
|
||||
// MockVersionVault implements VaultInterface for testing
|
||||
type MockVersionVault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
longTermKey *age.X25519Identity
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||
return errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*secret.PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func TestGenerateVersionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
|
||||
// Test first version generation
|
||||
version1, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||
|
||||
// Create the version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", version1)
|
||||
err = fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
// Verify they have the same date prefix
|
||||
assert.Equal(t, version1[:8], version2[:8])
|
||||
assert.NotEqual(t, version1, version2)
|
||||
}
|
||||
|
||||
func TestNewVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001")
|
||||
assert.NotEmpty(t, sv.Metadata.ID)
|
||||
assert.NotNil(t, sv.Metadata.CreatedAt)
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate and store long-term public key
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify files were created
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age")))
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
sv.Metadata.NotAfter = &now
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify loaded metadata
|
||||
assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID)
|
||||
assert.NotNil(t, sv2.Metadata.NotBefore)
|
||||
assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix())
|
||||
assert.NotNil(t, sv2.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
expectedValue := make([]byte, len(originalValue))
|
||||
copy(expectedValue, originalValue)
|
||||
|
||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||
defer originalBuffer.Destroy()
|
||||
|
||||
err = sv.Save(originalBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedBuffer.Destroy()
|
||||
|
||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
|
||||
// Create some versions
|
||||
testVersions := []string{
|
||||
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
|
||||
}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(
|
||||
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be sorted in reverse chronological order
|
||||
expected := []string{
|
||||
"20231216.001", "20231215.002", "20231215.001", "20231214.001",
|
||||
}
|
||||
assert.Equal(t, expected, versions)
|
||||
}
|
||||
|
||||
func TestGetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
|
||||
// The current file contains just the version name
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231217.001", version)
|
||||
}
|
||||
|
||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that all timestamp fields behave consistently as pointers
|
||||
vm := secret.VersionMetadata{
|
||||
ID: "test-id",
|
||||
}
|
||||
|
||||
// All should be nil initially
|
||||
assert.Nil(t, vm.CreatedAt)
|
||||
assert.Nil(t, vm.NotBefore)
|
||||
assert.Nil(t, vm.NotAfter)
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
epoch := time.Unix(1, 0)
|
||||
future := now.Add(time.Hour)
|
||||
|
||||
vm.CreatedAt = &now
|
||||
vm.NotBefore = &epoch
|
||||
vm.NotAfter = &future
|
||||
|
||||
// All should be non-nil
|
||||
assert.NotNil(t, vm.CreatedAt)
|
||||
assert.NotNil(t, vm.NotBefore)
|
||||
assert.NotNil(t, vm.NotAfter)
|
||||
|
||||
// Values should match
|
||||
assert.Equal(t, now.Unix(), vm.CreatedAt.Unix())
|
||||
assert.Equal(t, int64(1), vm.NotBefore.Unix())
|
||||
assert.Equal(t, future.Unix(), vm.NotAfter.Unix())
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
|
||||
return exists
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package vault
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by vault operations.
|
||||
//
|
||||
// Several of these carry deliberately partial text: the message a caller
|
||||
// composes with fmt.Errorf places the interpolated value where it has
|
||||
// always appeared, and the sentinel supplies only the surrounding fixed
|
||||
// words. This keeps every composed message byte-identical to the dynamic
|
||||
// errors these sentinels replaced. Each such sentinel notes the message it
|
||||
// participates in.
|
||||
var (
|
||||
// ErrMnemonicMismatch indicates the mnemonic-derived public key does
|
||||
// not match the vault's stored public key hash.
|
||||
ErrMnemonicMismatch = errors.New(
|
||||
"derived public key does not match vault: mnemonic may be incorrect",
|
||||
)
|
||||
|
||||
// ErrInvalidVaultName indicates a vault name that breaks the naming
|
||||
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
|
||||
// empty, "." or "..". Composed by ValidateVaultName as
|
||||
// "invalid vault name '<name>': <the rule>".
|
||||
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||
|
||||
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||
// as "vault <name> does not exist".
|
||||
ErrVaultNotFound = errors.New("does not exist")
|
||||
|
||||
// ErrVaultExists indicates that a vault to be created already exists.
|
||||
// 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
|
||||
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
|
||||
// no leading '.' or '/', no trailing '/', no '//', no '..' path segment.
|
||||
// Composed by ValidateSecretName as
|
||||
// "invalid secret name '<name>': <the rule>".
|
||||
ErrInvalidSecretName = errors.New("invalid secret name")
|
||||
|
||||
// ErrSecretExists indicates the secret already exists and --force
|
||||
// was not supplied. Composed as
|
||||
// "secret <name> already exists (use --force to overwrite)", or as
|
||||
// "secret '<name>' already exists in vault '<vault>' (use --force to
|
||||
// overwrite)" when copying between vaults.
|
||||
ErrSecretExists = errors.New("already exists")
|
||||
|
||||
// ErrSecretNotFound indicates the named secret does not exist.
|
||||
// Composed as "secret <name> not found".
|
||||
ErrSecretNotFound = errors.New("not found")
|
||||
|
||||
// ErrVersionNotFound indicates the requested secret version does not
|
||||
// exist. Composed as
|
||||
// "version '<version>' not found for secret '<name>'".
|
||||
ErrVersionNotFound = errors.New("not found for secret")
|
||||
|
||||
// ErrNoVersions indicates the source secret has no versions. Composed
|
||||
// as "source secret '<name>' has no versions".
|
||||
ErrNoVersions = errors.New("has no versions")
|
||||
|
||||
// ErrUnsupportedUnlockerType indicates an unlocker metadata type
|
||||
// that this build does not support.
|
||||
ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||
|
||||
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
|
||||
// Composed as "unlocker with ID <id> not found".
|
||||
ErrUnlockerNotFound = errors.New("not found")
|
||||
|
||||
// ErrNoLockForFilesystem indicates LockStateDir was given a filesystem
|
||||
// it cannot lock. Composed as "cannot lock the state directory on
|
||||
// filesystem <type>".
|
||||
ErrNoLockForFilesystem = errors.New(
|
||||
"cannot lock the state directory on filesystem")
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// otherMnemonic is a valid BIP39 mnemonic other than testMnemonic.
|
||||
otherMnemonic = "legal winner thank year wave sausage worth useful " +
|
||||
"legal winner thank yellow"
|
||||
|
||||
// missingName names no vault, secret or unlocker.
|
||||
missingName = "missing"
|
||||
)
|
||||
|
||||
// newErrorTestVault creates the vault testVaultName, with the secret
|
||||
// testSecretName in it, on a new in-memory filesystem.
|
||||
func newErrorTestVault(t *testing.T) *vault.Vault {
|
||||
t.Helper()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewMemMapFs(), testStateDir,
|
||||
testVaultName, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||
t.Cleanup(value.Destroy)
|
||||
|
||||
require.NoError(t, vlt.AddSecret(testSecretName, value, false))
|
||||
|
||||
return vlt
|
||||
}
|
||||
|
||||
// TestVaultErrors checks that each failure returns its exported error,
|
||||
// wrapped or not, so that errors.Is tells it apart from the others.
|
||||
func TestVaultErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(vlt *vault.Vault) error
|
||||
want error
|
||||
}{
|
||||
{"create an existing vault", func(vlt *vault.Vault) error {
|
||||
_, err := vault.CreateVault(vlt.GetFilesystem(), testStateDir,
|
||||
testVaultName, nil, nil)
|
||||
|
||||
return err
|
||||
}, vault.ErrVaultExists},
|
||||
{"select a missing vault", func(vlt *vault.Vault) error {
|
||||
return vault.SelectVault(vlt.GetFilesystem(), testStateDir, missingName)
|
||||
}, vault.ErrVaultNotFound},
|
||||
{"add a nil value", func(vlt *vault.Vault) error {
|
||||
return vlt.AddSecret(missingName, nil, false)
|
||||
}, secret.ErrNilValueBuffer},
|
||||
{"get a missing secret", func(vlt *vault.Vault) error {
|
||||
_, err := vlt.GetSecret(missingName)
|
||||
|
||||
return err
|
||||
}, vault.ErrSecretNotFound},
|
||||
{"copy onto an existing secret", func(vlt *vault.Vault) error {
|
||||
return vlt.CopySecretAllVersions(vlt, testSecretName, testSecretName, false)
|
||||
}, vault.ErrSecretExists},
|
||||
{"copy a secret without versions", func(vlt *vault.Vault) error {
|
||||
const versionless = "versionless"
|
||||
|
||||
err := vlt.GetFilesystem().MkdirAll(
|
||||
filepath.Join(vaultDir, "secrets.d", versionless), secret.DirPerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
||||
}, vault.ErrNoVersions},
|
||||
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
||||
_, err := vlt.RemoveUnlocker(missingName)
|
||||
|
||||
return err
|
||||
}, 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)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// deriveVaultIdentity derives the long-term identity for the given vault
|
||||
// from testMnemonic using the derivation index stored in its metadata.
|
||||
func deriveVaultIdentity(
|
||||
t *testing.T, fs afero.Fs, vlt *vault.Vault,
|
||||
) *age.X25519Identity {
|
||||
t.Helper()
|
||||
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
return ltIdentity
|
||||
}
|
||||
|
||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for our tests
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test currentvault file handling (plain file with relative path)
|
||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCurrentVaultFileHandling(t, fs, tempDir)
|
||||
})
|
||||
|
||||
// Test secret operations with deeply nested paths
|
||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testDeepPathSecrets(t, fs, tempDir)
|
||||
})
|
||||
|
||||
// Test key caching in GetOrDeriveLongTermKey
|
||||
t.Run("KeyCaching", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testKeyCaching(t, fs, tempDir)
|
||||
})
|
||||
|
||||
// Test vault name validation
|
||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testVaultNameValidation(t, fs, tempDir)
|
||||
})
|
||||
|
||||
// Test multiple vaults and switching between them
|
||||
t.Run("MultipleVaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testMultipleVaults(t, fs, tempDir)
|
||||
})
|
||||
|
||||
// Test adding a secret in one vault and verifying it's not visible in
|
||||
// another
|
||||
t.Run("VaultIsolation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
testVaultIsolation(t, fs, tempDir)
|
||||
})
|
||||
}
|
||||
|
||||
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Verify the currentvault file exists and contains just the vault name
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||
}
|
||||
|
||||
if string(currentVaultContents) != testVaultName {
|
||||
t.Errorf("Expected currentvault to contain %q, got %q",
|
||||
testVaultName, string(currentVaultContents))
|
||||
}
|
||||
|
||||
// Test that ResolveVaultSymlink correctly resolves the path
|
||||
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
||||
}
|
||||
|
||||
if resolvedPath != vaultDir {
|
||||
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault writes the public key derived from
|
||||
// the mnemonic
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic using the vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a secret with a deeply nested path
|
||||
deepPath := "api/credentials/production/database/primary"
|
||||
secretValue := []byte("supersecretdbpassword")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||
}
|
||||
|
||||
// List secrets and verify our deep path secret is there
|
||||
secrets, err := vlt.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(secrets, deepPath) {
|
||||
t.Errorf("Deep path secret not found in listed secrets")
|
||||
}
|
||||
|
||||
// Retrieve the secret and verify its value
|
||||
retrievedValue, err := vlt.GetSecret(deepPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||
}
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||
expectedValue, retrievedValue.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault - CreateVault writes the public key derived from
|
||||
// the mnemonic
|
||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get its derivation index
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic for verification using the
|
||||
// vault's derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||
vaultMetadata.DerivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is locked initially
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked initially")
|
||||
}
|
||||
|
||||
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
||||
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is now unlocked
|
||||
if vlt.Locked() {
|
||||
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
||||
}
|
||||
|
||||
// Second call should return the cached key without re-deriving
|
||||
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cached long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify both keys are the same instance
|
||||
if firstKey != secondKey {
|
||||
t.Errorf("Second key call should return same instance as first call")
|
||||
}
|
||||
|
||||
// Verify the public key matches what we expect
|
||||
expectedPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
actualPubKey := firstKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
|
||||
// Now clear the key and verify it's locked again
|
||||
vlt.ClearLongTermKey()
|
||||
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked after clearing key")
|
||||
}
|
||||
|
||||
// Get the key again and verify it works
|
||||
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the public key still matches
|
||||
actualPubKey = thirdKey.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Test valid vault names
|
||||
validNames := []string{
|
||||
"default",
|
||||
"test-vault",
|
||||
"production.vault",
|
||||
"vault_123",
|
||||
"a-very-long-vault-name-with-dashes",
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test invalid vault names
|
||||
invalidNames := []string{
|
||||
"", // Empty
|
||||
"UPPERCASE", // Uppercase not allowed
|
||||
"invalid/name", // Slashes not allowed in vault names
|
||||
"invalid name", // Spaces not allowed
|
||||
"invalid@name", // Special chars not allowed
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||
if !errors.Is(err, vault.ErrInvalidVaultName) {
|
||||
t.Errorf("Expected ErrInvalidVaultName creating vault with "+
|
||||
"invalid name %q, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create three vaults
|
||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||
for _, name := range vaultNames {
|
||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// List vaults and verify all three are there
|
||||
vaults, err := vault.ListVaults(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list vaults: %v", err)
|
||||
}
|
||||
|
||||
if len(vaults) != 3 {
|
||||
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
||||
}
|
||||
|
||||
// Test switching between vaults
|
||||
for _, name := range vaultNames {
|
||||
// Select the vault
|
||||
err := vault.SelectVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault %s: %v", name, err)
|
||||
}
|
||||
|
||||
// Get current vault and verify it's the one we selected
|
||||
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current vault after selecting %s: %v",
|
||||
name, err)
|
||||
}
|
||||
|
||||
if currentVault.GetName() != name {
|
||||
t.Errorf("Expected current vault to be %s, got %s",
|
||||
name, currentVault.GetName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||
t.Helper()
|
||||
|
||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||
|
||||
err := os.MkdirAll(stateDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create two vaults - CreateVault writes the public key derived from
|
||||
// the mnemonic
|
||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault1: %v", err)
|
||||
}
|
||||
|
||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault2: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term keys from mnemonic
|
||||
// Note: Both vaults will have different derivation indexes due to
|
||||
// GetNextDerivationIndex
|
||||
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
|
||||
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
|
||||
|
||||
// Unlock the vaults with their respective keys
|
||||
vault1.Unlock(ltIdentity1)
|
||||
vault2.Unlock(ltIdentity2)
|
||||
|
||||
// Add a secret to vault1
|
||||
secretValue := []byte("secret in vault1")
|
||||
|
||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
err = vault1.AddSecret(testSecretName, secretBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||
}
|
||||
|
||||
// Verify the secret exists in vault1
|
||||
vault1Secrets, err := vault1.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
||||
}
|
||||
|
||||
if !slices.Contains(vault1Secrets, testSecretName) {
|
||||
t.Errorf("Secret not found in vault1")
|
||||
}
|
||||
|
||||
// Verify the secret does NOT exist in vault2
|
||||
vault2Secrets, err := vault2.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
||||
}
|
||||
|
||||
if slices.Contains(vault2Secrets, testSecretName) {
|
||||
t.Errorf("Secret from vault1 should not be visible in vault2")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
// Version Support Integration Tests
|
||||
//
|
||||
// Comprehensive integration tests for version functionality:
|
||||
//
|
||||
// - TestVersionIntegrationWorkflow: End-to-end workflow testing
|
||||
// - Creating initial version with proper metadata
|
||||
// - Creating multiple versions with timestamp updates
|
||||
// - Retrieving specific versions by name
|
||||
// - Promoting old versions to current
|
||||
// - Testing version serial number limits (999/day)
|
||||
// - Error cases and edge conditions
|
||||
//
|
||||
// - TestVersionConcurrency: Tests concurrent read operations
|
||||
//
|
||||
// - TestVersionCompatibility: Tests handling of legacy non-versioned secrets
|
||||
//
|
||||
// Test Environment:
|
||||
// - Uses in-memory filesystem (afero.MemMapFs)
|
||||
// - Consistent test mnemonic for reproducible keys
|
||||
// - Proper cleanup and isolation between tests
|
||||
|
||||
//nolint:testpackage // uses white-box test helpers shared with this package
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// errUnexpectedValue is returned by concurrent readers when a secret value
|
||||
// does not match the expected contents.
|
||||
var errUnexpectedValue = errors.New("unexpected value")
|
||||
|
||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||
//
|
||||
//nolint:paralleltest // the subtests are steps that build on each other
|
||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault without a long-term key, which is set up below
|
||||
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault
|
||||
vault.Unlock(ltIdentity)
|
||||
|
||||
secretName := "integration/test"
|
||||
|
||||
// Step 1: Create initial version
|
||||
t.Run("create_initial_version", func(t *testing.T) {
|
||||
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
})
|
||||
|
||||
// Step 2: Create second version
|
||||
t.Run("create_second_version", func(t *testing.T) {
|
||||
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
})
|
||||
|
||||
// Step 3: Create third version
|
||||
t.Run("create_third_version", func(t *testing.T) {
|
||||
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
|
||||
})
|
||||
|
||||
// Step 4: Retrieve specific versions
|
||||
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
||||
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
|
||||
})
|
||||
|
||||
// Step 5: Promote old version to current
|
||||
t.Run("promote_old_version", func(t *testing.T) {
|
||||
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||
})
|
||||
|
||||
// Step 6: Test version limits
|
||||
t.Run("version_serial_limits", func(t *testing.T) {
|
||||
testVersionSerialLimits(t, fs, vault, vaultDir)
|
||||
})
|
||||
|
||||
// Step 7: Test error cases
|
||||
t.Run("error_cases", func(t *testing.T) {
|
||||
testVersionErrorCases(t, vault, secretName)
|
||||
})
|
||||
}
|
||||
|
||||
func testCreateInitialVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-1-data"), false)
|
||||
|
||||
// Verify secret can be retrieved
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
||||
|
||||
// Verify version directory structure
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 1)
|
||||
|
||||
// Verify current symlink exists
|
||||
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, versions[0], currentVersion)
|
||||
|
||||
// Verify metadata
|
||||
version := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||
assert.NotNil(t, version.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||
// NotAfter should be nil for current version
|
||||
assert.Nil(t, version.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func testCreateSecondVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Small delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get first version name before creating second
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstVersionName := versions[0]
|
||||
|
||||
// Create second version
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
|
||||
|
||||
// Verify new value is current
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2-data"), value.Bytes())
|
||||
|
||||
// Verify we now have two versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 2)
|
||||
|
||||
// Verify first version metadata was updated with notAfter
|
||||
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
|
||||
// Verify second version metadata
|
||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||
|
||||
// NotBefore of second should equal NotAfter of first
|
||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
|
||||
secondVersion.Metadata.NotBefore.Unix())
|
||||
}
|
||||
|
||||
func testCreateThirdVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
|
||||
|
||||
// Verify we now have three versions
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 3)
|
||||
|
||||
// Current should be version-3
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-3-data"), value.Bytes())
|
||||
}
|
||||
|
||||
func testRetrieveSpecificVersions(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
|
||||
// Get each version by its name
|
||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value1.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
|
||||
|
||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value2.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
|
||||
|
||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value3.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
|
||||
|
||||
// An empty version is not one of the versions; GetSecret gets the
|
||||
// current one
|
||||
_, err = vault.GetSecretVersion(secretName, "")
|
||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||
}
|
||||
|
||||
func testPromoteOldVersion(
|
||||
t *testing.T, fs afero.Fs, vault *Vault,
|
||||
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Promote the first version (oldest) to current
|
||||
oldestVersion := versions[2]
|
||||
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify current now returns the old version's value
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
||||
|
||||
// Verify the version metadata hasn't changed
|
||||
// (promoting shouldn't modify timestamps)
|
||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
// should still have its old notAfter
|
||||
assert.NotNil(t, version.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func testVersionSerialLimits(
|
||||
t *testing.T, fs afero.Fs, vault *Vault, vaultDir string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Create a new secret for this test
|
||||
limitSecretName := "limit/test"
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||
|
||||
// Create 998 versions (we already have one from the first AddSecret)
|
||||
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
|
||||
|
||||
// Get today's date for consistent version names
|
||||
today := time.Now().Format("20060102")
|
||||
|
||||
// Manually create many versions with same date
|
||||
for i := 2; i <= 998; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
versionDir := filepath.Join(secretDir, versionName)
|
||||
err := fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should be able to create one more (999)
|
||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, today+".999", versionName)
|
||||
|
||||
// Create the 999th version directory
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||
|
||||
// Try to get version of non-existent secret
|
||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||
require.ErrorIs(t, err, ErrSecretNotFound)
|
||||
|
||||
// Try to add secret without force when it exists
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||
defer failBuffer.Destroy()
|
||||
|
||||
err = vault.AddSecret(secretName, failBuffer, false)
|
||||
require.ErrorIs(t, err, ErrSecretExists)
|
||||
}
|
||||
|
||||
// TestVersionConcurrency tests concurrent version operations
|
||||
func TestVersionConcurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
secretName := "concurrent/test"
|
||||
|
||||
// Create initial version
|
||||
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
|
||||
|
||||
// Test concurrent reads
|
||||
t.Run("concurrent_reads", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
done := make(chan bool, 10)
|
||||
errCh := make(chan error, 10)
|
||||
|
||||
for range 10 {
|
||||
go func() {
|
||||
value, err := vault.GetSecret(secretName)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
} else {
|
||||
if value.String() != "initial" {
|
||||
errCh <- fmt.Errorf("%w: %s",
|
||||
errUnexpectedValue, value.Bytes())
|
||||
}
|
||||
|
||||
value.Destroy()
|
||||
}
|
||||
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("concurrent read failed: %v", err)
|
||||
default:
|
||||
// No errors
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestVersionCompatibility tests that old secrets without versions still work
|
||||
func TestVersionCompatibility(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Manually create an old-style secret (no versions)
|
||||
secretName := "legacy/secret"
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
|
||||
err = fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create old-style encrypted value directly in secret directory
|
||||
testValue := []byte("legacy-value")
|
||||
|
||||
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testValueBuffer.Destroy()
|
||||
|
||||
ltRecipient := ltIdentity.Recipient()
|
||||
|
||||
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||
require.NoError(t, err)
|
||||
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
err = afero.WriteFile(fs, valuePath, encrypted, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to get with version-aware methods
|
||||
_, err = vault.GetSecret(secretName)
|
||||
require.Error(t, err)
|
||||
|
||||
// List versions should return empty
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
||||
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
|
||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
||||
//
|
||||
//nolint:gochecknoglobals // must outlive the call that takes it
|
||||
var memFsLock sync.Mutex
|
||||
|
||||
// LockStateDir takes the lock that a command changing anything under
|
||||
// stateDir holds until it returns, and returns the function that releases
|
||||
// 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
|
||||
// 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
|
||||
// stateDir, which the kernel releases when the process dies, so a killed
|
||||
// command never leaves the tool locked. The in-memory filesystem the tests
|
||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
||||
// Any other filesystem is refused rather than left unlocked.
|
||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
||||
var release func()
|
||||
|
||||
switch fs.(type) {
|
||||
case *afero.OsFs:
|
||||
var err error
|
||||
|
||||
release, err = flockStateDir(stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case *afero.MemMapFs:
|
||||
memFsLock.Lock()
|
||||
|
||||
release = memFsLock.Unlock
|
||||
default:
|
||||
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
|
||||
// directory and the file if needed. Go opens files close-on-exec, so
|
||||
// programs the command runs, such as gpg, do not inherit the lock.
|
||||
func flockStateDir(stateDir string) (func(), error) {
|
||||
err := os.MkdirAll(stateDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(stateDir, lockFileName)
|
||||
|
||||
//nolint:gosec // G304: the path is the lock file in the state directory
|
||||
file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE, secret.FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open lock file: %w", err)
|
||||
}
|
||||
|
||||
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
|
||||
return nil, fmt.Errorf("failed to lock %s: %w", lockPath, err)
|
||||
}
|
||||
|
||||
// Closing the file releases the lock.
|
||||
return func() { _ = file.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// lockWait is how long a test waits for the lock before deciding it
|
||||
// will never come free.
|
||||
lockWait = 10 * time.Second
|
||||
|
||||
// heldWait is how long a test watches a second holder fail to take a
|
||||
// lock that is held. Broken exclusion lets it in at once.
|
||||
heldWait = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// lockFilesystem is a filesystem LockStateDir can lock, with a state
|
||||
// directory on it.
|
||||
type lockFilesystem struct {
|
||||
name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
}
|
||||
|
||||
// lockFilesystems returns the real filesystem, locked with flock, and the
|
||||
// in-memory one, locked with a mutex.
|
||||
func lockFilesystems(t *testing.T) []lockFilesystem {
|
||||
t.Helper()
|
||||
|
||||
return []lockFilesystem{
|
||||
{"memory", afero.NewMemMapFs(), testStateDir},
|
||||
{"real", afero.NewOsFs(), t.TempDir()},
|
||||
}
|
||||
}
|
||||
|
||||
// lockInBackground starts taking the lock and returns a channel that
|
||||
// delivers the function releasing it once it has been taken.
|
||||
func lockInBackground(
|
||||
t *testing.T, fs afero.Fs, stateDir string,
|
||||
) <-chan func() {
|
||||
t.Helper()
|
||||
|
||||
taken := make(chan func(), 1)
|
||||
|
||||
go func() {
|
||||
release, err := vault.LockStateDir(fs, stateDir)
|
||||
if assert.NoError(t, err) {
|
||||
taken <- release
|
||||
}
|
||||
}()
|
||||
|
||||
return taken
|
||||
}
|
||||
|
||||
// TestLockStateDirExcludes checks that while the lock is held a second
|
||||
// holder, with its own open lock file on the real filesystem, waits, and
|
||||
// that it gets the lock once the first releases it.
|
||||
func TestLockStateDirExcludes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, lfs := range lockFilesystems(t) {
|
||||
t.Run(lfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
taken := lockInBackground(t, lfs.fs, lfs.stateDir)
|
||||
|
||||
select {
|
||||
case second := <-taken:
|
||||
second()
|
||||
release()
|
||||
t.Fatal("a second holder took the lock while it was held")
|
||||
case <-time.After(heldWait):
|
||||
}
|
||||
|
||||
release()
|
||||
|
||||
select {
|
||||
case second := <-taken:
|
||||
second()
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("the second holder never got the lock")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockStateDirFreeAfterPanic checks that a holder that panics, and
|
||||
// releases the lock with defer as every command does, leaves it free.
|
||||
func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, lfs := range lockFilesystems(t) {
|
||||
t.Run(lfs.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Panics(t, func() {
|
||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer release()
|
||||
|
||||
panic("the command failed")
|
||||
})
|
||||
|
||||
select {
|
||||
case release := <-lockInBackground(t, lfs.fs, lfs.stateDir):
|
||||
release()
|
||||
case <-time.After(lockWait):
|
||||
t.Fatal("the lock was still held after its holder panicked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// lock implementation is refused instead of being used unlocked.
|
||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewReadOnlyFs(afero.NewMemMapFs())
|
||||
|
||||
release, err := vault.LockStateDir(fs, testStateDir)
|
||||
require.ErrorIs(t, err, vault.ErrNoLockForFilesystem)
|
||||
assert.Nil(t, release)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package vault provides functionality for managing encrypted vaults.
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Register the GetCurrentVault function with the secret package
|
||||
//
|
||||
//nolint:gochecknoinits // registers the vault accessor with the secret package
|
||||
func init() {
|
||||
secret.RegisterGetCurrentVaultFunc(
|
||||
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||
return GetCurrentVault(fs, stateDir)
|
||||
})
|
||||
}
|
||||
|
||||
// isValidVaultName reports whether name is a valid vault name: only
|
||||
// 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 {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||
|
||||
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
|
||||
// The file contains just the vault name (e.g., "default")
|
||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
||||
|
||||
fileData, err := afero.ReadFile(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read currentvault file", "error", err)
|
||||
|
||||
return "", fmt.Errorf("failed to read currentvault file: %w", err)
|
||||
}
|
||||
|
||||
// The file contains just the vault name like "default"
|
||||
vaultName := strings.TrimSpace(string(fileData))
|
||||
secret.Debug("Read vault name from file", "vault_name", vaultName)
|
||||
|
||||
// Resolve to absolute path: stateDir/vaults.d/vaultName
|
||||
stateDir := filepath.Dir(currentVaultPath)
|
||||
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
|
||||
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
secret.Debug("Getting current vault", "state_dir", stateDir)
|
||||
|
||||
// Check if the current vault symlink exists
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||
|
||||
_, err := fs.Stat(currentVaultPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to stat current vault symlink",
|
||||
"error", err, "path", currentVaultPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Current vault symlink exists")
|
||||
|
||||
// Resolve the symlink to get the actual vault directory
|
||||
secret.Debug("Resolving vault symlink")
|
||||
|
||||
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.Debug("Resolved vault symlink", "target_path", targetPath)
|
||||
|
||||
// Extract the vault name from the path
|
||||
// The path will be something like "/path/to/vaults.d/default"
|
||||
vaultName := filepath.Base(targetPath)
|
||||
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
||||
|
||||
secret.Debug("Current vault resolved",
|
||||
"vault_name", vaultName, "target_path", targetPath)
|
||||
|
||||
// Create and return the vault
|
||||
return NewVault(fs, stateDir, vaultName), nil
|
||||
}
|
||||
|
||||
// ListVaults lists all vaults in the state directory
|
||||
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||
|
||||
// Check if vaults directory exists
|
||||
exists, err := afero.DirExists(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Read the vaults directory
|
||||
entries, err := afero.ReadDir(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract vault names
|
||||
var vaults []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
vaults = append(vaults, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||
// It returns the long-term key, nil when there is no mnemonic, and the
|
||||
// derivation index, public key hash, and family hash.
|
||||
func processMnemonicForVault(
|
||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||
mnemonicBuffer *memguard.LockedBuffer,
|
||||
) (*age.X25519Identity, uint32, string, string, error) {
|
||||
if mnemonicBuffer == nil {
|
||||
secret.Debug("No mnemonic given, vault created without long-term key",
|
||||
"vault", vaultName)
|
||||
// Use 0 for derivation index when no mnemonic is provided
|
||||
return nil, 0, "", "", nil
|
||||
}
|
||||
|
||||
mnemonic := mnemonicBuffer.String()
|
||||
|
||||
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||
if err != nil {
|
||||
return nil, 0, "", "",
|
||||
fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the actual derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Write the public key
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
||||
if err != nil {
|
||||
return nil, 0, "", "",
|
||||
fmt.Errorf("failed to write long-term public key: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
// Compute verification hash from actual derivation index
|
||||
publicKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||
|
||||
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
||||
// This is used to identify which vaults belong to the same mnemonic family
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, 0, "", "",
|
||||
fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault and selects it as the current vault. When
|
||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||
// long-term key until one is imported. 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)
|
||||
|
||||
err := ValidateVaultName(name)
|
||||
if err != nil {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.Debug("Vault name validation passed", "vault_name", 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)
|
||||
|
||||
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
|
||||
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Select the newly created vault as current
|
||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||
|
||||
err = SelectVault(fs, stateDir, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to select vault: %w", err)
|
||||
}
|
||||
|
||||
// Create and return the vault
|
||||
secret.Debug("Successfully created vault", "name", name)
|
||||
|
||||
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
|
||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||
|
||||
err := ValidateVaultName(name)
|
||||
if err != nil {
|
||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||
|
||||
// Check if vault exists
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
|
||||
exists, err := afero.DirExists(fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
||||
}
|
||||
|
||||
// Create or replace the currentvault file with just the vault name. It
|
||||
// is replaced in one rename, so it never goes missing.
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
|
||||
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||
|
||||
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select vault: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully selected vault", "vault_name", name)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Metadata is an alias for secret.VaultMetadata
|
||||
type Metadata = secret.VaultMetadata
|
||||
|
||||
// UnlockerMetadata is an alias for secret.UnlockerMetadata
|
||||
type UnlockerMetadata = secret.UnlockerMetadata
|
||||
|
||||
// SecretMetadata is an alias for secret.Metadata
|
||||
type SecretMetadata = secret.Metadata
|
||||
|
||||
// Configuration is an alias for secret.Configuration
|
||||
type Configuration = secret.Configuration
|
||||
|
||||
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
|
||||
func ComputeDoubleSHA256(data []byte) string {
|
||||
firstHash := sha256.Sum256(data)
|
||||
secondHash := sha256.Sum256(firstHash[:])
|
||||
|
||||
return hex.EncodeToString(secondHash[:])
|
||||
}
|
||||
|
||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
||||
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||
func GetNextDerivationIndex(
|
||||
fs afero.Fs, stateDir string, mnemonic string,
|
||||
) (uint32, error) {
|
||||
// First, derive the public key for index 0 to get our identifier
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
|
||||
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||
|
||||
// Check if vaults directory exists
|
||||
exists, err := afero.DirExists(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// No vaults yet, start with index 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Read all vault directories
|
||||
entries, err := afero.ReadDir(fs, vaultsDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read vaults directory: %w", err)
|
||||
}
|
||||
|
||||
// Track which indices are in use for this mnemonic
|
||||
usedIndices := make(map[uint32]bool)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to read vault metadata
|
||||
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
// Skip vaults without metadata
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata Metadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
// Skip vaults with invalid metadata
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this vault uses the same mnemonic by comparing family hashes
|
||||
if metadata.MnemonicFamilyHash == pubKeyHash {
|
||||
usedIndices[metadata.DerivationIndex] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first available index
|
||||
var index uint32
|
||||
for usedIndices[index] {
|
||||
index++
|
||||
}
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// SaveVaultMetadata saves vault metadata to the vault directory
|
||||
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
||||
}
|
||||
|
||||
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write vault metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadVaultMetadata loads vault metadata from the vault directory
|
||||
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
|
||||
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 Metadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
||||
func TestVaultMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
||||
testComputeDoubleSHA256(t)
|
||||
})
|
||||
|
||||
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
||||
testGetNextDerivationIndex(t, fs)
|
||||
})
|
||||
|
||||
t.Run("MetadataPersistence", func(t *testing.T) {
|
||||
testMetadataPersistence(t, fs)
|
||||
})
|
||||
|
||||
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
||||
testDifferentKeysForDifferentIndices(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testComputeDoubleSHA256(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Test data
|
||||
data := []byte("test data")
|
||||
hash := vault.ComputeDoubleSHA256(data)
|
||||
|
||||
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
||||
}
|
||||
|
||||
// Verify consistency
|
||||
hash2 := vault.ComputeDoubleSHA256(data)
|
||||
if hash != hash2 {
|
||||
t.Errorf("Hash should be consistent for same input")
|
||||
}
|
||||
|
||||
// Verify different input produces different hash
|
||||
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
|
||||
if hash == hash3 {
|
||||
t.Errorf("Different input should produce different hash")
|
||||
}
|
||||
}
|
||||
|
||||
// createVaultDirWithMetadata creates a vault directory containing a public
|
||||
// key derived from testMnemonic at the given index plus saved metadata, and
|
||||
// returns the derived public key hash. An empty familyHash defaults to the
|
||||
// derived key's own hash.
|
||||
func createVaultDirWithMetadata(
|
||||
t *testing.T, fs afero.Fs, vaultName string,
|
||||
derivationIndex uint32, familyHash string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
|
||||
|
||||
err := fs.MkdirAll(vaultDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for the requested index
|
||||
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
|
||||
pubKey := identity.Recipient().String()
|
||||
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
|
||||
|
||||
// Write public key
|
||||
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
||||
[]byte(pubKey), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
if familyHash == "" {
|
||||
familyHash = pubKeyHash
|
||||
}
|
||||
|
||||
metadata := &vault.Metadata{
|
||||
DerivationIndex: derivationIndex,
|
||||
PublicKeyHash: pubKeyHash,
|
||||
MnemonicFamilyHash: familyHash,
|
||||
}
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
return pubKeyHash
|
||||
}
|
||||
|
||||
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
// Test with no existing vaults
|
||||
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||
}
|
||||
|
||||
// Create a vault with metadata and matching public key (index 0; the
|
||||
// family hash is the index 0 key hash)
|
||||
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
|
||||
|
||||
// Next index for same mnemonic should be 1
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
||||
}
|
||||
|
||||
// Different mnemonic should start at 0
|
||||
//nolint:dupword // BIP39-style test mnemonic
|
||||
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 0 {
|
||||
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
|
||||
index)
|
||||
}
|
||||
|
||||
// Add another vault with same mnemonic but higher index (5), sharing
|
||||
// the same family hash since it's from the same mnemonic
|
||||
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
|
||||
|
||||
// Next index should be 1 (not 6): we look for the first available slot
|
||||
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
|
||||
err := fs.MkdirAll(vaultDir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Create and save metadata
|
||||
metadata := &vault.Metadata{
|
||||
DerivationIndex: 3,
|
||||
PublicKeyHash: "test-public-key-hash",
|
||||
}
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load metadata: %v", err)
|
||||
}
|
||||
|
||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
|
||||
metadata.DerivationIndex, loaded.DerivationIndex)
|
||||
}
|
||||
|
||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
|
||||
metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func testDifferentKeysForDifferentIndices(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Derive keys with different indices
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
||||
}
|
||||
|
||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
||||
}
|
||||
|
||||
// Compute public key hashes
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKey1 := identity1.Recipient().String()
|
||||
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Verify different indices produce different public keys
|
||||
if pubKey0 == pubKey1 {
|
||||
t.Errorf("Different derivation indices should produce different public keys")
|
||||
}
|
||||
|
||||
// But the hash of index 0's public key should be the same for the same
|
||||
// mnemonic. This is what we use as the identifier
|
||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
pubKey0Again := identity0Again.Recipient().String()
|
||||
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||
|
||||
if hash0 != hash0Again {
|
||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyHashConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Derive identity from index 0 multiple times
|
||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive first identity: %v", err)
|
||||
}
|
||||
|
||||
identity2, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive second identity: %v", err)
|
||||
}
|
||||
|
||||
// Verify identities are the same
|
||||
if identity1.Recipient().String() != identity2.Recipient().String() {
|
||||
t.Errorf("Identity derivation is not deterministic")
|
||||
t.Logf("First: %s", identity1.Recipient().String())
|
||||
t.Logf("Second: %s", identity2.Recipient().String())
|
||||
}
|
||||
|
||||
// Compute public key hashes
|
||||
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
||||
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
||||
|
||||
// Verify hashes are the same
|
||||
if hash1 != hash2 {
|
||||
t.Errorf("Public key hash computation is not deterministic")
|
||||
t.Logf("First hash: %s", hash1)
|
||||
t.Logf("Second hash: %s", hash2)
|
||||
}
|
||||
|
||||
t.Logf("Test mnemonic public key hash (index 0): %s", hash1)
|
||||
}
|
||||
|
||||
func TestSampleHashCalculation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test with the exact mnemonic from integration test if available. We
|
||||
// also test with a few different mnemonics to make sure they produce
|
||||
// different hashes
|
||||
mnemonics := []string{
|
||||
testMnemonic,
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||
//nolint:dupword // BIP39-style test mnemonic
|
||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||
}
|
||||
|
||||
for i, mnemonic := range mnemonics {
|
||||
identity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
|
||||
t.Logf(" Recipient: %s", identity.Recipient().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||
}
|
||||
|
||||
// Load metadata for vault1
|
||||
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||
|
||||
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||
|
||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||
}
|
||||
|
||||
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||
|
||||
// Simulate the vault import process: get the next available derivation
|
||||
// index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Next derivation index for import: %d", derivationIndex)
|
||||
|
||||
// Calculate public key hash from index 0 (same as in VaultImport)
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||
}
|
||||
|
||||
publicKeyHash := vault.ComputeDoubleSHA256(
|
||||
[]byte(identity0.Recipient().String()))
|
||||
|
||||
// Load existing metadata and update it (same as in VaultImport)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||
}
|
||||
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
|
||||
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||
}
|
||||
|
||||
// Load updated metadata for vault2
|
||||
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||
metadata2.DerivationIndex, metadata2.PublicKeyHash)
|
||||
|
||||
// Verify that both vaults have the same public key hash
|
||||
if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
|
||||
t.Errorf("Public key hashes don't match!")
|
||||
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||
} else {
|
||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
||||
metadata1.PublicKeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseEngineerHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This is the hash that the work vault is getting in the failing test
|
||||
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
||||
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
||||
|
||||
// Calculate hash for test mnemonic
|
||||
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
|
||||
calculatedHash := vault.ComputeDoubleSHA256(
|
||||
[]byte(identity.Recipient().String()))
|
||||
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
||||
|
||||
if calculatedHash == correctHash {
|
||||
t.Logf("Test mnemonic produces the correct hash")
|
||||
} else {
|
||||
t.Errorf("Test mnemonic does not produce the correct hash")
|
||||
}
|
||||
|
||||
if calculatedHash == wrongHash {
|
||||
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
|
||||
}
|
||||
|
||||
// Try some other possibilities: maybe a string normalization issue?
|
||||
variations := []string{
|
||||
testMnemonic,
|
||||
" " + testMnemonic + " ",
|
||||
testMnemonic + "\n",
|
||||
strings.TrimSpace(testMnemonic),
|
||||
}
|
||||
|
||||
for i, variation := range variations {
|
||||
identity, err := agehd.DeriveIdentity(variation, 0)
|
||||
if err != nil {
|
||||
t.Logf("Variation %d failed: %v", i, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Variation %d hash: %s", i, hash)
|
||||
|
||||
if hash == wrongHash {
|
||||
t.Logf("Found variation that produces wrong hash: '%s'", variation)
|
||||
}
|
||||
}
|
||||
|
||||
// Maybe let's try an empty mnemonic or something else?
|
||||
emptyMnemonics := []string{
|
||||
"",
|
||||
" ",
|
||||
}
|
||||
|
||||
for i, emptyMnemonic := range emptyMnemonics {
|
||||
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
||||
|
||||
if hash == wrongHash {
|
||||
t.Logf("Empty mnemonic produces wrong hash!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package vault_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||
// validates the secret name and rejects path traversal attempts.
|
||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a legitimate secret so the vault is set up
|
||||
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
|
||||
err = vlt.AddSecret("legit", value, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// These names contain path traversal and should be rejected
|
||||
maliciousNames := []string{
|
||||
"../../../etc/passwd",
|
||||
"..%2f..%2fetc/passwd",
|
||||
".secret",
|
||||
"../sibling-vault/secrets.d/target",
|
||||
"foo/../bar",
|
||||
"a/../../etc/passwd",
|
||||
}
|
||||
|
||||
for _, name := range maliciousNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := vlt.GetSecretVersion(name, "")
|
||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
||||
"GetSecretVersion should reject malicious name: %s", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
||||
// GetSecretVersion) also rejects path traversal names.
|
||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
||||
}
|
||||
|
||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||
// also validates names and rejects path traversal attempts.
|
||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
maliciousNames := []string{
|
||||
"../../../etc/passwd",
|
||||
"foo/../bar",
|
||||
"a/../../etc/passwd",
|
||||
}
|
||||
|
||||
for _, name := range maliciousNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := vlt.GetSecretObject(name)
|
||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
||||
"GetSecretObject should reject: %s", name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// ListSecrets returns a list of secret names in this vault
|
||||
func (v *Vault) ListSecrets() ([]string, error) {
|
||||
secret.DebugWith("Listing secrets in vault", slog.String("vault_name", v.Name))
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for secret listing",
|
||||
"error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
// Check if secrets directory exists
|
||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check secrets directory",
|
||||
"error", err, "secrets_dir", secretsDir)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secrets directory does not exist",
|
||||
"secrets_dir", secretsDir, "vault_name", v.Name)
|
||||
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// List directories in secrets.d
|
||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read secrets directory",
|
||||
"error", err, "secrets_dir", secretsDir)
|
||||
|
||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||
}
|
||||
|
||||
var secrets []string
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
// Convert storage name back to secret name
|
||||
secretName := strings.ReplaceAll(file.Name(), "%", "/")
|
||||
secrets = append(secrets, secretName)
|
||||
}
|
||||
}
|
||||
|
||||
secret.DebugWith("Found secrets in vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.Int("secret_count", len(secrets)),
|
||||
slog.Any("secret_names", secrets),
|
||||
)
|
||||
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
|
||||
// but with additional restrictions:
|
||||
// - No leading or trailing slashes
|
||||
// - No double slashes
|
||||
// - No names starting with dots
|
||||
// - No ".." path segments
|
||||
func isValidSecretName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for leading/trailing slashes
|
||||
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for double slashes
|
||||
if strings.Contains(name, "//") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for names starting with dot
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for path traversal via ".." components
|
||||
if slices.Contains(strings.Split(name, "/"), "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check the basic pattern
|
||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// ValidateSecretName returns an error wrapping ErrInvalidSecretName when
|
||||
// name is not a valid secret name. Call it on the name exactly as the user
|
||||
// gave it, before building any path from it.
|
||||
func ValidateSecretName(name string) error {
|
||||
if !isValidSecretName(name) {
|
||||
return fmt.Errorf(
|
||||
"%w '%s': only ASCII letters, digits, '.', '-', '_' and '/' are allowed, "+
|
||||
"and a name must not be empty, start with '.' or '/', end with '/', "+
|
||||
"contain '//', or have '..' as a path segment",
|
||||
ErrInvalidSecretName, name,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to this vault
|
||||
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||
if value == nil {
|
||||
return secret.ErrNilValueBuffer
|
||||
}
|
||||
|
||||
secret.DebugWith("Adding secret to vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("value_length", value.Size()),
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
// Validate secret name
|
||||
err := ValidateSecretName(name)
|
||||
if err != nil {
|
||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Secret name validation passed", "secret_name", name)
|
||||
|
||||
secret.Debug("Getting vault directory")
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory for secret addition",
|
||||
"error", err, "vault_name", v.Name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
secret.DebugWith("Secret storage details",
|
||||
slog.String("storage_name", storageName),
|
||||
slog.String("secret_dir", secretDir),
|
||||
)
|
||||
|
||||
// Check for an existing secret and the version the new one supersedes
|
||||
exists, previousVersion, err := v.checkExistingSecret(name, secretDir, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return v.addVersion(name, secretDir, value, previousVersion)
|
||||
}
|
||||
|
||||
return v.addNewSecret(name, secretDir, value)
|
||||
}
|
||||
|
||||
// addNewSecret creates a secret by assembling its first version and current
|
||||
// pointer in a temporary directory, then renaming that directory to
|
||||
// secretDir, so an interrupted add leaves no half-made secret behind.
|
||||
func (v *Vault) addNewSecret(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
) error {
|
||||
buildDir, err := secret.TempDirFor(v.fs, secretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Once the rename below has moved it into place, this finds nothing.
|
||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
||||
|
||||
err = v.addVersion(name, buildDir, value, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.fs.Rename(buildDir, secretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move new secret into place: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addVersion saves value as a new version under secretDir, sets the
|
||||
// notAfter timestamp of the version it supersedes, if any, and then points
|
||||
// current at the new version. Until that last step, current still names the
|
||||
// previous version, which stays readable.
|
||||
func (v *Vault) addVersion(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
// Unlock the vault before writing anything, so that a wrong passphrase
|
||||
// or mnemonic leaves the secret as it was
|
||||
err := v.loadPreviousVersionMetadata(previousVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Taken after the unlock, which can wait for the user to type the
|
||||
// passphrase, so that the new version starts when it is created
|
||||
now := time.Now()
|
||||
|
||||
// Create the new version and save the encrypted value
|
||||
versionName, err := v.createAndSaveVersion(
|
||||
name, secretDir, value, previousVersion, &now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update previous version's notAfter timestamp if it exists
|
||||
err = v.updatePreviousVersion(previousVersion, &now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set current symlink to new version
|
||||
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully added secret version to vault",
|
||||
"secret_name", name, "version", versionName,
|
||||
"vault_name", v.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVersionMetadata updates the metadata of an existing version
|
||||
func updateVersionMetadata(
|
||||
fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
// Read the version's encrypted private key
|
||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt version private key using long-term key
|
||||
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Marshal updated metadata
|
||||
metadataBytes, err := json.MarshalIndent(version.Metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer,
|
||||
versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted metadata
|
||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||
|
||||
err = secret.WriteFileAtomic(fs, metadataPath, encryptedMetadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSecret retrieves the current version of a secret from this vault.
|
||||
// The caller must destroy the returned buffer.
|
||||
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
|
||||
secret.DebugWith("Getting secret from vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
)
|
||||
|
||||
// GetSecretObject validates the name and checks that the secret exists
|
||||
secretObj, err := v.GetSecretObject(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretObj.Directory)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
return v.GetSecretVersion(name, currentVersion)
|
||||
}
|
||||
|
||||
// GetSecretVersion retrieves a specific version of a secret. The version
|
||||
// must be one of the secret's versions; GetSecret gets the current one.
|
||||
// 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",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
)
|
||||
|
||||
// Validate the name and check that the version exists
|
||||
err := v.checkSecretVersion(name, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create version object
|
||||
secretVersion := secret.NewVersion(v, name, version)
|
||||
|
||||
secret.Debug("Version exists, proceeding with vault unlock and decryption",
|
||||
"version", version, "secret_name", name)
|
||||
|
||||
// Unlock the vault (get long-term key in memory)
|
||||
longTermIdentity, err := v.UnlockVault()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to unlock vault: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully unlocked vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Get the version's value
|
||||
secret.Debug("About to call secretVersion.GetValue",
|
||||
"version", version, "secret_name", name)
|
||||
|
||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt version value",
|
||||
"error", err, "version", version, "secret_name", name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted secret version",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.Int("decrypted_length", decryptedValue.Size()),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// UnlockVault unlocks the vault and returns the long-term private key
|
||||
func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
secret.Debug("Unlocking vault", "vault_name", v.Name)
|
||||
|
||||
// If vault is already unlocked, return the cached key
|
||||
if !v.Locked() {
|
||||
secret.Debug("Vault already unlocked, returning cached long-term key",
|
||||
"vault_name", v.Name)
|
||||
|
||||
return v.longTermKey, nil
|
||||
}
|
||||
|
||||
// Get or derive the long-term key (but don't store it yet)
|
||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get or derive long-term key",
|
||||
"error", err, "vault_name", v.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Now unlock the vault by storing the key in memory
|
||||
v.Unlock(longTermIdentity)
|
||||
|
||||
secret.DebugWith("Successfully unlocked vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return longTermIdentity, nil
|
||||
}
|
||||
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
err := ValidateSecretName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// First check if the secret exists by checking for the metadata file
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret directory exists
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Create a Secret object
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
|
||||
// Load the metadata from disk
|
||||
err = secretObj.LoadMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return secretObj, nil
|
||||
}
|
||||
|
||||
// CopySecretVersion copies a single version from source into destSecretDir
|
||||
// in this vault. It decrypts the value using srcIdentity and re-encrypts
|
||||
// for this vault.
|
||||
func (v *Vault) CopySecretVersion(
|
||||
srcVersion *secret.Version,
|
||||
srcIdentity *age.X25519Identity,
|
||||
destSecretName string,
|
||||
destSecretDir string,
|
||||
destVersionName string,
|
||||
) error {
|
||||
secret.DebugWith("Copying secret version to vault",
|
||||
slog.String("src_secret", srcVersion.SecretName),
|
||||
slog.String("src_version", srcVersion.Version),
|
||||
slog.String("dest_vault", v.Name),
|
||||
slog.String("dest_secret", destSecretName),
|
||||
slog.String("dest_version", destVersionName),
|
||||
)
|
||||
|
||||
// Get the decrypted value from source
|
||||
valueBuffer, err := srcVersion.GetValue(srcIdentity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt source version: %w", err)
|
||||
}
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
// Load source metadata
|
||||
err = srcVersion.LoadMetadata(srcIdentity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load source metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create destination version with same name
|
||||
destVersion := secret.NewVersion(v, destSecretName, destVersionName)
|
||||
destVersion.Directory = filepath.Join(destSecretDir, "versions", destVersionName)
|
||||
|
||||
// Copy metadata (preserve original timestamps)
|
||||
destVersion.Metadata = srcVersion.Metadata
|
||||
|
||||
// Save the version (encrypts to this vault's LT key)
|
||||
err = destVersion.Save(valueBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save destination version: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully copied secret version",
|
||||
"src_version", srcVersion.Version,
|
||||
"dest_version", destVersionName,
|
||||
"dest_vault", v.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopySecretAllVersions copies all versions of a secret from source vault to this vault
|
||||
// It re-encrypts each version with this vault's long-term key
|
||||
func (v *Vault) CopySecretAllVersions(
|
||||
srcVault *Vault,
|
||||
srcSecretName string,
|
||||
destSecretName string,
|
||||
force bool,
|
||||
) error {
|
||||
secret.DebugWith("Copying all secret versions between vaults",
|
||||
slog.String("src_vault", srcVault.Name),
|
||||
slog.String("src_secret", srcSecretName),
|
||||
slog.String("dest_vault", v.Name),
|
||||
slog.String("dest_secret", destSecretName),
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
// Get destination vault directory
|
||||
destVaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Refuse to replace an existing destination secret unless forced
|
||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||
|
||||
err = v.checkCopyDestination(destSecretDir, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get source vault's long-term key
|
||||
srcIdentity, err := srcVault.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err)
|
||||
}
|
||||
|
||||
// Get source secret directory
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
|
||||
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
|
||||
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
|
||||
|
||||
// List all versions
|
||||
versions, err := secret.ListVersions(srcVault.fs, srcSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list source versions: %w", err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return fmt.Errorf("source secret '%s' %w", srcSecretName, ErrNoVersions)
|
||||
}
|
||||
|
||||
// Get current version name
|
||||
currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Copy each version and the current pointer, then move the copy into place
|
||||
err = v.copyVersions(srcVault, srcIdentity,
|
||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully copied all secret versions",
|
||||
slog.String("src_vault", srcVault.Name),
|
||||
slog.String("dest_vault", v.Name),
|
||||
slog.Int("version_count", len(versions)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkExistingSecret reports whether the secret already exists, refuses to
|
||||
// overwrite it unless force is set, and returns its current version, which
|
||||
// the new version supersedes, if any.
|
||||
func (v *Vault) checkExistingSecret(
|
||||
name, secretDir string, force bool,
|
||||
) (bool, *secret.Version, error) {
|
||||
// Check if secret already exists
|
||||
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists",
|
||||
"error", err, "secret_dir", secretDir)
|
||||
|
||||
return false, nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
if !exists {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
if !force {
|
||||
secret.Debug("Secret already exists and force not specified",
|
||||
"secret_name", name, "secret_dir", secretDir)
|
||||
|
||||
return true, nil, fmt.Errorf(
|
||||
"secret %s %w (use --force to overwrite)",
|
||||
name, ErrSecretExists,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the current version to update its notAfter timestamp
|
||||
var previousVersion *secret.Version
|
||||
|
||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err == nil && currentVersionName != "" {
|
||||
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
||||
// We'll need to load and update its metadata after we unlock the vault
|
||||
}
|
||||
|
||||
return true, previousVersion, nil
|
||||
}
|
||||
|
||||
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
|
||||
// the version being superseded. It is a no-op when previousVersion is nil.
|
||||
func (v *Vault) loadPreviousVersionMetadata(
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
if previousVersion == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for previous version", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
|
||||
// a no-op when previousVersion is nil.
|
||||
func (v *Vault) updatePreviousVersion(
|
||||
previousVersion *secret.Version, now *time.Time,
|
||||
) error {
|
||||
if previousVersion == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPreviousVersionMetadata unlocked the vault, so this returns the
|
||||
// key it keeps in memory
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = now
|
||||
|
||||
// Re-save the metadata (we need to implement an update method)
|
||||
err = updateVersionMetadata(v.fs, previousVersion, ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSecretVersion validates the secret name and verifies that the secret
|
||||
// exists and that version is one of its versions.
|
||||
func (v *Vault) checkSecretVersion(name, version string) error {
|
||||
// Validate secret name to prevent path traversal
|
||||
err := ValidateSecretName(name)
|
||||
if err != nil {
|
||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
|
||||
return fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
exists, err = secret.VersionExists(v.fs, secretDir, version)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||
|
||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||
|
||||
return fmt.Errorf("version '%s' %w '%s'", version, ErrVersionNotFound, name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAndSaveVersion generates a new version name, sets the version
|
||||
// timestamps, and saves the encrypted value under secretDir, which is a
|
||||
// temporary directory while a new secret is being assembled.
|
||||
func (v *Vault) createAndSaveVersion(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
previousVersion *secret.Version, now *time.Time,
|
||||
) (string, error) {
|
||||
// Generate new version name
|
||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||
|
||||
return "", fmt.Errorf("failed to generate version name: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||
|
||||
// Create new version
|
||||
newVersion := secret.NewVersion(v, name, versionName)
|
||||
newVersion.Directory = filepath.Join(secretDir, "versions", versionName)
|
||||
|
||||
// Set version timestamps
|
||||
if previousVersion == nil {
|
||||
// First version: notBefore = epoch + 1 second
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
newVersion.Metadata.NotBefore = &epochPlusOne
|
||||
} else {
|
||||
// New version: notBefore = now
|
||||
newVersion.Metadata.NotBefore = now
|
||||
|
||||
// We'll update the previous version's notAfter after we save the
|
||||
// new version
|
||||
}
|
||||
|
||||
// Save the new version - pass the LockedBuffer directly
|
||||
err = newVersion.Save(value)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||
|
||||
return "", fmt.Errorf("failed to save version: %w", err)
|
||||
}
|
||||
|
||||
return versionName, nil
|
||||
}
|
||||
|
||||
// copyVersions copies each version of the source secret and its current
|
||||
// pointer into a temporary directory, then moves that directory to
|
||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
||||
// temporary directory behind.
|
||||
func (v *Vault) copyVersions(
|
||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||
srcSecretName, destSecretName, destSecretDir string,
|
||||
versions []string, currentVersion string,
|
||||
) error {
|
||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Once the rename below has moved it into place, this finds nothing.
|
||||
defer func() { _ = v.fs.RemoveAll(buildDir) }()
|
||||
|
||||
for _, versionName := range versions {
|
||||
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
||||
|
||||
err = v.CopySecretVersion(
|
||||
srcVersion, srcIdentity, destSecretName, buildDir, versionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
// With --force, the secret being replaced goes only now that its
|
||||
// replacement is complete
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check destination: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
||||
|
||||
err = secret.RemoveDirAtomic(v.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = v.fs.Rename(buildDir, destSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move copied secret into place: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
||||
// is set. A secret being replaced is removed by copyVersions, once its
|
||||
// replacement is complete.
|
||||
func (v *Vault) checkCopyDestination(
|
||||
destSecretDir, destSecretName string, force bool,
|
||||
) error {
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check destination: %w", err)
|
||||
}
|
||||
|
||||
if exists && !force {
|
||||
return fmt.Errorf(
|
||||
"secret '%s' %w in vault '%s' (use --force to overwrite)",
|
||||
destSecretName, ErrSecretExists, v.Name,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//nolint:testpackage // white-box test of unexported isValidSecretName
|
||||
package vault
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
}{
|
||||
// Lowercase (existing behavior)
|
||||
{"valid-name", true},
|
||||
{"valid.name", true},
|
||||
{"valid_name", true},
|
||||
{"valid/path/name", true},
|
||||
{"123valid", true},
|
||||
|
||||
// Uppercase (new behavior - issue #2)
|
||||
{"Valid-Upper-Name", true},
|
||||
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
|
||||
{"MixedCase/Path/Name", true},
|
||||
{"ALLUPPERCASE", true},
|
||||
{"ABC123", true},
|
||||
|
||||
// Still invalid
|
||||
{"", false},
|
||||
{"invalid name", false},
|
||||
{"invalid@name", false},
|
||||
{".dotstart", false},
|
||||
{"/leading-slash", false},
|
||||
{"trailing-slash/", false},
|
||||
{"double//slash", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := isValidSecretName(tt.name)
|
||||
if result != tt.valid {
|
||||
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Vault-Level Version Operation Tests
|
||||
//
|
||||
// Integration tests for vault-level version operations:
|
||||
//
|
||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
||||
// version structure
|
||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
||||
// force flag
|
||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
||||
// version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||
// across versions
|
||||
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||
// taken after AddSecret unlocks the vault
|
||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||
//
|
||||
// Version Management:
|
||||
// - List versions in reverse chronological order
|
||||
// - Promote any version to current
|
||||
// - Promotion doesn't modify timestamps
|
||||
// - Metadata remains encrypted and intact
|
||||
|
||||
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
||||
package vault
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// testMnemonic is the mnemonic used to derive the vault long-term key.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon about"
|
||||
|
||||
// Shared fixtures for white-box tests in this package.
|
||||
const (
|
||||
testStateDir = "/test/state"
|
||||
testSecretPath = "test/secret"
|
||||
)
|
||||
|
||||
// Helper function to add a secret to vault with proper buffer protection
|
||||
func addTestSecretToVault(
|
||||
t *testing.T, vault *Vault, name string, value []byte, force bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
buffer := memguard.NewBufferFromBytes(value)
|
||||
defer buffer.Destroy()
|
||||
|
||||
err := vault.AddSecret(name, buffer, force)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper function to create a vault named "test" with its long-term key set
|
||||
// up and unlocked
|
||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||
t.Helper()
|
||||
|
||||
// Create vault without a long-term key, which is set up below
|
||||
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPubKeyPath,
|
||||
[]byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault with the derived key
|
||||
vault.Unlock(ltIdentity)
|
||||
|
||||
return vault
|
||||
}
|
||||
|
||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Add a secret
|
||||
secretValue := []byte("initial-value")
|
||||
expectedValue := make([]byte, len(secretValue))
|
||||
copy(expectedValue, secretValue)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
||||
|
||||
// Check that version directory was created
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versionsDir := secretDir + "/versions"
|
||||
|
||||
// Should have one version
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
|
||||
// Should have current symlink
|
||||
currentPath := secretDir + "/current"
|
||||
exists, err := afero.Exists(fs, currentPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Get the secret value
|
||||
retrievedValue, err := vault.GetSecret(testSecretPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedValue.Destroy()
|
||||
|
||||
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
||||
}
|
||||
|
||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Add first version
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||
|
||||
// Try to add again without force - should fail
|
||||
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||
defer failBuffer.Destroy()
|
||||
|
||||
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
||||
require.ErrorIs(t, err, ErrSecretExists)
|
||||
|
||||
// Add with force - should create new version
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
|
||||
// Check that we have two versions
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 2)
|
||||
|
||||
// Current value should be version-2
|
||||
value, err := vault.GetSecret(testSecretPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
||||
}
|
||||
|
||||
func TestVaultGetSecretVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Add multiple versions
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||
|
||||
// Small delay to ensure different version names
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
|
||||
// Get versions list
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Get specific version (first one)
|
||||
firstVersion := versions[1] // Last in list is first created
|
||||
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer first.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1"), first.Bytes())
|
||||
|
||||
// Get specific version (second one)
|
||||
secondVersion := versions[0] // First in list is most recent
|
||||
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer second.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2"), second.Bytes())
|
||||
|
||||
// An empty version is not one of the versions; GetSecret gets the
|
||||
// current one
|
||||
_, err = vault.GetSecretVersion(testSecretPath, "")
|
||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||
}
|
||||
|
||||
func TestVaultVersionTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add first version
|
||||
beforeFirst := time.Now()
|
||||
|
||||
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||
defer v1Buffer.Destroy()
|
||||
|
||||
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterFirst := time.Now()
|
||||
|
||||
// Get first version metadata
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check first version timestamps
|
||||
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
||||
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
|
||||
|
||||
// Add second version
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
beforeSecond := time.Now()
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
|
||||
afterSecond := time.Now()
|
||||
|
||||
// Get updated versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Reload first version metadata (should have notAfter now)
|
||||
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||
|
||||
// Check second version timestamps
|
||||
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.True(t,
|
||||
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t,
|
||||
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||
}
|
||||
|
||||
// longTermKeyReadFs records when longterm.age, the last file an unlock
|
||||
// reads, was last opened.
|
||||
type longTermKeyReadFs struct {
|
||||
afero.Fs
|
||||
|
||||
readAt time.Time
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
|
||||
if filepath.Base(name) == "longterm.age" {
|
||||
f.readAt = time.Now()
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
|
||||
// unlock the vault to replace a version, the new version's notBefore and the
|
||||
// replaced version's notAfter are taken after the unlock, which can wait for
|
||||
// the user to type the passphrase.
|
||||
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
|
||||
|
||||
// A new instance is locked; AddSecret unlocks it with the passphrase
|
||||
vault := NewVault(fs, testStateDir, "test")
|
||||
vault.SetUnlockPassphrase(passphrase)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
|
||||
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
|
||||
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
|
||||
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
|
||||
}
|
||||
|
||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Add a secret
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||
}
|
||||
|
||||
func TestUpdateVersionMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs)
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a version manually to test updateVersionMetadata
|
||||
versionName := "20231215.001"
|
||||
version := secret.NewVersion(vault, testSecretPath, versionName)
|
||||
|
||||
// Set initial metadata
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
version.Metadata.NotBefore = &epochPlusOne
|
||||
version.Metadata.NotAfter = nil
|
||||
|
||||
// Save version
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = version.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update metadata
|
||||
version.Metadata.NotAfter = &now
|
||||
err = updateVersionMetadata(fs, version, ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load and verify
|
||||
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
||||
err = version2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, version2.Metadata.NotAfter)
|
||||
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user