Compare commits
174
Commits
985d79d3c0
...
v1.0.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go mod why:*)",
|
||||
"Bash(go list:*)",
|
||||
"Bash(~/go/bin/govulncheck -mode=module .)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
EXTREMELY IMPORTANT: Read and follow the policies, procedures, and
|
||||
instructions in the `AGENTS.md` file in the root of the repository. Make
|
||||
sure you follow *all* of the instructions meticulously.
|
||||
@@ -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
|
||||
+52
-4
@@ -1,6 +1,54 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
/secret
|
||||
*.log
|
||||
cli.test
|
||||
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
|
||||
|
||||
+90
-90
@@ -1,99 +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
|
||||
go: "1.22"
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Additional linters requested
|
||||
- testifylint # Checks usage of github.com/stretchr/testify
|
||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
||||
- tagliatelle # Checks the struct tags
|
||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
||||
- nestif # Reports deeply nested if statements
|
||||
- mnd # An analyzer to detect magic numbers
|
||||
- lll # Reports long lines
|
||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||
- gofumpt # Gofumpt checks whether code was gofumpt-ed
|
||||
- gochecknoglobals # Check that no global variables exist
|
||||
|
||||
# Default/existing linters that are commonly useful
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosimple
|
||||
- ineffassign
|
||||
- typecheck
|
||||
- gofmt
|
||||
- goimports
|
||||
- misspell
|
||||
- revive
|
||||
- gosec
|
||||
- unconvert
|
||||
- unparam
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 120
|
||||
|
||||
mnd:
|
||||
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
|
||||
checks:
|
||||
- argument
|
||||
- case
|
||||
- condition
|
||||
- operation
|
||||
- return
|
||||
- assign
|
||||
ignored-numbers:
|
||||
- '0'
|
||||
- '1'
|
||||
- '2'
|
||||
- '8'
|
||||
- '16'
|
||||
- '40' # GPG fingerprint length
|
||||
- '64'
|
||||
- '128'
|
||||
- '256'
|
||||
- '512'
|
||||
- '1024'
|
||||
- '2048'
|
||||
- '4096'
|
||||
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
|
||||
nlreturn:
|
||||
block-size: 2
|
||||
|
||||
tagliatelle:
|
||||
case:
|
||||
# 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:
|
||||
json: snake
|
||||
yaml: snake
|
||||
xml: snake
|
||||
bson: snake
|
||||
|
||||
testifylint:
|
||||
enable-all: true
|
||||
|
||||
usetesting:
|
||||
strict: true
|
||||
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:
|
||||
exclude-rules:
|
||||
# Exclude some linters from running on tests files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gochecknoglobals
|
||||
- mnd
|
||||
- unparam
|
||||
|
||||
# Allow long lines in generated code or test data
|
||||
- path: ".*_gen\\.go"
|
||||
linters:
|
||||
- lll
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -4,140 +4,179 @@ Version: 2025-06-08
|
||||
|
||||
# Instructions and Contextual Information
|
||||
|
||||
* Be direct, robotic, expert, accurate, and professional.
|
||||
- Be direct, robotic, expert, accurate, and professional.
|
||||
|
||||
* Do not butter me up or kiss my ass.
|
||||
- Do not butter me up or kiss my ass.
|
||||
|
||||
* Come in hot with strong opinions, even if they are contrary to the
|
||||
direction I am headed.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
- 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
|
||||
(`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
|
||||
`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.
|
||||
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.
|
||||
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
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
```
|
||||
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.
|
||||
- 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`.**
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# 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. That leaves out the files built only with cgo
|
||||
# on macOS: the keychain unlocker's calls into the keychain
|
||||
# (keychainunlocker_cgo.go, and keychainunlocker_test.go) and the Secure
|
||||
# Enclave bindings (internal/macse). Nothing on Linux checks those.
|
||||
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,26 +1,50 @@
|
||||
export CGO_ENABLED=1
|
||||
|
||||
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
|
||||
docker-run clean install hooks
|
||||
|
||||
default: check
|
||||
|
||||
build: ./secret
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
# Simple build (no code signing needed)
|
||||
./secret:
|
||||
go build -v -o $@ cmd/secret/main.go
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
|
||||
build:
|
||||
@script/build
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
@script/test
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
@script/fmt
|
||||
|
||||
# Vets and lints the Linux build, then the macOS build
|
||||
lint:
|
||||
golangci-lint run --timeout 5m
|
||||
@script/lint
|
||||
|
||||
# Check all code quality (build + vet + lint + unit tests)
|
||||
check: ./secret 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 ./secret
|
||||
|
||||
install: build
|
||||
cp ./secret $(HOME)/bin/secret
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
@@ -1,167 +1,297 @@
|
||||
# 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. **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
|
||||
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
|
||||
- Encrypted metadata including creation time and validity period
|
||||
- Metadata including creation time and validity period, encrypted to the
|
||||
version's key pair
|
||||
- Immutable value storage
|
||||
- Atomic version switching via symlink updates
|
||||
|
||||
The secret's `current` file names its current version. Switching versions
|
||||
replaces that file in one rename, so it is never half-written.
|
||||
|
||||
### Vault System
|
||||
|
||||
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).
|
||||
|
||||
## Installation
|
||||
|
||||
Build from source:
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd secret
|
||||
make build
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Initialize the secret manager**:
|
||||
```bash
|
||||
secret init
|
||||
```
|
||||
This creates the default vault and prompts for a BIP39 mnemonic phrase.
|
||||
|
||||
2. **Generate a mnemonic** (if needed):
|
||||
```bash
|
||||
secret generate mnemonic
|
||||
```
|
||||
|
||||
3. **Add a secret**:
|
||||
```bash
|
||||
echo "my-password" | secret add myservice/password
|
||||
```
|
||||
|
||||
4. **Retrieve a secret**:
|
||||
```bash
|
||||
secret get myservice/password
|
||||
```
|
||||
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> [--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>`
|
||||
Lists all versions of a secret showing creation time, status, and validity period.
|
||||
#### `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 updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
|
||||
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.
|
||||
|
||||
#### `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
|
||||
|
||||
### Unlocker Management
|
||||
|
||||
#### `secret unlockers list [--json]`
|
||||
Lists all unlockers in the current vault with their metadata.
|
||||
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
||||
|
||||
Lists all unlockers in the current vault with their metadata. An unlocker's ID,
|
||||
which `secret unlocker select` and `secret unlocker remove` take, is the name of
|
||||
its directory in `unlockers.d`.
|
||||
|
||||
#### `secret unlocker add <type> [options]`
|
||||
|
||||
#### `secret unlockers add <type> [options]`
|
||||
Creates a new unlocker of the specified type:
|
||||
|
||||
**Types:**
|
||||
|
||||
- `passphrase`: Traditional passphrase-protected unlocker
|
||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||
- `keychain`: macOS Keychain integration (macOS only)
|
||||
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
||||
|
||||
**Options:**
|
||||
- `--keyid <id>`: GPG key ID (required for PGP type)
|
||||
|
||||
#### `secret unlockers rm <unlocker-id>`
|
||||
Removes an unlocker.
|
||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
|
||||
specified)
|
||||
|
||||
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||
which is removed only once the new one is the current unlocker.
|
||||
|
||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||
|
||||
**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.
|
||||
|
||||
- `--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/
|
||||
│ │ ├── unlockers.d/
|
||||
│ │ │ ├── passphrase/ # Passphrase unlocker
|
||||
│ │ │ └── pgp/ # PGP unlocker
|
||||
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
|
||||
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
|
||||
│ │ ├── secrets.d/
|
||||
│ │ │ ├── api%key/ # Secret: api/key
|
||||
│ │ │ │ ├── versions/
|
||||
@@ -171,48 +301,74 @@ Decrypts data using an Age key stored as a secret.
|
||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||
│ │ │ │ └── current -> versions/20231216.001
|
||||
│ │ │ │ └── current # Current version's name: 20231216.001
|
||||
│ │ │ └── database%password/ # Secret: database/password
|
||||
│ │ │ ├── versions/
|
||||
│ │ │ └── current -> versions/20231215.001
|
||||
│ │ │ └── current # Current version's name: 20231215.001
|
||||
│ │ ├── vault-metadata.json # Vault metadata
|
||||
│ │ ├── pub.age # Long-term public key
|
||||
│ │ └── current-unlocker -> ../unlockers.d/passphrase
|
||||
│ │ └── current-unlocker # Current unlocker's directory name
|
||||
│ └── work/
|
||||
│ ├── unlockers.d/
|
||||
│ ├── secrets.d/
|
||||
│ ├── vault-metadata.json
|
||||
│ ├── pub.age
|
||||
│ └── current-unlocker
|
||||
└── currentvault -> vaults.d/default
|
||||
├── currentvault # Current vault's name: default
|
||||
└── lock # Locked by each command that changes anything
|
||||
```
|
||||
|
||||
`current`, `currentvault` and `current-unlocker` are plain files that each hold
|
||||
one name. Changing one replaces it in one rename, so it is never half-written.
|
||||
|
||||
### Key Management and Encryption Flow
|
||||
|
||||
#### 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 unlockers
|
||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by
|
||||
unlockers
|
||||
|
||||
#### 2: Unlockers
|
||||
|
||||
#### Unlockers
|
||||
Unlockers provide different authentication methods to access the long-term keys:
|
||||
|
||||
1. **Passphrase Unlockers**:
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
|
||||
2. **PGP Unlockers**:
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
|
||||
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
|
||||
3. **Keychain Unlockers** (macOS only):
|
||||
- Stores unlock keys in macOS Keychain
|
||||
- Kept on this Mac only: the keychain item is never synced to other devices
|
||||
- Automatic unlocking when Keychain is unlocked
|
||||
- Cross-application integration
|
||||
|
||||
#### Secret-specific Keys
|
||||
- Each secret has its own encryption key pair
|
||||
4. **Secure Enclave Unlockers** (macOS):
|
||||
- Hardware-backed key storage using Apple Secure Enclave
|
||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||
Program required)
|
||||
- ECIES encryption: the vault long-term key is encrypted directly to the SE
|
||||
key, and only the SE can decrypt it
|
||||
- The SE key cannot leave this Mac; using it asks for no Touch ID or
|
||||
password
|
||||
|
||||
Each vault maintains its own set of unlockers and one long-term key. The
|
||||
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
||||
access vault secrets.
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -221,30 +377,49 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
||||
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
||||
|
||||
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
|
||||
they hold. Other processes running as the same user can read a process's
|
||||
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
||||
shell or script that sets them inherits them, `gpg` included. Set on a command
|
||||
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
||||
each one as soon as it has read it, so that the programs it runs itself, such as
|
||||
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||
started with, and its memory, still hold the value. The interactive prompt,
|
||||
which every command except `secret vault import` offers when the variable is not
|
||||
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||
variables.
|
||||
|
||||
## Security Features
|
||||
|
||||
### 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-version encryption keys limit exposure if compromised
|
||||
- Each version is independently encrypted
|
||||
- Long-term keys protected by multiple unlocker layers
|
||||
- Historical versions remain encrypted with their original keys
|
||||
|
||||
### Hardware Integration
|
||||
|
||||
- Hardware token support via PGP/GPG integration
|
||||
- macOS Keychain integration for system-level security
|
||||
- Secure Enclave integration for hardware-backed key protection (macOS, via
|
||||
`sc_auth` / CryptoTokenKit)
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```bash
|
||||
# Initialize with a new mnemonic
|
||||
secret generate mnemonic # Copy the output
|
||||
@@ -259,9 +434,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
|
||||
@@ -270,7 +449,7 @@ secret vault create personal
|
||||
# Work with work vault
|
||||
secret vault select work
|
||||
echo "work-db-pass" | secret add database/password
|
||||
secret unlockers add passphrase # Add passphrase authentication
|
||||
secret unlocker add passphrase # Add passphrase authentication
|
||||
|
||||
# Switch to personal vault
|
||||
secret vault select personal
|
||||
@@ -278,22 +457,45 @@ 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 unlockers add passphrase # Password-based
|
||||
secret unlockers add pgp --keyid ABCD1234 # GPG key
|
||||
secret unlocker add passphrase # Password-based
|
||||
secret unlocker add pgp --keyid ABCD1234 # GPG key
|
||||
secret unlocker add keychain # macOS Keychain (macOS only)
|
||||
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
|
||||
|
||||
# List unlockers
|
||||
secret unlockers list
|
||||
secret unlocker list
|
||||
|
||||
# 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
|
||||
@@ -308,35 +510,51 @@ 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
|
||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
||||
|
||||
- **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
|
||||
- **Public Key Hash**: Double SHA-256 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
|
||||
|
||||
- **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 including Keychain and Secure Enclave integration
|
||||
- **Linux**: Full support (excluding macOS-specific features)
|
||||
|
||||
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
|
||||
build without cgo, such as one cross-compiled from Linux, offers them but fails
|
||||
to add or use them.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
- Protects against unauthorized access to secret values
|
||||
- Provides defense against compromise of individual components
|
||||
- Supports hardware-backed authentication where available
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Use strong, unique passphrases for unlockers
|
||||
2. Enable hardware authentication (Keychain, hardware tokens) when available
|
||||
3. Regularly audit unlockers and remove unused ones
|
||||
@@ -344,6 +562,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
5. Use separate vaults for different security contexts
|
||||
|
||||
### Limitations
|
||||
|
||||
- Requires access to unlockers for secret retrieval
|
||||
- Mnemonic phrases must be securely stored and backed up
|
||||
- Hardware features limited to supported platforms
|
||||
@@ -351,6 +570,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
@@ -358,18 +578,82 @@ make lint # Run linter
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes comprehensive tests:
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
go test ./... # Unit tests
|
||||
go test -tags=integration -v ./internal/cli # Integration 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`), which a Linux build never compiles; cgo is off
|
||||
there, so the keychain unlocker's calls into the keychain
|
||||
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
|
||||
and the Secure Enclave bindings (`internal/macse`) are not checked
|
||||
- `script/fmt` — format all Go code 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 and PGP-based unlockers
|
||||
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
|
||||
and Secure Enclave 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,203 +1,547 @@
|
||||
# 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.
|
||||
|
||||
- [x] **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
|
||||
|
||||
- [x] **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.
|
||||
|
||||
- [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
|
||||
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
|
||||
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
|
||||
|
||||
- [x] **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.
|
||||
|
||||
- [x] **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.
|
||||
|
||||
- [x] **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
|
||||
|
||||
- [x] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
|
||||
|
||||
- [x] **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
|
||||
|
||||
- [x] **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 unlocker types properly implement the Unlocker 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
|
||||
|
||||
- [x] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
|
||||
|
||||
- [x] **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.
|
||||
|
||||
### Architecture Issues
|
||||
- **Need to refactor unlock key hierarchy**: Current implementation has confusion between the top-level concepts. Fix in progress.
|
||||
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
|
||||
- Need to settle on consistent naming: "unlock keys" vs "unlockers" throughout the codebase
|
||||
|
||||
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.
|
||||
# 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-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).
|
||||
|
||||
+7
-2
@@ -1,7 +1,12 @@
|
||||
// Package main is the entry point for the secret CLI application.
|
||||
package main
|
||||
|
||||
import "git.eeqj.de/sneak/secret/internal/cli"
|
||||
import (
|
||||
"os"
|
||||
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cli.CLIEntry()
|
||||
os.Exit(cli.Entry())
|
||||
}
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
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/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/afero v1.14.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,10 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZ
|
||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI=
|
||||
github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo=
|
||||
github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI=
|
||||
github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
|
||||
@@ -31,6 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M=
|
||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -39,6 +45,10 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -59,7 +69,14 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk=
|
||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
@@ -90,8 +107,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
@@ -107,11 +122,14 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+75
-46
@@ -1,93 +1,122 @@
|
||||
// Package cli implements the command-line interface for the secret application.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// Global scanner for consistent stdin reading
|
||||
var stdinScanner *bufio.Scanner
|
||||
|
||||
// CLIInstance encapsulates all CLI functionality and state
|
||||
type CLIInstance struct {
|
||||
// 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() *CLIInstance {
|
||||
func NewCLIInstance() (*Instance, error) {
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
return &CLIInstance{
|
||||
|
||||
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) *CLIInstance {
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
return &CLIInstance{
|
||||
// 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) *CLIInstance {
|
||||
return &CLIInstance{
|
||||
// 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 *CLIInstance) SetFilesystem(fs afero.Fs) {
|
||||
func (cli *Instance) SetFilesystem(fs afero.Fs) {
|
||||
cli.fs = fs
|
||||
}
|
||||
|
||||
// SetStateDir sets the state directory for this CLI instance (for testing)
|
||||
func (cli *CLIInstance) SetStateDir(stateDir string) {
|
||||
func (cli *Instance) SetStateDir(stateDir string) {
|
||||
cli.stateDir = stateDir
|
||||
}
|
||||
|
||||
// GetStateDir returns the state directory for this CLI instance
|
||||
func (cli *CLIInstance) GetStateDir() string {
|
||||
func (cli *Instance) GetStateDir() string {
|
||||
return cli.stateDir
|
||||
}
|
||||
|
||||
// getStdinScanner returns a shared scanner for stdin to avoid buffering issues
|
||||
func getStdinScanner() *bufio.Scanner {
|
||||
if stdinScanner == nil {
|
||||
stdinScanner = bufio.NewScanner(os.Stdin)
|
||||
}
|
||||
return stdinScanner
|
||||
}
|
||||
// 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)
|
||||
|
||||
// readLineFromStdin reads a single line from stdin with a prompt
|
||||
// Uses a shared scanner to avoid buffering issues between multiple calls
|
||||
func readLineFromStdin(prompt string) (string, error) {
|
||||
// Check if stderr is a terminal - if not, we can't prompt interactively
|
||||
if !term.IsTerminal(int(syscall.Stderr)) {
|
||||
return "", fmt.Errorf("cannot prompt for input: stderr is not a terminal (running in non-interactive mode)")
|
||||
}
|
||||
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
scanner := getStdinScanner()
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
return func() {
|
||||
if mnemonic != nil {
|
||||
mnemonic.Destroy()
|
||||
}
|
||||
|
||||
if passphrase != nil {
|
||||
passphrase.Destroy()
|
||||
}
|
||||
return "", fmt.Errorf("failed to read from stdin: EOF")
|
||||
}
|
||||
return strings.TrimSpace(scanner.Text()), nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
+31
-22
@@ -1,34 +1,43 @@
|
||||
package cli
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"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"
|
||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
if cli.GetStateDir() != testStateDir {
|
||||
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
|
||||
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()
|
||||
cli := NewCLIInstanceWithFs(fs)
|
||||
|
||||
instance, err := cli.NewCLIInstanceWithFs(fs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
// The state directory should be determined automatically
|
||||
stateDir := cli.GetStateDir()
|
||||
stateDir := instance.GetStateDir()
|
||||
if stateDir == "" {
|
||||
t.Error("Expected non-empty state directory")
|
||||
}
|
||||
@@ -37,31 +46,31 @@ func TestCLIInstanceWithFs(t *testing.T) {
|
||||
func TestDetermineStateDir(t *testing.T) {
|
||||
// Test the determineStateDir function from the secret package
|
||||
|
||||
// Save original environment and restore it after test
|
||||
originalStateDir := os.Getenv(secret.EnvStateDir)
|
||||
defer func() {
|
||||
if originalStateDir == "" {
|
||||
os.Unsetenv(secret.EnvStateDir)
|
||||
} else {
|
||||
os.Setenv(secret.EnvStateDir, originalStateDir)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test with environment variable set
|
||||
testEnvDir := "/test-env-dir"
|
||||
os.Setenv(secret.EnvStateDir, testEnvDir)
|
||||
t.Setenv(secret.EnvStateDir, testEnvDir)
|
||||
|
||||
stateDir, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
stateDir := secret.DetermineStateDir("")
|
||||
if stateDir != testEnvDir {
|
||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||
}
|
||||
|
||||
// Test with custom config dir
|
||||
os.Unsetenv(secret.EnvStateDir)
|
||||
_ = os.Unsetenv(secret.EnvStateDir)
|
||||
customConfigDir := "/custom-config"
|
||||
stateDir = secret.DetermineStateDir(customConfigDir)
|
||||
|
||||
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)
|
||||
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,414 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"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 a stdin that is not a terminal said "failed to
|
||||
// read mnemonic: failed to read passphrase: ...". The error must wrap
|
||||
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
||||
// pinned on the built binary, whose stdin is surely not a terminal.
|
||||
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(afero.NewMemMapFs(), testStateDir)
|
||||
require.ErrorIs(t, c.Init(discardCmd()), secret.ErrMnemonicNotRead)
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
||||
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
|
||||
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
|
||||
"or run interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
|
||||
// create` killed after the passphrase prompt but before the unlocker was
|
||||
// written left a vault with no unlocker, which neither command would then
|
||||
// create again. After the prompt, each command changes the state directory
|
||||
// only through vault.CreateVault. The test makes that call as the command
|
||||
// does and records the state directory before each change it makes, and once
|
||||
// after it returns: what a stop at that point leaves. Each must hold either
|
||||
// no vault, and not name it current, or exactly the finished vault, which
|
||||
// opens with the passphrase through its current unlocker. The command run
|
||||
// again after a stop first takes the lock, which must delete what the stop
|
||||
// left under a temporary name. Running the command is slow, so it runs once
|
||||
// on each different state the lock leaves, and must create the vault there,
|
||||
// or refuse the one there.
|
||||
//
|
||||
//nolint:paralleltest // commands on the in-memory filesystem share one lock
|
||||
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(io.Discard)
|
||||
|
||||
t.Run("init", func(t *testing.T) {
|
||||
// From an empty state directory
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
|
||||
|
||||
requireStopsLeaveWholeVaultOrNone(t, fs, "default", mnemonic, passphrase,
|
||||
func(c *cli.Instance) error { return c.Init(cmd) })
|
||||
})
|
||||
|
||||
t.Run("vault create work", func(t *testing.T) {
|
||||
// From a state directory holding the vault "default"
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
requireStopsLeaveWholeVaultOrNone(t, fs, "work", mnemonic, passphrase,
|
||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
|
||||
})
|
||||
}
|
||||
|
||||
// requireStopsLeaveWholeVaultOrNone checks, as
|
||||
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
|
||||
// command run, creating the vault name on fs with mnemonic and passphrase.
|
||||
// Run again where the vault is there, the command must fail with
|
||||
// vault.ErrVaultExists.
|
||||
func requireStopsLeaveWholeVaultOrNone(
|
||||
t *testing.T, fs afero.Fs, name string,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
run func(c *cli.Instance) error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var stops []map[string]string
|
||||
|
||||
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
|
||||
|
||||
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
|
||||
testStateDir, name, mnemonic, passphrase)
|
||||
require.NoError(t, err)
|
||||
record()
|
||||
|
||||
vaultDir := testStateDir + "/vaults.d/" + name
|
||||
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
|
||||
|
||||
finished := entriesUnder(stops[len(stops)-1], vaultDir)
|
||||
|
||||
opener := vault.NewVault(fs, testStateDir, name)
|
||||
opener.UnlockPassphrase = passphrase
|
||||
|
||||
key, err := opener.UnlockVault()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
|
||||
|
||||
// Each different state the command run again finds once it holds the lock
|
||||
var locked []map[string]string
|
||||
|
||||
for i, stop := range stops {
|
||||
if _, there := stop[vaultDir+"/"]; there {
|
||||
require.Equal(t, finished, entriesUnder(stop, vaultDir),
|
||||
"stop %d left a partial vault", i)
|
||||
} else {
|
||||
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
|
||||
"stop %d made a missing vault current", i)
|
||||
}
|
||||
|
||||
stopped := newFsFromSnapshot(t, stop)
|
||||
release, err := vault.LockStateDir(stopped, testStateDir)
|
||||
require.NoError(t, err)
|
||||
release()
|
||||
|
||||
state := snapshotStateDir(t, stopped)
|
||||
for path := range state {
|
||||
require.NotContains(t, path, ".tmp-", "stop %d", i)
|
||||
}
|
||||
|
||||
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
|
||||
return maps.Equal(s, state)
|
||||
}) {
|
||||
locked = append(locked, state)
|
||||
}
|
||||
}
|
||||
|
||||
for _, state := range locked {
|
||||
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
|
||||
c.Mnemonic = mnemonic
|
||||
c.UnlockPassphrase = passphrase
|
||||
|
||||
if _, there := state[vaultDir+"/"]; there {
|
||||
require.ErrorIs(t, run(c), vault.ErrVaultExists)
|
||||
} else {
|
||||
require.NoError(t, run(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
|
||||
// that are under dir.
|
||||
func entriesUnder(tree map[string]string, dir string) map[string]string {
|
||||
entries := map[string]string{}
|
||||
|
||||
for path, content := range tree {
|
||||
if strings.HasPrefix(path, dir+"/") {
|
||||
entries[path] = content
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// hookFs passes every call through to Fs, but first calls before for each
|
||||
// call that can change the filesystem.
|
||||
type hookFs struct {
|
||||
afero.Fs
|
||||
|
||||
before func()
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) Create(name string) (afero.File, error) {
|
||||
h.before()
|
||||
|
||||
return h.Fs.Create(name)
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements afero.Fs
|
||||
func (h hookFs) OpenFile(
|
||||
name string, flag int, perm os.FileMode,
|
||||
) (afero.File, error) {
|
||||
h.before()
|
||||
|
||||
return h.Fs.OpenFile(name, flag, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
||||
h.before()
|
||||
|
||||
return h.Fs.Mkdir(name, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
||||
h.before()
|
||||
|
||||
return h.Fs.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (h hookFs) Remove(name string) error {
|
||||
h.before()
|
||||
|
||||
return h.Fs.Remove(name)
|
||||
}
|
||||
|
||||
func (h hookFs) RemoveAll(path string) error {
|
||||
h.before()
|
||||
|
||||
return h.Fs.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (h hookFs) Rename(oldname, newname string) error {
|
||||
h.before()
|
||||
|
||||
return h.Fs.Rename(oldname, newname)
|
||||
}
|
||||
+164
-95
@@ -1,115 +1,173 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
func newEncryptCmd() *cobra.Command {
|
||||
// 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: "encrypt <secret-name>",
|
||||
Short: "Encrypt data using an age secret key stored in a secret",
|
||||
Long: `Encrypt data using an age secret key. If the secret doesn't exist, a new age key is generated and stored.`,
|
||||
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 := NewCLIInstance()
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd = cmd
|
||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
||||
|
||||
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 {
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt <secret-name>",
|
||||
Short: "Decrypt data using an age secret key stored in a secret",
|
||||
Long: `Decrypt data using an age secret key stored in the specified secret.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inputFile, _ := cmd.Flags().GetString("input")
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
cli := NewCLIInstance()
|
||||
cli.cmd = cmd
|
||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
||||
return cmd
|
||||
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 *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error {
|
||||
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
|
||||
}
|
||||
|
||||
var ageSecretKey string
|
||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||
|
||||
// Check if secret exists
|
||||
secretObj := secret.NewSecret(vlt, secretName)
|
||||
exists, err := secretObj.Exists()
|
||||
// Get or create the age secret key for this secret
|
||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
// Secret exists, get the age secret key from it
|
||||
var secretValue []byte
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
secretValue, err = secretObj.GetValue(nil)
|
||||
} else {
|
||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||
}
|
||||
secretValue, err = secretObj.GetValue(unlocker)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey = string(secretValue)
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
}
|
||||
} else {
|
||||
// Secret doesn't exist, generate new age key and store it
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate age key: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey = identity.String()
|
||||
|
||||
// Store the generated key as a secret
|
||||
err = vlt.AddSecret(secretName, []byte(ageSecretKey), false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store age key: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer keyBuffer.Destroy()
|
||||
|
||||
// Parse the secret key
|
||||
identity, err := age.ParseX25519Identity(ageSecretKey)
|
||||
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||
}
|
||||
@@ -119,23 +177,27 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
|
||||
// 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 file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
var output io.Writer = cli.cmd.OutOrStdout()
|
||||
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 file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -145,11 +207,13 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(encryptor, input); err != nil {
|
||||
_, err = io.Copy(encryptor, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
if err := encryptor.Close(); err != nil {
|
||||
err = encryptor.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||
}
|
||||
|
||||
@@ -157,71 +221,73 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using an age secret key stored in a secret
|
||||
func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error {
|
||||
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' does not exist", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Get the age secret key from the secret
|
||||
var secretValue []byte
|
||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||
secretValue, err = secretObj.GetValue(nil)
|
||||
} else {
|
||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||
if unlockErr != nil {
|
||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||
}
|
||||
secretValue, err = secretObj.GetValue(unlocker)
|
||||
}
|
||||
secretBuffer, err := vlt.GetSecret(secretName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get secret value: %w", err)
|
||||
}
|
||||
|
||||
ageSecretKey := string(secretValue)
|
||||
defer secretBuffer.Destroy()
|
||||
|
||||
// Validate that it's a valid age secret key
|
||||
if !isValidAgeSecretKey(ageSecretKey) {
|
||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||
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(ageSecretKey)
|
||||
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 file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
input = file
|
||||
}
|
||||
|
||||
// Set up output writer
|
||||
var output io.Writer = cli.cmd.OutOrStdout()
|
||||
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 file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
output = file
|
||||
}
|
||||
|
||||
@@ -231,15 +297,18 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
|
||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(output, decryptor); err != nil {
|
||||
_, 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
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+84
-25
@@ -2,13 +2,26 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
"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 {
|
||||
@@ -28,9 +41,15 @@ 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, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
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)
|
||||
},
|
||||
}
|
||||
@@ -40,29 +59,39 @@ 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),
|
||||
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 := NewCLIInstance()
|
||||
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", 16, "Length of the generated secret (default 16)")
|
||||
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
||||
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 *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
// Generate 128 bits of entropy for a 12-word mnemonic
|
||||
entropy, err := bip39.NewEntropy(128)
|
||||
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate entropy: %w", err)
|
||||
}
|
||||
@@ -82,7 +111,8 @@ func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
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,
|
||||
" • 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)")
|
||||
@@ -92,13 +122,21 @@ func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// GenerateSecret generates a random secret and stores it in the vault
|
||||
func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, length int, secretType string, force bool) error {
|
||||
func (cli *Instance) GenerateSecret(
|
||||
cmd *cobra.Command,
|
||||
secretName string,
|
||||
length int,
|
||||
secretType string,
|
||||
force bool,
|
||||
) error {
|
||||
if length < 1 {
|
||||
return fmt.Errorf("length must be at least 1")
|
||||
return errLengthTooSmall
|
||||
}
|
||||
|
||||
var secretValue string
|
||||
var err error
|
||||
var (
|
||||
secretValue string
|
||||
err error
|
||||
)
|
||||
|
||||
switch secretType {
|
||||
case "base58":
|
||||
@@ -106,55 +144,76 @@ func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, le
|
||||
case "alnum":
|
||||
secretValue, err = generateRandomAlnum(length)
|
||||
case "mnemonic":
|
||||
return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
|
||||
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
|
||||
errUnsupportedSecretType)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
|
||||
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
|
||||
}
|
||||
|
||||
if err := vlt.AddSecret(secretName, []byte(secretValue), force); err != nil {
|
||||
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)
|
||||
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
|
||||
// generateRandomString generates a random string of the specified length
|
||||
// using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
if length < 1 {
|
||||
return "", errLengthTooSmall
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
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()]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+114
-115
@@ -1,47 +1,97 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
"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,
|
||||
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, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
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)
|
||||
}
|
||||
|
||||
// Init initializes the secret manager
|
||||
func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
// 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 from stdin", "error", err)
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
|
||||
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))
|
||||
|
||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -50,125 +100,64 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Prompt for mnemonic
|
||||
var mnemonicStr string
|
||||
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment variable")
|
||||
mnemonicStr = envMnemonic
|
||||
} else {
|
||||
secret.Debug("Prompting user for mnemonic phrase")
|
||||
// Read mnemonic from stdin using shared line reader
|
||||
var err error
|
||||
mnemonicStr, err = readLineFromStdin("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
||||
}
|
||||
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupMnemonic()
|
||||
|
||||
mnemonicStr := mnemonic.String()
|
||||
if mnemonicStr == "" {
|
||||
secret.Debug("Empty mnemonic provided")
|
||||
return fmt.Errorf("mnemonic cannot be empty")
|
||||
|
||||
return errMnemonicEmpty
|
||||
}
|
||||
|
||||
// Validate the mnemonic using BIP39
|
||||
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||
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("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
||||
errInvalidMnemonicPhrase)
|
||||
}
|
||||
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
defer func() {
|
||||
if originalMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}()
|
||||
// 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 - it will handle key derivation internally
|
||||
// Create the default vault with its passphrase unlocker
|
||||
secret.Debug("Creating default vault")
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key using the same index that CreateVault used
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||
unlocker, err := vlt.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Prompt for passphrase for unlocker
|
||||
var passphraseStr string
|
||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
passphraseStr = envPassphrase
|
||||
} else {
|
||||
secret.Debug("Prompting user for unlock passphrase")
|
||||
// Use secure passphrase input with confirmation
|
||||
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to create unlocker", "error", err)
|
||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker
|
||||
unlockerDir := passphraseUnlocker.GetDirectory()
|
||||
|
||||
// Read unlocker public key
|
||||
unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unlocker public key: %w", err)
|
||||
}
|
||||
|
||||
unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse unlocker public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to unlocker
|
||||
ltPrivKeyData := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockerRecipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
if err := afero.WriteFile(cli.fs, filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if cmd != nil {
|
||||
cmd.Printf("\nDefault vault created and configured\n")
|
||||
cmd.Printf("Long-term public key: %s\n", ltPubKey)
|
||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||
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.")
|
||||
@@ -179,23 +168,33 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
|
||||
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
|
||||
// This version adds confirmation (read twice) for creating new unlockers
|
||||
func readSecurePassphrase(prompt string) (string, error) {
|
||||
// Returns a LockedBuffer containing the passphrase
|
||||
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Get the first passphrase
|
||||
passphrase1, err := secret.ReadPassphrase(prompt)
|
||||
passphraseBuffer1, err := secret.ReadPassphrase(prompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read confirmation passphrase
|
||||
passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||
passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
||||
passphraseBuffer1.Destroy()
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
|
||||
}
|
||||
|
||||
// Compare passphrases
|
||||
if passphrase1 != passphrase2 {
|
||||
return "", fmt.Errorf("passphrases do not match")
|
||||
if passphraseBuffer1.String() != passphraseBuffer2.String() {
|
||||
passphraseBuffer1.Destroy()
|
||||
passphraseBuffer2.Destroy()
|
||||
|
||||
return nil, errPassphraseMismatch
|
||||
}
|
||||
|
||||
return passphrase1, nil
|
||||
// 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)
|
||||
}
|
||||
+1051
-501
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,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))
|
||||
}
|
||||
+60
-10
@@ -3,27 +3,73 @@ package cli
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
// CLIEntry is the entry point for the secret CLI application
|
||||
func CLIEntry() {
|
||||
cmd := newRootCmd()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
// 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 terminal echo
|
||||
// off 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.
|
||||
terminalState, terminalErr := term.GetState(unix.Stdin)
|
||||
|
||||
memguard.CatchSignal(func(os.Signal) {
|
||||
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
|
||||
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(unix.Stdin, 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.`,
|
||||
// Ensure usage is shown after errors
|
||||
SilenceUsage: false,
|
||||
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")
|
||||
@@ -34,13 +80,17 @@ func newRootCmd() *cobra.Command {
|
||||
cmd.AddCommand(newAddCmd())
|
||||
cmd.AddCommand(newGetCmd())
|
||||
cmd.AddCommand(newListCmd())
|
||||
cmd.AddCommand(newUnlockersCmd())
|
||||
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
|
||||
}
|
||||
|
||||
+900
-119
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,92 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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")
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"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) {
|
||||
func ExecuteCommandInProcess(
|
||||
args []string, stdin string, env map[string]string,
|
||||
) (string, error) {
|
||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||
|
||||
// Save current environment
|
||||
@@ -20,7 +22,7 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
||||
|
||||
// Set test environment
|
||||
for k, v := range env {
|
||||
os.Setenv(k, v)
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
|
||||
// Create root command
|
||||
@@ -43,19 +45,21 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
||||
err := rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
||||
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)
|
||||
secret.Debug("Warning: Command executed successfully but produced no output",
|
||||
"args", args)
|
||||
}
|
||||
|
||||
// Restore environment
|
||||
for k, v := range savedEnv {
|
||||
if v == "" {
|
||||
os.Unsetenv(k)
|
||||
_ = os.Unsetenv(k)
|
||||
} else {
|
||||
os.Setenv(k, v)
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
package cli
|
||||
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 := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||
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 = ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||
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,374 @@
|
||||
// Unlock Failure Tests
|
||||
//
|
||||
// When a vault cannot be opened through its current unlocker, because a
|
||||
// file the unlocker needs is missing or the passphrase is wrong, the error
|
||||
// keeps its cause and ends by saying that the mnemonic still opens that
|
||||
// vault, but only for a vault that the mnemonic does open, and not when the
|
||||
// passphrase could not be read at all. When a secret's current file is
|
||||
// missing, the error says how to make a version current again. Each test
|
||||
// that pins such advice also follows it.
|
||||
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// mnemonicAdvice ends the error when the current vault "default", which
|
||||
// its mnemonic opens, cannot be opened through its current unlocker.
|
||||
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
|
||||
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
|
||||
"to the mnemonic to give it a new unlocker"
|
||||
|
||||
// versionAdvice ends the error when a secret's current file cannot be
|
||||
// read.
|
||||
versionAdvice = "; this file only names the current version: " +
|
||||
"'secret version list' lists the secret's versions, and " +
|
||||
"'secret version promote' makes one of them current"
|
||||
|
||||
// unlockTestVaultDir is the directory of the vault "default" of
|
||||
// newTwoVaultFs, the current vault, whose secret "x" is "value".
|
||||
unlockTestVaultDir = testStateDir + "/vaults.d/default"
|
||||
)
|
||||
|
||||
// currentUnlockerDir returns the directory of the current unlocker of the
|
||||
// vault in vaultDir on fs.
|
||||
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
|
||||
t.Helper()
|
||||
|
||||
unlockerName, err := afero.ReadFile(fs,
|
||||
filepath.Join(vaultDir, "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
|
||||
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
||||
}
|
||||
|
||||
// newUnlockTestCLI returns the directory of the current unlocker of the
|
||||
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
|
||||
// instance on fs that has the unlock passphrase, as from the environment,
|
||||
// but not the mnemonic.
|
||||
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
|
||||
t.Helper()
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
|
||||
}
|
||||
|
||||
// discardCmd returns a command whose output is discarded.
|
||||
func discardCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(io.Discard)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getSecret returns what `secret get name` prints.
|
||||
func getSecret(t *testing.T, c *cli.Instance, name string) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
require.NoError(t, c.GetSecret(cmd, name))
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
|
||||
// file that opening the vault through its current unlocker needs is
|
||||
// missing: it keeps the cause, which names the file, and ends with the
|
||||
// advice that the mnemonic still opens the vault. The test then follows
|
||||
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
|
||||
// the vault a new unlocker, which opens it.
|
||||
func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
file string // the file removed
|
||||
inVaultDir bool // the file is the vault's, not the unlocker's
|
||||
want string // the message before the cause
|
||||
}{
|
||||
{
|
||||
file: "current-unlocker",
|
||||
inVaultDir: true,
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to get current unlocker: " +
|
||||
"failed to read current unlocker: ",
|
||||
},
|
||||
{
|
||||
file: "priv.age",
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to get unlocker identity: " +
|
||||
"failed to read unlocker private key: ",
|
||||
},
|
||||
{
|
||||
file: "longterm.age",
|
||||
want: "failed to unlock vault: failed to get long-term key: " +
|
||||
"failed to read encrypted long-term private key: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.file, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
path := filepath.Join(unlockerDir, tt.file)
|
||||
|
||||
if tt.inVaultDir {
|
||||
path = filepath.Join(unlockTestVaultDir, tt.file)
|
||||
}
|
||||
|
||||
require.NoError(t, fs.Remove(path))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Equal(t, path, cause.Path)
|
||||
|
||||
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
|
||||
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||
|
||||
c.Mnemonic = nil
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
||||
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
||||
// error and ends with the advice that the mnemonic still opens the vault.
|
||||
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
|
||||
require.ErrorAs(t, err, &noMatch)
|
||||
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
||||
noMatch.Error()+mnemonicAdvice)
|
||||
}
|
||||
|
||||
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
||||
// the vault "work", which is not the current vault, when "work" cannot be
|
||||
// opened through its current unlocker: the advice names "work" and says to
|
||||
// select it first, since `secret unlocker add` acts on the current vault.
|
||||
// The test then follows that advice, and the move succeeds.
|
||||
func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
_, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
path := filepath.Join(
|
||||
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
|
||||
require.NoError(t, fs.Remove(path))
|
||||
|
||||
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
assert.Equal(t, path, cause.Path)
|
||||
|
||||
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
|
||||
"failed to get unlocker identity: failed to read unlocker private key: "+
|
||||
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
|
||||
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
|
||||
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
|
||||
|
||||
require.NoError(t, c.SelectVault(discardCmd(), "work"))
|
||||
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||
|
||||
c.Mnemonic = nil
|
||||
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
|
||||
assert.Equal(t, "value", getSecret(t, c, "y"))
|
||||
}
|
||||
|
||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
||||
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
||||
// and adding one would need a passphrase read the same way, so the error
|
||||
// is the cause alone, without the advice to use the mnemonic.
|
||||
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(
|
||||
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("x", value, false))
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, "Error: failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
||||
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
||||
"run interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
||||
// `secret decrypt`, reading the key secret, end with the same advice as
|
||||
// `secret get` when the vault cannot be opened through its current
|
||||
// unlocker.
|
||||
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
command string
|
||||
run func(c *cli.Instance) error
|
||||
}{
|
||||
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
|
||||
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
path := filepath.Join(unlockerDir, "priv.age")
|
||||
require.NoError(t, fs.Remove(path))
|
||||
|
||||
err := tt.run(c)
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
assert.Equal(t, path, cause.Path)
|
||||
|
||||
require.EqualError(t, err, "failed to get secret value: "+
|
||||
"failed to unlock vault: failed to get long-term key: "+
|
||||
"failed to get unlocker identity: "+
|
||||
"failed to read unlocker private key: "+cause.Error()+
|
||||
mnemonicAdvice)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
|
||||
// get` when the secret's current file is missing: it keeps the cause, which
|
||||
// names the file, and ends with the advice that says how to make a version
|
||||
// current again. The test then follows that advice.
|
||||
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
_, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
|
||||
path := filepath.Join(secretDir, "current")
|
||||
require.NoError(t, fs.Remove(path))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Equal(t, path, cause.Path)
|
||||
|
||||
require.EqualError(t, err, "failed to get current version: "+
|
||||
"failed to read current version file: "+cause.Error()+versionAdvice)
|
||||
|
||||
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
require.NoError(t, c.ListVersions(cmd, "x"))
|
||||
assert.Contains(t, out.String(), versions[0].Name())
|
||||
|
||||
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
|
||||
// created without a mnemonic, which no mnemonic opens, gets no advice to
|
||||
// use one: `secret unlocker add passphrase` there fails with the cause
|
||||
// alone.
|
||||
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
err = c.UnlockersAdd("passphrase", discardCmd())
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
|
||||
require.EqualError(t, err, "failed to get long-term key: "+
|
||||
"failed to get current unlocker: failed to read current unlocker: "+
|
||||
cause.Error())
|
||||
}
|
||||
+724
-234
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
||||
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
||||
|
||||
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
|
||||
const (
|
||||
addTestSecretName = "api-key"
|
||||
addTestSecretValue = "value"
|
||||
)
|
||||
|
||||
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
|
||||
// with a passphrase unlocker, getting the vault's long-term key from the
|
||||
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
|
||||
// then reads a secret with neither the mnemonic nor the passphrase given, so
|
||||
// through the new unlocker, which the add selects.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||
func TestAddPGPUnlocker(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// mnemonic is the mnemonic given while the unlocker is added, or nil.
|
||||
mnemonic *memguard.LockedBuffer
|
||||
}{
|
||||
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
|
||||
{"long-term key from the current unlocker", nil},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret(addTestSecretName,
|
||||
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = vlt.CreatePassphraseUnlocker(
|
||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.Mnemonic = test.mnemonic
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
|
||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||
|
||||
current, err := reopened.GetCurrentUnlocker()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unlockerTypePGP, current.GetType())
|
||||
|
||||
value, err := reopened.GetSecret(addTestSecretName)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, addTestSecretValue, value.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||
// lookup moved after anything is written would also come after getting the
|
||||
// vault's long-term key, which fails first here: this vault's unlockers hold
|
||||
// no keys.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
base := newListTestVault(t, 1)
|
||||
instance, cmd := newTestInstance(base)
|
||||
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
|
||||
|
||||
err := instance.addPGPUnlocker(cmd)
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrGPGKeyNotFound)
|
||||
assertDirEntries(t, base,
|
||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
||||
listTestUnlockerDirOne)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Corrupt Unlocker Tests
|
||||
//
|
||||
// `secret unlocker select` and `secret unlocker remove` find an unlocker
|
||||
// by its ID. These tests give the first unlocker, which sorts before the
|
||||
// one the commands act on, metadata that is not JSON, and check that the
|
||||
// commands step past it, and that it can itself be removed by its
|
||||
// directory name, which `secret unlocker list` names in its warning, as can
|
||||
// one with no metadata file. A last test checks that an unlocker whose
|
||||
// metadata file cannot be read counts as the last unlocker when it is
|
||||
// removed by its directory name.
|
||||
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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,79 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
||||
// side by side whose metadata is the same, creation time included, as
|
||||
// copying an unlocker directory leaves them. It asserts that `unlocker
|
||||
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
||||
// give each its own ID, and that each is selected and removed by its ID
|
||||
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
|
||||
// get their IDs the same way.
|
||||
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir := testVaultDir(listTestVaultName)
|
||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||
dirNames := []string{
|
||||
"passphrase-2026-10-04.12.30.00.000000000",
|
||||
"passphrase-2026-10-04.12.30.00.000000000-copy",
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
||||
Type: unlockerTypePassphrase,
|
||||
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, dirName := range dirNames {
|
||||
dir := filepath.Join(unlockersDir, dirName)
|
||||
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dir, listTestMetadataFileName), metadata,
|
||||
listTestFilePerm))
|
||||
}
|
||||
|
||||
listed := listUnlockersJSON(t, fs)
|
||||
require.Len(t, listed, len(dirNames))
|
||||
|
||||
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
|
||||
nil, nil, "")
|
||||
assert.Equal(t, dirNames, completed)
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
|
||||
for i, unlocker := range listed {
|
||||
assert.Equal(t, dirNames[i], unlocker.ID)
|
||||
|
||||
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
|
||||
|
||||
current, err := afero.ReadFile(fs,
|
||||
filepath.Join(vaultDir, "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dirNames[i], string(current))
|
||||
}
|
||||
|
||||
// The second one first: an ID both shared would remove the first one
|
||||
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
|
||||
assertDirEntries(t, fs, unlockersDir, dirNames[0])
|
||||
|
||||
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
|
||||
assertDirEntries(t, fs, unlockersDir)
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
+483
-86
@@ -2,17 +2,34 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
"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 {
|
||||
@@ -26,23 +43,30 @@ func newVaultCmd() *cobra.Command {
|
||||
cmd.AddCommand(newVaultCreateCmd())
|
||||
cmd.AddCommand(newVaultSelectCmd())
|
||||
cmd.AddCommand(newVaultImportCmd())
|
||||
cmd.AddCommand(newVaultRemoveCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVaultListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
Use: cmdUseList,
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List available vaults",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
cli := NewCLIInstance()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -52,75 +76,148 @@ func newVaultCreateCmd() *cobra.Command {
|
||||
Short: "Create a new vault",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli := NewCLIInstance()
|
||||
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),
|
||||
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 := NewCLIInstance()
|
||||
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),
|
||||
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 := NewCLIInstance()
|
||||
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 *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"vaults": vaults,
|
||||
"current_vault": currentVault,
|
||||
result := map[string]any{
|
||||
"vaults": vaults,
|
||||
"currentVault": currentVault,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println(string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
cmd.Println("Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
||||
|
||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err == nil {
|
||||
currentVault = currentVlt.GetName()
|
||||
}
|
||||
|
||||
@@ -137,109 +234,168 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
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 *CLIInstance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||
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 *CLIInstance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// VaultImport imports a mnemonic into a specific vault
|
||||
func (cli *CLIInstance) VaultImport(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)
|
||||
|
||||
// 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
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||
vaultName, vault.ErrVaultNotFound)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
||||
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
||||
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
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC environment variable not set")
|
||||
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 fmt.Errorf("invalid BIP39 mnemonic")
|
||||
return "", "", "", errInvalidMnemonicPhrase
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), 0600); 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()))
|
||||
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(cli.fs, vaultDir)
|
||||
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.VaultMetadata{
|
||||
existingMetadata = &vault.Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -249,16 +405,101 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
existingMetadata.MnemonicFamilyHash = familyHash
|
||||
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||
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
|
||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
if passphraseStr == "" {
|
||||
return fmt.Errorf("SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||
passphraseBuffer := cli.UnlockPassphrase
|
||||
if passphraseBuffer == nil {
|
||||
return errPassphraseEnvNotSet
|
||||
}
|
||||
|
||||
secret.Debug("Using unlock passphrase from environment variable")
|
||||
@@ -268,9 +509,11 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
|
||||
// Create passphrase-protected unlocker
|
||||
secret.Debug("Creating passphrase-protected unlocker")
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -280,3 +523,157 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+274
-58
@@ -1,37 +1,61 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"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 := NewCLIInstance()
|
||||
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 *CLIInstance) *cobra.Command {
|
||||
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",
|
||||
Long: "Commands for managing secret versions including listing, " +
|
||||
"promoting, and retrieving specific versions",
|
||||
}
|
||||
|
||||
// List versions command
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list <secret-name>",
|
||||
Short: "List all versions of a secret",
|
||||
Args: cobra.ExactArgs(1),
|
||||
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])
|
||||
},
|
||||
}
|
||||
@@ -40,31 +64,79 @@ func VersionCommands(cli *CLIInstance) *cobra.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),
|
||||
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])
|
||||
},
|
||||
}
|
||||
|
||||
versionCmd.AddCommand(listCmd, promoteCmd)
|
||||
// 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 *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,22 +148,27 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
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' not found", 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 {
|
||||
cmd.Println("No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,9 +176,12 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
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 {
|
||||
@@ -109,56 +189,82 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
|
||||
}
|
||||
|
||||
// Create table writer
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||
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 {
|
||||
sv := secret.NewSecretVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Debug("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, "-", "-")
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := "-"
|
||||
if sv.Metadata.CreatedAt != nil {
|
||||
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notBefore := "-"
|
||||
if sv.Metadata.NotBefore != nil {
|
||||
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notAfter := "-"
|
||||
if sv.Metadata.NotAfter != nil {
|
||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
_ = 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 *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
||||
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 {
|
||||
@@ -175,20 +281,130 @@ func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, ve
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
||||
|
||||
// Check if version exists
|
||||
versionDir := filepath.Join(secretDir, "versions", version)
|
||||
exists, err := afero.DirExists(cli.fs, versionDir)
|
||||
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' not found for secret '%s'", version, secretName)
|
||||
return fmt.Errorf("version '%s' %w '%s'",
|
||||
version, vault.ErrVersionNotFound, secretName)
|
||||
}
|
||||
|
||||
// Update the current symlink using the proper function
|
||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
+159
-51
@@ -7,6 +7,7 @@
|
||||
// - 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
|
||||
//
|
||||
@@ -14,71 +15,109 @@
|
||||
// - 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"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/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"
|
||||
)
|
||||
|
||||
// Helper function to set up a vault with long-term key
|
||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
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, stateDir, "default")
|
||||
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
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()), 0600)
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
err = vault.SelectVault(fs, stateDir, "default")
|
||||
err = vault.SelectVault(fs, testStateDir, "default")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestListVersionsCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
vlt.Mnemonic = cli.Mnemonic
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
@@ -103,53 +142,59 @@ func TestListVersionsCommand(t *testing.T) {
|
||||
// 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 := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
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")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
||||
}
|
||||
|
||||
func TestPromoteVersionCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -161,13 +206,17 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
// Current should be version-2
|
||||
value, err := vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-2"), value.Bytes())
|
||||
|
||||
// Promote first version
|
||||
firstVersion := versions[1] // Older version
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -183,57 +232,64 @@ func TestPromoteVersionCommand(t *testing.T) {
|
||||
assert.Contains(t, outputStr, firstVersion)
|
||||
|
||||
// Verify current is now version-1
|
||||
value, err = vlt.GetSecret("test/secret")
|
||||
promoted, err := vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
|
||||
defer promoted.Destroy()
|
||||
|
||||
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
||||
}
|
||||
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("value"), false)
|
||||
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")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
require.ErrorIs(t, err, vault.ErrVersionNotFound)
|
||||
}
|
||||
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
cli.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
setupTestVault(t, fs)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
vlt.Mnemonic = cli.Mnemonic
|
||||
|
||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
@@ -244,25 +300,74 @@ func TestGetSecretWithVersion(t *testing.T) {
|
||||
|
||||
// Create a command for output capture
|
||||
cmd := newRootCmd()
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
|
||||
// Test getting current version (empty version string)
|
||||
err = cli.GetSecretWithVersion(cmd, "test/secret", "")
|
||||
// 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 := NewCLIInstance()
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := VersionCommands(cli)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
@@ -279,21 +384,24 @@ func TestVersionCommandStructure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
stateDir := testStateDir
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
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, 0755)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build darwin
|
||||
|
||||
// Package macse provides Go bindings for macOS Secure Enclave operations
|
||||
// using CryptoTokenKit identities created via sc_auth.
|
||||
// Key creation and deletion shell out to sc_auth (which has SE entitlements).
|
||||
// Encrypt/decrypt use Security.framework ECIES directly (works unsigned).
|
||||
package macse
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation
|
||||
#include <stdlib.h>
|
||||
#include "secure_enclave.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// p256UncompressedKeySize is the size of an uncompressed P-256 public key.
|
||||
p256UncompressedKeySize = 65
|
||||
|
||||
// errorBufferSize is the size of the C error message buffer.
|
||||
errorBufferSize = 512
|
||||
|
||||
// hashBufferSize is the size of the hash output buffer.
|
||||
hashBufferSize = 128
|
||||
|
||||
// maxCiphertextSize is the max buffer for ECIES ciphertext.
|
||||
// ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext.
|
||||
maxCiphertextSize = 8192
|
||||
|
||||
// maxPlaintextSize is the max buffer for decrypted plaintext.
|
||||
maxPlaintextSize = 8192
|
||||
)
|
||||
|
||||
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
||||
// Returns the uncompressed public key bytes (65 bytes) and the identity hash
|
||||
// (for deletion). If getting the public key fails, CreateKey deletes the key
|
||||
// again; a failure to delete is returned along with the first error.
|
||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||
var hashBuf [hashBufferSize]C.char
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_create_key(cLabel,
|
||||
&hashBuf[0], C.int(hashBufferSize),
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
h := C.GoString(&hashBuf[0])
|
||||
|
||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||
|
||||
result = C.se_copy_public_key(cLabel,
|
||||
&pubKeyBuf[0], &pubKeyLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
|
||||
deleteErr := DeleteKey(h)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err,
|
||||
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
||||
}
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||
|
||||
return pk, h, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using the SE-backed public key via ECIES
|
||||
// (eciesEncryptionStandardVariableIVX963SHA256AESGCM).
|
||||
// Encryption uses only the public key; no SE interaction required.
|
||||
func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
||||
ciphertextBuf := make([]C.uint8_t, maxCiphertextSize)
|
||||
ciphertextLen := C.int(maxCiphertextSize)
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_encrypt(cLabel,
|
||||
(*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)),
|
||||
&ciphertextBuf[0], &ciphertextLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||
func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
||||
plaintextBuf := make([]C.uint8_t, maxPlaintextSize)
|
||||
plaintextLen := C.int(maxPlaintextSize)
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cLabel := C.CString(label)
|
||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_decrypt(cLabel,
|
||||
(*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)),
|
||||
&plaintextBuf[0], &plaintextLen,
|
||||
&errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
//nolint:nlreturn // CGo result extraction
|
||||
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth.
|
||||
func DeleteKey(hash string) error {
|
||||
var errBuf [errorBufferSize]C.char
|
||||
|
||||
cHash := C.CString(hash)
|
||||
defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern
|
||||
|
||||
result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize))
|
||||
|
||||
if result != 0 {
|
||||
return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||
package macse
|
||||
|
||||
import "errors"
|
||||
|
||||
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
|
||||
|
||||
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func CreateKey(_ string) ([]byte, string, error) {
|
||||
return nil, "", errNotSupported
|
||||
}
|
||||
|
||||
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||
func DeleteKey(_ string) error {
|
||||
return errNotSupported
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package macse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testKeyLabel = "berlin.sneak.app.secret.test.se-key"
|
||||
|
||||
// testKeyHash stores the hash of the created test key for cleanup.
|
||||
var testKeyHash string //nolint:gochecknoglobals
|
||||
|
||||
// skipIfNoSecureEnclave skips the test if SE access is unavailable.
|
||||
func skipIfNoSecureEnclave(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
probeLabel := "berlin.sneak.app.secret.test.se-probe"
|
||||
_, hash, err := CreateKey(probeLabel)
|
||||
if err != nil {
|
||||
t.Skipf("Secure Enclave unavailable (skipping): %v", err)
|
||||
}
|
||||
|
||||
if hash != "" {
|
||||
_ = DeleteKey(hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndDeleteKey(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
}
|
||||
|
||||
pubKey, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
t.Logf("Created key with hash: %s", hash)
|
||||
|
||||
// Verify valid uncompressed P-256 public key
|
||||
if len(pubKey) != p256UncompressedKeySize {
|
||||
t.Fatalf("expected public key length %d, got %d",
|
||||
p256UncompressedKeySize, len(pubKey))
|
||||
}
|
||||
|
||||
if pubKey[0] != 0x04 {
|
||||
t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0])
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
|
||||
// Delete the key
|
||||
if err := DeleteKey(hash); err != nil {
|
||||
t.Fatalf("DeleteKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = ""
|
||||
t.Log("Key created, verified, and deleted successfully")
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
_, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
|
||||
defer func() {
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
testKeyHash = ""
|
||||
}
|
||||
}()
|
||||
|
||||
// Test data simulating an age private key
|
||||
plaintext := []byte("AGE-SECRET-KEY-1" +
|
||||
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext))
|
||||
|
||||
if bytes.Equal(ciphertext, plaintext) {
|
||||
t.Fatal("ciphertext should differ from plaintext")
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := Decrypt(testKeyLabel, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(decrypted, plaintext) {
|
||||
t.Fatalf("decrypted data does not match original plaintext")
|
||||
}
|
||||
|
||||
t.Log("ECIES encrypt/decrypt round-trip successful")
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
skipIfNoSecureEnclave(t)
|
||||
|
||||
_, hash, err := CreateKey(testKeyLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey failed: %v", err)
|
||||
}
|
||||
|
||||
testKeyHash = hash
|
||||
|
||||
defer func() {
|
||||
if testKeyHash != "" {
|
||||
_ = DeleteKey(testKeyHash)
|
||||
testKeyHash = ""
|
||||
}
|
||||
}()
|
||||
|
||||
plaintext := []byte("test-secret-data")
|
||||
|
||||
ct1, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("first Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
ct2, err := Encrypt(testKeyLabel, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("second Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// ECIES uses a random ephemeral key each time, so ciphertexts should differ
|
||||
if bytes.Equal(ct1, ct2) {
|
||||
t.Fatal("two encryptions of same plaintext should produce different ciphertexts")
|
||||
}
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
dec1, err := Decrypt(testKeyLabel, ct1)
|
||||
if err != nil {
|
||||
t.Fatalf("first Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
dec2, err := Decrypt(testKeyLabel, ct2)
|
||||
if err != nil {
|
||||
t.Fatalf("second Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) {
|
||||
t.Fatal("both ciphertexts should decrypt to original plaintext")
|
||||
}
|
||||
|
||||
t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build darwin
|
||||
|
||||
#ifndef SECURE_ENCLAVE_H
|
||||
#define SECURE_ENCLAVE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
||||
// finds its identity hash. If the hash cannot be found, the key exists but
|
||||
// se_create_key fails, with an error naming the label.
|
||||
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||
// hash_out: output buffer for the identity hash (for deletion)
|
||||
// hash_out_len: size of hash_out buffer
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_create_key(const char *label,
|
||||
char *hash_out, int hash_out_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_copy_public_key copies the public key of a CTK identity.
|
||||
// label: label of the CTK identity
|
||||
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||
// label: label of the CTK identity whose public key to use
|
||||
// plaintext: data to encrypt
|
||||
// plaintext_len: length of plaintext
|
||||
// ciphertext_out: output buffer for the ECIES ciphertext
|
||||
// ciphertext_len: on input, size of buffer; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_encrypt(const char *label,
|
||||
const uint8_t *plaintext, int plaintext_len,
|
||||
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_decrypt decrypts ECIES ciphertext using the SE-backed private key.
|
||||
// The ECDH portion of decryption is performed inside the Secure Enclave.
|
||||
// label: label of the CTK identity whose private key to use
|
||||
// ciphertext: ECIES ciphertext produced by se_encrypt
|
||||
// ciphertext_len: length of ciphertext
|
||||
// plaintext_out: output buffer for decrypted data
|
||||
// plaintext_len: on input, size of buffer; on output, actual size written
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_decrypt(const char *label,
|
||||
const uint8_t *ciphertext, int ciphertext_len,
|
||||
uint8_t *plaintext_out, int *plaintext_len,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth.
|
||||
// hash: the identity hash returned by se_create_key
|
||||
// error_out: output buffer for error message
|
||||
// error_out_len: size of error_out buffer
|
||||
// Returns 0 on success, -1 on failure.
|
||||
int se_delete_key(const char *hash,
|
||||
char *error_out, int error_out_len);
|
||||
|
||||
#endif // SECURE_ENCLAVE_H
|
||||
@@ -0,0 +1,317 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Security/Security.h>
|
||||
#include "secure_enclave.h"
|
||||
#include <string.h>
|
||||
|
||||
// snprintf_error writes an error message string to the output buffer.
|
||||
static void snprintf_error(char *error_out, int error_out_len, NSString *msg) {
|
||||
if (error_out && error_out_len > 0) {
|
||||
snprintf(error_out, error_out_len, "%s", msg.UTF8String);
|
||||
}
|
||||
}
|
||||
|
||||
// lookup_ctk_identity finds a CTK identity by label and returns the private key.
|
||||
static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) {
|
||||
NSDictionary *query = @{
|
||||
(id)kSecClass: (id)kSecClassIdentity,
|
||||
(id)kSecAttrLabel: [NSString stringWithUTF8String:label],
|
||||
(id)kSecMatchLimit: (id)kSecMatchLimitOne,
|
||||
(id)kSecReturnRef: @YES,
|
||||
};
|
||||
|
||||
SecIdentityRef identity = NULL;
|
||||
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity);
|
||||
|
||||
if (status != errSecSuccess || !identity) {
|
||||
NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d",
|
||||
label, (int)status];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SecKeyRef privateKey = NULL;
|
||||
status = SecIdentityCopyPrivateKey(identity, &privateKey);
|
||||
CFRelease(identity);
|
||||
|
||||
if (status != errSecSuccess || !privateKey) {
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"failed to get private key from CTK identity '%s': OSStatus %d",
|
||||
label, (int)status];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
int se_create_key(const char *label,
|
||||
char *hash_out, int hash_out_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
NSString *labelStr = [NSString stringWithUTF8String:label];
|
||||
|
||||
// Shell out to sc_auth (which has SE entitlements) to create the key
|
||||
NSTask *task = [[NSTask alloc] init];
|
||||
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
task.arguments = @[
|
||||
@"create-ctk-identity",
|
||||
@"-k", @"p-256-ne",
|
||||
@"-t", @"none",
|
||||
@"-l", labelStr,
|
||||
];
|
||||
|
||||
NSPipe *stderrPipe = [NSPipe pipe];
|
||||
task.standardOutput = [NSPipe pipe];
|
||||
task.standardError = stderrPipe;
|
||||
|
||||
NSError *nsError = nil;
|
||||
if (![task launchAndReturnError:&nsError]) {
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
[task waitUntilExit];
|
||||
|
||||
if (task.terminationStatus != 0) {
|
||||
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@",
|
||||
stderrStr ?: @"unknown error"];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get the identity hash, which deleting the key needs, by parsing
|
||||
// sc_auth list output
|
||||
hash_out[0] = '\0';
|
||||
NSTask *listTask = [[NSTask alloc] init];
|
||||
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
listTask.arguments = @[@"list-ctk-identities"];
|
||||
|
||||
NSPipe *listPipe = [NSPipe pipe];
|
||||
listTask.standardOutput = listPipe;
|
||||
listTask.standardError = [NSPipe pipe];
|
||||
|
||||
if ([listTask launchAndReturnError:&nsError]) {
|
||||
[listTask waitUntilExit];
|
||||
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
|
||||
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||
if ([line containsString:labelStr]) {
|
||||
NSMutableArray *tokens = [NSMutableArray array];
|
||||
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||
if (part.length > 0) {
|
||||
[tokens addObject:part];
|
||||
}
|
||||
}
|
||||
if (tokens.count > 1) {
|
||||
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hash_out[0] == '\0') {
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
||||
label];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_copy_public_key(const char *label,
|
||||
uint8_t *pub_key_out, int *pub_key_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!publicKey) {
|
||||
snprintf_error(error_out, error_out_len, @"failed to get public key");
|
||||
return -1;
|
||||
}
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError);
|
||||
CFRelease(publicKey);
|
||||
|
||||
if (!pubKeyData) {
|
||||
NSError *err = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@",
|
||||
err.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *bytes = CFDataGetBytePtr(pubKeyData);
|
||||
CFIndex length = CFDataGetLength(pubKeyData);
|
||||
|
||||
if (length > *pub_key_len) {
|
||||
CFRelease(pubKeyData);
|
||||
snprintf_error(error_out, error_out_len, @"public key buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(pub_key_out, bytes, length);
|
||||
*pub_key_len = (int)length;
|
||||
CFRelease(pubKeyData);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_encrypt(const char *label,
|
||||
const uint8_t *plaintext, int plaintext_len,
|
||||
uint8_t *ciphertext_out, int *ciphertext_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!publicKey) {
|
||||
snprintf_error(error_out, error_out_len, @"failed to get public key for encryption");
|
||||
return -1;
|
||||
}
|
||||
|
||||
NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len];
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef encrypted = SecKeyCreateEncryptedData(
|
||||
publicKey,
|
||||
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||
(__bridge CFDataRef)plaintextData,
|
||||
&cfError
|
||||
);
|
||||
CFRelease(publicKey);
|
||||
|
||||
if (!encrypted) {
|
||||
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *encBytes = CFDataGetBytePtr(encrypted);
|
||||
CFIndex encLength = CFDataGetLength(encrypted);
|
||||
|
||||
if (encLength > *ciphertext_len) {
|
||||
CFRelease(encrypted);
|
||||
snprintf_error(error_out, error_out_len, @"ciphertext buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(ciphertext_out, encBytes, encLength);
|
||||
*ciphertext_len = (int)encLength;
|
||||
CFRelease(encrypted);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_decrypt(const char *label,
|
||||
const uint8_t *ciphertext, int ciphertext_len,
|
||||
uint8_t *plaintext_out, int *plaintext_len,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||
if (!privateKey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len];
|
||||
|
||||
CFErrorRef cfError = NULL;
|
||||
CFDataRef decrypted = SecKeyCreateDecryptedData(
|
||||
privateKey,
|
||||
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
|
||||
(__bridge CFDataRef)ciphertextData,
|
||||
&cfError
|
||||
);
|
||||
CFRelease(privateKey);
|
||||
|
||||
if (!decrypted) {
|
||||
NSError *nsError = (__bridge_transfer NSError *)cfError;
|
||||
NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const UInt8 *decBytes = CFDataGetBytePtr(decrypted);
|
||||
CFIndex decLength = CFDataGetLength(decrypted);
|
||||
|
||||
if (decLength > *plaintext_len) {
|
||||
CFRelease(decrypted);
|
||||
snprintf_error(error_out, error_out_len, @"plaintext buffer too small");
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(plaintext_out, decBytes, decLength);
|
||||
*plaintext_len = (int)decLength;
|
||||
CFRelease(decrypted);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int se_delete_key(const char *hash,
|
||||
char *error_out, int error_out_len) {
|
||||
@autoreleasepool {
|
||||
NSTask *task = [[NSTask alloc] init];
|
||||
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||
task.arguments = @[
|
||||
@"delete-ctk-identity",
|
||||
@"-h", [NSString stringWithUTF8String:hash],
|
||||
];
|
||||
|
||||
NSPipe *stderrPipe = [NSPipe pipe];
|
||||
task.standardOutput = [NSPipe pipe];
|
||||
task.standardError = stderrPipe;
|
||||
|
||||
NSError *nsError = nil;
|
||||
if (![task launchAndReturnError:&nsError]) {
|
||||
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
|
||||
nsError.localizedDescription];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
[task waitUntilExit];
|
||||
|
||||
if (task.terminationStatus != 0) {
|
||||
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
|
||||
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@",
|
||||
stderrStr ?: @"unknown error"];
|
||||
snprintf_error(error_out, error_out_len, msg);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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,941 @@
|
||||
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/macse"
|
||||
"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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
||||
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
||||
// the key must be deleted again. Skipped when the add fails before that, as
|
||||
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
||||
// Enclave.
|
||||
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
base := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
||||
var seKeyLabel string
|
||||
|
||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
||||
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
||||
|
||||
return errInjected
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
||||
nil)
|
||||
|
||||
if seKeyLabel == "" {
|
||||
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
||||
}
|
||||
|
||||
require.ErrorIs(t, err, errInjected)
|
||||
|
||||
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
||||
assert.Error(t, err, "Secure Enclave key left behind")
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package secret provides core types and constants for the secret application.
|
||||
package secret
|
||||
|
||||
import "os"
|
||||
@@ -6,18 +7,28 @@ const (
|
||||
// AppID is the unique identifier for this application
|
||||
AppID = "berlin.sneak.pkg.secret"
|
||||
|
||||
// Environment variable names
|
||||
EnvStateDir = "SB_SECRET_STATE_DIR"
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
// 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 = "SB_GPG_KEY_ID"
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
|
||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||
// new unlocker's directory, keychain item and Secure Enclave key. It runs
|
||||
// to the nanosecond, so that every new unlocker, even one added right
|
||||
// after another, gets a directory of its own.
|
||||
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
|
||||
)
|
||||
|
||||
// File system permission constants
|
||||
const (
|
||||
// DirPerms is the permission used for directories (read-write-execute for owner only)
|
||||
DirPerms os.FileMode = 0700
|
||||
DirPerms os.FileMode = 0o700
|
||||
|
||||
// FilePerms is the permission used for sensitive files (read-write for owner only)
|
||||
FilePerms os.FileMode = 0600
|
||||
FilePerms os.FileMode = 0o600
|
||||
)
|
||||
|
||||
+164
-29
@@ -2,49 +2,97 @@ package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errStdinNotTerminal = errors.New(
|
||||
"stdin is not a terminal (piped input or script)")
|
||||
errStderrNotTerminal = errors.New(
|
||||
"stderr is not a terminal (running in non-interactive mode)")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
||||
)
|
||||
|
||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
||||
// terminal to read the mnemonic from, reading it failed, or it was empty.
|
||||
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
|
||||
|
||||
// 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
|
||||
func EncryptToRecipient(data []byte, recipient age.Recipient) ([]byte, error) {
|
||||
Debug("EncryptToRecipient starting", "data_length", len(data))
|
||||
// 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)
|
||||
}
|
||||
Debug("Created age encryptor successfully")
|
||||
|
||||
Debug("Created age encryptor successfully")
|
||||
Debug("Writing data to encryptor")
|
||||
if _, err := w.Write(data); err != nil {
|
||||
|
||||
_, 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)
|
||||
}
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
Debug("Closing encryptor")
|
||||
if err := w.Close(); err != nil {
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
Debug("Failed to close encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
@@ -55,22 +103,76 @@ 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -80,29 +182,62 @@ func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a
|
||||
// passphrase. Every error it returns wraps ErrMnemonicNotRead.
|
||||
func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
|
||||
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
||||
}
|
||||
|
||||
// readFromTerminal reads input from the terminal without echoing it. Every
|
||||
// error it returns wraps notRead; without a terminal, the error says to set
|
||||
// envVar instead.
|
||||
func readFromTerminal(
|
||||
prompt string, notRead error, envVar string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(int(syscall.Stdin)) {
|
||||
// Not a terminal - never read passphrases from piped input for security reasons
|
||||
return "", fmt.Errorf("cannot read passphrase from non-terminal stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE environment variable or run interactively")
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read secrets from piped input
|
||||
// for security reasons
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %w. Please set the %s environment variable or run interactively",
|
||||
notRead, errStdinNotTerminal, envVar)
|
||||
}
|
||||
|
||||
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
||||
if !term.IsTerminal(int(syscall.Stderr)) {
|
||||
return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal (running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE environment variable")
|
||||
// stdin is a terminal, check if stderr is also a terminal for
|
||||
// interactive prompting
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
||||
notRead, errStderrNotTerminal, envVar)
|
||||
}
|
||||
|
||||
// Both stdin and stderr are terminals - use secure password reading
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
passphrase, err := term.ReadPassword(int(syscall.Stdin))
|
||||
|
||||
input, err := term.ReadPassword(syscall.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||
|
||||
if len(passphrase) == 0 {
|
||||
return "", fmt.Errorf("passphrase cannot be empty")
|
||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
||||
}
|
||||
|
||||
return string(passphrase), nil
|
||||
// Print newline to stderr since ReadPassword doesn't echo
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
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 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())
|
||||
}
|
||||
+40
-11
@@ -13,30 +13,36 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
debugEnabled bool
|
||||
debugLogger *slog.Logger
|
||||
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 current GODEBUG environment variable
|
||||
// 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.NewTextHandler(io.Discard, nil))
|
||||
debugLogger = slog.New(slog.DiscardHandler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disable stderr buffering for immediate debug output when debugging is enabled
|
||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
// 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(int(syscall.Stderr))
|
||||
isTTY := term.IsTerminal(syscall.Stderr)
|
||||
|
||||
var handler slog.Handler
|
||||
if isTTY {
|
||||
@@ -57,19 +63,36 @@ 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) {
|
||||
// Debugf logs a formatted debug message with optional attributes
|
||||
func Debugf(format string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
@@ -78,6 +101,7 @@ func DebugWith(msg string, attrs ...slog.Attr) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
||||
}
|
||||
|
||||
@@ -107,29 +131,34 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
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(attrs []slog.Attr) slog.Handler {
|
||||
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(name string) slog.Handler {
|
||||
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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//nolint:testpackage // white-box test of unexported debug internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
@@ -12,17 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestDebugLogging(t *testing.T) {
|
||||
// Save original GODEBUG and restore it
|
||||
originalGodebug := os.Getenv("GODEBUG")
|
||||
defer func() {
|
||||
if originalGodebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", originalGodebug)
|
||||
}
|
||||
// Re-initialize debug system with original setting
|
||||
InitDebugLogging()
|
||||
}()
|
||||
// Test cleanup handled by t.Setenv
|
||||
defer InitDebugLogging() // Re-initialize debug system after test
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -54,10 +45,8 @@ func TestDebugLogging(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set GODEBUG
|
||||
if tt.godebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", tt.godebug)
|
||||
if tt.godebug != "" {
|
||||
t.Setenv("GODEBUG", tt.godebug)
|
||||
}
|
||||
|
||||
// Re-initialize debug system
|
||||
@@ -76,7 +65,7 @@ func TestDebugLogging(t *testing.T) {
|
||||
|
||||
// Override the debug logger for testing
|
||||
oldLogger := debugLogger
|
||||
if term.IsTerminal(int(syscall.Stderr)) {
|
||||
if term.IsTerminal(syscall.Stderr) {
|
||||
// TTY: use colorized handler with our buffer
|
||||
debugLogger = slog.New(newColorizedHandler(&buf))
|
||||
} else {
|
||||
@@ -102,18 +91,12 @@ func TestDebugLogging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // exercises process-global debug logger state
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// Enable debug for testing
|
||||
originalGodebug := os.Getenv("GODEBUG")
|
||||
os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
defer func() {
|
||||
if originalGodebug == "" {
|
||||
os.Unsetenv("GODEBUG")
|
||||
} else {
|
||||
os.Setenv("GODEBUG", originalGodebug)
|
||||
}
|
||||
InitDebugLogging()
|
||||
}()
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
|
||||
defer InitDebugLogging() // Re-initialize after test
|
||||
|
||||
InitDebugLogging()
|
||||
|
||||
@@ -122,16 +105,16 @@ func TestDebugFunctions(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test that debug functions don't panic and can be called
|
||||
t.Run("Debug", func(t *testing.T) {
|
||||
t.Run("Debug", func(_ *testing.T) {
|
||||
Debug("test debug message")
|
||||
Debug("test with args", "key", "value", "number", 42)
|
||||
})
|
||||
|
||||
t.Run("DebugF", func(t *testing.T) {
|
||||
DebugF("formatted message: %s %d", "test", 123)
|
||||
t.Run("Debugf", func(_ *testing.T) {
|
||||
Debugf("formatted message: %s %d", "test", 123)
|
||||
})
|
||||
|
||||
t.Run("DebugWith", func(t *testing.T) {
|
||||
t.Run("DebugWith", func(_ *testing.T) {
|
||||
DebugWith("structured message",
|
||||
slog.String("string_key", "string_value"),
|
||||
slog.Int("int_key", 42),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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/pkg/agehd"
|
||||
)
|
||||
|
||||
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
||||
// using the same directory layout as vault.Vault.
|
||||
type realVault struct {
|
||||
name string
|
||||
stateDir string
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
func (v *realVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||
}
|
||||
func (v *realVault) GetName() string { return v.name }
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||
|
||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
func (v *realVault) CreatePassphraseUnlocker(
|
||||
*memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||
// filesystem, identical to what vault.CreateVault produces.
|
||||
func createRealVault(
|
||||
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
|
||||
) *realVault {
|
||||
t.Helper()
|
||||
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
|
||||
|
||||
metadata := VaultMetadata{
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
}
|
||||
metaBytes, err := json.Marshal(metadata)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||
|
||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||
}
|
||||
|
||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Derive expected keys at two different indices to prove they differ.
|
||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, key0.String(), key5.String(),
|
||||
"sanity check: different derivation indices must produce different keys")
|
||||
|
||||
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer result.Destroy()
|
||||
|
||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
|
||||
assert.NotEqual(t, key0.String(), string(result.Bytes()),
|
||||
"getLongTermPrivateKey must not use hardcoded index 0")
|
||||
}
|
||||
+20
-29
@@ -1,43 +1,23 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// generateRandomString generates a random string of the specified length using the given character set
|
||||
func generateRandomString(length int, charset string) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", fmt.Errorf("length must be positive")
|
||||
}
|
||||
|
||||
result := make([]byte, length)
|
||||
charsetLen := big.NewInt(int64(len(charset)))
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
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
|
||||
}
|
||||
|
||||
// DetermineStateDir determines the state directory based on environment variables and OS
|
||||
func DetermineStateDir(customConfigDir string) string {
|
||||
// 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
|
||||
return envStateDir, nil
|
||||
}
|
||||
|
||||
// Use custom config dir if provided
|
||||
if customConfigDir != "" {
|
||||
return filepath.Join(customConfigDir, AppID)
|
||||
return filepath.Join(customConfigDir, AppID), nil
|
||||
}
|
||||
|
||||
// Use os.UserConfigDir() which handles platform-specific directories:
|
||||
@@ -47,8 +27,19 @@ func DetermineStateDir(customConfigDir string) string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
// Fallback to a reasonable default if we can't determine user config dir
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".config", AppID)
|
||||
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)
|
||||
|
||||
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,142 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
var (
|
||||
errPassphraseLength = errors.New(
|
||||
"passphrase length must be a positive even number")
|
||||
errPassphraseNotHex = errors.New(
|
||||
"keychain passphrase must be lowercase hex")
|
||||
errNoKeychainPassphrase = errors.New(
|
||||
"keychain data has no agePrivKeyPassphrase string")
|
||||
)
|
||||
|
||||
// KeychainData is what a keychain unlocker stores in the macOS keychain.
|
||||
// It is stored as JSON, but encode and decodeKeychainData keep the
|
||||
// passphrase out of encoding/json, which would leave copies of it in
|
||||
// ordinary memory.
|
||||
type KeychainData struct {
|
||||
AgePublicKey string
|
||||
AgePrivKeyPassphrase *memguard.LockedBuffer
|
||||
EncryptedLongtermKey string
|
||||
}
|
||||
|
||||
// generateRandomPassphrase returns length random lowercase hex characters
|
||||
// in a locked buffer. The caller must destroy it.
|
||||
func generateRandomPassphrase(length int) (*memguard.LockedBuffer, error) {
|
||||
// Each random byte becomes two hex characters.
|
||||
randomBytes := hex.DecodedLen(length)
|
||||
if length <= 0 || hex.EncodedLen(randomBytes) != length {
|
||||
return nil, errPassphraseLength
|
||||
}
|
||||
|
||||
random := memguard.NewBufferRandom(randomBytes)
|
||||
defer random.Destroy()
|
||||
|
||||
passphrase := memguard.NewBuffer(length)
|
||||
hex.Encode(passphrase.Bytes(), random.Bytes())
|
||||
passphrase.Freeze()
|
||||
|
||||
return passphrase, nil
|
||||
}
|
||||
|
||||
// encode returns d as JSON in a locked buffer:
|
||||
// {"agePublicKey":"...","agePrivKeyPassphrase":"...","encryptedLongtermKey":"..."}.
|
||||
// The passphrase is copied straight into the buffer, so it must be hex,
|
||||
// which JSON does not escape. The caller must destroy the returned buffer.
|
||||
func (d *KeychainData) encode() (*memguard.LockedBuffer, error) {
|
||||
if d.AgePrivKeyPassphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
if d.AgePrivKeyPassphrase.Size() == 0 {
|
||||
return nil, errEmptyPassphrase
|
||||
}
|
||||
|
||||
for _, c := range d.AgePrivKeyPassphrase.Bytes() {
|
||||
if strings.IndexByte("0123456789abcdef", c) < 0 {
|
||||
return nil, errPassphraseNotHex
|
||||
}
|
||||
}
|
||||
|
||||
publicKey, err := json.Marshal(d.AgePublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode age public key: %w", err)
|
||||
}
|
||||
|
||||
longtermKey, err := json.Marshal(d.EncryptedLongtermKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode long-term key: %w", err)
|
||||
}
|
||||
|
||||
parts := [][]byte{
|
||||
[]byte(`{"agePublicKey":`), publicKey,
|
||||
[]byte(`,"agePrivKeyPassphrase":"`), d.AgePrivKeyPassphrase.Bytes(),
|
||||
[]byte(`","encryptedLongtermKey":`), longtermKey,
|
||||
[]byte(`}`),
|
||||
}
|
||||
|
||||
size := 0
|
||||
for _, part := range parts {
|
||||
size += len(part)
|
||||
}
|
||||
|
||||
encoded := memguard.NewBuffer(size)
|
||||
|
||||
written := 0
|
||||
for _, part := range parts {
|
||||
written += copy(encoded.Bytes()[written:], part)
|
||||
}
|
||||
|
||||
encoded.Freeze()
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// decodeKeychainData parses keychain data written by encode. The caller
|
||||
// must destroy the returned AgePrivKeyPassphrase.
|
||||
func decodeKeychainData(data *memguard.LockedBuffer) (*KeychainData, error) {
|
||||
if data == nil {
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
// json.Unmarshal gives a json.RawMessage field the field's JSON text
|
||||
// unchanged, in the one copy RawMessage makes; it is wiped on return.
|
||||
var fields struct {
|
||||
AgePublicKey string `json:"agePublicKey"`
|
||||
AgePrivKeyPassphrase json.RawMessage `json:"agePrivKeyPassphrase"`
|
||||
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
||||
}
|
||||
|
||||
defer func() { memguard.WipeBytes(fields.AgePrivKeyPassphrase) }()
|
||||
|
||||
err := json.Unmarshal(data.Bytes(), &fields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||
}
|
||||
|
||||
// json.Unmarshal accepted the JSON, so text that starts with a quote is
|
||||
// a whole string. The passphrase is hex, so it is the text between the
|
||||
// quotes.
|
||||
quoted := fields.AgePrivKeyPassphrase
|
||||
if !bytes.HasPrefix(quoted, []byte(`"`)) {
|
||||
return nil, errNoKeychainPassphrase
|
||||
}
|
||||
|
||||
return &KeychainData{
|
||||
AgePublicKey: fields.AgePublicKey,
|
||||
// NewBufferFromBytes wipes the bytes it copies.
|
||||
AgePrivKeyPassphrase: memguard.NewBufferFromBytes(
|
||||
quoted[1 : len(quoted)-1]),
|
||||
EncryptedLongtermKey: fields.EncryptedLongtermKey,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateRandomPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := generateRandomPassphrase(64)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer first.Destroy()
|
||||
|
||||
second, err := generateRandomPassphrase(64)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer second.Destroy()
|
||||
|
||||
assert.Regexp(t, `^[0-9a-f]{64}$`, first.String())
|
||||
assert.NotEqual(t, first.String(), second.String())
|
||||
assert.False(t, first.IsMutable())
|
||||
|
||||
for _, length := range []int{0, -2, 63} {
|
||||
_, err := generateRandomPassphrase(length)
|
||||
require.ErrorIs(t, err, errPassphraseLength, "length %d", length)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainDataEncodeDecode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte("0a1b2c3d"))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
data := KeychainData{
|
||||
AgePublicKey: "age1example",
|
||||
AgePrivKeyPassphrase: passphrase,
|
||||
EncryptedLongtermKey: "beef",
|
||||
}
|
||||
|
||||
encoded, err := data.encode()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer encoded.Destroy()
|
||||
|
||||
assert.JSONEq(t,
|
||||
`{"agePublicKey":"age1example",`+
|
||||
`"agePrivKeyPassphrase":"0a1b2c3d",`+
|
||||
`"encryptedLongtermKey":"beef"}`,
|
||||
encoded.String())
|
||||
assert.False(t, encoded.IsMutable())
|
||||
|
||||
decoded, err := decodeKeychainData(encoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer decoded.AgePrivKeyPassphrase.Destroy()
|
||||
|
||||
assert.Equal(t, "age1example", decoded.AgePublicKey)
|
||||
assert.Equal(t, "0a1b2c3d", decoded.AgePrivKeyPassphrase.String())
|
||||
assert.Equal(t, "beef", decoded.EncryptedLongtermKey)
|
||||
}
|
||||
|
||||
func TestKeychainDataEncodeRejectsBadPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
passphrase *memguard.LockedBuffer
|
||||
wantErr error
|
||||
}{
|
||||
{"nil", nil, errNilPassphraseBuffer},
|
||||
{"empty", memguard.NewBuffer(0), errEmptyPassphrase},
|
||||
{
|
||||
"not hex",
|
||||
memguard.NewBufferFromBytes([]byte(`abc"def`)),
|
||||
errPassphraseNotHex,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := KeychainData{AgePrivKeyPassphrase: tt.passphrase}
|
||||
_, err := data.encode()
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeychainDataRejectsBadData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, text := range []string{
|
||||
`{"agePublicKey":"age1example"}`,
|
||||
`{"agePrivKeyPassphrase":42}`,
|
||||
} {
|
||||
data := memguard.NewBufferFromBytes([]byte(text))
|
||||
_, err := decodeKeychainData(data)
|
||||
data.Destroy()
|
||||
require.ErrorIs(t, err, errNoKeychainPassphrase, text)
|
||||
}
|
||||
|
||||
notJSON := memguard.NewBufferFromBytes([]byte(`{"agePrivKeyPassphrase":`))
|
||||
defer notJSON.Destroy()
|
||||
|
||||
_, err := decodeKeychainData(notJSON)
|
||||
|
||||
var syntaxError *json.SyntaxError
|
||||
require.ErrorAs(t, err, &syntaxError)
|
||||
}
|
||||
+337
-253
@@ -1,32 +1,55 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
const (
|
||||
agePrivKeyPassphraseLength = 64
|
||||
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||
//
|
||||
//nolint:revive // ALL_CAPS is intentional for this constant
|
||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
||||
|
||||
// keychainUnlockerType is the metadata type string for keychain unlockers.
|
||||
keychainUnlockerType = "keychain"
|
||||
|
||||
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
|
||||
macOSFlag = "macos"
|
||||
)
|
||||
|
||||
// keychainItemNameRegex validates keychain item names
|
||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
var (
|
||||
// keychainItemNameRegex validates keychain item names
|
||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||
keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
|
||||
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
|
||||
errUnsupportedCurrentUnlocker = errors.New(
|
||||
"unsupported current unlocker type for keychain unlocker creation")
|
||||
)
|
||||
|
||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// Keychain item name
|
||||
KeychainItemName string `json:"keychain_item_name"`
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
// KeychainUnlocker represents a macOS Keychain-protected unlocker
|
||||
@@ -36,11 +59,15 @@ type KeychainUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// KeychainData represents the data stored in the macOS keychain
|
||||
type KeychainData struct {
|
||||
AgePublicKey string `json:"age_public_key"`
|
||||
AgePrivKeyPassphrase string `json:"age_priv_key_passphrase"`
|
||||
EncryptedLongtermKey string `json:"encrypted_longterm_key"`
|
||||
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
||||
func NewKeychainUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||
@@ -50,34 +77,11 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
slog.String("unlocker_type", k.GetType()),
|
||||
)
|
||||
|
||||
// Step 1: Get keychain item name
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
keychainData, err := k.readKeychainData()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 2: Retrieve data from keychain
|
||||
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||
if err != nil {
|
||||
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Retrieved data from keychain",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.Int("data_length", len(keychainDataBytes)),
|
||||
)
|
||||
|
||||
// Step 3: Parse keychain data
|
||||
var keychainData KeychainData
|
||||
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
||||
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
||||
|
||||
// Step 4: Read the encrypted age private key from filesystem
|
||||
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
||||
@@ -85,7 +89,9 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||
Debug("Failed to read encrypted age private key",
|
||||
"error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -95,23 +101,32 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
)
|
||||
|
||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
||||
Debug("Decrypting age private key with keychain passphrase",
|
||||
"unlocker_id", k.GetID())
|
||||
|
||||
agePrivKeyBuffer, err := DecryptWithPassphrase(
|
||||
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
||||
Debug("Failed to decrypt age private key with keychain passphrase",
|
||||
"error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to decrypt age private key with keychain passphrase: %w", err)
|
||||
}
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.Int("decrypted_length", len(agePrivKeyData)),
|
||||
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Step 6: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
||||
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -125,7 +140,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// GetType implements Unlocker interface
|
||||
func (k *KeychainUnlocker) GetType() string {
|
||||
return "keychain"
|
||||
return keychainUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface
|
||||
@@ -138,16 +153,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
||||
return k.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface - generates ID from keychain item name
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
// Generate ID using keychain item name
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
// The vault metadata is corrupt - this is a fatal error
|
||||
// We cannot continue with a fallback ID as that would mask data corruption
|
||||
panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
|
||||
}
|
||||
return fmt.Sprintf("%s-keychain", keychainItemName)
|
||||
return filepath.Base(k.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||
@@ -155,54 +163,105 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
// Step 1: Get keychain item name
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
||||
Debug("Failed to get keychain item name during removal",
|
||||
"error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Remove from keychain
|
||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||
if err := deleteFromKeychain(keychainItemName); err != nil {
|
||||
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
||||
|
||||
err = deleteFromKeychain(keychainItemName)
|
||||
if err != nil {
|
||||
Debug("Failed to remove keychain item",
|
||||
"error", err, "keychain_item", keychainItemName)
|
||||
|
||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Remove directory
|
||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||
if err := k.fs.RemoveAll(k.Directory); err != nil {
|
||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||
|
||||
err = RemoveDirAtomic(k.fs, k.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to remove keychain unlocker directory",
|
||||
"error", err, "directory", k.Directory)
|
||||
|
||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||
return nil
|
||||
}
|
||||
Debug("Successfully removed keychain unlocker",
|
||||
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||
|
||||
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
||||
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns the keychain item name from metadata
|
||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||
// Load the metadata
|
||||
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
||||
}
|
||||
|
||||
var keychainMetadata KeychainUnlockerMetadata
|
||||
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
|
||||
|
||||
err = json.Unmarshal(metadataData, &keychainMetadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
||||
}
|
||||
|
||||
return keychainMetadata.KeychainItemName, nil
|
||||
}
|
||||
|
||||
// readKeychainData reads and parses the data this unlocker keeps in the
|
||||
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
|
||||
// returned AgePrivKeyPassphrase.
|
||||
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
|
||||
// Step 1: Get keychain item name
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Retrieve data from keychain
|
||||
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
||||
|
||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||
if err != nil {
|
||||
Debug("Failed to retrieve data from keychain",
|
||||
"error", err, "keychain_item", keychainItemName)
|
||||
|
||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Retrieved data from keychain",
|
||||
slog.String("unlocker_id", k.GetID()),
|
||||
slog.Int("data_length", len(keychainDataBytes)),
|
||||
)
|
||||
|
||||
// Move the keychain data into locked memory; this wipes keychainDataBytes
|
||||
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
||||
defer keychainDataBuffer.Destroy()
|
||||
|
||||
// Step 3: Parse keychain data
|
||||
keychainData, err := decodeKeychainData(keychainDataBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
||||
|
||||
return keychainData, nil
|
||||
}
|
||||
|
||||
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
||||
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
@@ -210,18 +269,128 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
|
||||
// Format: secret-<vault>-<hostname>-<date>
|
||||
enrollmentDate := time.Now().Format("2006-01-02")
|
||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
||||
// Format: secret-<vault>-<hostname>-<time>
|
||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||
|
||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
||||
// Check if we're on macOS
|
||||
if err := checkMacOSAvailable(); err != nil {
|
||||
return nil, err
|
||||
// getLongTermPrivateKey derives the long-term private key from mnemonic when
|
||||
// it is not nil, else gets it through the current unlocker, which is given
|
||||
// passphrase when it is a passphrase unlocker.
|
||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||
func getLongTermPrivateKey(
|
||||
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if mnemonic != nil {
|
||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||
}
|
||||
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||
passphraseUnlocker.Passphrase = passphrase
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *KeychainUnlocker:
|
||||
// Read the encrypted long-term private key from another keychain
|
||||
// unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||
"from current keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, errUnsupportedCurrentUnlocker
|
||||
}
|
||||
|
||||
// Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||
encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Return the decrypted key buffer
|
||||
return ltPrivKeyBuffer, nil
|
||||
}
|
||||
|
||||
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
|
||||
// the vault's derivation index, for getLongTermPrivateKey and
|
||||
// getLongTermKeyForSE.
|
||||
func deriveLongTermPrivateKey(
|
||||
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Read vault metadata to get the correct derivation index
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use mnemonic with the vault's actual derivation index
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return IdentityToLockedBuffer(ltIdentity), nil
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
|
||||
// vault. The long-term key comes from mnemonic when it is not nil, else from
|
||||
// the current unlocker, as getLongTermPrivateKey describes.
|
||||
func CreateKeychainUnlocker(
|
||||
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
// Get current vault using the GetCurrentVault function from the same package
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
@@ -234,16 +403,13 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory using the keychain item name as the directory name
|
||||
// The unlocker directory is named after the keychain item
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||
ageIdentity, err := age.GenerateX25519Identity()
|
||||
@@ -251,125 +417,72 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||
}
|
||||
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
|
||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||
agePrivKeyPassphrase, err := generateRandomPassphrase(64)
|
||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
|
||||
}
|
||||
defer agePrivKeyPassphrase.Destroy()
|
||||
|
||||
// Step 3: Store age recipient as plaintext
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
// Step 3: Encrypt age private key with the generated passphrase
|
||||
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
||||
defer agePrivKeyBuffer.Destroy()
|
||||
|
||||
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
|
||||
encryptedAgePrivKey, err := EncryptWithPassphrase(
|
||||
agePrivKeyBuffer, agePrivKeyPassphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||
}
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Get or derive the long-term private key
|
||||
var ltPrivKeyData []byte
|
||||
|
||||
// Check if mnemonic is available in environment variable
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPrivKeyData = []byte(ltIdentity.String())
|
||||
} else {
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *KeychainUnlocker:
|
||||
// Read the encrypted long-term private key from another keychain unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
||||
}
|
||||
|
||||
// Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||
// Step 4: Get or derive the long-term private key
|
||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 5: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
||||
ltPrivKeyData, ageIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Prepare keychain data
|
||||
// Step 6: Prepare keychain data
|
||||
keychainData := KeychainData{
|
||||
AgePublicKey: ageRecipient,
|
||||
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
||||
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
|
||||
}
|
||||
|
||||
keychainDataBytes, err := json.Marshal(keychainData)
|
||||
keychainDataBuffer, err := keychainData.encode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
||||
return nil, fmt.Errorf("failed to encode keychain data: %w", err)
|
||||
}
|
||||
defer keychainDataBuffer.Destroy()
|
||||
|
||||
// Step 8: Store data in keychain
|
||||
if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
||||
}
|
||||
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
|
||||
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
|
||||
}
|
||||
|
||||
// Step 9: Create and write enhanced metadata
|
||||
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
|
||||
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
|
||||
// The data is stored after the unlocker's files are written, and the keychain
|
||||
// item is deleted again if moving the unlocker into place then fails.
|
||||
func writeKeychainUnlocker(
|
||||
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
||||
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
||||
keychainDataBuffer *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
// Step 7: Prepare enhanced metadata
|
||||
keychainMetadata := KeychainUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "keychain",
|
||||
Type: keychainUnlockerType,
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{"keychain", "macos"},
|
||||
Flags: []string{keychainUnlockerType, macOSFlag},
|
||||
},
|
||||
KeychainItemName: keychainItemName,
|
||||
}
|
||||
@@ -379,8 +492,51 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
// Step 8: Write the unlocker's files, the metadata last, then store the
|
||||
// data in the keychain
|
||||
stored := false
|
||||
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
||||
metadataBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||
}
|
||||
|
||||
stored = true
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil && stored {
|
||||
deleteErr := deleteFromKeychain(keychainItemName)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &KeychainUnlocker{
|
||||
@@ -390,88 +546,16 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkMacOSAvailable verifies that we're running on macOS and security command is available
|
||||
func checkMacOSAvailable() error {
|
||||
cmd := exec.Command("/usr/bin/security", "help")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
||||
// validateKeychainItemName validates that a keychain item name is safe for
|
||||
// command execution
|
||||
func validateKeychainItemName(itemName string) error {
|
||||
if itemName == "" {
|
||||
return fmt.Errorf("keychain item name cannot be empty")
|
||||
return errKeychainItemNameEmpty
|
||||
}
|
||||
|
||||
if !keychainItemNameRegex.MatchString(itemName) {
|
||||
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
||||
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeInKeychain stores data in the macOS keychain using the security command
|
||||
func storeInKeychain(itemName string, data []byte) error {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
|
||||
"-a", itemName,
|
||||
"-s", itemName,
|
||||
"-w", string(data),
|
||||
"-U") // Update if exists
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
|
||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
|
||||
"-a", itemName,
|
||||
"-s", itemName,
|
||||
"-w") // Return password only
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||
}
|
||||
|
||||
// Remove trailing newline if present
|
||||
if len(output) > 0 && output[len(output)-1] == '\n' {
|
||||
output = output[:len(output)-1]
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// deleteFromKeychain removes an item from the macOS keychain using the security command
|
||||
func deleteFromKeychain(itemName string) error {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec // Input validated by validateKeychainItemName
|
||||
"-a", itemName,
|
||||
"-s", itemName)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRandomPassphrase generates a random passphrase for encrypting the age private key
|
||||
func generateRandomPassphrase(length int) (string, error) {
|
||||
return generateRandomString(length, "0123456789abcdef")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
keychain "github.com/keybase/go-keychain"
|
||||
)
|
||||
|
||||
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
|
||||
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
|
||||
|
||||
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||
if data == nil {
|
||||
return errNilDataBuffer
|
||||
}
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||
item.SetAccount(itemName)
|
||||
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||
item.SetDescription("Secret vault keychain data")
|
||||
item.SetData(data.Bytes())
|
||||
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||
|
||||
// First try to delete any existing item
|
||||
deleteItem := keychain.NewItem()
|
||||
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||
deleteItem.SetAccount(itemName)
|
||||
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||
|
||||
// Add the new item
|
||||
if err := keychain.AddItem(item); err != nil {
|
||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
query := keychain.NewItem()
|
||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||
query.SetAccount(itemName)
|
||||
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||
query.SetReturnData(true)
|
||||
|
||||
results, err := keychain.QueryItem(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||
}
|
||||
|
||||
return results[0].Data, nil
|
||||
}
|
||||
|
||||
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
||||
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
||||
func deleteFromKeychain(itemName string) error {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||
item.SetAccount(itemName)
|
||||
|
||||
if err := keychain.DeleteItem(item); err != nil {
|
||||
// If the item doesn't exist, that's not an error - the goal is to ensure
|
||||
// the item is gone, and it already being gone satisfies that goal.
|
||||
// This is important for cleaning up unlocker directories when the keychain
|
||||
// item has already been removed (e.g., manually by user, or synced vault
|
||||
// from a different machine).
|
||||
if err == keychain.ErrorItemNotFound {
|
||||
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build darwin && !cgo
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// In a macOS build without cgo, these take the place of the functions in
|
||||
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
|
||||
|
||||
var errKeychainNotSupported = errors.New(
|
||||
"keychain unlockers need a macOS build with cgo")
|
||||
|
||||
// storeInKeychain fails: the keychain needs a macOS build with cgo.
|
||||
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
|
||||
return errKeychainNotSupported
|
||||
}
|
||||
|
||||
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
|
||||
func retrieveFromKeychain(_ string) ([]byte, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
|
||||
func deleteFromKeychain(_ string) error {
|
||||
return errKeychainNotSupported
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
// KeychainUnlocker is a stub for non-Darwin platforms
|
||||
type KeychainUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
var errKeychainNotSupported = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
|
||||
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin
|
||||
// platforms. The returned instance's methods that require macOS
|
||||
// functionality will return errors.
|
||||
func NewKeychainUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
// GetType returns the unlocker type
|
||||
func (k *KeychainUnlocker) GetType() string {
|
||||
return "keychain"
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata
|
||||
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return k.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory returns the unlocker directory
|
||||
func (k *KeychainUnlocker) GetDirectory() string {
|
||||
return k.Directory
|
||||
}
|
||||
|
||||
// GetID returns the unlocker ID, the name of the unlocker's directory
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
return filepath.Base(k.Directory)
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||
return "", errKeychainNotSupported
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) Remove() error {
|
||||
return errKeychainNotSupported
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||
func CreateKeychainUnlocker(
|
||||
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
|
||||
) (*KeychainUnlocker, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test data
|
||||
testItemName := "test-secret-keychain-item"
|
||||
testData := "test-secret-data-12345"
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
// Clean up any existing item first
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Test 1: Store data in keychain
|
||||
err := storeInKeychain(testItemName, testBuffer)
|
||||
require.NoError(t, err, "Failed to store data in keychain")
|
||||
|
||||
// Test 2: Retrieve data from keychain
|
||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||
assert.Equal(t, testData, string(retrievedData),
|
||||
"Retrieved data doesn't match stored data")
|
||||
|
||||
// Test 3: Update existing item (store again with different data)
|
||||
newTestData := "updated-test-data-67890"
|
||||
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
|
||||
defer newTestBuffer.Destroy()
|
||||
|
||||
err = storeInKeychain(testItemName, newTestBuffer)
|
||||
require.NoError(t, err, "Failed to update data in keychain")
|
||||
|
||||
// Verify updated data
|
||||
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||
assert.Equal(t, newTestData, string(retrievedData),
|
||||
"Retrieved data doesn't match updated data")
|
||||
|
||||
// Test 4: Delete from keychain
|
||||
err = deleteFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to delete data from keychain")
|
||||
|
||||
// Test 5: Verify item is deleted (should fail to retrieve)
|
||||
_, err = retrieveFromKeychain(testItemName)
|
||||
assert.Error(t, err, "Expected error when retrieving deleted item")
|
||||
}
|
||||
|
||||
func TestKeychainInvalidItemName(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
testData := memguard.NewBufferFromBytes([]byte("test"))
|
||||
defer testData.Destroy()
|
||||
|
||||
// Test an empty item name
|
||||
err := storeInKeychain("", testData)
|
||||
require.ErrorIs(t, err, errKeychainItemNameEmpty)
|
||||
|
||||
// Test invalid item names
|
||||
invalidNames := []string{
|
||||
"test space", // Contains space
|
||||
"test/slash", // Contains slash
|
||||
"test\\backslash", // Contains backslash
|
||||
"test:colon", // Contains colon
|
||||
"test;semicolon", // Contains semicolon
|
||||
"test|pipe", // Contains pipe
|
||||
"test@at", // Contains @
|
||||
"test#hash", // Contains #
|
||||
"test$dollar", // Contains $
|
||||
"test&ersand", // Contains &
|
||||
"test*asterisk", // Contains *
|
||||
"test?question", // Contains ?
|
||||
"test!exclamation", // Contains !
|
||||
"test'quote", // Contains single quote
|
||||
"test\"doublequote", // Contains double quote
|
||||
"test(paren", // Contains parenthesis
|
||||
"test[bracket", // Contains bracket
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
err := storeInKeychain(name, testData)
|
||||
require.ErrorIs(t, err, errInvalidKeychainItemName,
|
||||
"Expected error for invalid name: %s", name)
|
||||
}
|
||||
|
||||
// Test valid names (should not error on validation)
|
||||
validNames := []string{
|
||||
"test-name",
|
||||
"test_name",
|
||||
"test.name",
|
||||
"TestName123",
|
||||
"TEST_NAME_123",
|
||||
"com.example.test",
|
||||
"secret-vault-hostname-2024-01-01",
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
err := validateKeychainItemName(name)
|
||||
assert.NoError(t, err, "Expected no error for valid name: %s", name)
|
||||
// Clean up
|
||||
_ = deleteFromKeychain(name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainNilData(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test storing nil data
|
||||
err := storeInKeychain("test-item", nil)
|
||||
require.ErrorIs(t, err, errNilDataBuffer)
|
||||
}
|
||||
|
||||
func TestKeychainLargeData(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Test with larger hex-encoded data (512 bytes of binary data = 1KB hex)
|
||||
largeData := make([]byte, 512)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Convert to hex string for storage
|
||||
hexData := hex.EncodeToString(largeData)
|
||||
|
||||
testItemName := "test-large-data"
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
// Clean up first
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Store hex data
|
||||
err := storeInKeychain(testItemName, testBuffer)
|
||||
require.NoError(t, err, "Failed to store large data")
|
||||
|
||||
// Retrieve and verify
|
||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||
require.NoError(t, err, "Failed to retrieve large data")
|
||||
|
||||
// Decode hex and compare
|
||||
decodedData, err := hex.DecodeString(string(retrievedData))
|
||||
require.NoError(t, err, "Failed to decode hex data")
|
||||
assert.Equal(t, largeData, decodedData, "Large data mismatch")
|
||||
|
||||
// Clean up
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
}
|
||||
|
||||
func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
||||
// Skip test if not on macOS
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("Keychain tests only run on macOS")
|
||||
}
|
||||
|
||||
// Ensure item doesn't exist
|
||||
testItemName := "test-nonexistent-keychain-item-12345"
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Deleting a non-existent item should NOT return an error
|
||||
// This is important for cleaning up unlocker directories when the keychain item
|
||||
// has already been removed (e.g., manually by user, or on a different machine)
|
||||
err := deleteFromKeychain(testItemName)
|
||||
assert.NoError(t, err,
|
||||
"Deleting non-existent keychain item should not return an error")
|
||||
}
|
||||
|
||||
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
||||
// unlocker into place fail after its data is stored in the keychain: the
|
||||
// keychain item must be deleted again.
|
||||
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
||||
testItemName := "test-secret-keychain-unlocker-cleanup"
|
||||
_ = deleteFromKeychain(testItemName)
|
||||
|
||||
// Moving the unlocker into a read-only directory fails
|
||||
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
||||
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
||||
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
||||
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
||||
require.ErrorIs(t, err, os.ErrPermission,
|
||||
"moving the unlocker into place should fail")
|
||||
|
||||
_, err = retrieveFromKeychain(testItemName)
|
||||
assert.Error(t, err, "keychain item left behind")
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
|
||||
// VaultMetadata contains information about a vault
|
||||
type VaultMetadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DerivationIndex uint32 `json:"derivation_index"`
|
||||
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the actual long-term public key
|
||||
MnemonicFamilyHash string `json:"mnemonic_family_hash,omitempty"` // Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
|
||||
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
|
||||
@@ -20,8 +22,8 @@ type UnlockerMetadata struct {
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// SecretMetadata contains information about a secret
|
||||
type SecretMetadata struct {
|
||||
// Metadata contains information about a secret
|
||||
type Metadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
+185
-112
@@ -7,34 +7,151 @@ import (
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
// 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"
|
||||
|
||||
// Create a temporary directory for our tests
|
||||
tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
|
||||
// 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 create temp dir: %v", err)
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // Clean up after test
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
testPassphrase := "test-passphrase-123"
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the directory structure
|
||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -53,123 +170,78 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
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) {
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); 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)
|
||||
}
|
||||
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
// Test encrypting private key with passphrase
|
||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||
privKeyData := []byte(agePrivateKey)
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, testPassphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); 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)
|
||||
}
|
||||
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
||||
})
|
||||
|
||||
// Test writing long-term key
|
||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
||||
// 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)
|
||||
}
|
||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
ltPrivKeyData := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
unlocker.Passphrase = passphrase
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
// 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 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Save original environment variables and set test ones
|
||||
oldPassphrase := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
os.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||
unlocker.Passphrase = nil
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldPassphrase != "" {
|
||||
os.Setenv(secret.EnvUnlockPassphrase, oldPassphrase)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test getting identity from environment variable
|
||||
t.Run("GetIdentityFromEnv", func(t *testing.T) {
|
||||
identity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get identity from env: %v", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
// Unset the environment variable to test interactive prompt
|
||||
os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Test getting identity from prompt (this would require mocking the prompt)
|
||||
// For real integration tests, we'd need to provide a way to mock the passphrase input
|
||||
// Here we'll just verify the error is what we expect when no passphrase is available
|
||||
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||
// 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 passphrase env var")
|
||||
t.Errorf("Should have failed to get identity without a passphrase")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -185,6 +257,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
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,21 +1,38 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"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")
|
||||
|
||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||
type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
Passphrase string
|
||||
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
|
||||
@@ -25,27 +42,11 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
slog.String("unlocker_type", p.GetType()),
|
||||
)
|
||||
|
||||
// First check if we already have the passphrase
|
||||
passphraseStr := p.Passphrase
|
||||
if passphraseStr == "" {
|
||||
Debug("No passphrase in memory, checking environment")
|
||||
// Check environment variable for passphrase
|
||||
passphraseStr = os.Getenv(EnvUnlockPassphrase)
|
||||
if passphraseStr == "" {
|
||||
Debug("No passphrase in environment, prompting user")
|
||||
// Prompt for passphrase
|
||||
var err error
|
||||
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
} else {
|
||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||
}
|
||||
} else {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
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")
|
||||
@@ -53,7 +54,9 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
||||
Debug("Failed to read passphrase unlocker private key",
|
||||
"error", err, "path", unlockerPrivPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,22 +68,27 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
||||
|
||||
// Decrypt the unlocker private key with passphrase
|
||||
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
|
||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||
Debug("Failed to decrypt unlocker private key",
|
||||
"error", err, "unlocker_id", 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", len(privKeyData)),
|
||||
slog.Int("decrypted_length", privKeyBuffer.Size()),
|
||||
)
|
||||
|
||||
// Parse the decrypted private key
|
||||
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
||||
identity, err := age.ParseX25519Identity(string(privKeyData))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -107,34 +115,59 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface - generates ID from creation timestamp
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (p *PassphraseUnlocker) GetID() string {
|
||||
// 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"))
|
||||
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
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
// 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
|
||||
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
|
||||
// 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 {
|
||||
|
||||
+397
-344
@@ -1,8 +1,12 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,29 +17,31 @@ import (
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Register vault with secret package for testing
|
||||
func init() {
|
||||
// Register the vault.GetCurrentVault function with the secret package
|
||||
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||
return vault.GetCurrentVault(fs, stateDir)
|
||||
})
|
||||
}
|
||||
// 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, tempDir, passphrase, gnupgHomeDir string) {
|
||||
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
|
||||
`
|
||||
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0600); err != nil {
|
||||
|
||||
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||
}
|
||||
|
||||
@@ -44,8 +50,15 @@ pinentry-mode loopback
|
||||
origDecryptFunc := secret.GPGDecryptFunc
|
||||
|
||||
// Set custom GPG functions for this test
|
||||
secret.GPGEncryptFunc = func(data []byte, keyID string) ([]byte, error) {
|
||||
cmd := exec.Command("gpg",
|
||||
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",
|
||||
@@ -57,19 +70,22 @@ pinentry-mode loopback
|
||||
"-r", keyID)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
secret.GPGDecryptFunc = func(encryptedData []byte) ([]byte, error) {
|
||||
cmd := exec.Command("gpg",
|
||||
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",
|
||||
@@ -79,15 +95,18 @@ pinentry-mode loopback
|
||||
"--decrypt")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewReader(encryptedData)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
// Create a secure buffer for the decrypted data
|
||||
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// Restore original functions after test
|
||||
@@ -98,20 +117,24 @@ pinentry-mode loopback
|
||||
}
|
||||
|
||||
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
||||
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
|
||||
cmdArgs := []string{
|
||||
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,
|
||||
}
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
}, args...)
|
||||
|
||||
cmd := exec.Command("gpg", cmdArgs...)
|
||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
|
||||
cmd.Stdin = input
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
@@ -123,46 +146,12 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||
// Check if gpg is available
|
||||
if _, err := exec.LookPath("gpg"); 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
|
||||
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // Clean up after test
|
||||
|
||||
// Create a temporary GNUPGHOME
|
||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||
if err := os.MkdirAll(gnupgHomeDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||
}
|
||||
|
||||
// Save original GNUPGHOME
|
||||
origGnupgHome := os.Getenv("GNUPGHOME")
|
||||
|
||||
// Set new GNUPGHOME
|
||||
os.Setenv("GNUPGHOME", gnupgHomeDir)
|
||||
|
||||
// Clean up environment after test
|
||||
defer func() {
|
||||
if origGnupgHome != "" {
|
||||
os.Setenv("GNUPGHOME", origGnupgHome)
|
||||
} else {
|
||||
os.Unsetenv("GNUPGHOME")
|
||||
}
|
||||
}()
|
||||
|
||||
// Test passphrase for GPG key
|
||||
testPassphrase := "test123"
|
||||
|
||||
// Setup non-interactive GPG with custom functions
|
||||
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
||||
// 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")
|
||||
@@ -172,25 +161,29 @@ Key-Length: 2048
|
||||
Name-Real: Test User
|
||||
Name-Email: test@example.com
|
||||
Expire-Date: 0
|
||||
Passphrase: ` + testPassphrase + `
|
||||
Passphrase: ` + passphrase + `
|
||||
%commit
|
||||
%echo Key generation completed
|
||||
`
|
||||
if err := os.WriteFile(batchFile, []byte(batchContent), 0600); err != nil {
|
||||
|
||||
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(gnupgHomeDir, testPassphrase,
|
||||
|
||||
_, 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(gnupgHomeDir, testPassphrase,
|
||||
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)
|
||||
@@ -198,8 +191,8 @@ Passphrase: ` + testPassphrase + `
|
||||
|
||||
// Parse output to get key ID and fingerprint
|
||||
var keyID, fingerprint string
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.HasPrefix(line, "sec:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 5 {
|
||||
@@ -209,6 +202,7 @@ Passphrase: ` + testPassphrase + `
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
fingerprint = fields[9]
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -217,51 +211,64 @@ Passphrase: ` + testPassphrase + `
|
||||
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
|
||||
oldAgentInfo := os.Getenv("GPG_AGENT_INFO")
|
||||
os.Setenv("GPG_AGENT_INFO", "")
|
||||
defer func() {
|
||||
if oldAgentInfo != "" {
|
||||
os.Setenv("GPG_AGENT_INFO", oldAgentInfo)
|
||||
} else {
|
||||
os.Unsetenv("GPG_AGENT_INFO")
|
||||
}
|
||||
}()
|
||||
t.Setenv("GPG_AGENT_INFO", "")
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test data
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Save original environment variable
|
||||
oldMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
oldGPGKeyID := os.Getenv(secret.EnvGPGKeyID)
|
||||
|
||||
// Set test environment variables
|
||||
os.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||
os.Setenv(secret.EnvGPGKeyID, keyID)
|
||||
|
||||
// Clean up after test
|
||||
defer func() {
|
||||
if oldMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, oldMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
if oldGPGKeyID != "" {
|
||||
os.Setenv(secret.EnvGPGKeyID, oldGPGKeyID)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvGPGKeyID)
|
||||
}
|
||||
}()
|
||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||
|
||||
// Set up vault structure for testing
|
||||
stateDir := tempDir
|
||||
@@ -269,160 +276,20 @@ Passphrase: ` + testPassphrase + `
|
||||
|
||||
// Test creation of a PGP unlock key through a vault
|
||||
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
||||
// Set a limited test timeout to avoid hanging
|
||||
timer := time.AfterFunc(30*time.Second, func() {
|
||||
t.Fatalf("Test timed out after 30 seconds")
|
||||
})
|
||||
defer timer.Stop()
|
||||
|
||||
// Create a test vault directory structure
|
||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Set the current vault
|
||||
err = vault.SelectVault(fs, stateDir, vaultName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to select vault: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
// Create a passphrase unlocker first (to have current unlocker)
|
||||
passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||
}
|
||||
|
||||
// Verify passphrase unlocker was created
|
||||
if passUnlocker == nil {
|
||||
t.Fatal("Passphrase unlocker is nil")
|
||||
}
|
||||
|
||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the PGP unlock key was created
|
||||
if pgpUnlocker == nil {
|
||||
t.Fatal("PGP unlock key is nil")
|
||||
}
|
||||
|
||||
// Check if the key has the correct type
|
||||
if pgpUnlocker.GetType() != "pgp" {
|
||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||
}
|
||||
|
||||
// Check if the key ID includes the GPG fingerprint
|
||||
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
||||
}
|
||||
|
||||
// Check if the key directory exists
|
||||
unlockerDir := pgpUnlocker.GetDirectory()
|
||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
||||
}
|
||||
if !keyExists {
|
||||
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
||||
}
|
||||
|
||||
// Check if required files exist
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
recipientExists, err := afero.Exists(fs, recipientPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
||||
}
|
||||
if !recipientExists {
|
||||
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key file exists: %v", err)
|
||||
}
|
||||
if !privKeyExists {
|
||||
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
metadataExists, err := afero.Exists(fs, metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
||||
}
|
||||
if !metadataExists {
|
||||
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
||||
}
|
||||
|
||||
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
longtermExists, err := afero.Exists(fs, longtermPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
||||
}
|
||||
if !longtermExists {
|
||||
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
||||
}
|
||||
|
||||
// Read and verify metadata
|
||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read metadata: %v", err)
|
||||
}
|
||||
|
||||
var metadata struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Flags []string `json:"flags"`
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
t.Fatalf("Failed to parse metadata: %v", err)
|
||||
}
|
||||
|
||||
if metadata.Type != "pgp" {
|
||||
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
||||
}
|
||||
|
||||
if metadata.GPGKeyID != fingerprint {
|
||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
||||
}
|
||||
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
||||
})
|
||||
|
||||
// Set up key directory for individual tests
|
||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||
|
||||
err = os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||
}
|
||||
|
||||
// Set up test metadata
|
||||
metadata := secret.UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
Type: pgpUnlockerType,
|
||||
CreatedAt: time.Now(),
|
||||
Flags: []string{"gpg", "encrypted"},
|
||||
}
|
||||
@@ -430,106 +297,292 @@ Passphrase: ` + testPassphrase + `
|
||||
// Create a PGP unlocker for the remaining tests
|
||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||
|
||||
// Test getting GPG key ID
|
||||
t.Run("GetGPGKeyID", func(t *testing.T) {
|
||||
// Create PGP metadata with GPG key ID
|
||||
type PGPUnlockerMetadata struct {
|
||||
secret.UnlockerMetadata
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
}
|
||||
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: metadata,
|
||||
GPGKeyID: fingerprint,
|
||||
}
|
||||
|
||||
// Write metadata file
|
||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal metadata: %v", err)
|
||||
}
|
||||
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write metadata: %v", err)
|
||||
}
|
||||
|
||||
// Get GPG key ID
|
||||
retrievedKeyID, err := unlocker.GetGPGKeyID()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get GPG key ID: %v", err)
|
||||
}
|
||||
|
||||
// Verify key ID (should be the fingerprint)
|
||||
if retrievedKeyID != fingerprint {
|
||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
||||
}
|
||||
})
|
||||
|
||||
// Test getting identity from PGP unlocker
|
||||
t.Run("GetIdentity", func(t *testing.T) {
|
||||
// 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")
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write recipient: %v", err)
|
||||
}
|
||||
|
||||
// GPG encrypt the private key using our custom encrypt function
|
||||
privKeyData := []byte(ageIdentity.String())
|
||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyData, keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||
}
|
||||
|
||||
// Write the encrypted data to a file
|
||||
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Now try to get the identity - this will use our custom GPGDecryptFunc
|
||||
identity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get identity: %v", err)
|
||||
}
|
||||
|
||||
// Verify the identity matches
|
||||
expectedPubKey := ageIdentity.Recipient().String()
|
||||
actualPubKey := identity.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
||||
}
|
||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
||||
})
|
||||
|
||||
// Test removing the unlocker
|
||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+227
-177
@@ -1,7 +1,9 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -12,19 +14,40 @@ import (
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"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
|
||||
GPGEncryptFunc = gpgEncryptDefault
|
||||
//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
|
||||
GPGDecryptFunc = gpgDecryptDefault
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGDecryptFunc func(
|
||||
encryptedData []byte,
|
||||
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
|
||||
// gpgKeyIDRegex validates GPG key IDs
|
||||
// Allows either:
|
||||
@@ -43,8 +66,9 @@ var (
|
||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||
type PGPUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// GPG key ID used for encryption
|
||||
GPGKeyID string `json:"gpg_key_id"`
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
|
||||
// PGPUnlocker represents a PGP-protected unlocker
|
||||
@@ -54,6 +78,17 @@ type PGPUnlocker struct {
|
||||
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",
|
||||
@@ -67,7 +102,9 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -78,22 +115,28 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 2: Decrypt the age private key using GPG
|
||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
|
||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
||||
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", len(agePrivKeyData)),
|
||||
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(string(agePrivKeyData))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -120,192 +163,103 @@ func (p *PGPUnlocker) GetDirectory() string {
|
||||
return p.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface - generates ID from GPG key ID
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||
func (p *PGPUnlocker) GetID() string {
|
||||
// Generate ID using GPG key ID: <keyid>-pgp
|
||||
gpgKeyID, err := p.GetGPGKeyID()
|
||||
if err != nil {
|
||||
// The vault metadata is corrupt - this is a fatal error
|
||||
// We cannot continue with a fallback ID as that would mask data corruption
|
||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||
}
|
||||
return fmt.Sprintf("%s-pgp", gpgKeyID)
|
||||
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
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
err := RemoveDirAtomic(p.fs, p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGPGKeyID returns the GPG key ID from metadata
|
||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||
// Load the metadata
|
||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
var pgpMetadata PGPUnlockerMetadata
|
||||
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
|
||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
return pgpMetadata.GPGKeyID, nil
|
||||
}
|
||||
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Format: hostname-pgp-YYYY-MM-DD
|
||||
enrollmentDate := time.Now().Format("2006-01-02")
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
||||
}
|
||||
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
|
||||
// Check if GPG is available
|
||||
if err := checkGPGAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate the unlocker name based on hostname and date
|
||||
// Generate the unlocker name based on hostname and time
|
||||
unlockerName, err := generatePGPUnlockerName()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory using the generated name
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlocker 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: Store age recipient as plaintext
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Get or derive the long-term private key
|
||||
var ltPrivKeyData []byte
|
||||
|
||||
// Check if mnemonic is available in environment variable
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPrivKeyData = []byte(ltIdentity.String())
|
||||
} else {
|
||||
// Get the vault to access current unlocker
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Get the current unlocker identity
|
||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Get encrypted long-term key from current unlocker, handling different types
|
||||
var encryptedLtPrivKey []byte
|
||||
switch currentUnlocker := currentUnlocker.(type) {
|
||||
case *PassphraseUnlocker:
|
||||
// Read the encrypted long-term private key from passphrase unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||
}
|
||||
|
||||
case *PGPUnlocker:
|
||||
// Read the encrypted long-term private key from PGP unlocker
|
||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
|
||||
}
|
||||
|
||||
// Step 6: Decrypt long-term private key using current unlocker
|
||||
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||
// 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, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Encrypt age private key to the GPG key ID
|
||||
agePrivateKeyBytes := []byte(ageIdentity.String())
|
||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBytes, gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
||||
}
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Create and write enhanced metadata with full fingerprint
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
@@ -320,8 +274,13 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write 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{
|
||||
@@ -331,35 +290,110 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||
func validateGPGKeyID(keyID string) error {
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("GPG key ID cannot be empty")
|
||||
// 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)
|
||||
}
|
||||
|
||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
||||
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
|
||||
}
|
||||
|
||||
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||
func resolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
if err := validateGPGKeyID(keyID); err != 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
|
||||
cmd := exec.Command("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID)
|
||||
// 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
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
@@ -368,26 +402,39 @@ func resolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
|
||||
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
||||
}
|
||||
|
||||
// checkGPGAvailable verifies that GPG is available
|
||||
func checkGPGAvailable() error {
|
||||
cmd := exec.Command("gpg", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||
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 []byte, keyID string) ([]byte, error) {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
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.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
||||
cmd.Stdin = strings.NewReader(string(data))
|
||||
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 {
|
||||
@@ -398,8 +445,8 @@ func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||
func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
||||
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||
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()
|
||||
@@ -407,5 +454,8 @@ func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
// 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()))
|
||||
}
|
||||
+46
-168
@@ -1,34 +1,46 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"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 []byte, force bool) error
|
||||
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
|
||||
GetName() string
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, 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 SecretMetadata
|
||||
Metadata Metadata
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
@@ -54,185 +66,40 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
Name: name,
|
||||
Directory: secretDir,
|
||||
vault: vault,
|
||||
Metadata: SecretMetadata{
|
||||
Metadata: Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Save is deprecated - use vault.AddSecret directly which creates versions
|
||||
// Kept for backward compatibility
|
||||
func (s *Secret) Save(value []byte, force bool) error {
|
||||
DebugWith("Saving secret (deprecated method)",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Int("value_length", len(value)),
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
err := s.vault.AddSecret(s.Name, value, force)
|
||||
if err != nil {
|
||||
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret", "secret_name", s.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := s.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
}
|
||||
|
||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Create version object
|
||||
version := NewSecretVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
|
||||
// Use the provided unlocker to get the vault's long-term private key
|
||||
if unlocker == nil {
|
||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
||||
}
|
||||
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully obtained vault's long-term key",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
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 = SecretMetadata{
|
||||
s.Metadata = Metadata{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
func (s *Secret) GetMetadata() SecretMetadata {
|
||||
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, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
|
||||
return nil, errGetEncryptedDataDeprecated
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
@@ -245,12 +112,15 @@ func (s *Secret) Exists() (bool, error) {
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -258,6 +128,7 @@ func (s *Secret) Exists() (bool, error) {
|
||||
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("No current version found", "error", err, "secret_name", s.Name)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -272,21 +143,28 @@ func (s *Secret) Exists() (bool, error) {
|
||||
// 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, fmt.Errorf("GetCurrentVault function not registered")
|
||||
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)) {
|
||||
// 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
|
||||
}
|
||||
|
||||
+163
-167
@@ -1,42 +1,67 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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 []byte, force bool) error {
|
||||
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)
|
||||
if err := m.fs.MkdirAll(secretDir, 0700); err != nil {
|
||||
|
||||
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)
|
||||
if err := m.fs.MkdirAll(versionDir, 0700); err != nil {
|
||||
|
||||
err = m.fs.MkdirAll(versionDir, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,24 +69,74 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
||||
|
||||
// Derive long-term key using the vault's derivation index
|
||||
mnemonic := os.Getenv(EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
||||
if m.mnemonic == nil {
|
||||
return errMnemonicNotSet
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
||||
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write long-term public key if it doesn't exist
|
||||
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
||||
_, err = m.fs.Stat(ltPubKeyPath)
|
||||
if os.IsNotExist(err) {
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0600); err != nil {
|
||||
|
||||
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 {
|
||||
@@ -70,11 +145,14 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
|
||||
// Write version public key
|
||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0600); err != nil {
|
||||
|
||||
err = afero.WriteFile(
|
||||
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt value to version's public key
|
||||
// Encrypt value to version's public key (value is already a LockedBuffer)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -82,69 +160,32 @@ func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
|
||||
// Write encrypted value
|
||||
valuePath := filepath.Join(versionDir, "value.age")
|
||||
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0600); err != nil {
|
||||
|
||||
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt version private key to long-term public key
|
||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionIdentity.String()), ltIdentity.Recipient())
|
||||
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")
|
||||
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current symlink pointing to the version
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
// For MemMapFs, write a file with the target path
|
||||
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set up test environment variables
|
||||
oldMnemonic := os.Getenv(EnvMnemonic)
|
||||
defer func() {
|
||||
if oldMnemonic == "" {
|
||||
os.Unsetenv(EnvMnemonic)
|
||||
} else {
|
||||
os.Setenv(EnvMnemonic, oldMnemonic)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
os.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
// 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)
|
||||
@@ -153,18 +194,19 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generate a long-term keypair for the vault using the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
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()),
|
||||
0600,
|
||||
0o600,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
@@ -172,10 +214,56 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
|
||||
// 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{
|
||||
@@ -183,44 +271,26 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
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, secretValue, false)
|
||||
err := vault.AddSecret(secretName, valueBuffer, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddSecret failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that all expected files were created
|
||||
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 symlink exists
|
||||
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")
|
||||
}
|
||||
verifySecretFiles(t, fs, vaultDir, secretName)
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
@@ -249,85 +319,11 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
// For testing purposes only
|
||||
func isValidSecretName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash
|
||||
for _, char := range name {
|
||||
if (char < 'a' || char > 'z') && // lowercase letters
|
||||
(char < '0' || char > '9') && // numbers
|
||||
char != '-' && // dash
|
||||
char != '.' && // dot
|
||||
char != '_' && // underscore
|
||||
char != '/' { // slash
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestSecretNameValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
}{
|
||||
{"valid-name", true},
|
||||
{"valid.name", true},
|
||||
{"valid_name", true},
|
||||
{"valid/path/name", true},
|
||||
{"123valid", true},
|
||||
{"", false},
|
||||
{"Invalid-Name", false}, // uppercase not allowed
|
||||
{"invalid name", false}, // space not allowed
|
||||
{"invalid@name", false}, // @ not allowed
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := isValidSecretName(test.name)
|
||||
if result != test.valid {
|
||||
t.Errorf(
|
||||
"isValidSecretName(%q) = %v, want %v",
|
||||
test.name,
|
||||
result,
|
||||
test.valid,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
// This test demonstrates the bug where GetValue uses hardcoded index 0
|
||||
// instead of the vault's actual derivation index when using environment mnemonic
|
||||
|
||||
// Set up test mnemonic
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
originalEnv := os.Getenv(EnvMnemonic)
|
||||
os.Setenv(EnvMnemonic, testMnemonic)
|
||||
defer os.Setenv(EnvMnemonic, originalEnv)
|
||||
|
||||
// Create temporary directory for vaults
|
||||
fs := afero.NewOsFs()
|
||||
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = fs.RemoveAll(tempDir)
|
||||
}()
|
||||
|
||||
stateDir := filepath.Join(tempDir, ".secret")
|
||||
require.NoError(t, fs.MkdirAll(stateDir, 0700))
|
||||
|
||||
// This test is now in the integration test file where it can use real vaults
|
||||
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
||||
t.Log("This test demonstrates the bug in the integration test file")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
//go:build darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/secret/internal/macse"
|
||||
)
|
||||
|
||||
const (
|
||||
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
|
||||
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
|
||||
|
||||
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
|
||||
seUnlockerType = "secure-enclave"
|
||||
|
||||
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
|
||||
seLongtermFilename = "longterm.age.se"
|
||||
)
|
||||
|
||||
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||
type SecureEnclaveUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
SEKeyLabel string `json:"seKeyLabel"`
|
||||
SEKeyHash string `json:"seKeyHash"`
|
||||
}
|
||||
|
||||
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
|
||||
type SecureEnclaveUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
||||
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting SE unlocker identity",
|
||||
slog.String("unlocker_id", s.GetID()),
|
||||
)
|
||||
|
||||
// Get SE key label from metadata
|
||||
seKeyLabel, _, err := s.getSEKeyInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get SE key info: %w", err)
|
||||
}
|
||||
|
||||
// Read ECIES-encrypted long-term private key from disk
|
||||
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||
|
||||
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read SE-encrypted long-term key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
DebugWith("Read SE-encrypted long-term key",
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
|
||||
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to decrypt long-term key with SE: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the decrypted long-term private key
|
||||
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
|
||||
|
||||
// Clear sensitive data immediately
|
||||
for i := range decryptedData {
|
||||
decryptedData[i] = 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to parse long-term private key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term key via SE",
|
||||
slog.String("unlocker_id", s.GetID()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetType implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return seUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
return s.Directory
|
||||
}
|
||||
|
||||
// GetID implements Unlocker interface: the name of the unlocker's directory.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return filepath.Base(s.Directory)
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface.
|
||||
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
_, seKeyHash, err := s.getSEKeyInfo()
|
||||
if err != nil {
|
||||
Debug("Failed to get SE key info during removal", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get SE key info: %w", err)
|
||||
}
|
||||
|
||||
if seKeyHash != "" {
|
||||
Debug("Deleting SE key", "hash", seKeyHash)
|
||||
|
||||
err = macse.DeleteKey(seKeyHash)
|
||||
if err != nil {
|
||||
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||
|
||||
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||
|
||||
err = RemoveDirAtomic(s.fs, s.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
|
||||
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||
}
|
||||
|
||||
var seMetadata SecureEnclaveUnlockerMetadata
|
||||
|
||||
err = json.Unmarshal(metadataData, &seMetadata)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||
}
|
||||
|
||||
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||
}
|
||||
|
||||
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||
func generateSEKeyLabel(vaultName string) (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.%s-%s-%s",
|
||||
seKeyLabelPrefix,
|
||||
vaultName,
|
||||
hostname,
|
||||
enrollmentTime,
|
||||
), nil
|
||||
}
|
||||
|
||||
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||
// using ECIES. No intermediate age keypair is used.
|
||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||
// current unlocker, as getLongTermKeyForSE describes.
|
||||
// The SE key is created once the long-term key is in hand and the unlocker's
|
||||
// path is known, and is deleted again if a later step fails.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
stateDir string,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate SE key label
|
||||
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Get the vault's long-term private key
|
||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to get long-term private key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 2: Prepare the unlocker directory's path
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||
|
||||
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
|
||||
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||
|
||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||
|
||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
||||
ltPrivKeyData)
|
||||
if err != nil {
|
||||
deleteErr := macse.DeleteKey(seKeyHash)
|
||||
if deleteErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf(
|
||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return unlocker, nil
|
||||
}
|
||||
|
||||
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
||||
// new unlocker into unlockerDir (steps 4 and 5 of
|
||||
// CreateSecureEnclaveUnlocker).
|
||||
func writeSEUnlocker(
|
||||
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
||||
ltPrivKeyData *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
||||
// prepare the metadata
|
||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term key with SE: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Flags: []string{seUnlockerType, macOSFlag},
|
||||
},
|
||||
SEKeyLabel: seKeyLabel,
|
||||
SEKeyHash: seKeyHash,
|
||||
}
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: seMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
|
||||
// SE-encrypted long-term key, then the metadata.
|
||||
func writeSEUnlockerFiles(
|
||||
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
|
||||
) error {
|
||||
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
|
||||
encryptedLtKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to write SE-encrypted long-term key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
err = WriteFileAtomic(fs,
|
||||
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
|
||||
// from mnemonic when it is not nil, else through the current unlocker, which
|
||||
// is given passphrase when it is a passphrase unlocker.
|
||||
func getLongTermKeyForSE(
|
||||
fs afero.Fs,
|
||||
vault VaultInterface,
|
||||
mnemonic, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if mnemonic != nil {
|
||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||
}
|
||||
|
||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||
passphraseUnlocker.Passphrase = passphrase
|
||||
}
|
||||
|
||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to get current unlocker identity: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// All unlocker types store longterm.age in their directory
|
||||
longtermPath := filepath.Join(
|
||||
currentUnlocker.GetDirectory(),
|
||||
"longterm.age",
|
||||
)
|
||||
|
||||
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted long-term key: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||
encryptedLtKey,
|
||||
currentIdentity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
|
||||
}
|
||||
|
||||
return ltPrivKeyBuffer, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//go:build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// seUnlockerType is the type string for Secure Enclave unlockers.
|
||||
const seUnlockerType = "secure-enclave"
|
||||
|
||||
var errSENotSupported = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS",
|
||||
)
|
||||
|
||||
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||
type SecureEnclaveUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
SEKeyLabel string `json:"seKeyLabel"`
|
||||
SEKeyHash string `json:"seKeyHash"`
|
||||
}
|
||||
|
||||
// SecureEnclaveUnlocker is a stub for non-Darwin platforms.
|
||||
type SecureEnclaveUnlocker struct {
|
||||
Directory string
|
||||
Metadata UnlockerMetadata
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on
|
||||
// non-Darwin platforms. The returned instance's methods that require
|
||||
// macOS functionality will return errors.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errSENotSupported
|
||||
}
|
||||
|
||||
// GetType returns the unlocker type.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return seUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata.
|
||||
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetDirectory returns the unlocker directory.
|
||||
func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
return s.Directory
|
||||
}
|
||||
|
||||
// GetID returns the unlocker ID, the name of the unlocker's directory.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return filepath.Base(s.Directory)
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
return errSENotSupported
|
||||
}
|
||||
|
||||
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
_ afero.Fs,
|
||||
_ string,
|
||||
_, _ *memguard.LockedBuffer,
|
||||
) (*SecureEnclaveUnlocker, error) {
|
||||
return nil, errSENotSupported
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build !darwin
|
||||
|
||||
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||
|
||||
// Test GetType returns correct type
|
||||
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
|
||||
// Test GetDirectory returns the directory we passed in
|
||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||
|
||||
// Test GetID returns the name of the unlocker's directory
|
||||
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
err := unlocker.Remove()
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
|
||||
assert.Nil(t, unlocker)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// Verify the stub implements the Unlocker interface
|
||||
var _ Unlocker = unlocker
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
|
||||
package secret
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||
|
||||
// Test GetType returns correct type
|
||||
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
|
||||
// Test GetDirectory returns the directory we passed in
|
||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// Verify the darwin implementation implements the Unlocker interface
|
||||
var _ Unlocker = unlocker
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
// The ID is the name of the unlocker's directory
|
||||
assert.Equal(t, "test", unlocker.GetID())
|
||||
}
|
||||
|
||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
label, err := generateSEKeyLabel("test-vault")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Label should contain the prefix and vault name
|
||||
assert.Contains(t, label, seKeyLabelPrefix)
|
||||
assert.Contains(t, label, "test-vault")
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker-missing"
|
||||
|
||||
// Create unlocker directory with metadata but no encrypted key file
|
||||
require.NoError(t, fs.MkdirAll(dir, DirPerms))
|
||||
|
||||
metadataJSON := `{
|
||||
"type": "secure-enclave",
|
||||
"createdAt": "2026-01-15T10:30:00Z",
|
||||
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||
"seKeyHash": "abc123"
|
||||
}`
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
|
||||
))
|
||||
|
||||
metadata := UnlockerMetadata{
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
|
||||
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
|
||||
}
|
||||
@@ -10,6 +10,6 @@ type Unlocker interface {
|
||||
GetType() string
|
||||
GetMetadata() UnlockerMetadata
|
||||
GetDirectory() string
|
||||
GetID() string // Generate ID based on unlocker type and data
|
||||
GetID() string // The name of the unlocker's directory, unique in its vault
|
||||
Remove() error // Remove the unlocker and any associated resources
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build darwin
|
||||
|
||||
//nolint:testpackage // white-box test of unexported validateKeychainItemName
|
||||
package secret
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
|
||||
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
|
||||
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
|
||||
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
|
||||
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
|
||||
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
|
||||
|
||||
// Invalid cases
|
||||
{name: "empty item name", itemName: "", wantErr: true},
|
||||
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
|
||||
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
|
||||
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
|
||||
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
|
||||
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
|
||||
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
|
||||
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
|
||||
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
|
||||
{name: "item name with newline", itemName: "key\nname", wantErr: true},
|
||||
{name: "item name with carriage return", itemName: "key\rname", wantErr: true},
|
||||
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
|
||||
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
|
||||
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
|
||||
{name: "item name with parentheses", itemName: "key(test)", wantErr: true},
|
||||
{name: "item name with brackets", itemName: "key[test]", wantErr: true},
|
||||
{name: "item name with asterisk", itemName: "key*", wantErr: true},
|
||||
{name: "item name with question mark", itemName: "key?", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -5,148 +6,60 @@ import (
|
||||
)
|
||||
|
||||
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},
|
||||
{
|
||||
name: "valid email address",
|
||||
keyID: "test@example.com",
|
||||
wantErr: false,
|
||||
"valid fingerprint (40 hex chars)",
|
||||
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
||||
},
|
||||
{
|
||||
name: "valid email with dots and hyphens",
|
||||
keyID: "test.user-name@example-domain.co.uk",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid email with plus",
|
||||
keyID: "test+tag@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid short key ID (8 hex chars)",
|
||||
keyID: "ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid long key ID (16 hex chars)",
|
||||
keyID: "ABCDEF1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid fingerprint (40 hex chars)",
|
||||
keyID: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase hex fingerprint",
|
||||
keyID: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case hex",
|
||||
keyID: "AbCdEf1234567890",
|
||||
wantErr: 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},
|
||||
{
|
||||
name: "empty key ID",
|
||||
keyID: "",
|
||||
wantErr: 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},
|
||||
{
|
||||
name: "key ID with spaces",
|
||||
keyID: "test user@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with semicolon (command injection)",
|
||||
keyID: "test@example.com; rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with pipe (command injection)",
|
||||
keyID: "test@example.com | cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backticks (command injection)",
|
||||
keyID: "test@example.com`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with dollar sign (command injection)",
|
||||
keyID: "test@example.com$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with quotes",
|
||||
keyID: "test\"@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with single quotes",
|
||||
keyID: "test'@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backslash",
|
||||
keyID: "test\\@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with newline",
|
||||
keyID: "test@example.com\nrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with carriage return",
|
||||
keyID: "test@example.com\rrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (7 chars)",
|
||||
keyID: "ABCDEF1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (9 chars)",
|
||||
keyID: "ABCDEF123",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with non-hex characters",
|
||||
keyID: "ABCDEFGH",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed format (email with hex)",
|
||||
keyID: "test@ABCDEF12",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with ampersand",
|
||||
keyID: "test@example.com & echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with redirect",
|
||||
keyID: "test@example.com > /tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with null byte",
|
||||
keyID: "test@example.com\x00",
|
||||
wantErr: 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)
|
||||
@@ -154,144 +67,3 @@ func TestValidateGPGKeyID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeychainItemName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemName string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "valid simple name",
|
||||
itemName: "my-secret-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with dots",
|
||||
itemName: "com.example.app.key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
itemName: "my_secret_key_123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid alphanumeric",
|
||||
itemName: "Secret123Key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with hyphen at start",
|
||||
itemName: "-my-key",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with dot at start",
|
||||
itemName: ".hidden-key",
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases
|
||||
{
|
||||
name: "empty item name",
|
||||
itemName: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with spaces",
|
||||
itemName: "my secret key",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with semicolon",
|
||||
itemName: "key;rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with pipe",
|
||||
itemName: "key|cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backticks",
|
||||
itemName: "key`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with dollar sign",
|
||||
itemName: "key$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with quotes",
|
||||
itemName: "key\"name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with single quotes",
|
||||
itemName: "key'name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with backslash",
|
||||
itemName: "key\\name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with newline",
|
||||
itemName: "key\nname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with carriage return",
|
||||
itemName: "key\rname",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with ampersand",
|
||||
itemName: "key&echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with redirect",
|
||||
itemName: "key>/tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with null byte",
|
||||
itemName: "key\x00name",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with parentheses",
|
||||
itemName: "key(test)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with brackets",
|
||||
itemName: "key[test]",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with asterisk",
|
||||
itemName: "key*",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "item name with question mark",
|
||||
itemName: "key?",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKeychainItemName(tt.itemName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+313
-159
@@ -2,29 +2,41 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"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"` // When this version expires (nil = current)
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
|
||||
}
|
||||
|
||||
// SecretVersion represents a version of a secret
|
||||
type SecretVersion struct {
|
||||
// Version represents a version of a secret
|
||||
type Version struct {
|
||||
SecretName string
|
||||
Version string
|
||||
Directory string
|
||||
@@ -32,8 +44,8 @@ type SecretVersion struct {
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
// NewSecretVersion creates a new SecretVersion instance
|
||||
func NewSecretVersion(vault VaultInterface, secretName string, version string) *SecretVersion {
|
||||
// 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),
|
||||
@@ -51,7 +63,8 @@ func NewSecretVersion(vault VaultInterface, secretName string, version string) *
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
return &SecretVersion{
|
||||
|
||||
return &Version{
|
||||
SecretName: secretName,
|
||||
Version: version,
|
||||
Directory: versionDir,
|
||||
@@ -69,7 +82,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Ensure versions directory exists
|
||||
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
|
||||
err := fs.MkdirAll(versionsDir, DirPerms)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -83,147 +97,122 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
prefix := today + "."
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
||||
// Extract serial number
|
||||
parts := strings.Split(entry.Name(), ".")
|
||||
if len(parts) == 2 {
|
||||
var serial int
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
|
||||
if serial > maxSerial {
|
||||
maxSerial = serial
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 > 999 {
|
||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||
if newSerial > maxVersionsPerDay {
|
||||
return "", errMaxVersionsPerDay
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
}
|
||||
|
||||
// Save saves the version metadata and value
|
||||
func (sv *SecretVersion) Save(value []byte) error {
|
||||
// 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", len(value)),
|
||||
slog.Int("value_length", value.Size()),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create version directory
|
||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Step 1: Generate a new keypair for this version
|
||||
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)
|
||||
}
|
||||
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
versionPrivateKey := versionIdentity.String()
|
||||
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
||||
defer versionPrivateKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
slog.String("public_key", versionPublicKey),
|
||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 2: Store the version's public key
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: 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())
|
||||
err = sv.writePublicKeyAndValue(fs, tmpDir, versionIdentity, value)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Store the encrypted value
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Get vault's long-term public key for encrypting the version's private key
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 6: Encrypt the version's private key to the long-term public key
|
||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionPrivateKey), ltRecipient)
|
||||
err = fs.Rename(tmpDir, sv.Directory)
|
||||
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)
|
||||
Debug("Failed to move version into place", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to move version into place: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Store the encrypted private key
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); 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)
|
||||
}
|
||||
Debug("Successfully saved secret version",
|
||||
"version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
// Step 8: Encrypt and store metadata
|
||||
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
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBytes, 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(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %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 *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
DebugWith("Loading version metadata",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
@@ -233,55 +222,73 @@ func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// 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)
|
||||
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
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
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(string(versionPrivKeyData))
|
||||
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)
|
||||
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
|
||||
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||
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
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
|
||||
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 *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
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),
|
||||
@@ -299,52 +306,208 @@ func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error
|
||||
// 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("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))
|
||||
|
||||
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)
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
|
||||
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)
|
||||
}
|
||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
|
||||
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(string(versionPrivKeyData))
|
||||
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)
|
||||
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))
|
||||
|
||||
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)
|
||||
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
|
||||
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", len(value),
|
||||
"is_empty", len(value) == 0)
|
||||
return value, nil
|
||||
"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
|
||||
@@ -356,6 +519,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -367,6 +531,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
}
|
||||
|
||||
var versions []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
versions = append(versions, entry.Name())
|
||||
@@ -379,58 +544,47 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// GetCurrentVersion returns the version that the "current" symlink points to
|
||||
// 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")
|
||||
|
||||
// Try to read as a real symlink first
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
target, err := os.Readlink(currentPath)
|
||||
if err == nil {
|
||||
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) >= 2 && parts[0] == "versions" {
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to reading as a file (for MemMapFs testing)
|
||||
fileData, err := afero.ReadFile(fs, currentPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read current version symlink: %w", err)
|
||||
return "", fmt.Errorf("failed to read current version file: %w; "+
|
||||
"this file only names the current version: 'secret version list' "+
|
||||
"lists the secret's versions, and 'secret version promote' makes "+
|
||||
"one of them current", err)
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(string(fileData))
|
||||
version := strings.TrimSpace(string(fileData))
|
||||
|
||||
// Extract version from path
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) >= 2 && parts[0] == "versions" {
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// SetCurrentVersion updates the "current" symlink to point to a specific version
|
||||
// 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")
|
||||
targetPath := filepath.Join("versions", version)
|
||||
|
||||
// Remove existing symlink if it exists
|
||||
_ = fs.Remove(currentPath)
|
||||
|
||||
// Try to create a real symlink first (works on Unix systems)
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
if err := os.Symlink(targetPath, currentPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to creating a file with the target path (for MemMapFs testing)
|
||||
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to create current version symlink: %w", err)
|
||||
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)
|
||||
}
|
||||
+123
-77
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// - TestGenerateVersionName: Tests version name generation with date and serial format
|
||||
// - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit
|
||||
// - TestNewSecretVersion: Tests secret version object creation
|
||||
// - 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
|
||||
@@ -32,21 +32,31 @@
|
||||
// - Long-term key required for all operations
|
||||
// - Concurrent reads handled safely
|
||||
|
||||
package secret
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// MockVault implements VaultInterface for testing
|
||||
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
|
||||
@@ -58,42 +68,56 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
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 := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
|
||||
// Test first version generation
|
||||
version1, err := GenerateVersionName(fs, secretDir)
|
||||
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, 0755)
|
||||
err = fs.MkdirAll(versionDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := GenerateVersionName(fs, secretDir)
|
||||
version2, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
@@ -102,34 +126,17 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
assert.NotEqual(t, version1, version2)
|
||||
}
|
||||
|
||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
func TestNewVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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), 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Try to create one more - should fail
|
||||
_, err := GenerateVersionName(fs, secretDir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewSecretVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
@@ -139,32 +146,39 @@ func TestNewSecretVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
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()), 0600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
err = sv.Save(testValue)
|
||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify files were created
|
||||
@@ -175,38 +189,45 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
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()), 0600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
sv.Metadata.NotAfter = &now
|
||||
|
||||
err = sv.Save([]byte("test-value"))
|
||||
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 := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -218,115 +239,139 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
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()), 0600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
expectedValue := make([]byte, len(originalValue))
|
||||
copy(expectedValue, originalValue)
|
||||
|
||||
err = sv.Save(originalValue)
|
||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||
defer originalBuffer.Destroy()
|
||||
|
||||
err = sv.Save(originalBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalValue, retrievedValue)
|
||||
defer retrievedBuffer.Destroy()
|
||||
|
||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
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"}
|
||||
testVersions := []string{
|
||||
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
|
||||
}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0755)
|
||||
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"), 0600)
|
||||
err = afero.WriteFile(
|
||||
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = ListVersions(fs, secretDir)
|
||||
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"}
|
||||
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 := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
|
||||
// Simulate symlink with file content (works for both OsFs and MemMapFs)
|
||||
// The current file contains just the version name
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0600)
|
||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
t.Parallel()
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := testSecretDir
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = GetCurrentVersion(fs, secretDir)
|
||||
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 := VersionMetadata{
|
||||
vm := secret.VersionMetadata{
|
||||
ID: "test-id",
|
||||
}
|
||||
|
||||
@@ -358,5 +403,6 @@ func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user