Compare commits
1
Commits
next
..
ec202eb07b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec202eb07b |
+19
-68
@@ -1,78 +1,29 @@
|
|||||||
# .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
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
# 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
|
# 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.
|
# 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
|
.git/config
|
||||||
# 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.
|
# Build artifacts
|
||||||
# Anchored because it occurs once where agents run at the repo root.
|
secret
|
||||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
coverage.out
|
||||||
# `services/api/.claude/` and must add its own anchored entry.
|
*.test
|
||||||
.claude
|
|
||||||
|
|
||||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
# IDE and editor files
|
||||||
# convention. Re-include a committed template with a negation if the
|
.vscode
|
||||||
# build needs one: `!docs/example.env`.
|
.idea
|
||||||
**/*.[eE][nN][vV]
|
*.swp
|
||||||
**/.[eE][nN][vV].*
|
*.swo
|
||||||
**/.[eE][nN][vV][rR][cC]
|
*~
|
||||||
|
|
||||||
# Private keys and the bundles carrying them. Public certificates
|
# Dependencies
|
||||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
node_modules
|
||||||
**/*.[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.
|
# macOS
|
||||||
**/node_modules
|
.DS_Store
|
||||||
|
|
||||||
# OS metadata.
|
# Claude files
|
||||||
**/.DS_Store
|
.claude/
|
||||||
**/Thumbs.db
|
|
||||||
|
|
||||||
# Editor state: never a build input, and it churns COPY.
|
# Local settings
|
||||||
**/*.swp
|
.claude/settings.local.json
|
||||||
**/*.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
|
|
||||||
|
|||||||
@@ -10,6 +10,3 @@ insert_final_newline = true
|
|||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
[*.go]
|
|
||||||
indent_style = tab
|
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-28
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- run: script/cibuild
|
- run: script/cibuild
|
||||||
|
|||||||
+10
-30
@@ -20,35 +20,15 @@ Thumbs.db
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Secrets. Unanchored like every entry above, so each matches at every
|
# Environment / secrets
|
||||||
# depth. Matching is case-sensitive on Linux, so names use character
|
.env
|
||||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
# This repo. /secret is the built binary, anchored so that it does not
|
||||||
# convention. Only the templates `example.env` and `sample.env` are
|
# also match the internal/secret/ package directory.
|
||||||
# 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
|
/secret
|
||||||
|
*.log
|
||||||
|
*.test
|
||||||
|
settings.local.json
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ linters:
|
|||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
|||||||
@@ -4,32 +4,33 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
# Instructions and Contextual Information
|
# Instructions and Contextual Information
|
||||||
|
|
||||||
- Be direct, robotic, expert, accurate, and professional.
|
* Be direct, robotic, expert, accurate, and professional.
|
||||||
|
|
||||||
- Do not butter me up or kiss my ass.
|
* Do not butter me up or kiss my ass.
|
||||||
|
|
||||||
- Come in hot with strong opinions, even if they are contrary to the direction I
|
* Come in hot with strong opinions, even if they are contrary to the
|
||||||
am headed.
|
direction I am headed.
|
||||||
|
|
||||||
- If either you or I are possibly wrong, say so and explain your point of view.
|
* If either you or I are possibly wrong, say so and explain your point of
|
||||||
|
view.
|
||||||
|
|
||||||
- Point out great alternatives I haven't thought of, even when I'm not asking
|
* Point out great alternatives I haven't thought of, even when I'm not
|
||||||
for them.
|
asking for them.
|
||||||
|
|
||||||
- Treat me like the world's leading expert in every situation and every
|
* Treat me like the world's leading expert in every situation and every
|
||||||
conversation, and deliver the absolute best recommendations.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
- I want excellence, so always be on the lookout for divergences from good data
|
* I want excellence, so always be on the lookout for divergences from good
|
||||||
model design or best practices for object oriented development.
|
data model design or best practices for object oriented development.
|
||||||
|
|
||||||
- IMPORTANT: This is production code, not a research or teaching exercise.
|
* IMPORTANT: This is production code, not a research or teaching exercise.
|
||||||
Deliver professional-level results, not prototypes.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
- Please read and understand the `README.md` file in the root of the repo for
|
* Please read and understand the `README.md` file in the root of the repo
|
||||||
project-specific contextual information, including development policies,
|
for project-specific contextual information, including development
|
||||||
practices, and current implementation status.
|
policies, practices, and current implementation status.
|
||||||
|
|
||||||
- Be proactive in suggesting improvements or refactorings in places where we
|
* Be proactive in suggesting improvements or refactorings in places where we
|
||||||
diverge from best practices for clean, modular, maintainable code.
|
diverge from best practices for clean, modular, maintainable code.
|
||||||
|
|
||||||
# Policies
|
# Policies
|
||||||
@@ -37,19 +38,20 @@ Version: 2025-06-08
|
|||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
1. Before committing, tests must pass (`make test`), linting must pass
|
||||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. When you think your changes are complete, rather than
|
`golangci-lint run`. When you think your changes are complete, rather
|
||||||
making three different tool calls to check, you can just run
|
than making three different tool calls to check, you can just run `make
|
||||||
`make test && make fmt && make lint` as a single tool call which will save
|
test && make fmt && make lint` as a single tool call which will save
|
||||||
time.
|
time.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with a
|
2. Always write a `Makefile` with the default target being `test`, and with
|
||||||
`fmt` target that formats the code. The `test` target should run all tests in
|
a `fmt` target that formats the code. The `test` target should run all
|
||||||
the project, and the `fmt` target should format the code. `test` should also
|
tests in the project, and the `fmt` target should format the code.
|
||||||
have a prerequisite target `lint` that should run any linters that are
|
`test` should also have a prerequisite target `lint` that should run any
|
||||||
configured for the project.
|
linters that are configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do all of
|
3. After each completed bugfix or feature, the code must be committed. Do
|
||||||
the pre-commit checks (test, lint, fmt) before committing, of course.
|
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||||
|
course.
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
4. When creating a very simple test script for testing out a new feature,
|
||||||
instead of making a throwaway to be deleted after verification, write an
|
instead of making a throwaway to be deleted after verification, write an
|
||||||
@@ -57,69 +59,55 @@ Version: 2025-06-08
|
|||||||
complex, but it should be a real test that can be run.
|
complex, but it should be a real test that can be run.
|
||||||
|
|
||||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
tests, or change the tests specifically to make them pass (unless there is a
|
tests, or change the tests specifically to make them pass (unless there
|
||||||
bug in the test). This is cheating, and it is bad. You should only be
|
is a bug in the test). This is cheating, and it is bad. You should only
|
||||||
modifying the test if it is incorrect or if the test is no longer relevant.
|
be modifying the test if it is incorrect or if the test is no longer
|
||||||
In almost all cases, you should be fixing the code that is being tested, or
|
relevant. In almost all cases, you should be fixing the code that is
|
||||||
updating the tests to match a refactored implementation.
|
being tested, or updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
6. When dealing with dates and times or timestamps, always use, display, and
|
6. When dealing with dates and times or timestamps, always use, display, and
|
||||||
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||||
the time in a different timezone, store the user's timezone in a separate
|
to see the time in a different timezone, store the user's timezone in a
|
||||||
field and convert the UTC time to the user's timezone when displaying it. For
|
separate field and convert the UTC time to the user's timezone when
|
||||||
internal use and internal applications and administrative purposes, always
|
displaying it. For internal use and internal applications and
|
||||||
display UTC.
|
administrative purposes, always display UTC.
|
||||||
|
|
||||||
7. Always write tests, even if they are extremely simple and just check for
|
7. Always write tests, even if they are extremely simple and just check for
|
||||||
correct syntax (ability to compile/import). If you are writing a new feature,
|
correct syntax (ability to compile/import). If you are writing a new
|
||||||
write a test for it. You don't need to target complete coverage, but you
|
feature, write a test for it. You don't need to target complete
|
||||||
should at least test any new functionality you add. If you are fixing a bug,
|
coverage, but you should at least test any new functionality you add. If
|
||||||
write a test first that reproduces the bug, and then fix the bug in the code.
|
you are fixing a bug, write a test first that reproduces the bug, and
|
||||||
|
then fix the bug in the code.
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such as
|
8. When implementing new features, be aware of potential side-effects (such
|
||||||
state files on disk, data in the database, etc.) and ensure that it is
|
as state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If stdout is not a terminal, output the
|
messages (but do not log secrets). If stdout is not a terminal, output
|
||||||
structured logs in jsonl format.
|
the structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top of the
|
anywhere more than once. Always define a constant (usually at the top
|
||||||
file) and give it a descriptive name, then use that constant in the code
|
of the file) and give it a descriptive name, then use that constant in
|
||||||
instead of the bare string or number.
|
the code instead of the bare string or number.
|
||||||
|
|
||||||
11. You do not need to summarize your changes in the chat after making them.
|
11. You do not need to summarize your changes in the chat after making them.
|
||||||
Making the changes and committing them is sufficient. If anything out of the
|
Making the changes and committing them is sufficient. If anything out
|
||||||
ordinary happened, please explain it, but in the normal case where you found
|
of the ordinary happened, please explain it, but in the normal case
|
||||||
and fixed the bug, or implemented the feature, there is no need for the
|
where you found and fixed the bug, or implemented the feature, there is
|
||||||
end-of-change summary.
|
no need for the end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project without
|
12. Do not create additional files in the root directory of the project
|
||||||
asking permission first. Configuration files, documentation, and build files
|
without asking permission first. Configuration files, documentation, and
|
||||||
are acceptable in the root, but source code and other files should be
|
build files are acceptable in the root, but source code and other files
|
||||||
organized in appropriate subdirectories.
|
should be organized in appropriate subdirectories.
|
||||||
|
|
||||||
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
|
## Python-Specific Guidelines
|
||||||
|
|
||||||
1. **Type Annotations (UP006)**: Use built-in collection types directly for type
|
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error.
|
||||||
annotations instead of importing from `typing`. This avoids the UP006 linter
|
|
||||||
error.
|
|
||||||
|
|
||||||
**Good (modern Python 3.9+):**
|
**Good (modern Python 3.9+):**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def process_items(items: list[str]) -> dict[str, int]:
|
def process_items(items: list[str]) -> dict[str, int]:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
@@ -127,7 +115,6 @@ Version: 2025-06-08
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Avoid (triggers UP006):**
|
**Avoid (triggers UP006):**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
@@ -137,7 +124,6 @@ Version: 2025-06-08
|
|||||||
```
|
```
|
||||||
|
|
||||||
For optional types, use the `|` operator instead of `Union`:
|
For optional types, use the `|` operator instead of `Union`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Good
|
# Good
|
||||||
def get_value(key: str) -> str | None:
|
def get_value(key: str) -> str | None:
|
||||||
@@ -158,25 +144,14 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
## Go-Specific Guidelines
|
## Go-Specific Guidelines
|
||||||
|
|
||||||
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
|
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
|
||||||
errors via return values.
|
|
||||||
|
|
||||||
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
|
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
|
||||||
errors, not crash.
|
|
||||||
|
|
||||||
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
||||||
|
|
||||||
4. **Never modify linter config** (`.golangci.yml`) to suppress findings, and do
|
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
|
||||||
not modify it at all unless specifically instructed. Fix the code.
|
|
||||||
|
|
||||||
5. **All PRs must pass `make check` with zero failures.** No exceptions, no
|
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
|
||||||
"pre-existing issue" excuses.
|
|
||||||
|
|
||||||
6. **Pin external dependencies by commit hash**, not mutable tags.
|
6. **Pin external dependencies by commit hash**, not mutable tags.
|
||||||
|
|
||||||
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`.**
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# IMPORTANT RULES
|
||||||
|
|
||||||
|
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||||
|
commit messages (which it erroneously refers to as "attribution") is not
|
||||||
|
attribution, as I am the sole author of code created using Claude. It is
|
||||||
|
corporate advertising for Anthropic and is therefore completely
|
||||||
|
unacceptable in commit messages.
|
||||||
|
|
||||||
|
* Tests should always be run before committing code. No commits should be
|
||||||
|
made that do not pass tests.
|
||||||
|
|
||||||
|
* Code should always be formatted before committing. Do not commit
|
||||||
|
unformatted code.
|
||||||
|
|
||||||
|
* Code should always be linted and linter errors fixed before committing.
|
||||||
|
NEVER commit code that does not pass the linter. DO NOT modify the linter
|
||||||
|
config unless specifically instructed.
|
||||||
|
|
||||||
|
* The test suite is fast and local. When running tests, NEVER run
|
||||||
|
individual parts of the test suite, always run the whole thing by running
|
||||||
|
"make test".
|
||||||
|
|
||||||
|
* Do not stop working on a task until you have reached the definition of
|
||||||
|
done provided to you in the initial instruction. Don't do part or most of
|
||||||
|
the work, do all of the work until the criteria for done are met.
|
||||||
|
|
||||||
|
* When you complete each task, if the tests are passing and the code is
|
||||||
|
formatted and there are no linter errors, always commit and push your
|
||||||
|
work. Use a good commit message and don't mention any author or co-author
|
||||||
|
attribution.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* Do not use 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.
|
||||||
|
|
||||||
|
* If you are fixing a bug, write a test first that reproduces the bug and
|
||||||
|
fails, and then fix the bug in the code, using the test to verify that the
|
||||||
|
fix worked.
|
||||||
|
|
||||||
|
* 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 when designing an
|
||||||
|
API.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* When implementing programs, put the main.go in
|
||||||
|
./cmd/<program_name>/main.go and put the program's code in
|
||||||
|
./internal/<program_name>/. This allows for multiple programs to be
|
||||||
|
implemented in the same repository without cluttering the root directory.
|
||||||
|
main.go should simply import and call <program_name>.CLIEntry(). The
|
||||||
|
full implementation should be in ./internal/<program_name>/.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* 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. After each commit, push to the remote.
|
||||||
|
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
* 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. Use go's log/slog.
|
||||||
|
|
||||||
|
* 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.
|
||||||
+43
-57
@@ -1,79 +1,65 @@
|
|||||||
# Lint phase. The linter is invoked directly rather than through `make
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# lint` or `script/lint`, which are themselves a docker build.
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
|
||||||
|
# below run again on each build, an unchanged tree included, while the
|
||||||
|
# steps above stay cached. ARG is per stage: the build stage declares it too.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go vet ./...
|
|
||||||
|
RUN make fmt-check
|
||||||
|
# Not make lint: script/lint is a docker build, which cannot run in here.
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
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
|
# Build stage — tests and compilation
|
||||||
# image ships and the alpine one does not.
|
# golang 1.24.13-alpine (2026-03-10)
|
||||||
# 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
|
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
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 gnupg
|
||||||
RUN apk add --no-cache gcc musl-dev make git
|
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
WORKDIR /build
|
||||||
RUN git config --system --add safe.directory /src
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# As in the lint stage: the RUN steps below run again on each script/cibuild.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
RUN make test
|
||||||
# `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
|
# The version stamped into the binary: the VERSION build argument when one
|
||||||
# alpine 3.23, 2026-03-10
|
# is given, otherwise `git describe --tags --always` of the .git the build
|
||||||
|
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
|
||||||
|
# one, the short commit when no tag is reachable. A context that carries .git
|
||||||
|
# and still yields no version fails the build.
|
||||||
|
ARG VERSION
|
||||||
|
RUN version="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
|
||||||
|
[ "$version" = unknown ]; }; then \
|
||||||
|
echo "no version could be derived although the build context carries .git" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
make build VERSION="${version:-dev}"
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
# alpine 3.23 (2026-03-10)
|
||||||
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates gnupg
|
RUN apk add --no-cache ca-certificates gnupg
|
||||||
|
|
||||||
RUN adduser -D -s /bin/sh secret
|
RUN adduser -D -s /bin/sh secret
|
||||||
|
|
||||||
COPY --from=builder /src/secret /usr/local/bin/secret
|
COPY --from=builder /build/secret /usr/local/bin/secret
|
||||||
RUN chmod +x /usr/local/bin/secret
|
RUN chmod +x /usr/local/bin/secret
|
||||||
|
|
||||||
USER secret
|
USER secret
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Lint image, built by script/lint: golangci-lint runs as a build step, so a
|
||||||
|
# successful build is a clean lint. Works where the docker daemon is remote
|
||||||
|
# and bind mounts are impossible.
|
||||||
|
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# script/lint rebuilds this stage on every run, by this name; the module
|
||||||
|
# download above stays cached.
|
||||||
|
FROM deps AS lint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -21,7 +21,6 @@ test:
|
|||||||
fmt:
|
fmt:
|
||||||
@script/fmt
|
@script/fmt
|
||||||
|
|
||||||
# Vets and lints the Linux build, then the macOS build
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -180,8 +180,8 @@ period.
|
|||||||
|
|
||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
|
|
||||||
Promotes a specific version to current by rewriting the secret's `current` file
|
Promotes a specific version to current by updating the symlink. Does not modify
|
||||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||||
|
|
||||||
@@ -211,9 +211,7 @@ Generates and stores a random secret.
|
|||||||
|
|
||||||
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
||||||
|
|
||||||
Lists all unlockers in the current vault with their metadata. An unlocker's ID,
|
Lists all unlockers in the current vault with their metadata.
|
||||||
which `secret unlocker select` and `secret unlocker remove` take, is the name of
|
|
||||||
its directory in `unlockers.d`.
|
|
||||||
|
|
||||||
#### `secret unlocker add <type> [options]`
|
#### `secret unlocker add <type> [options]`
|
||||||
|
|
||||||
@@ -280,13 +278,8 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
|
|
||||||
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
|
|
||||||
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
|
|
||||||
the state directory instead. On Linux:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.config/berlin.sneak.pkg.secret/
|
~/.local/share/secret/
|
||||||
├── vaults.d/
|
├── vaults.d/
|
||||||
│ ├── default/
|
│ ├── default/
|
||||||
│ │ ├── unlockers.d/
|
│ │ ├── unlockers.d/
|
||||||
@@ -299,12 +292,12 @@ the state directory instead. On Linux:
|
|||||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
||||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||||
│ │ │ │ └── current # Current version's name: 20231216.001
|
│ │ │ │ └── current -> versions/20231216.001
|
||||||
│ │ │ └── database%password/ # Secret: database/password
|
│ │ │ └── database%password/ # Secret: database/password
|
||||||
│ │ │ ├── versions/
|
│ │ │ ├── versions/
|
||||||
│ │ │ └── current # Current version's name: 20231215.001
|
│ │ │ └── current -> versions/20231215.001
|
||||||
│ │ ├── vault-metadata.json # Vault metadata
|
│ │ ├── vault-metadata.json # Vault metadata
|
||||||
│ │ ├── pub.age # Long-term public key
|
│ │ ├── pub.age # Long-term public key
|
||||||
│ │ └── current-unlocker # Current unlocker's directory name
|
│ │ └── current-unlocker # Current unlocker's directory name
|
||||||
@@ -314,13 +307,9 @@ the state directory instead. On Linux:
|
|||||||
│ ├── vault-metadata.json
|
│ ├── vault-metadata.json
|
||||||
│ ├── pub.age
|
│ ├── pub.age
|
||||||
│ └── current-unlocker
|
│ └── current-unlocker
|
||||||
├── currentvault # Current vault's name: default
|
└── currentvault -> vaults.d/default
|
||||||
└── lock # Locked by each command that changes anything
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`current`, `currentvault` and `current-unlocker` are plain files that each hold
|
|
||||||
one name. Changing one replaces it in one rename, so it is never half-written.
|
|
||||||
|
|
||||||
### Key Management and Encryption Flow
|
### Key Management and Encryption Flow
|
||||||
|
|
||||||
#### 1: Long-term Keys
|
#### 1: Long-term Keys
|
||||||
@@ -347,7 +336,7 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
|
|
||||||
3. **Keychain Unlockers** (macOS only):
|
3. **Keychain Unlockers** (macOS only):
|
||||||
- Stores unlock keys in macOS Keychain
|
- Stores unlock keys in macOS Keychain
|
||||||
- Kept on this Mac only: the keychain item is never synced to other devices
|
- Protected by system authentication (Touch ID, password)
|
||||||
- Automatic unlocking when Keychain is unlocked
|
- Automatic unlocking when Keychain is unlocked
|
||||||
- Cross-application integration
|
- Cross-application integration
|
||||||
|
|
||||||
@@ -355,10 +344,8 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
- Hardware-backed key storage using Apple Secure Enclave
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||||
Program required)
|
Program required)
|
||||||
- ECIES encryption: the vault long-term key is encrypted directly to the SE
|
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
||||||
key, and only the SE can decrypt it
|
- Protected by biometric authentication (Touch ID) or system password
|
||||||
- The SE key cannot leave this Mac; using it asks for no Touch ID or
|
|
||||||
password
|
|
||||||
|
|
||||||
Each vault maintains its own set of unlockers and one long-term key. The
|
Each vault maintains its own set of unlockers and one long-term key. The
|
||||||
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
||||||
@@ -368,7 +355,7 @@ access vault secrets.
|
|||||||
|
|
||||||
- Each secret version has its own encryption key pair
|
- Each secret version has its own encryption key pair
|
||||||
- Private key encrypted to the vault's long-term key
|
- Private key encrypted to the vault's long-term key
|
||||||
- A version's private key decrypts only that version's value and metadata
|
- Provides forward secrecy and granular access control
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -518,21 +505,17 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
|
|
||||||
- **age Files**: Standard age encryption format (.age extension), except
|
- **age Files**: Standard age encryption format (.age extension)
|
||||||
`pub.age`, which holds an age public key as text
|
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
||||||
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
|
- **Vault Metadata**: JSON containing vault name, creation time, derivation
|
||||||
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
|
index, and public key hash
|
||||||
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
|
|
||||||
the version's public key
|
|
||||||
- **Vault Metadata**: JSON containing creation time, derivation index, and the
|
|
||||||
public key hashes described below
|
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
- **Derivation Index**: Each vault uses a unique derivation index from the
|
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||||
mnemonic, and thus a unique key pair
|
mnemonic, and thus a unique key pair
|
||||||
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
|
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies
|
||||||
hash of the index-0 public key identifies vaults from the same mnemonic
|
vaults from the same mnemonic
|
||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
||||||
automatically derived
|
automatically derived
|
||||||
|
|
||||||
@@ -541,10 +524,6 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
- **macOS**: Full support including Keychain and Secure Enclave integration
|
- **macOS**: Full support including Keychain and Secure Enclave integration
|
||||||
- **Linux**: Full support (excluding macOS-specific features)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
|
|
||||||
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
|
|
||||||
build without cgo, such as one cross-compiled from Linux, offers them but fails
|
|
||||||
to add or use them.
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Threat Model
|
### Threat Model
|
||||||
@@ -583,6 +562,8 @@ The project includes comprehensive tests:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests
|
||||||
|
go test ./... # Unit tests
|
||||||
|
go test -tags=integration -v ./internal/cli # Integration tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -593,10 +574,8 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call them. We
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (Go, Go module download, and
|
- `script/bootstrap` — install all dependencies (Go, Go module download),
|
||||||
node, yarn and prettier for formatting markdown), idempotently; prettier is
|
idempotently; golangci-lint is not installed, it runs in docker
|
||||||
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/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
- `script/projectname` — output the project name (`secret`); used by other
|
- `script/projectname` — output the project name (`secret`); used by other
|
||||||
@@ -604,24 +583,18 @@ provide:
|
|||||||
- `script/build` — build the `secret` binary into the repo root, stamping the
|
- `script/build` — build the `secret` binary into the repo root, stamping the
|
||||||
version (`VERSION` from the environment, else `git describe`) and the git
|
version (`VERSION` from the environment, else `git describe`) and the git
|
||||||
commit
|
commit
|
||||||
- `script/test` — build the `test` phase of the `Dockerfile`, which runs the
|
- `script/test` — run `go vet` and the test suite (verbose rerun on failure)
|
||||||
test suite with the race detector, a 90-second timeout and coverage; on
|
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
|
||||||
failure it reruns the tests verbosely for the details and fails even when the
|
where the linter is a build step that runs on every call, also on an unchanged
|
||||||
rerun passes
|
tree
|
||||||
- `script/lint` — build the `lint` phase of the `Dockerfile`, which runs
|
- `script/fmt` — format all Go code (writes)
|
||||||
`go vet` and `golangci-lint`, then both again on the code as a macOS build
|
- `script/fmt-check` — check formatting without writing
|
||||||
compiles it (`GOOS=darwin`), which a Linux build never compiles; cgo is off
|
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`
|
||||||
there, so the keychain unlocker's calls into the keychain
|
- `script/docker` — build the Docker image tagged with the project name
|
||||||
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
|
- `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
|
||||||
and the Secure Enclave bindings (`internal/macse`) are not checked
|
(memguard needs mlock; the Dockerfile runs the checks), with a new
|
||||||
- `script/fmt` — format all Go code with `go fmt` and every markdown file with
|
`CHECK_EPOCH` build argument on every run so the checks run again on an
|
||||||
prettier (4-space tabs, `proseWrap: always`) (writes)
|
unchanged tree
|
||||||
- `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/precommit` — pre-commit checks: `go mod tidy` verification, then
|
||||||
`script/check`
|
`script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
|
|||||||
+82
-353
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-04
|
last_modified: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,28 +60,17 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
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
|
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
scripts are our own extensions to the standard: `script/check` runs
|
||||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
what the git pre-commit hook runs, and it calls `script/check`;
|
||||||
pristine checkout with nothing installed the run dies there, after the
|
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
target shims to it); and `script/projectname` (literally that filename) simply
|
||||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
outputs the project's name. Scripts that need the name call
|
||||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
source nvm for the pinned node version before invoking it, exactly as
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
`script/precommit`, not in the hook itself. Model scripts are at
|
||||||
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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -100,198 +89,87 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
image cannot be built unless they pass. For non-server repos the final stage
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
brings up a development environment; for server repos it is the runtime image.
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
The gate phases and the build stage start from their pinned base images and
|
stage before the final image is assembled. Dockerfiles install development
|
||||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||||
repo's own `Dockerfile` does for its yarn packages. The development
|
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||||
environment stage installs development prerequisites by running
|
layer stays cached until dependencies change.
|
||||||
`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
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
and nothing else:
|
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||||
|
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||||
|
then declares an explicit dependency on the lint stage via
|
||||||
|
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||||
|
linting before proceeding to compilation and tests. This ensures lint failures
|
||||||
|
surface in seconds rather than minutes, without blocking on dependency
|
||||||
|
download or compilation in the build stage.
|
||||||
|
|
||||||
```sh
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
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
|
```dockerfile
|
||||||
# Lint phase
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
# Build stage
|
||||||
# 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
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
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
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
ARG VERSION=dev
|
||||||
# `git describe --tags --always` on the .git in the build context. With
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
# .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}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /app ./cmd/app/
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
# Runtime stage, and the last one
|
# Runtime stage
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||||
both Go and the linter), so nothing needs installing.
|
includes both Go and the linter), so there is no need to install the
|
||||||
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
linter separately.
|
||||||
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||||
and a stage nothing depends on is not built at all, so without these two
|
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||||
lines a red gate would not fail the build.
|
this line, the build stage would not wait for lint to finish and a lint
|
||||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
failure might not fail the overall build.
|
||||||
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
|
- 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
|
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`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
|
The lint stage should not depend on the actual build output — it exists to
|
||||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
fail fast.
|
||||||
has no `apk`, so install with `apt-get` under the Debian package name
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
lists in the same `RUN`, so the layer does not keep them:
|
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||||
|
build stage, not the lint stage, because they may require compiled
|
||||||
```dockerfile
|
artifacts or heavier dependencies.
|
||||||
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
|
- 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.
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
successful run means every check passed; a bare `docker build .` does not
|
pass.
|
||||||
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
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -311,21 +189,14 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
Makefile.
|
||||||
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
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
show full output. This keeps CI logs and `docker build` output clean on
|
||||||
on success (just package/suite summaries) while providing full diagnostic
|
success (just package/suite summaries) while providing full diagnostic detail
|
||||||
detail on failure (every test case, every assertion). The command lives in the
|
on failure (every test case, every assertion). The general shell pattern:
|
||||||
`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
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -338,26 +209,11 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -count=1 -timeout 90s -race -cover ./... || \
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 30s -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:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -383,84 +239,10 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
Fetch the standard `.gitignore` from
|
||||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
a new repo.
|
||||||
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
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -476,56 +258,9 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
_NEVER_ be modified by an agent: fetch it from
|
manually by the user. Fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
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
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -639,14 +374,12 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
and language-specific config). Everything else goes in a subdirectory.
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
Canonical subdirectory names:
|
subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
- `cmd/` — Go command entrypoints
|
||||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
|
||||||
`cmd/`
|
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -673,7 +406,3 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- 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,10 +1,12 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
- branch from `next`
|
* branch (from `main`)
|
||||||
- do the Next Step: the next open issue in the `1.0.0` milestone
|
* do the work in Next Step
|
||||||
- log it at the top of Completed Steps
|
* move Next Step to the top of Completed Steps
|
||||||
- commit (`TODO.md` changes in the same commit as the work)
|
* move the top item of Future Steps into Next Step
|
||||||
- push, and open a PR against `next`
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
* push
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
@@ -18,530 +20,303 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
|
||||||
`secret unlocker remove` ask `[y/N]` before removing anything
|
`secret unlocker remove` ask `[y/N]` before removing anything
|
||||||
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
|
(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
|
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
|
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
|
last, and for the last the vault's secret count and that the vault then
|
||||||
only with its mnemonic. Only `y` or `yes` goes ahead. Without `--force`, a
|
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
|
||||||
command whose stdin is not a terminal fails at once. `--force` (now also on
|
`--force`, a command whose stdin is not a terminal fails at once. `--force`
|
||||||
`rm` and `version rm`) removes without asking; it replaces the old refusals to
|
(now also on `rm` and `version rm`) removes without asking; it replaces the
|
||||||
remove a vault with secrets or the last unlocker of one without `--force`,
|
old refusals to remove a vault with secrets or the last unlocker of one
|
||||||
which the question now covers. The checks run, and the question is asked,
|
without `--force`, which the question now covers. The checks run, and the
|
||||||
before the state directory lock is taken; under the lock the checks run again,
|
question is asked, before the state directory lock is taken; under the
|
||||||
and if they would ask a different question, nothing is removed. `secret rm`
|
lock the checks run again, and if they would ask a different question,
|
||||||
fails when it cannot count the versions.
|
nothing is removed. `secret rm` fails when it cannot count the versions.
|
||||||
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
||||||
current unlocker that cannot open the vault
|
current unlocker that cannot open the vault
|
||||||
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
||||||
directory of its own, named with the time to the nanosecond:
|
directory of its own, named with the time to the nanosecond:
|
||||||
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave
|
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
|
||||||
unlocker the keychain item or Secure Enclave key, which names the directory,
|
Enclave unlocker the keychain item or Secure Enclave key, which names the
|
||||||
carries the time instead of the day. `secret.WriteDir` fails on a directory
|
directory, carries the time instead of the day. `secret.WriteDir` fails on a
|
||||||
that exists instead of writing into it. `unlocker add passphrase` writes the
|
directory that exists instead of writing into it. `unlocker add passphrase`
|
||||||
new unlocker, makes it current, and only then removes the vault's other
|
writes the new unlocker, makes it current, and only then removes the vault's
|
||||||
passphrase unlockers; a crash between the last two steps leaves the old one
|
other passphrase unlockers; a crash between the last two steps leaves the old
|
||||||
beside the new, and the old passphrase still opens the vault through it until
|
one beside the new, and the old passphrase still opens the vault through it
|
||||||
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
|
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
|
||||||
keychain or Secure Enclave unlocker added on the same host and day as another
|
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
|
||||||
of its type is added beside it instead of replacing it.
|
another of its type is added beside it instead of replacing it.
|
||||||
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once per
|
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
|
||||||
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset
|
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
|
||||||
at once, so that no program the command runs, `gpg` included, inherits them
|
unset at once, so that no program the command runs, `gpg` included,
|
||||||
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads
|
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
|
||||||
the environment; the buffers are passed down: `vault.CreateVault` takes the
|
the command reads the environment; the buffers are passed down:
|
||||||
mnemonic (nil for none), a `Vault` derives its long-term key from its
|
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
||||||
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the
|
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
||||||
PGP, keychain and Secure Enclave unlocker constructors take both.
|
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
||||||
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and
|
constructors take both. `CreatePGPUnlocker` sets both on the vault it
|
||||||
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its
|
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
|
||||||
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic
|
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
|
||||||
into the environment. Unsetting erases nothing: the starting environment
|
`vault create` no longer put the mnemonic into the environment. Unsetting
|
||||||
(`/proc/<pid>/environ`) and memory still hold the value. The README warns
|
erases nothing: the starting environment (`/proc/<pid>/environ`) and
|
||||||
against both variables.
|
memory still hold the value. The README warns against both variables.
|
||||||
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`,
|
- 2026-10-04: `.golangci.yml` is again the canonical file from
|
||||||
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs
|
`sneak/prompts`, byte for byte
|
||||||
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer
|
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
|
||||||
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out
|
in place of the deprecated `gomodguard`, so the lint no longer warns,
|
||||||
of non-test files. Neither raised a finding in this repo.
|
and enables `depguard` with a rule that keeps `net/http/httptest` out of
|
||||||
|
non-test files. Neither raised a finding in this repo.
|
||||||
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
||||||
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the
|
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
|
||||||
vault's long-term key as adding a passphrase unlocker does, with the vault's
|
the vault's long-term key as adding a passphrase unlocker does, with the
|
||||||
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic,
|
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
|
||||||
checked against the vault, or else from the current unlocker. Before, it used
|
mnemonic, checked against the vault, or else from the current unlocker.
|
||||||
the keychain unlocker's helper, which on every platform but macOS always
|
Before, it used the keychain unlocker's helper, which on every platform
|
||||||
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the
|
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
|
||||||
long-term key once from the mnemonic and once from a passphrase unlocker, and
|
key, getting the long-term key once from the mnemonic and once from a
|
||||||
reads a secret through the new unlocker.
|
passphrase unlocker, and reads a secret through the new unlocker.
|
||||||
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`,
|
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
||||||
`-` and `_`, and must not be empty, `.` or `..`
|
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
||||||
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state
|
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
||||||
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both
|
state the rule. `vault create`, `vault import`, `vault select`,
|
||||||
vault names of `mv` and shell completion of a `vault:secret` argument check
|
`vault remove`, both vault names of `mv` and shell completion of a
|
||||||
the name as typed with `vault.ValidateVaultName` before building any path from
|
`vault:secret` argument check the name as typed with
|
||||||
it. Before, `vault import ..` wrote a long-term key and an unlocker into the
|
`vault.ValidateVaultName` before building any path from it. Before,
|
||||||
state directory itself, and `vault select ..` made that the current vault.
|
`vault import ..` wrote a long-term key and an unlocker into the state
|
||||||
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree
|
directory itself, and `vault select ..` made that the current vault.
|
||||||
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as
|
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
||||||
the `CHECK_EPOCH` build argument, which both the lint and the build stage of
|
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
||||||
the `Dockerfile` declare after their module download, so the `RUN` steps below
|
current time as the `CHECK_EPOCH` build argument, which both the lint
|
||||||
the argument run again on each build while the base images and module
|
and the build stage of the `Dockerfile` declare after their module
|
||||||
downloads stay cached. Before, a second run on the same tree took every check
|
download, so the `RUN` steps below the argument run again on each
|
||||||
from the build cache and reported success having run nothing.
|
build while the base images and module downloads stay cached. Before,
|
||||||
|
a second run on the same tree took every check from the build cache
|
||||||
|
and reported success having run nothing.
|
||||||
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
||||||
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
||||||
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its
|
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for
|
||||||
duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
its duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
||||||
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and
|
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
|
||||||
encrypt everything before writing anything. All four unlocker types write
|
and encrypt everything before writing anything. All four unlocker
|
||||||
their files through `secret.WriteDir`: a new unlocker is built in a temporary
|
types write their files through `secret.WriteDir`: a new unlocker is
|
||||||
directory, renamed into place when complete and removed on a failure.
|
built in a temporary directory, renamed into place when complete and
|
||||||
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with
|
removed on a failure.
|
||||||
the warning `unlocker list` gives, an unlocker directory whose metadata file
|
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
|
||||||
cannot be checked for, read or parsed, instead of failing when it sorts before
|
skip, with the warning `unlocker list` gives, an unlocker directory
|
||||||
the unlocker asked for. Such a directory, or one without a metadata file, is
|
whose metadata file cannot be checked for, read or parsed, instead of
|
||||||
removed by its directory name, the name the warning gives; only the directory
|
failing when it sorts before the unlocker asked for. Such a directory,
|
||||||
is removed, since its type is unknown. Removing one whose metadata file is
|
or one without a metadata file, is removed by its directory name, the
|
||||||
missing or corrupt never counts as removing the last unlocker. Removing one
|
name the warning gives; only the directory is removed, since its type
|
||||||
whose metadata file cannot be checked for or read always does, since it may be
|
is unknown. Removing one whose metadata file is missing or corrupt
|
||||||
the only working unlocker, so in a vault with secrets it needs `--force`.
|
never counts as removing the last unlocker. Removing one whose metadata
|
||||||
- 2026-10-04: A failed command prints its error once, without the usage text
|
file cannot be checked for or read always does, since it may be the
|
||||||
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed
|
only working unlocker, so in a vault with secrets it needs `--force`.
|
||||||
for a command called wrongly: wrong number of arguments, unknown flag, bad
|
- 2026-10-04: A failed command prints its error once, without the usage
|
||||||
flag value, missing required flag, or flags that break a flag group (mutually
|
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
|
||||||
exclusive, required together, one required). The root command's
|
still printed for a command called wrongly: wrong number of arguments,
|
||||||
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values
|
unknown flag, bad flag value, missing required flag, or flags that
|
||||||
before that hook but required flags and flag groups only after it, so the hook
|
break a flag group (mutually exclusive, required together, one
|
||||||
checks those two first. Root `SilenceUsage` would have hidden usage for all of
|
required). The root command's `PersistentPreRunE` turns usage off.
|
||||||
these.
|
Cobra checks arguments and flag values before that hook but required
|
||||||
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it
|
flags and flag groups only after it, so the hook checks those two
|
||||||
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and
|
first. Root `SilenceUsage` would have hidden usage for all of these.
|
||||||
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller
|
- 2026-10-04: `secret get` keeps the secret in locked memory until it
|
||||||
destroys, and `secret get` writes its bytes straight to stdout, still with no
|
writes it out (https://git.eeqj.de/sneak/secret/issues/37):
|
||||||
trailing newline. Before, the value was copied into ordinary memory that
|
`Vault.GetSecret` and `Vault.GetSecretVersion` return a
|
||||||
nothing wiped, and `get --version` also wrote it to the debug log.
|
`*memguard.LockedBuffer`, which every caller destroys, and `secret get`
|
||||||
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets
|
writes its bytes straight to stdout, still with no trailing newline.
|
||||||
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets.
|
Before, the value was copied into ordinary memory that nothing wiped,
|
||||||
`make build` calls the new `script/build`, which stamps the version (`VERSION`
|
and `get --version` also wrote it to the debug log.
|
||||||
from the environment, else `git describe`) and the git commit as before.
|
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
|
||||||
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install`
|
targets use the local docker daemon, or whatever `DOCKER_HOST` the
|
||||||
depends on `build`. The `vet` target is gone: `script/test` runs `go vet`
|
environment sets. `make build` calls the new `script/build`, which
|
||||||
first.
|
stamps the version (`VERSION` from the environment, else
|
||||||
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`,
|
`git describe`) and the git commit as before. `build`, `clean`,
|
||||||
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's
|
`install` and `docker-run` are in `.PHONY`; `make install` depends on
|
||||||
`/secret`, `*.log`, `*.test` and `settings.local.json`
|
`build`. The `vet` target is gone: `script/test` runs `go vet` first.
|
||||||
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out
|
- 2026-10-04: `.gitignore` is the org's standard file, which ignores
|
||||||
`node_modules`; `.git` stays in the build context for the version stamp.
|
`.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus
|
||||||
|
this repo's `/secret`, `*.log`, `*.test` and `settings.local.json`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also
|
||||||
|
leaves out `node_modules`; `.git` stays in the build context for the
|
||||||
|
version stamp.
|
||||||
- 2026-10-04: `secret init` refuses when the default vault exists, and
|
- 2026-10-04: `secret init` refuses when the default vault exists, and
|
||||||
`secret vault create NAME` when `NAME` does, with "vault NAME already exists",
|
`secret vault create NAME` when `NAME` does, with "vault NAME already
|
||||||
before writing anything. The check is in `vault.CreateVault`, which both
|
exists", before writing anything. The check is in `vault.CreateVault`,
|
||||||
commands call while holding the state directory lock, so two creates of one
|
which both commands call while holding the state directory lock, so two
|
||||||
vault at once cannot both pass the check. Before, either command replaced the
|
creates of one vault at once cannot both pass the check. Before, either
|
||||||
vault's metadata, passphrase unlocker and `longterm.age`, so none of its
|
command replaced the vault's metadata, passphrase unlocker and
|
||||||
secrets could be decrypted any more. Both commands now ask for the unlocker
|
`longterm.age`, so none of its secrets could be decrypted any more. Both
|
||||||
passphrase before creating the vault, so one stopped at that prompt leaves no
|
commands now ask for the unlocker passphrase before creating the vault,
|
||||||
vault behind.
|
so one stopped at that prompt leaves no vault behind.
|
||||||
- 2026-10-04: The `internal/cli` tests are back to about their time before the
|
- 2026-10-04: The `internal/cli` tests are back to about their time
|
||||||
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test
|
before the state directory lock
|
||||||
that each changing command waits for the lock releases it as soon as it sees
|
(https://git.eeqj.de/sneak/secret/issues/80). The test that each
|
||||||
the command waiting there, instead of after a fixed 100 ms. The two vaults
|
changing command waits for the lock releases it as soon as it sees the
|
||||||
with passphrase unlockers that the path and move tests start from are made
|
command waiting there, instead of after a fixed 100 ms. The two vaults
|
||||||
once and copied for each test.
|
with passphrase unlockers that the path and move tests start from are
|
||||||
- 2026-10-04: `secret mv` rejects a move whose destination is the source under
|
made once and copied for each test.
|
||||||
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the
|
- 2026-10-04: `secret mv` rejects a move whose destination is the source
|
||||||
macOS default) or a name reached through a symbolic link, before changing
|
under another name, such as `foo` for `Foo` on a case-insensitive
|
||||||
anything, with or without `--force`, within a vault and between vaults;
|
filesystem (the macOS default) or a name reached through a symbolic
|
||||||
before, `--force` removed the destination and so deleted the secret. A rename
|
link, before changing anything, with or without `--force`, within a
|
||||||
that changes only letter case works on a case-sensitive filesystem as before.
|
vault and between vaults; before, `--force` removed the destination and
|
||||||
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`,
|
so deleted the secret. A rename that changes only letter case works on a
|
||||||
where golangci-lint is a build step rebuilt on every run
|
case-sensitive filesystem as before.
|
||||||
(`--no-cache-filter`), so an unchanged tree is linted too; the module download
|
- 2026-10-04: Lint runs only in docker: `script/lint` builds
|
||||||
stays cached. `script/bootstrap` no longer installs golangci-lint, and the
|
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on
|
||||||
`Dockerfile` lint stage calls it directly instead of `make lint`.
|
every run (`--no-cache-filter`), so an unchanged tree is linted too;
|
||||||
`golangci-lint config verify` is not run: it fetches its schema live over
|
the module download stays cached. `script/bootstrap` no longer
|
||||||
unpinned HTTPS.
|
installs golangci-lint, and the `Dockerfile` lint stage calls it
|
||||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer
|
directly instead of `make lint`. `golangci-lint config verify` is not
|
||||||
panics: `GetID()` warns with the unlocker's directory and returns
|
run: it fetches its schema live over unpinned HTTPS.
|
||||||
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose
|
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
||||||
metadata file cannot be checked for, read or parsed instead of failing, so
|
no longer panics: `GetID()` warns with the unlocker's directory and
|
||||||
`secret unlocker list` still lists the others; the listing's ID lookup no
|
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
||||||
longer warns about that directory again.
|
unlocker whose metadata file cannot be checked for, read or parsed
|
||||||
- 2026-10-03: `secret mv` rejects a move whose destination is the source
|
instead of failing, so `secret unlocker list` still lists the others;
|
||||||
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which
|
the listing's ID lookup no longer warns about that directory again.
|
||||||
defaults to the source name) before changing anything; before, `--force`
|
- 2026-10-03: `secret mv` rejects a move whose destination is the
|
||||||
removed the destination first and so deleted the secret. Every vault name
|
source (`mv --force x x`, `mv --force work:x work:`, or an empty
|
||||||
given with `vault:` must be one of the existing vaults by exact name, so
|
destination, which defaults to the source name) before changing
|
||||||
`work:x work/:x` is rejected instead of being taken for a move between two
|
anything; before, `--force` removed the destination first and so
|
||||||
vaults. A move within a named vault no longer makes that vault the current
|
deleted the secret. Every vault name given with `vault:` must be one
|
||||||
one, whether it succeeds or fails.
|
of the existing vaults by exact name, so `work:x work/:x` is rejected
|
||||||
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on
|
instead of being taken for a move between two vaults. A move within a
|
||||||
`lock` in the state directory; a mutex on the in-memory test filesystem), so
|
named vault no longer makes that vault the current one, whether it
|
||||||
concurrent commands no longer lose versions or race on the current pointers.
|
succeeds or fails.
|
||||||
Every file is written through `secret.WriteFileAtomic` (temporary file, sync,
|
- 2026-10-03: Commands that change the state directory hold one lock
|
||||||
rename), so no file is ever half-written and `current`, `currentvault` and
|
(`flock` on `lock` in the state directory; a mutex on the in-memory
|
||||||
`current-unlocker` never go missing. New versions, new secrets and cross-vault
|
test filesystem), so concurrent commands no longer lose versions or
|
||||||
copies are built in a temporary directory and renamed into place, and removals
|
race on the current pointers. Every file is written through
|
||||||
rename out of the way first, so a version or secret is never half-added and
|
`secret.WriteFileAtomic` (temporary file, sync, rename), so no file
|
||||||
never half-removed.
|
is ever half-written and `current`, `currentvault` and
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an error
|
`current-unlocker` never go missing. New versions, new secrets and
|
||||||
naming the path and cause when they cannot read what they inspect, instead of
|
cross-vault copies are built in a temporary directory and renamed
|
||||||
reading the failure as "nothing there": the duplicate check before
|
into place, and removals rename out of the way first, so a version
|
||||||
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file),
|
or secret is never half-added and never half-removed. An
|
||||||
the secret count that guards removing the last unlocker and removing a vault,
|
interrupted command can still leave:
|
||||||
and the existing long-term key check before `vault import`.
|
- from `init` or `vault create` killed after the passphrase prompt
|
||||||
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a
|
but before the unlocker is written, a vault with no unlocker,
|
||||||
version only if it is one of the versions `version list` lists for that
|
which `vault create` has already made the current vault;
|
||||||
secret, compared as typed before any path is built (`secret.VersionExists`),
|
- data under a `.tmp-` name in the state directory: a secret,
|
||||||
and touch nothing otherwise. An empty `--version` is rejected instead of
|
version or unlocker being added, or the secret, version, unlocker
|
||||||
meaning the current version. Before, `secret version rm x ../../..` deleted
|
or vault being removed, encrypted keys included. Nothing deletes
|
||||||
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every
|
it; it must be deleted by hand
|
||||||
version.
|
(https://git.eeqj.de/sneak/secret/issues/75).
|
||||||
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit
|
- 2026-10-03: The checks run before changing a vault now stop with an
|
||||||
code after its deferred `memguard.Purge()` has run, and only `main` calls
|
error naming the path and cause when they cannot read what they
|
||||||
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every
|
inspect, instead of reading the failure as "nothing there": the
|
||||||
buffer before exiting; when the process is in the terminal's foreground
|
duplicate check before `unlocker add pgp` (an unreadable
|
||||||
process group it first restores the terminal settings from startup, so an
|
`unlockers.d` or unlocker metadata file), the secret count that
|
||||||
interrupted passphrase prompt no longer leaves echo off.
|
guards removing the last unlocker and removing a vault, and the
|
||||||
- 2026-10-03: Every command that builds a path from a secret name checks the
|
existing long-term key check before `vault import`.
|
||||||
name first with `vault.ValidateSecretName` and touches nothing when it is
|
- 2026-10-03: `version rm`, `version promote` and `get --version`
|
||||||
invalid: `rm`, `mv` (both names, within a vault and between vaults, before
|
accept a version only if it is one of the versions `version list`
|
||||||
switching the current vault), `import`, `version list`/`promote`/`rm`,
|
lists for that secret, compared as typed before any path is built
|
||||||
`encrypt` and `decrypt`. The error and `README.md` state the naming rule.
|
(`secret.VersionExists`), and touch nothing otherwise. An empty
|
||||||
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret
|
`--version` is rejected instead of meaning the current version.
|
||||||
in it.
|
Before, `secret version rm x ../../..` deleted the whole vault,
|
||||||
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory:
|
`secret version rm x ..` the secret, and `.` or `""` every version.
|
||||||
it is generated into a locked buffer, and the keychain JSON is written and
|
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
|
||||||
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on
|
the exit code after its deferred `memguard.Purge()` has run, and only
|
||||||
Linux) without `encoding/json` holding it; the JSON field names are unchanged.
|
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
|
||||||
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case
|
handler, which wipes every buffer before exiting; when the process is
|
||||||
that needs more locked memory than the process can lock, and run every case
|
in the terminal's foreground process group it first restores the
|
||||||
under `script/cibuild`. The image stamps the `VERSION` build argument, else
|
terminal settings from startup, so an interrupted passphrase prompt no
|
||||||
`git describe --tags --always`, into `Version`, and fails if `.git` is present
|
longer leaves echo off.
|
||||||
but yields no version; `make build` stamps `git describe` too, not a fixed
|
- 2026-10-03: Every command that builds a path from a secret name
|
||||||
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the
|
checks the name first with `vault.ValidateSecretName` and touches
|
||||||
|
nothing when it is invalid: `rm`, `mv` (both names, within a vault
|
||||||
|
and between vaults, before switching the current vault), `import`,
|
||||||
|
`version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error
|
||||||
|
and `README.md` state the naming rule. Before, `secret rm ..`
|
||||||
|
deleted the whole vault and `secret rm .` every secret in it.
|
||||||
|
- 2026-10-03: The keychain unlocker's age key passphrase stays in
|
||||||
|
locked memory: it is generated into a locked buffer, and the
|
||||||
|
keychain JSON is written and read by `KeychainData` code in
|
||||||
|
`internal/secret/keychaindata.go` (tested on Linux) without
|
||||||
|
`encoding/json` holding it; the JSON field names are unchanged.
|
||||||
|
- 2026-10-02: A plain `docker build .` builds again: the size tests
|
||||||
|
skip a case that needs more locked memory than the process can
|
||||||
|
lock, and run every case under `script/cibuild`. The image stamps the
|
||||||
|
`VERSION` build argument, else `git describe --tags --always`, into
|
||||||
|
`Version`, and fails if `.git` is present but yields no version;
|
||||||
|
`make build` stamps `git describe` too, not a fixed `0.1.0`.
|
||||||
|
`.dockerignore` keeps `.git/config` out; `script/docker` is the
|
||||||
canonical copy.
|
canonical copy.
|
||||||
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
||||||
`.golangci.yml` (all linters enabled minus the standard disable list, `lll`
|
`.golangci.yml` (all linters enabled minus the standard disable
|
||||||
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged
|
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
|
||||||
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and
|
image to the tagged v2.12.2 Debian digest; fixed all ~1550 new
|
||||||
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`,
|
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
|
||||||
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl`
|
blank lines, sentinel errors for `err113`, `t.Parallel()` where
|
||||||
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the
|
safe, `_test` package conversions, complexity/`dupl` helper
|
||||||
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and
|
extraction) on branch `golangci-v2.12.2`. Reworked after review:
|
||||||
`pkg/bip85` were reshaped so every composed error message is byte-identical to
|
the `err113` sentinels in `internal/vault`, `internal/secret`,
|
||||||
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
`internal/cli` and `pkg/bip85` were reshaped so every composed
|
||||||
skips an unreadable `unlockers.d` entry with a warning instead of emitting a
|
error message is byte-identical to `main`, and
|
||||||
fabricated fallback ID.
|
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||||
|
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||||
|
emitting a fabricated fallback ID.
|
||||||
- 2026-08-07: Added `.editorconfig`
|
- 2026-08-07: Added `.editorconfig`
|
||||||
(https://git.eeqj.de/sneak/secret/issues/27).
|
(https://git.eeqj.de/sneak/secret/issues/27).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
||||||
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
||||||
- 2026-03-30: Added the `make fmt-check` target and
|
- 2026-03-30: Added the `make fmt-check` target and
|
||||||
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
||||||
`Dockerfile` base images are pinned by sha256.
|
`Dockerfile` base images are pinned by sha256.
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret protection,
|
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||||
plus review fixes (stub panics, derivation index, tests, README) on branch
|
protection, plus review fixes (stub panics, derivation index, tests,
|
||||||
secure-enclave-unlocker.
|
README) on branch secure-enclave-unlocker.
|
||||||
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
|
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
|
||||||
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing
|
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
|
||||||
metadata, allow uppercase secret names, fix hardcoded derivation index,
|
missing metadata, allow uppercase secret names, fix hardcoded
|
||||||
validate names in GetSecretVersion against path traversal, return errors
|
derivation index, validate names in GetSecretVersion against path
|
||||||
instead of panicking, add Warn() on silent anomalies.
|
traversal, return errors instead of panicking, add Warn() on silent
|
||||||
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths
|
anomalies.
|
||||||
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs
|
- Memory security hardening: LockedBuffer used through encrypt/decrypt
|
||||||
removed.
|
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
|
||||||
- Per-secret keypair architecture, vault package refactor, versioning with
|
bare-[]byte APIs removed.
|
||||||
--version, comprehensive test suite with in-memory filesystem.
|
- Per-secret keypair architecture, vault package refactor, versioning
|
||||||
|
with --version, comprehensive test suite with in-memory filesystem.
|
||||||
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
|
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
|
||||||
- Renamed SEP unlocker to Keychain, reorganized import commands.
|
- Renamed SEP unlocker to Keychain, reorganized import commands.
|
||||||
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI).
|
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
|
||||||
|
CLI).
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Implement version-number shell completion for the second arg of
|
- Implement version-number shell completion for the second arg of
|
||||||
`secret version promote` and `secret version rm` (`internal/cli/version.go`;
|
`secret version promote` and `secret version rm`
|
||||||
was an in-code TODO removed for godox).
|
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||||
- Cover mnemonic-vs-xprv identity consistency in `pkg/agehd/agehd_test.go`
|
- Cover mnemonic-vs-xprv identity consistency in
|
||||||
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox).
|
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
|
||||||
- CI does not compile, lint or test the files built only with cgo on macOS,
|
in-code FIXME removed for godox).
|
||||||
since compiling them needs Apple's SDK:
|
- Darwin-gated files (`internal/secret/keychainunlocker.go`,
|
||||||
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
|
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
|
||||||
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
|
tests) are not linted on the Linux CI runner and still contain lines
|
||||||
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never
|
over the new 88-column limit; they will surface if lint ever runs on
|
||||||
run on them, so it would likely find more there than the line lengths. No
|
macOS.
|
||||||
macOS test runs in CI. A macOS runner would cover all of it (asked on
|
|
||||||
https://git.eeqj.de/sneak/secret/issues/50).
|
|
||||||
- 1.0 critical security blockers (from repo TODO.md):
|
- 1.0 critical security blockers (from repo TODO.md):
|
||||||
- Memory security: age writes an identity's private key out as a string in
|
- Memory security: age identity .String() creates unprotected copies of
|
||||||
ordinary memory, and the copies it makes on the way stay there
|
private keys; the call sites are listed in
|
||||||
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
https://git.eeqj.de/sneak/secret/issues/38.
|
||||||
- Medium priority:
|
- Medium priority:
|
||||||
- Standardize error messages; stop leaking internals.
|
- Standardize error messages; stop leaking internals.
|
||||||
|
- Graceful handling of corrupted or missing key files with recovery
|
||||||
|
suggestions.
|
||||||
|
- Validate GPG key existence before creating PGP unlock keys.
|
||||||
- Split oversized CLI functions.
|
- Split oversized CLI functions.
|
||||||
- Cleanups: read statedir from environment or default instead of passing it
|
- mlock/munlock for sensitive allocations.
|
||||||
around.
|
- Cleanups: read statedir from environment or default instead of
|
||||||
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
passing it around.
|
||||||
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
- Enhancements: help examples, shell completion, colored output,
|
||||||
consistency, vault export/import, batch operations, search, secret metadata
|
--quiet flag, name suggestions on miss, audit logging, hardware
|
||||||
|
integration tests (Keychain, GPG), naming consistency, vault
|
||||||
|
export/import, batch operations, search, secret metadata
|
||||||
(descriptions, tags).
|
(descriptions, tags).
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module sneak.berlin/go/secret
|
module git.eeqj.de/sneak/secret
|
||||||
|
|
||||||
go 1.24.1
|
go 1.24.1
|
||||||
|
|
||||||
@@ -9,14 +9,13 @@ require (
|
|||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 // v1.1.24's Open can return another pty's terminal
|
github.com/creack/pty v1.1.24
|
||||||
github.com/dustin/go-humanize v1.0.1
|
|
||||||
github.com/fatih/color v1.18.0
|
|
||||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.14.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.8.4
|
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/crypto v0.38.0
|
||||||
golang.org/x/sys v0.33.0
|
golang.org/x/sys v0.33.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
@@ -27,6 +26,8 @@ require (
|
|||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
|
|||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8 h1:CY3gjC7naqYGLMiywvj3suPfa1i0p/QEr7o8ujxL/2M=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.25-0.20260601142114-9246436fffe8/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -107,6 +107,8 @@ 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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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/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-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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
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
@@ -1,19 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+1
-1
@@ -6,10 +6,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Instance encapsulates all CLI functionality and state
|
// Instance encapsulates all CLI functionality and state
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLIInstanceStateDir(t *testing.T) {
|
func TestCLIInstanceStateDir(t *testing.T) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// getSecretNamesCompletionFunc returns a completion function that provides
|
// getSecretNamesCompletionFunc returns a completion function that provides
|
||||||
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||||
// unlocker IDs, the names of the unlockers' directories in unlockers.d
|
// unlocker IDs
|
||||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
@@ -57,15 +57,38 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerMetadata, err := vlt.ListUnlockers()
|
// Get unlocker metadata list
|
||||||
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get vault directory
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unlocker IDs
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
if strings.HasPrefix(id, toComplete) {
|
|
||||||
|
for _, metadata := range unlockerMetadataList {
|
||||||
|
// Get the actual unlocker ID by creating the unlocker instance
|
||||||
|
id, err := findUnlockerIDByMetadata(
|
||||||
|
fs, unlockersDir, metadata, false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn(
|
||||||
|
"Could not read unlockers directory during completion, "+
|
||||||
|
"skipping unlocker",
|
||||||
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if id != "" && strings.HasPrefix(id, toComplete) {
|
||||||
completions = append(completions, id)
|
completions = append(completions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for asking the user to confirm a removal
|
// Sentinel errors for asking the user to confirm a removal
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -63,10 +63,10 @@ func newConfirmTestVaults(
|
|||||||
fs := &afero.MemMapFs{}
|
fs := &afero.MemMapFs{}
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
@@ -101,8 +101,7 @@ func newRemoval(t *testing.T, command string) removal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
||||||
// The first unlocker's directory name, written by newConfirmTestVaults
|
unlockerID := "pgp-" + listTestGPGKeyID + "A"
|
||||||
unlockerID := "pgp-0"
|
|
||||||
|
|
||||||
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
return cli.UnlockersRemove(unlockerID, force, cmd)
|
return cli.UnlockersRemove(unlockerID, force, cmd)
|
||||||
@@ -143,7 +142,7 @@ func newRemoval(t *testing.T, command string) removal {
|
|||||||
return removal{
|
return removal{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
run: removeFirstUnlocker,
|
run: removeFirstUnlocker,
|
||||||
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
"' from vault 'work'? It is not the vault's last unlocker.",
|
"' from vault 'work'? It is not the vault's last unlocker.",
|
||||||
}
|
}
|
||||||
@@ -151,7 +150,7 @@ func newRemoval(t *testing.T, command string) removal {
|
|||||||
return removal{
|
return removal{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
run: removeFirstUnlocker,
|
run: removeFirstUnlocker,
|
||||||
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
|
||||||
question: "Permanently remove unlocker '" + unlockerID +
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
"', the last unlocker of vault 'work', which holds 1 " +
|
"', the last unlocker of vault 'work', which holds 1 " +
|
||||||
"secret(s)? Without an unlocker the vault opens only " +
|
"secret(s)? Without an unlocker the vault opens only " +
|
||||||
@@ -353,9 +352,9 @@ func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
|
|||||||
// for its answer, another command can take the state directory lock and
|
// for its answer, another command can take the state directory lock and
|
||||||
// change the secret, and that the removal then removes nothing, since the
|
// change the secret, and that the removal then removes nothing, since the
|
||||||
// secret is no longer what the question named.
|
// 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) {
|
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
r := newRemoval(t, "rm")
|
r := newRemoval(t, "rm")
|
||||||
|
|
||||||
answers, answerWriter := io.Pipe()
|
answers, answerWriter := io.Pipe()
|
||||||
|
|||||||
@@ -2,21 +2,16 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCreateExistingVaultChangesNothing is a regression test for
|
// TestCreateExistingVaultChangesNothing is a regression test for
|
||||||
@@ -67,18 +62,22 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
|
want string
|
||||||
run func(c *cli.Instance) error
|
run func(c *cli.Instance) error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"init",
|
"init",
|
||||||
|
"failed to create default vault: vault default already exists",
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) },
|
func(c *cli.Instance) error { return c.Init(cmd) },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"vault create default",
|
"vault create default",
|
||||||
|
"vault default already exists",
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"vault create work",
|
"vault create work",
|
||||||
|
"vault work already exists",
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
|||||||
|
|
||||||
err := tt.run(newCLI(fs))
|
err := tt.run(newCLI(fs))
|
||||||
|
|
||||||
require.ErrorIs(t, err, vault.ErrVaultExists)
|
require.EqualError(t, err, tt.want)
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -156,7 +155,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|||||||
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
||||||
|
|
||||||
withDefault := afero.NewMemMapFs()
|
withDefault := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
|
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
@@ -189,226 +188,8 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|||||||
|
|
||||||
err := tt.run(c)
|
err := tt.run(c)
|
||||||
|
|
||||||
require.ErrorIs(t, err, secret.ErrPassphraseNotRead)
|
require.ErrorContains(t, err, "failed to read passphrase")
|
||||||
require.Equal(t, before, snapshotStateDir(t, tt.fs))
|
require.Equal(t, before, snapshotStateDir(t, tt.fs))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
|
|
||||||
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
|
|
||||||
// read mnemonic: failed to read passphrase: ...". The error must wrap
|
|
||||||
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
|
||||||
// pinned on the built binary, whose stdin is surely not a terminal.
|
|
||||||
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(afero.NewMemMapFs(), testStateDir)
|
|
||||||
require.ErrorIs(t, c.Init(discardCmd()), secret.ErrMnemonicNotRead)
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
|
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
||||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "init")
|
|
||||||
cmd.Env = []string{
|
|
||||||
secret.EnvStateDir + "=" + stateDir,
|
|
||||||
"PATH=" + os.Getenv("PATH"),
|
|
||||||
"HOME=" + os.Getenv("HOME"),
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
|
||||||
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
|
|
||||||
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
|
|
||||||
"or run interactively\n", string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
|
|
||||||
// create` killed after the passphrase prompt but before the unlocker was
|
|
||||||
// written left a vault with no unlocker, which neither command would then
|
|
||||||
// create again. After the prompt, each command changes the state directory
|
|
||||||
// only through vault.CreateVault. The test makes that call as the command
|
|
||||||
// does and records the state directory before each change it makes, and once
|
|
||||||
// after it returns: what a stop at that point leaves. Each must hold either
|
|
||||||
// no vault, and not name it current, or exactly the finished vault, which
|
|
||||||
// opens with the passphrase through its current unlocker. The command run
|
|
||||||
// again after a stop first takes the lock, which must delete what the stop
|
|
||||||
// left under a temporary name. Running the command is slow, so it runs once
|
|
||||||
// on each different state the lock leaves, and must create the vault there,
|
|
||||||
// or refuse the one there.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // commands on the in-memory filesystem share one lock
|
|
||||||
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(passphrase.Destroy)
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
t.Run("init", func(t *testing.T) {
|
|
||||||
// From an empty state directory
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
|
|
||||||
|
|
||||||
requireStopsLeaveWholeVaultOrNone(t, fs, "default", mnemonic, passphrase,
|
|
||||||
func(c *cli.Instance) error { return c.Init(cmd) })
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("vault create work", func(t *testing.T) {
|
|
||||||
// From a state directory holding the vault "default"
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
requireStopsLeaveWholeVaultOrNone(t, fs, "work", mnemonic, passphrase,
|
|
||||||
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireStopsLeaveWholeVaultOrNone checks, as
|
|
||||||
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
|
|
||||||
// command run, creating the vault name on fs with mnemonic and passphrase.
|
|
||||||
// Run again where the vault is there, the command must fail with
|
|
||||||
// vault.ErrVaultExists.
|
|
||||||
func requireStopsLeaveWholeVaultOrNone(
|
|
||||||
t *testing.T, fs afero.Fs, name string,
|
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
run func(c *cli.Instance) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var stops []map[string]string
|
|
||||||
|
|
||||||
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
|
|
||||||
testStateDir, name, mnemonic, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
record()
|
|
||||||
|
|
||||||
vaultDir := testStateDir + "/vaults.d/" + name
|
|
||||||
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
|
|
||||||
|
|
||||||
finished := entriesUnder(stops[len(stops)-1], vaultDir)
|
|
||||||
|
|
||||||
opener := vault.NewVault(fs, testStateDir, name)
|
|
||||||
opener.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
key, err := opener.UnlockVault()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
|
|
||||||
|
|
||||||
// Each different state the command run again finds once it holds the lock
|
|
||||||
var locked []map[string]string
|
|
||||||
|
|
||||||
for i, stop := range stops {
|
|
||||||
if _, there := stop[vaultDir+"/"]; there {
|
|
||||||
require.Equal(t, finished, entriesUnder(stop, vaultDir),
|
|
||||||
"stop %d left a partial vault", i)
|
|
||||||
} else {
|
|
||||||
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
|
|
||||||
"stop %d made a missing vault current", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
stopped := newFsFromSnapshot(t, stop)
|
|
||||||
release, err := vault.LockStateDir(stopped, testStateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
release()
|
|
||||||
|
|
||||||
state := snapshotStateDir(t, stopped)
|
|
||||||
for path := range state {
|
|
||||||
require.NotContains(t, path, ".tmp-", "stop %d", i)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
|
|
||||||
return maps.Equal(s, state)
|
|
||||||
}) {
|
|
||||||
locked = append(locked, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, state := range locked {
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
|
|
||||||
c.Mnemonic = mnemonic
|
|
||||||
c.UnlockPassphrase = passphrase
|
|
||||||
|
|
||||||
if _, there := state[vaultDir+"/"]; there {
|
|
||||||
require.ErrorIs(t, run(c), vault.ErrVaultExists)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, run(c))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
|
|
||||||
// that are under dir.
|
|
||||||
func entriesUnder(tree map[string]string, dir string) map[string]string {
|
|
||||||
entries := map[string]string{}
|
|
||||||
|
|
||||||
for path, content := range tree {
|
|
||||||
if strings.HasPrefix(path, dir+"/") {
|
|
||||||
entries[path] = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// hookFs passes every call through to Fs, but first calls before for each
|
|
||||||
// call that can change the filesystem.
|
|
||||||
type hookFs struct {
|
|
||||||
afero.Fs
|
|
||||||
|
|
||||||
before func()
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) Create(name string) (afero.File, error) {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.Create(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:ireturn // implements afero.Fs
|
|
||||||
func (h hookFs) OpenFile(
|
|
||||||
name string, flag int, perm os.FileMode,
|
|
||||||
) (afero.File, error) {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.OpenFile(name, flag, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.Mkdir(name, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.MkdirAll(path, perm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Remove(name string) error {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.Remove(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) RemoveAll(path string) error {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.RemoveAll(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h hookFs) Rename(oldname, newname string) error {
|
|
||||||
h.before()
|
|
||||||
|
|
||||||
return h.Fs.Rename(oldname, newname)
|
|
||||||
}
|
|
||||||
|
|||||||
+25
-6
@@ -7,16 +7,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for encrypt/decrypt operations
|
// Sentinel errors for encrypt/decrypt operations
|
||||||
var (
|
var (
|
||||||
errNotAgeSecretKey = errors.New(
|
errNotAgeSecretKey = errors.New(
|
||||||
"does not contain a valid age secret key")
|
"does not contain a valid age secret key")
|
||||||
|
errSecretDoesNotExist = errors.New("does not exist")
|
||||||
)
|
)
|
||||||
|
|
||||||
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
||||||
@@ -90,7 +91,8 @@ func (cli *Instance) storeNewEncryptionKey(
|
|||||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secureBuffer := secret.IdentityToLockedBuffer(identity)
|
// Store the generated key directly in a secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +131,7 @@ func (cli *Instance) resolveEncryptionKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Secret exists, get the age secret key from it
|
// Secret exists, get the age secret key from it
|
||||||
secretBuffer, err := vlt.GetSecret(secretName)
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -244,11 +246,11 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the age secret key from the secret
|
// Get the age secret key from the secret
|
||||||
secretBuffer, err := vlt.GetSecret(secretName)
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -312,3 +314,20 @@ func isValidAgeSecretKey(key string) bool {
|
|||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSecretValue retrieves the value of a secret with the vault's mnemonic
|
||||||
|
// when it has one, else with the current unlocker
|
||||||
|
func (cli *Instance) getSecretValue(
|
||||||
|
vlt *vault.Vault, secretObj *secret.Secret,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
if vlt.Mnemonic != nil {
|
||||||
|
return secretObj.GetValue(nil, vlt.Mnemonic)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretObj.GetValue(unlocker, nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry must return its exit code rather than exit, so that its deferred
|
// Entry must return its exit code rather than exit, so that its deferred
|
||||||
@@ -51,7 +52,7 @@ func TestInterruptExitsThroughMemguard(t *testing.T) {
|
|||||||
|
|
||||||
const waitingForValue = "Reading secret value from stdin"
|
const waitingForValue = "Reading secret value from stdin"
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
wd, err := filepath.Abs("../..")
|
wd, err := filepath.Abs("../..")
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMissingSecretOrVaultErrors checks that a command that finds no such
|
|
||||||
// secret or vault returns the vault package's error for it, as `secret get`
|
|
||||||
// does, and leaves the vaults unchanged. "default" is the current vault, and
|
|
||||||
// both vaults hold the secret "x".
|
|
||||||
func TestMissingSecretOrVaultErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
want error
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"rm --force nosuch", vault.ErrSecretNotFound,
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
return c.RemoveSecret(&cobra.Command{}, "nosuch", true)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version rm --force nosuch", vault.ErrSecretNotFound,
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
return c.RemoveVersion(&cobra.Command{}, "nosuch", "20260101.001", true)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work:nosuch default", vault.ErrSecretNotFound,
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(&cobra.Command{}, "work:nosuch", "default", true)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"decrypt nosuch", vault.ErrSecretNotFound,
|
|
||||||
func(c *cli.Instance) error { return c.Decrypt("nosuch", "", "") },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vault rm --force nosuch", vault.ErrVaultNotFound,
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
return c.RemoveVault(&cobra.Command{}, "nosuch", true)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
requireRejectedAndUnchanged(t, before, tt.want, tt.run)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,6 +21,10 @@ const (
|
|||||||
// Sentinel errors for secret generation
|
// Sentinel errors for secret generation
|
||||||
var (
|
var (
|
||||||
errLengthTooSmall = errors.New("length must be at least 1")
|
errLengthTooSmall = errors.New("length must be at least 1")
|
||||||
|
errLengthNotPositive = errors.New("length must be positive")
|
||||||
|
errMnemonicTypeNotSupported = errors.New(
|
||||||
|
"mnemonic type not supported for secret generation, " +
|
||||||
|
"use 'secret generate mnemonic' instead")
|
||||||
errUnsupportedSecretType = errors.New("unsupported type")
|
errUnsupportedSecretType = errors.New("unsupported type")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,8 +148,7 @@ func (cli *Instance) GenerateSecret(
|
|||||||
case "alnum":
|
case "alnum":
|
||||||
secretValue, err = generateRandomAlnum(length)
|
secretValue, err = generateRandomAlnum(length)
|
||||||
case "mnemonic":
|
case "mnemonic":
|
||||||
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
|
return errMnemonicTypeNotSupported
|
||||||
errUnsupportedSecretType)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||||
errUnsupportedSecretType, secretType)
|
errUnsupportedSecretType, secretType)
|
||||||
@@ -201,8 +204,8 @@ func generateRandomAlnum(length int) (string, error) {
|
|||||||
// generateRandomString generates a random string of the specified length
|
// generateRandomString generates a random string of the specified length
|
||||||
// using the given character set
|
// using the given character set
|
||||||
func generateRandomString(length int, charset string) (string, error) {
|
func generateRandomString(length int, charset string) (string, error) {
|
||||||
if length < 1 {
|
if length <= 0 {
|
||||||
return "", errLengthTooSmall
|
return "", errLengthNotPositive
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version info - these are set at build time
|
// Version info - these are set at build time
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// vaultStats accumulates statistics while walking vault directories
|
// vaultStats accumulates statistics while walking vault directories
|
||||||
|
|||||||
+67
-24
@@ -6,13 +6,16 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errPassphraseMismatch is returned when passphrase confirmation fails
|
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||||
@@ -55,11 +58,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|||||||
secret.Debug("Prompting user for mnemonic phrase")
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
|
||||||
// Read mnemonic securely without echo
|
// Read mnemonic securely without echo
|
||||||
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
@@ -67,6 +70,43 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|||||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupDefaultVault creates the default vault and derives its long-term
|
||||||
|
// identity from the mnemonic
|
||||||
|
func (cli *Instance) setupDefaultVault(
|
||||||
|
stateDir string, mnemonic *memguard.LockedBuffer,
|
||||||
|
) (*vault.Vault, *age.X25519Identity, error) {
|
||||||
|
// Create the default vault - it will handle key derivation internally
|
||||||
|
secret.Debug("Creating default vault")
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the vault metadata to retrieve the derivation index
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||||
|
|
||||||
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to load vault metadata", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the long-term key using the same index that CreateVault used
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to derive long-term key", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vlt, ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes the secret manager, holding the state directory lock
|
// Init initializes the secret manager, holding the state directory lock
|
||||||
// while initialize runs
|
// while initialize runs
|
||||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||||
@@ -133,31 +173,34 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
defer cleanupPassphrase()
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
// Create the default vault with its passphrase unlocker
|
// Create the default vault and derive its long-term key
|
||||||
secret.Debug("Creating default vault")
|
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
|
|
||||||
mnemonic, passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create default vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlocker, err := vlt.GetCurrentUnlocker()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
|
// Unlock the vault with the derived long-term key
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create passphrase-protected unlocker
|
||||||
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
|
||||||
|
// private key to longterm.age, so no need to do it again here.
|
||||||
|
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd.Printf("\nDefault vault created and configured\n")
|
cmd.Printf("\nDefault vault created and configured\n")
|
||||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
cmd.Printf("Long-term public key: %s\n", ltPubKey)
|
||||||
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||||
cmd.Println("\nYour secret manager is ready to use!")
|
cmd.Println("\nYour secret manager is ready to use!")
|
||||||
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
||||||
cmd.Println("unlockers are not required for secret operations.")
|
cmd.Println("unlockers are not required for secret operations.")
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
|
||||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
|
|
||||||
// "other" has no long-term key, as vault import needs.
|
|
||||||
func TestInvalidMnemonicError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *Instance) error
|
|
||||||
}{
|
|
||||||
{"secret init", func(c *Instance) error { return c.Init(c.cmd) }},
|
|
||||||
{"secret vault create work", func(c *Instance) error {
|
|
||||||
return c.CreateVault(c.cmd, "work")
|
|
||||||
}},
|
|
||||||
{"secret vault import other", func(c *Instance) error {
|
|
||||||
return c.VaultImport(c.cmd, "other")
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
instance, _ := newTestInstance(fs)
|
|
||||||
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
|
||||||
t.Cleanup(instance.Mnemonic.Destroy)
|
|
||||||
|
|
||||||
require.ErrorIs(t, tt.run(instance), errInvalidMnemonicPhrase)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenerateSecretErrors checks that `secret generate secret` gives one
|
|
||||||
// error for a length below 1 and one for a type it cannot generate.
|
|
||||||
func TestGenerateSecretErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(afero.NewMemMapFs())
|
|
||||||
|
|
||||||
err := instance.GenerateSecret(cmd, "x", 0, "base58", false)
|
|
||||||
require.ErrorIs(t, err, errLengthTooSmall)
|
|
||||||
|
|
||||||
_, err = generateRandomString(0, "ab")
|
|
||||||
require.ErrorIs(t, err, errLengthTooSmall)
|
|
||||||
|
|
||||||
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "mnemonic", false)
|
|
||||||
require.ErrorIs(t, err, errUnsupportedSecretType)
|
|
||||||
|
|
||||||
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "hex", false)
|
|
||||||
require.ErrorIs(t, err, errUnsupportedSecretType)
|
|
||||||
}
|
|
||||||
@@ -17,27 +17,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// testMnemonic is a standard BIP39 mnemonic used for testing
|
// testMnemonic is a standard BIP39 mnemonic used for testing
|
||||||
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||||
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// commandWait is how long a test lets the secret binary run before it
|
|
||||||
// kills it and fails. It stays well under the 30 seconds script/test
|
|
||||||
// gives the whole package, so a command that hangs fails the test with
|
|
||||||
// the test's own message instead of Go's timeout panic.
|
|
||||||
commandWait = 10 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errEmptyValue indicates a concurrent reader received an empty secret value.
|
// errEmptyValue indicates a concurrent reader received an empty secret value.
|
||||||
@@ -58,12 +52,8 @@ func runSecretWithStdin(stdin string, env map[string]string, args ...string) (st
|
|||||||
return cli.ExecuteCommandInProcess(args, stdin, env)
|
return cli.ExecuteCommandInProcess(args, stdin, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMain runs before all tests and ensures the binary is built. It also
|
// TestMain runs before all tests and ensures the binary is built
|
||||||
// makes passphrase encryption in the tests cheap (see
|
|
||||||
// secret.ScryptWorkFactor); the binary keeps age's work factor.
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
secret.ScryptWorkFactor = 1
|
|
||||||
|
|
||||||
// Get the current working directory
|
// Get the current working directory
|
||||||
wd, err := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -690,10 +680,10 @@ func test06GetSecret(t *testing.T, testMnemonic string, runSecret func(...string
|
|||||||
require.NoError(t, err, "get secret should succeed")
|
require.NoError(t, err, "get secret should succeed")
|
||||||
assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value")
|
assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value")
|
||||||
|
|
||||||
// Test that without mnemonic, we get an error: the passphrase unlocker
|
// Test that without mnemonic, we get an error
|
||||||
// cannot ask for its passphrase, as the tests have no terminal
|
output, err = runSecret("get", "database/password")
|
||||||
_, err = runSecret("get", "database/password")
|
require.Error(t, err, "get should fail without unlock method")
|
||||||
require.ErrorIs(t, err, secret.ErrPassphraseNotRead, "get should fail without unlock method")
|
assert.Contains(t, output, "failed to unlock vault", "should indicate unlock failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
@@ -849,11 +839,12 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
|
|||||||
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
|
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
|
||||||
|
|
||||||
// An empty --version is not a version; it does not mean the current one
|
// An empty --version is not a version; it does not mean the current one
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "--version", "", "database/password")
|
}, "get", "--version", "", "database/password")
|
||||||
|
|
||||||
require.ErrorIs(t, err, vault.ErrVersionNotFound, "should reject the empty version")
|
require.Error(t, err, "get with an empty version should fail")
|
||||||
|
assert.Contains(t, output, "version '' not found", "should reject the empty version")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
@@ -1167,7 +1158,11 @@ func testInvalidSecretNames(t *testing.T, testMnemonic string, runSecretWithStdi
|
|||||||
shouldFail := slices.Contains(definitelyInvalid, invalidName)
|
shouldFail := slices.Contains(definitelyInvalid, invalidName)
|
||||||
|
|
||||||
if shouldFail {
|
if shouldFail {
|
||||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName, "add '%s' should fail", invalidName)
|
require.Error(t, err, "add '%s' should fail", invalidName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
assert.Contains(t, output, "invalid secret name", "should indicate invalid name for '%s'", invalidName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For the slash cases and .hidden, they might succeed
|
// For the slash cases and .hidden, they might succeed
|
||||||
// Just log what happened
|
// Just log what happened
|
||||||
@@ -1226,8 +1221,9 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
|
|||||||
|
|
||||||
// Test error cases
|
// Test error cases
|
||||||
// Try to move non-existent secret
|
// Try to move non-existent secret
|
||||||
_, err = runSecret("move", "test/nonexistent", "test/destination")
|
output, err = runSecret("move", "test/nonexistent", "test/destination")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound, "move non-existent should fail")
|
require.Error(t, err, "move non-existent should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate source not found")
|
||||||
|
|
||||||
// Try to move to existing destination
|
// Try to move to existing destination
|
||||||
_, err = runSecretWithStdin("dest-value", map[string]string{
|
_, err = runSecretWithStdin("dest-value", map[string]string{
|
||||||
@@ -1235,8 +1231,9 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
|
|||||||
}, "add", "test/existing-dest")
|
}, "add", "test/existing-dest")
|
||||||
require.NoError(t, err, "add test/existing-dest should succeed")
|
require.NoError(t, err, "add test/existing-dest should succeed")
|
||||||
|
|
||||||
_, err = runSecret("move", "test/renamed", "test/existing-dest")
|
output, err = runSecret("move", "test/renamed", "test/existing-dest")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretExists, "move to existing destination should fail")
|
require.Error(t, err, "move to existing destination should fail")
|
||||||
|
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||||
|
|
||||||
// Verify the source wasn't removed since move failed
|
// Verify the source wasn't removed since move failed
|
||||||
getOutput, err = runSecretWithEnv(map[string]string{
|
getOutput, err = runSecretWithEnv(map[string]string{
|
||||||
@@ -1313,8 +1310,9 @@ func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv f
|
|||||||
require.NoError(t, err, "add force/test in work should succeed")
|
require.NoError(t, err, "add force/test in work should succeed")
|
||||||
|
|
||||||
// Move without force should fail
|
// Move without force should fail
|
||||||
_, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretExists, "move without force should fail when dest exists")
|
require.Error(t, err, "move without force should fail when dest exists")
|
||||||
|
assert.Contains(t, output, "already exists", "should indicate destination exists")
|
||||||
|
|
||||||
// Move with force should succeed
|
// Move with force should succeed
|
||||||
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
|
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
|
||||||
@@ -1429,8 +1427,9 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
|
|||||||
require.NoError(t, err, "vault select default should succeed")
|
require.NoError(t, err, "vault select default should succeed")
|
||||||
|
|
||||||
// Test selecting non-existent vault
|
// Test selecting non-existent vault
|
||||||
_, err = runSecret("vault", "select", "nonexistent")
|
output, err := runSecret("vault", "select", "nonexistent")
|
||||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "selecting non-existent vault should fail")
|
require.Error(t, err, "selecting non-existent vault should fail")
|
||||||
|
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
@@ -1451,10 +1450,11 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
|
|||||||
require.NoError(t, err, "vault select work should succeed")
|
require.NoError(t, err, "vault select work should succeed")
|
||||||
|
|
||||||
// Try to get the default-only secret (should fail)
|
// Try to get the default-only secret (should fail)
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err := runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "default-only/secret")
|
}, "get", "default-only/secret")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get default vault secret from work vault")
|
require.Error(t, err, "should not be able to get default vault secret from work vault")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate secret not found")
|
||||||
|
|
||||||
// Add a unique secret to work vault
|
// Add a unique secret to work vault
|
||||||
_, err = runSecretWithStdin("work-vault-secret", map[string]string{
|
_, err = runSecretWithStdin("work-vault-secret", map[string]string{
|
||||||
@@ -1467,13 +1467,14 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
|
|||||||
require.NoError(t, err, "vault select default should succeed")
|
require.NoError(t, err, "vault select default should succeed")
|
||||||
|
|
||||||
// Try to get the work-only secret (should fail)
|
// Try to get the work-only secret (should fail)
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "work-only/secret")
|
}, "get", "work-only/secret")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get work vault secret from default vault")
|
require.Error(t, err, "should not be able to get work vault secret from default vault")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate secret not found")
|
||||||
|
|
||||||
// Verify we can still get the default-only secret
|
// Verify we can still get the default-only secret
|
||||||
output, err := runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "default-only/secret")
|
}, "get", "default-only/secret")
|
||||||
require.NoError(t, err, "get default-only secret should succeed")
|
require.NoError(t, err, "get default-only secret should succeed")
|
||||||
@@ -1585,10 +1586,11 @@ func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretW
|
|||||||
// Just verify the import succeeded
|
// Just verify the import succeeded
|
||||||
|
|
||||||
// Test importing non-existent file
|
// Test importing non-existent file
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "import", "imported/nonexistent", "--source", "/nonexistent/file")
|
}, "import", "imported/nonexistent", "--source", "/nonexistent/file")
|
||||||
require.ErrorIs(t, err, os.ErrNotExist, "importing non-existent file should fail")
|
require.Error(t, err, "importing non-existent file should fail")
|
||||||
|
assert.Contains(t, output, "failed", "should indicate failure")
|
||||||
|
|
||||||
// Verify filesystem structure
|
// Verify filesystem structure
|
||||||
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
|
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
|
||||||
@@ -1903,10 +1905,11 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Get non-existent secret
|
// Get non-existent secret
|
||||||
_, err := runSecretWithEnv(map[string]string{
|
output, err := runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "nonexistent/secret")
|
}, "get", "nonexistent/secret")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound, "get non-existent secret should fail")
|
require.Error(t, err, "get non-existent secret should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate secret not found")
|
||||||
|
|
||||||
// Add secret without mnemonic or unlocker
|
// Add secret without mnemonic or unlocker
|
||||||
unsetMnemonic := os.Getenv(secret.EnvMnemonic)
|
unsetMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
@@ -1936,28 +1939,32 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
// Invalid secret names (already tested in test 12)
|
// Invalid secret names (already tested in test 12)
|
||||||
|
|
||||||
// Non-existent vault operations
|
// Non-existent vault operations
|
||||||
_, err = runSecret("vault", "select", "nonexistent")
|
output, err = runSecret("vault", "select", "nonexistent")
|
||||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail")
|
require.Error(t, err, "select non-existent vault should fail")
|
||||||
|
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
|
||||||
|
|
||||||
// Import to non-existent vault with test passphrase
|
// Import to non-existent vault with test passphrase
|
||||||
testPassphrase := "test-passphrase-123" // Define testPassphrase locally
|
testPassphrase := "test-passphrase-123" // Define testPassphrase locally
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
secret.EnvUnlockPassphrase: testPassphrase,
|
secret.EnvUnlockPassphrase: testPassphrase,
|
||||||
}, "vault", "import", "nonexistent")
|
}, "vault", "import", "nonexistent")
|
||||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "import to non-existent vault should fail")
|
require.Error(t, err, "import to non-existent vault should fail")
|
||||||
|
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
|
||||||
|
|
||||||
// Get specific version that doesn't exist
|
// Get specific version that doesn't exist
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "--version", "99999999.999", "database/password")
|
}, "get", "--version", "99999999.999", "database/password")
|
||||||
require.ErrorIs(t, err, vault.ErrVersionNotFound, "get non-existent version should fail")
|
require.Error(t, err, "get non-existent version should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate version not found")
|
||||||
|
|
||||||
// Promote non-existent version
|
// Promote non-existent version
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "version", "promote", "database/password", "99999999.999")
|
}, "version", "promote", "database/password", "99999999.999")
|
||||||
require.ErrorIs(t, err, vault.ErrVersionNotFound, "promote non-existent version should fail")
|
require.Error(t, err, "promote non-existent version should fail")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate version not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) {
|
func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) {
|
||||||
@@ -2360,10 +2367,11 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
assert.NotEmpty(t, output, "restored secret should have value")
|
assert.NotEmpty(t, output, "restored secret should have value")
|
||||||
|
|
||||||
// Verify post-backup secret is gone
|
// Verify post-backup secret is gone
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
output, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "post-backup/secret")
|
}, "get", "post-backup/secret")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound, "post-backup secret should not exist after restore")
|
require.Error(t, err, "post-backup secret should not exist after restore")
|
||||||
|
assert.Contains(t, output, "not found", "should indicate secret not found")
|
||||||
|
|
||||||
t.Log("Backup and restore completed successfully")
|
t.Log("Backup and restore completed successfully")
|
||||||
}
|
}
|
||||||
@@ -2428,7 +2436,8 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
|
|||||||
t.Logf("Output: %s", getOutput)
|
t.Logf("Output: %s", getOutput)
|
||||||
|
|
||||||
// This is the expected behavior with the current bug
|
// This is the expected behavior with the current bug
|
||||||
require.ErrorIs(t, err, vault.ErrMnemonicMismatch, "get should fail due to wrong derivation index")
|
require.Error(t, err, "get should fail due to wrong derivation index")
|
||||||
|
assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
|
||||||
|
|
||||||
// Document what should happen when the bug is fixed
|
// Document what should happen when the bug is fixed
|
||||||
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
|
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
|
||||||
@@ -2554,7 +2563,7 @@ func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
|
|||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
defer mnemonic.Destroy()
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic, nil)
|
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||||
@@ -2589,7 +2598,7 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
|
|||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd, secretDir := secretRmCommand(ctx, t)
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
@@ -2607,13 +2616,7 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
|
|||||||
// and stderr, not both: whether it asks must depend on stdin alone, where
|
// and stderr, not both: whether it asks must depend on stdin alone, where
|
||||||
// the answer is read from. pty.Open returns the two ends of a new terminal:
|
// the answer is read from. pty.Open returns the two ends of a new terminal:
|
||||||
// tty is the end a program uses as its terminal, and ptmx the end the test
|
// tty is the end a program uses as its terminal, and ptmx the end the test
|
||||||
// reads what the terminal shows from and types into. Reading ptmx stops at
|
// reads what the terminal shows from and types into.
|
||||||
// the context's deadline, commandWait (10 seconds) after the test starts,
|
|
||||||
// when secret rm is killed too, so a terminal that stays open fails the test
|
|
||||||
// then with its own message instead of hanging it. The deadline works only
|
|
||||||
// while ptmx stays non-blocking, as pty.Open of the github.com/creack/pty
|
|
||||||
// commit in go.mod leaves it: calling ptmx.Fd() or going back to v1.1.24
|
|
||||||
// makes the read ignore the deadline, without any error.
|
|
||||||
|
|
||||||
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
|
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
|
||||||
// terminal. stdin is a pipe, so nobody can answer there, and the command
|
// terminal. stdin is a pipe, so nobody can answer there, and the command
|
||||||
@@ -2621,7 +2624,7 @@ func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
|
|||||||
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd, secretDir := secretRmCommand(ctx, t)
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
@@ -2631,9 +2634,6 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = ptmx.Close() }()
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
deadline, _ := ctx.Deadline()
|
|
||||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
|
||||||
|
|
||||||
cmd.Stdin = strings.NewReader("y\n")
|
cmd.Stdin = strings.NewReader("y\n")
|
||||||
cmd.Stdout = tty
|
cmd.Stdout = tty
|
||||||
cmd.Stderr = tty
|
cmd.Stderr = tty
|
||||||
@@ -2643,16 +2643,9 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
|||||||
_ = tty.Close()
|
_ = tty.Close()
|
||||||
|
|
||||||
// The read ends once secret rm has exited and so closed the terminal.
|
// The read ends once secret rm has exited and so closed the terminal.
|
||||||
shown, err := io.ReadAll(ptmx)
|
shown, _ := io.ReadAll(ptmx)
|
||||||
require.NotErrorIs(t, err, os.ErrDeadlineExceeded,
|
|
||||||
"the terminal was still open %s after secret rm started: %s",
|
|
||||||
commandWait, shown)
|
|
||||||
|
|
||||||
err = cmd.Wait()
|
require.Error(t, cmd.Wait())
|
||||||
|
|
||||||
require.NoError(t, ctx.Err(), "secret rm did not exit within %s",
|
|
||||||
commandWait)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, string(shown), "pass --force")
|
assert.Contains(t, string(shown), "pass --force")
|
||||||
assert.DirExists(t, secretDir)
|
assert.DirExists(t, secretDir)
|
||||||
}
|
}
|
||||||
@@ -2662,7 +2655,7 @@ func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
|||||||
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd, secretDir := secretRmCommand(ctx, t)
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
@@ -2672,9 +2665,6 @@ func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
|||||||
|
|
||||||
defer func() { _ = ptmx.Close() }()
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
deadline, _ := ctx.Deadline()
|
|
||||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
|
||||||
|
|
||||||
cmd.Stdin = tty
|
cmd.Stdin = tty
|
||||||
// Not a file, so exec.Cmd connects stdout through a pipe.
|
// Not a file, so exec.Cmd connects stdout through a pipe.
|
||||||
cmd.Stdout = io.Discard
|
cmd.Stdout = io.Discard
|
||||||
@@ -2692,8 +2682,7 @@ func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
|||||||
terminal := bufio.NewReader(ptmx)
|
terminal := bufio.NewReader(ptmx)
|
||||||
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
|
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
|
||||||
char, err = terminal.ReadByte()
|
char, err = terminal.ReadByte()
|
||||||
require.NoError(t, err, "secret rm did not ask on the terminal: %s",
|
require.NoError(t, err, "secret rm ended without asking: %s", shown)
|
||||||
shown)
|
|
||||||
|
|
||||||
shown = append(shown, char)
|
shown = append(shown, char)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestLeftoversRemovedByNextChangingCommand is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
|
|
||||||
// killed part-way leaves in each directory where secret.TempDirFor and
|
|
||||||
// secret.WriteFileAtomic make temporary entries: a temporary directory
|
|
||||||
// holding a vault, secret, unlocker or version being added or removed, and
|
|
||||||
// a temporary file beside a file being replaced. `secret list` must leave
|
|
||||||
// them all, and the next command that takes the state directory lock, here
|
|
||||||
// `secret vault select` of the vault already current, must delete exactly
|
|
||||||
// them: a vault named like a temporary directory stays. The copy has no
|
|
||||||
// lock file yet, so that command, as after a killed one, finds no mark that
|
|
||||||
// the last holder of the lock finished.
|
|
||||||
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
|
|
||||||
|
|
||||||
before := snapshotStateDir(t, fs)
|
|
||||||
|
|
||||||
vaultDir := testStateDir + "/vaults.d/default"
|
|
||||||
secretDir := vaultDir + "/secrets.d/x"
|
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 1)
|
|
||||||
|
|
||||||
for _, dir := range []string{
|
|
||||||
testStateDir + "/.tmp-1/default",
|
|
||||||
vaultDir + "/.tmp-2/x",
|
|
||||||
secretDir + "/.tmp-3/" + testVersion,
|
|
||||||
} {
|
|
||||||
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
|
||||||
require.NoError(t, afero.WriteFile(fs, dir+"/value.age",
|
|
||||||
[]byte("encrypted"), secret.FilePerms))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range []string{
|
|
||||||
testStateDir + "/.currentvault.tmp-4",
|
|
||||||
vaultDir + "/.current-unlocker.tmp-5",
|
|
||||||
secretDir + "/.current.tmp-6",
|
|
||||||
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
|
|
||||||
} {
|
|
||||||
require.NoError(t, afero.WriteFile(fs, file,
|
|
||||||
[]byte("partial"), secret.FilePerms))
|
|
||||||
}
|
|
||||||
|
|
||||||
planted := snapshotStateDir(t, fs)
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
require.NoError(t, c.ListSecrets(cmd, false, false, ""))
|
|
||||||
require.Equal(t, planted, snapshotStateDir(t, fs))
|
|
||||||
|
|
||||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
||||||
}
|
|
||||||
+13
-17
@@ -13,13 +13,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -94,9 +94,9 @@ func numbered(prefix string, count int) []string {
|
|||||||
// lock, adds of a new secret all find it absent and replace each other, and
|
// 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
|
// forced adds read the same highest version number and overwrite each
|
||||||
// other's version. With it they behave as if run one after another.
|
// other's version. With it they behave as if run one after another.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
const adds = 8
|
const adds = 8
|
||||||
@@ -110,9 +110,7 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
{"real", afero.NewOsFs(), t.TempDir()},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
|
||||||
|
|
||||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// One add creates the secret; the others find that it exists
|
// One add creates the secret; the others find that it exists
|
||||||
@@ -187,7 +185,7 @@ func (r *readNotifier) Read(p []byte) (int, error) {
|
|||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
func TestEncryptPipedIntoAdd(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
||||||
|
|
||||||
@@ -237,9 +235,9 @@ func TestEncryptPipedIntoAdd(t *testing.T) {
|
|||||||
|
|
||||||
// TestFailedCommandReleasesLock checks that a command failing after it
|
// TestFailedCommandReleasesLock checks that a command failing after it
|
||||||
// took the state directory lock leaves the lock free for the next command.
|
// 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) {
|
func TestFailedCommandReleasesLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
@@ -294,14 +292,14 @@ func setupEveryCommand(
|
|||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
otherDir, err := other.GetDirectory()
|
otherDir, err := other.GetDirectory()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
@@ -364,6 +362,7 @@ func requireWaitsForLock(
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
||||||
|
before := stateDirModTimes(t, fs)
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
release, err := vault.LockStateDir(fs, testStateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -373,9 +372,6 @@ func requireWaitsForLock(
|
|||||||
release = sync.OnceFunc(release)
|
release = sync.OnceFunc(release)
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Taken only now, since taking the lock writes the lock file.
|
|
||||||
before := stateDirModTimes(t, fs)
|
|
||||||
|
|
||||||
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
defer unlockPassphrase.Destroy()
|
defer unlockPassphrase.Destroy()
|
||||||
|
|
||||||
@@ -489,7 +485,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
||||||
|
|
||||||
@@ -527,7 +523,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|||||||
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
func TestEncryptStreamsUnlocked(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
||||||
|
|
||||||
|
|||||||
+29
-54
@@ -5,12 +5,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
|
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
|
||||||
@@ -30,8 +30,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|||||||
workX = "work:x"
|
workX = "work:x"
|
||||||
)
|
)
|
||||||
|
|
||||||
// internal/cli declares these errors itself and does not export them, so
|
|
||||||
// only their text can be compared.
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
source, dest string
|
source, dest string
|
||||||
@@ -45,6 +43,30 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|||||||
{`mv --force work:x ""`, workX, "", true, ontoItself},
|
{`mv --force work:x ""`, workX, "", true, ontoItself},
|
||||||
// "work" is a vault name, so the destination is work:x.
|
// "work" is a vault name, so the destination is work:x.
|
||||||
{"mv --force work:x work", workX, "work", true, ontoItself},
|
{"mv --force work:x work", workX, "work", true, ontoItself},
|
||||||
|
{
|
||||||
|
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
||||||
|
"secret 'nosuch' not found",
|
||||||
|
},
|
||||||
|
// Only an existing vault is used.
|
||||||
|
{
|
||||||
|
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
||||||
|
"vault 'nosuch' does not exist",
|
||||||
|
},
|
||||||
|
// Each of these spells "work" a second way. The spelling is not a
|
||||||
|
// valid vault name, so the move is not taken for a move between two
|
||||||
|
// vaults, which would delete the destination, here the source.
|
||||||
|
{
|
||||||
|
"mv --force work:x work/:x", workX, "work/:x", true,
|
||||||
|
vault.ValidateVaultName("work/").Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv --force work/:x work:", "work/:x", "work:", true,
|
||||||
|
vault.ValidateVaultName("work/").Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv --force work:x ./work:x", workX, "./work:x", true,
|
||||||
|
vault.ValidateVaultName("./work").Error(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -60,53 +82,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|||||||
require.EqualError(t, err, tt.wantErr)
|
require.EqualError(t, err, tt.wantErr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
missing := []struct {
|
|
||||||
command string
|
|
||||||
source, dest string
|
|
||||||
force bool
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
|
||||||
vault.ErrSecretNotFound,
|
|
||||||
},
|
|
||||||
// Only an existing vault is used.
|
|
||||||
{
|
|
||||||
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
|
||||||
vault.ErrVaultNotFound,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range missing {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
requireRejectedAndUnchanged(t, before, tt.want, func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each of these spells "work" a second way. The spelling is not a valid
|
|
||||||
// vault name, so the move is not taken for a move between two vaults,
|
|
||||||
// which would delete the destination, here the source.
|
|
||||||
invalidNames := []struct{ source, dest string }{
|
|
||||||
{workX, "work/:x"},
|
|
||||||
{"work/:x", "work:"},
|
|
||||||
{workX, "./work:x"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range invalidNames {
|
|
||||||
t.Run("mv --force "+tt.source+" "+tt.dest, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
|
|
||||||
func(c *cli.Instance) error {
|
|
||||||
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
|
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
|
||||||
@@ -181,10 +156,10 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
|
|||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||||
|
|
||||||
// "default" is created last, so it is the current vault.
|
// "default" is created last, so it is the current vault.
|
||||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
@@ -230,7 +205,7 @@ func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
stateDir := t.TempDir()
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -68,7 +68,7 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
|
|||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
for _, name := range []string{"work", "default"} {
|
for _, name := range []string{"work", "default"} {
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
|
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
@@ -90,8 +90,6 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
|
|||||||
// snapshotStateDir maps every file under the state directory to its
|
// snapshotStateDir maps every file under the state directory to its
|
||||||
// contents, and every directory, written with a trailing "/", to "". Two
|
// contents, and every directory, written with a trailing "/", to "". Two
|
||||||
// snapshots are equal only if nothing in it was added, removed or changed.
|
// snapshots are equal only if nothing in it was added, removed or changed.
|
||||||
// The lock file, which every command that takes the lock writes, is left
|
|
||||||
// out.
|
|
||||||
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -104,10 +102,6 @@ func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == testStateDir+"/lock" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
tree[path+"/"] = ""
|
tree[path+"/"] = ""
|
||||||
|
|
||||||
@@ -154,10 +148,11 @@ func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requireRejectedAndUnchanged runs a command on a copy of the state
|
// requireRejectedAndUnchanged runs a command on a copy of the state
|
||||||
// directory recorded in before. It requires the error want, so that a later
|
// directory recorded in before. It requires an error with exactly the
|
||||||
// check rejecting the argument does not count, and everything under the
|
// message of want, so that a later check rejecting the argument does not
|
||||||
// state directory as it was: the error alone proves nothing, since it could
|
// count, and everything under the state directory as it was: the error
|
||||||
// come after the vault had already been deleted.
|
// alone proves nothing, since it could come after the vault had already
|
||||||
|
// been deleted.
|
||||||
func requireRejectedAndUnchanged(
|
func requireRejectedAndUnchanged(
|
||||||
t *testing.T, before map[string]string, want error,
|
t *testing.T, before map[string]string, want error,
|
||||||
run func(c *cli.Instance) error,
|
run func(c *cli.Instance) error,
|
||||||
@@ -169,7 +164,7 @@ func requireRejectedAndUnchanged(
|
|||||||
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
||||||
|
|
||||||
require.Equal(t, before, snapshotStateDir(t, fs))
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||||
require.ErrorIs(t, err, want)
|
require.EqualError(t, err, want.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
||||||
@@ -193,75 +188,76 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
|
rejected string // the secret name the command must reject
|
||||||
run func(c *cli.Instance) error
|
run func(c *cli.Instance) error
|
||||||
}{
|
}{
|
||||||
{"rm --force ..", func(c *cli.Instance) error {
|
{"rm --force ..", "..", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "..", true)
|
return c.RemoveSecret(cmd, "..", true)
|
||||||
}},
|
}},
|
||||||
{"rm --force .", func(c *cli.Instance) error {
|
{"rm --force .", ".", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, ".", true)
|
return c.RemoveSecret(cmd, ".", true)
|
||||||
}},
|
}},
|
||||||
{`rm --force ""`, func(c *cli.Instance) error {
|
{`rm --force ""`, "", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "", true)
|
return c.RemoveSecret(cmd, "", true)
|
||||||
}},
|
}},
|
||||||
{"rm --force ../../etc", func(c *cli.Instance) error {
|
{"rm --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "../../etc", true)
|
return c.RemoveSecret(cmd, "../../etc", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force .. x", func(c *cli.Instance) error {
|
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "..", "x", true)
|
return c.MoveSecret(cmd, "..", "x", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force x ..", func(c *cli.Instance) error {
|
{"mv --force x ..", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "x", "..", true)
|
return c.MoveSecret(cmd, "x", "..", true)
|
||||||
}},
|
}},
|
||||||
{`mv --force x ""`, func(c *cli.Instance) error {
|
{`mv --force x ""`, "", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "x", "", true)
|
return c.MoveSecret(cmd, "x", "", true)
|
||||||
}},
|
}},
|
||||||
// "work" is not the current vault: a move within it must not
|
// "work" is not the current vault: a move within it must not
|
||||||
// select it when a name is rejected.
|
// select it when a name is rejected.
|
||||||
{"mv --force work:.. work:x", func(c *cli.Instance) error {
|
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force work:x work:..", func(c *cli.Instance) error {
|
{"mv --force work:x work:..", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force default:.. work", func(c *cli.Instance) error {
|
{"mv --force default:.. work", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "default:..", "work", true)
|
return c.MoveSecret(cmd, "default:..", "work", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force default:.. work:y", func(c *cli.Instance) error {
|
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force default:x work:..", func(c *cli.Instance) error {
|
{"mv --force default:x work:..", "..", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
||||||
}},
|
}},
|
||||||
{"import --force ..", func(c *cli.Instance) error {
|
{"import --force ..", "..", func(c *cli.Instance) error {
|
||||||
return c.ImportSecret(cmd, "..", missingFile, true)
|
return c.ImportSecret(cmd, "..", missingFile, true)
|
||||||
}},
|
}},
|
||||||
{"import --force .", func(c *cli.Instance) error {
|
{"import --force .", ".", func(c *cli.Instance) error {
|
||||||
return c.ImportSecret(cmd, ".", missingFile, true)
|
return c.ImportSecret(cmd, ".", missingFile, true)
|
||||||
}},
|
}},
|
||||||
{"import --force ../../etc", func(c *cli.Instance) error {
|
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
||||||
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
||||||
}},
|
}},
|
||||||
{"version list ..", func(c *cli.Instance) error {
|
{"version list ..", "..", func(c *cli.Instance) error {
|
||||||
return c.ListVersions(cmd, "..")
|
return c.ListVersions(cmd, "..")
|
||||||
}},
|
}},
|
||||||
{"version promote ..", func(c *cli.Instance) error {
|
{"version promote ..", "..", func(c *cli.Instance) error {
|
||||||
return c.PromoteVersion(cmd, "..", testVersion)
|
return c.PromoteVersion(cmd, "..", testVersion)
|
||||||
}},
|
}},
|
||||||
{"version rm --force ..", func(c *cli.Instance) error {
|
{"version rm --force ..", "..", func(c *cli.Instance) error {
|
||||||
return c.RemoveVersion(cmd, "..", testVersion, true)
|
return c.RemoveVersion(cmd, "..", testVersion, true)
|
||||||
}},
|
}},
|
||||||
{"encrypt ..", func(c *cli.Instance) error {
|
{"encrypt ..", "..", func(c *cli.Instance) error {
|
||||||
return c.Encrypt("..", "", "")
|
return c.Encrypt("..", "", "")
|
||||||
}},
|
}},
|
||||||
{"decrypt ..", func(c *cli.Instance) error {
|
{"decrypt ..", "..", func(c *cli.Instance) error {
|
||||||
return c.Decrypt("..", "", "")
|
return c.Decrypt("..", "", "")
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
requireRejectedAndUnchanged(t, before, vault.ErrInvalidSecretName, tt.run)
|
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,7 +293,9 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
for _, tt := range commands {
|
for _, tt := range commands {
|
||||||
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
||||||
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
||||||
requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound,
|
want := fmt.Errorf("version '%s' %w '%s'",
|
||||||
|
version, vault.ErrVersionNotFound, "x")
|
||||||
|
requireRejectedAndUnchanged(t, before, want,
|
||||||
func(c *cli.Instance) error { return tt.run(c, version) })
|
func(c *cli.Instance) error { return tt.run(c, version) })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -351,7 +349,7 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
|||||||
for _, tt := range commands {
|
for _, tt := range commands {
|
||||||
for _, name := range []string{"", ".", "..", "a/b"} {
|
for _, name := range []string{"", ".", "..", "a/b"} {
|
||||||
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
||||||
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
|
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
|
||||||
func(c *cli.Instance) error {
|
func(c *cli.Instance) error {
|
||||||
c.Mnemonic = mnemonic
|
c.Mnemonic = mnemonic
|
||||||
c.UnlockPassphrase = passphrase
|
c.UnlockPassphrase = passphrase
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry runs the secret CLI and returns the process exit code. It wipes
|
// Entry runs the secret CLI and returns the process exit code. It wipes
|
||||||
|
|||||||
+17
-8
@@ -11,11 +11,11 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -33,6 +33,12 @@ const (
|
|||||||
// Sentinel errors for secret operations
|
// Sentinel errors for secret operations
|
||||||
var (
|
var (
|
||||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||||
|
errSecretFileTooLarge = errors.New(
|
||||||
|
"secret file too large: exceeds 100MB limit")
|
||||||
|
errSecretNotFound = errors.New("not found")
|
||||||
|
errSecretExistsNoForce = errors.New(
|
||||||
|
"already exists (use --force to overwrite)")
|
||||||
|
errVaultDoesNotExist = errors.New("does not exist")
|
||||||
errCrossVaultSourceUnqualified = errors.New(
|
errCrossVaultSourceUnqualified = errors.New(
|
||||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||||
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
||||||
@@ -667,6 +673,10 @@ func (cli *Instance) ImportSecret(
|
|||||||
|
|
||||||
buffers, totalSize, err := readSecretFromReader(file)
|
buffers, totalSize, err := readSecretFromReader(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errSecretTooLarge) {
|
||||||
|
return errSecretFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||||
}
|
}
|
||||||
defer destroyBuffers(buffers)
|
defer destroyBuffers(buffers)
|
||||||
@@ -766,7 +776,7 @@ func (cli *Instance) findSecretToRemove(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return secretToRemove{},
|
return secretToRemove{},
|
||||||
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A secret without a versions directory has no versions, and can
|
// A secret without a versions directory has no versions, and can
|
||||||
@@ -897,7 +907,7 @@ func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(vaults, name) {
|
if !slices.Contains(vaults, name) {
|
||||||
return nil, fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
|
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
||||||
@@ -928,7 +938,7 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", source, vault.ErrSecretNotFound)
|
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||||
@@ -953,8 +963,7 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
if !force {
|
if !force {
|
||||||
return fmt.Errorf("secret '%s' %w (use --force to overwrite)",
|
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||||
dest, vault.ErrSecretExists)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
||||||
@@ -1019,7 +1028,7 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
exists, err := afero.DirExists(cli.fs, srcSecretDir)
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||||
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
srcSecretName, errSecretNotFound, srcVault.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The source is removed after the copy, so a destination that is the
|
// The source is removed after the copy, so a destination that is the
|
||||||
|
|||||||
@@ -10,17 +10,57 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
"golang.org/x/sys/unix"
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testVaultName is the vault name used by the size tests.
|
// testVaultName is the vault name used by the size tests.
|
||||||
const testVaultName = "test-vault"
|
const testVaultName = "test-vault"
|
||||||
|
|
||||||
|
// lockedBytesPerSecretByte bounds the locked memory that storing a secret
|
||||||
|
// holds at once: the buffers it is read into reach up to 1.5 times its
|
||||||
|
// size, and they are then copied into one more buffer of its size.
|
||||||
|
const lockedBytesPerSecretByte = 3
|
||||||
|
|
||||||
|
// skipIfLockedMemoryTooLow skips the test when this process cannot lock
|
||||||
|
// the memory a secret of size bytes needs, found by locking a buffer of
|
||||||
|
// that size and releasing it. memguard panics, ending the whole test run,
|
||||||
|
// when it cannot lock a buffer, and a plain `docker build .` runs the
|
||||||
|
// tests under an 8 MiB locked-memory limit (RLIMIT_MEMLOCK). A process
|
||||||
|
// allowed to lock past that limit runs every case.
|
||||||
|
func skipIfLockedMemoryTooLow(t *testing.T, size int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
need := lockedBytesPerSecretByte * size
|
||||||
|
|
||||||
|
buf, err := unix.Mmap(-1, 0, need,
|
||||||
|
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
lockErr := unix.Mlock(buf)
|
||||||
|
|
||||||
|
// Unmapping the buffer also unlocks it.
|
||||||
|
err = unix.Munmap(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if lockErr != nil {
|
||||||
|
var limit unix.Rlimit
|
||||||
|
|
||||||
|
err = unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Skipf("a %d-byte secret needs up to %d bytes of locked memory, "+
|
||||||
|
"which could not be locked under the locked-memory limit "+
|
||||||
|
"(RLIMIT_MEMLOCK) of %d bytes: %v",
|
||||||
|
size, need, limit.Cur, lockErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newSizeTestVault creates an in-memory vault unlocked with the test
|
// newSizeTestVault creates an in-memory vault unlocked with the test
|
||||||
// mnemonic and returns the filesystem and vault.
|
// mnemonic and returns the filesystem and vault.
|
||||||
//
|
//
|
||||||
@@ -31,8 +71,7 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
@@ -53,9 +92,10 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
||||||
// verifies that it is stored.
|
// verifies the outcome.
|
||||||
func runAddSecretSizeCase(t *testing.T, size int) {
|
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
skipIfLockedMemoryTooLow(t, size)
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
@@ -86,6 +126,14 @@ func runAddSecretSizeCase(t *testing.T, size int) {
|
|||||||
// Test adding the secret
|
// Test adding the secret
|
||||||
secretName := fmt.Sprintf("test-secret-%d", size)
|
secretName := fmt.Sprintf("test-secret-%d", size)
|
||||||
err = cli.AddSecret(secretName, false)
|
err = cli.AddSecret(secretName, false)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
@@ -99,9 +147,10 @@ func runAddSecretSizeCase(t *testing.T, size int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runImportSecretSizeCase imports a secret file of the given size and
|
// runImportSecretSizeCase imports a secret file of the given size and
|
||||||
// verifies that it is stored.
|
// verifies the outcome.
|
||||||
func runImportSecretSizeCase(t *testing.T, size int) {
|
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
skipIfLockedMemoryTooLow(t, size)
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
@@ -130,6 +179,14 @@ func runImportSecretSizeCase(t *testing.T, size int) {
|
|||||||
// Test importing the secret
|
// Test importing the secret
|
||||||
secretName := fmt.Sprintf("imported-secret-%d", size)
|
secretName := fmt.Sprintf("imported-secret-%d", size)
|
||||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
@@ -144,73 +201,127 @@ func runImportSecretSizeCase(t *testing.T, size int) {
|
|||||||
|
|
||||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestAddSecretVariousSizes(t *testing.T) {
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size int
|
size int
|
||||||
|
shouldError bool
|
||||||
|
errorMsg string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "1KB secret",
|
name: "1KB secret",
|
||||||
size: 1024,
|
size: 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "10KB secret",
|
name: "10KB secret",
|
||||||
size: 10 * 1024,
|
size: 10 * 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "100KB secret",
|
name: "100KB secret",
|
||||||
size: 100 * 1024,
|
size: 100 * 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "1MB secret",
|
name: "1MB secret",
|
||||||
size: 1024 * 1024,
|
size: 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10MB secret",
|
||||||
|
size: 10 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "99MB secret",
|
||||||
|
size: 99 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100MB secret minus 1 byte",
|
||||||
|
size: 100*1024*1024 - 1,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "101MB secret - should fail",
|
||||||
|
size: 101 * 1024 * 1024,
|
||||||
|
shouldError: true,
|
||||||
|
errorMsg: "secret too large: exceeds 100MB limit",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
runAddSecretSizeCase(t, tt.size)
|
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestImportSecretVariousSizes(t *testing.T) {
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size int
|
size int
|
||||||
|
shouldError bool
|
||||||
|
errorMsg string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "1KB file",
|
name: "1KB file",
|
||||||
size: 1024,
|
size: 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "10KB file",
|
name: "10KB file",
|
||||||
size: 10 * 1024,
|
size: 10 * 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "100KB file",
|
name: "100KB file",
|
||||||
size: 100 * 1024,
|
size: 100 * 1024,
|
||||||
|
shouldError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "1MB file",
|
name: "1MB file",
|
||||||
size: 1024 * 1024,
|
size: 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10MB file",
|
||||||
|
size: 10 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "99MB file",
|
||||||
|
size: 99 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100MB file",
|
||||||
|
size: 100 * 1024 * 1024,
|
||||||
|
shouldError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "101MB file - should fail",
|
||||||
|
size: 101 * 1024 * 1024,
|
||||||
|
shouldError: true,
|
||||||
|
errorMsg: "secret file too large: exceeds 100MB limit",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
runImportSecretSizeCase(t, tt.size)
|
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Test various sizes that should trigger buffer growth
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
@@ -229,10 +340,13 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
131072, // 128KB
|
131072, // 128KB
|
||||||
524288, // 512KB
|
524288, // 512KB
|
||||||
1048576, // 1MB
|
1048576, // 1MB
|
||||||
|
2097152, // 2MB
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||||
|
skipIfLockedMemoryTooLow(t, size)
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
// Create test data of exactly the specified size
|
// Create test data of exactly the specified size
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
||||||
// value to stdout, not stderr
|
// value to stdout, not stderr
|
||||||
func TestGetCommandOutputsToStdout(t *testing.T) {
|
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||||
t.Parallel()
|
// Create a temporary directory for our vault
|
||||||
|
|
||||||
// Create a temporary directory for our vault; each command is given it
|
|
||||||
// in its environment
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Set environment variables for the test
|
||||||
|
t.Setenv(secret.EnvStateDir, tempDir)
|
||||||
|
|
||||||
// Find the secret binary path
|
// Find the secret binary path
|
||||||
wd, err := filepath.Abs("../..")
|
wd, err := filepath.Abs("../..")
|
||||||
require.NoError(t, err, "should get working directory")
|
require.NoError(t, err, "should get working directory")
|
||||||
@@ -41,18 +41,6 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
|||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "init should succeed: %s", string(output))
|
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
|
// Add a secret
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package cli_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:paralleltest // executes the CLI in-process against shared state
|
//nolint:paralleltest // executes the CLI in-process against shared state
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
// Unlock Failure Tests
|
|
||||||
//
|
|
||||||
// When a vault cannot be opened through its current unlocker, because a
|
|
||||||
// file the unlocker needs is missing or the passphrase is wrong, the error
|
|
||||||
// keeps its cause and ends by saying that the mnemonic still opens that
|
|
||||||
// vault, but only for a vault that the mnemonic does open, and not when the
|
|
||||||
// passphrase could not be read at all. When a secret's current file is
|
|
||||||
// missing, the error says how to make a version current again. Each test
|
|
||||||
// that pins such advice also follows it.
|
|
||||||
|
|
||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// mnemonicAdvice ends the error when the current vault "default", which
|
|
||||||
// its mnemonic opens, cannot be opened through its current unlocker.
|
|
||||||
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
|
|
||||||
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
|
|
||||||
"to the mnemonic to give it a new unlocker"
|
|
||||||
|
|
||||||
// versionAdvice ends the error when a secret's current file cannot be
|
|
||||||
// read.
|
|
||||||
versionAdvice = "; this file only names the current version: " +
|
|
||||||
"'secret version list' lists the secret's versions, and " +
|
|
||||||
"'secret version promote' makes one of them current"
|
|
||||||
|
|
||||||
// unlockTestVaultDir is the directory of the vault "default" of
|
|
||||||
// newTwoVaultFs, the current vault, whose secret "x" is "value".
|
|
||||||
unlockTestVaultDir = testStateDir + "/vaults.d/default"
|
|
||||||
)
|
|
||||||
|
|
||||||
// currentUnlockerDir returns the directory of the current unlocker of the
|
|
||||||
// vault in vaultDir on fs.
|
|
||||||
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
unlockerName, err := afero.ReadFile(fs,
|
|
||||||
filepath.Join(vaultDir, "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
|
||||||
}
|
|
||||||
|
|
||||||
// newUnlockTestCLI returns the directory of the current unlocker of the
|
|
||||||
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
|
|
||||||
// instance on fs that has the unlock passphrase, as from the environment,
|
|
||||||
// but not the mnemonic.
|
|
||||||
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
||||||
|
|
||||||
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
|
|
||||||
}
|
|
||||||
|
|
||||||
// discardCmd returns a command whose output is discarded.
|
|
||||||
func discardCmd() *cobra.Command {
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(io.Discard)
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSecret returns what `secret get name` prints.
|
|
||||||
func getSecret(t *testing.T, c *cli.Instance, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
require.NoError(t, c.GetSecret(cmd, name))
|
|
||||||
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
|
|
||||||
// file that opening the vault through its current unlocker needs is
|
|
||||||
// missing: it keeps the cause, which names the file, and ends with the
|
|
||||||
// advice that the mnemonic still opens the vault. The test then follows
|
|
||||||
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
|
|
||||||
// the vault a new unlocker, which opens it.
|
|
||||||
func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
file string // the file removed
|
|
||||||
inVaultDir bool // the file is the vault's, not the unlocker's
|
|
||||||
want string // the message before the cause
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
file: "current-unlocker",
|
|
||||||
inVaultDir: true,
|
|
||||||
want: "failed to unlock vault: failed to get long-term key: " +
|
|
||||||
"failed to get current unlocker: " +
|
|
||||||
"failed to read current unlocker: ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "priv.age",
|
|
||||||
want: "failed to unlock vault: failed to get long-term key: " +
|
|
||||||
"failed to get unlocker identity: " +
|
|
||||||
"failed to read unlocker private key: ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: "longterm.age",
|
|
||||||
want: "failed to unlock vault: failed to get long-term key: " +
|
|
||||||
"failed to read encrypted long-term private key: ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
|
||||||
|
|
||||||
path := filepath.Join(unlockerDir, tt.file)
|
|
||||||
|
|
||||||
if tt.inVaultDir {
|
|
||||||
path = filepath.Join(unlockTestVaultDir, tt.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, fs.Remove(path))
|
|
||||||
|
|
||||||
err := c.GetSecret(discardCmd(), "x")
|
|
||||||
|
|
||||||
var cause *os.PathError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
require.ErrorIs(t, err, os.ErrNotExist)
|
|
||||||
assert.Equal(t, path, cause.Path)
|
|
||||||
|
|
||||||
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
|
|
||||||
|
|
||||||
c.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
|
||||||
|
|
||||||
c.Mnemonic = nil
|
|
||||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
|
||||||
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
|
||||||
// error and ends with the advice that the mnemonic still opens the vault.
|
|
||||||
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
|
||||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
|
||||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
||||||
|
|
||||||
err := c.GetSecret(discardCmd(), "x")
|
|
||||||
|
|
||||||
var noMatch *age.NoIdentityMatchError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &noMatch)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to unlock vault: "+
|
|
||||||
"failed to get long-term key: failed to get unlocker identity: "+
|
|
||||||
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
|
||||||
noMatch.Error()+mnemonicAdvice)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
|
||||||
// the vault "work", which is not the current vault, when "work" cannot be
|
|
||||||
// opened through its current unlocker: the advice names "work" and says to
|
|
||||||
// select it first, since `secret unlocker add` acts on the current vault.
|
|
||||||
// The test then follows that advice, and the move succeeds.
|
|
||||||
func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
_, c := newUnlockTestCLI(t, fs)
|
|
||||||
|
|
||||||
path := filepath.Join(
|
|
||||||
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
|
|
||||||
require.NoError(t, fs.Remove(path))
|
|
||||||
|
|
||||||
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
|
|
||||||
|
|
||||||
var cause *os.PathError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
assert.Equal(t, path, cause.Path)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
|
|
||||||
"failed to get unlocker identity: failed to read unlocker private key: "+
|
|
||||||
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
|
|
||||||
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
|
|
||||||
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
|
|
||||||
|
|
||||||
require.NoError(t, c.SelectVault(discardCmd(), "work"))
|
|
||||||
|
|
||||||
c.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
|
||||||
|
|
||||||
c.Mnemonic = nil
|
|
||||||
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
|
|
||||||
assert.Equal(t, "value", getSecret(t, c, "y"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
|
||||||
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
|
||||||
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
|
||||||
// and adding one would need a passphrase read the same way, so the error
|
|
||||||
// is the cause alone, without the advice to use the mnemonic.
|
|
||||||
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
||||||
defer mnemonic.Destroy()
|
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
defer passphrase.Destroy()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(
|
|
||||||
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("x", value, false))
|
|
||||||
|
|
||||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
||||||
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
|
|
||||||
cmd.Env = []string{
|
|
||||||
secret.EnvStateDir + "=" + stateDir,
|
|
||||||
"PATH=" + os.Getenv("PATH"),
|
|
||||||
"HOME=" + os.Getenv("HOME"),
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "Error: failed to unlock vault: "+
|
|
||||||
"failed to get long-term key: failed to get unlocker identity: "+
|
|
||||||
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
|
||||||
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
|
||||||
"run interactively\n", string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
|
||||||
// `secret decrypt`, reading the key secret, end with the same advice as
|
|
||||||
// `secret get` when the vault cannot be opened through its current
|
|
||||||
// unlocker.
|
|
||||||
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
command string
|
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
|
||||||
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
|
|
||||||
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
|
||||||
|
|
||||||
path := filepath.Join(unlockerDir, "priv.age")
|
|
||||||
require.NoError(t, fs.Remove(path))
|
|
||||||
|
|
||||||
err := tt.run(c)
|
|
||||||
|
|
||||||
var cause *os.PathError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
assert.Equal(t, path, cause.Path)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to get secret value: "+
|
|
||||||
"failed to unlock vault: failed to get long-term key: "+
|
|
||||||
"failed to get unlocker identity: "+
|
|
||||||
"failed to read unlocker private key: "+cause.Error()+
|
|
||||||
mnemonicAdvice)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
|
|
||||||
// get` when the secret's current file is missing: it keeps the cause, which
|
|
||||||
// names the file, and ends with the advice that says how to make a version
|
|
||||||
// current again. The test then follows that advice.
|
|
||||||
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
|
||||||
_, c := newUnlockTestCLI(t, fs)
|
|
||||||
|
|
||||||
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
|
|
||||||
path := filepath.Join(secretDir, "current")
|
|
||||||
require.NoError(t, fs.Remove(path))
|
|
||||||
|
|
||||||
err := c.GetSecret(discardCmd(), "x")
|
|
||||||
|
|
||||||
var cause *os.PathError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
require.ErrorIs(t, err, os.ErrNotExist)
|
|
||||||
assert.Equal(t, path, cause.Path)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to get current version: "+
|
|
||||||
"failed to read current version file: "+cause.Error()+versionAdvice)
|
|
||||||
|
|
||||||
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 1)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
require.NoError(t, c.ListVersions(cmd, "x"))
|
|
||||||
assert.Contains(t, out.String(), versions[0].Name())
|
|
||||||
|
|
||||||
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
|
|
||||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
|
|
||||||
// created without a mnemonic, which no mnemonic opens, gets no advice to
|
|
||||||
// use one: `secret unlocker add passphrase` there fails with the cause
|
|
||||||
// alone.
|
|
||||||
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
||||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
||||||
|
|
||||||
err = c.UnlockersAdd("passphrase", discardCmd())
|
|
||||||
|
|
||||||
var cause *os.PathError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to get long-term key: "+
|
|
||||||
"failed to get current unlocker: failed to read current unlocker: "+
|
|
||||||
cause.Error())
|
|
||||||
}
|
|
||||||
+163
-25
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,10 +14,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Unlocker type names and platform identifiers shared across the CLI
|
// Unlocker type names and platform identifiers shared across the CLI
|
||||||
@@ -39,10 +38,15 @@ var (
|
|||||||
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
||||||
errKeyIDOnlyForPGP = errors.New(
|
errKeyIDOnlyForPGP = errors.New(
|
||||||
"--keyid flag is only valid for PGP unlockers")
|
"--keyid flag is only valid for PGP unlockers")
|
||||||
|
errKeychainMacOSOnly = errors.New(
|
||||||
|
"keychain unlockers are only supported on macOS")
|
||||||
|
errSecureEnclaveMacOSOnly = errors.New(
|
||||||
|
"secure enclave unlockers are only supported on macOS")
|
||||||
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
||||||
// composes "GPG key <id> is already added as an unlocker".
|
// composes "GPG key <id> is already added as an unlocker".
|
||||||
errGPGKeyAlreadyUnlocker = errors.New(
|
errGPGKeyAlreadyUnlocker = errors.New(
|
||||||
"is already added as an unlocker")
|
"is already added as an unlocker")
|
||||||
|
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||||
)
|
)
|
||||||
|
|
||||||
// UnlockerInfo represents unlocker information for display
|
// UnlockerInfo represents unlocker information for display
|
||||||
@@ -309,8 +313,91 @@ func newUnlockerSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersList lists unlockers in the current vault, each under its ID,
|
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
||||||
// the name of its directory in unlockers.d
|
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
||||||
|
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
||||||
|
func unlockerIDFromDir(
|
||||||
|
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
||||||
|
includeSecureEnclave bool,
|
||||||
|
) string {
|
||||||
|
// Create the appropriate unlocker instance
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
|
||||||
|
switch metadata.Type {
|
||||||
|
case unlockerTypePassphrase:
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypeKeychain:
|
||||||
|
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypePGP:
|
||||||
|
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypeSecureEnclave:
|
||||||
|
if includeSecureEnclave {
|
||||||
|
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlocker.GetID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
||||||
|
// stored metadata matches the given type and creation time and returns
|
||||||
|
// the matching unlocker's ID. It returns ("", nil) when the directory is
|
||||||
|
// readable but holds no match, and a non-nil error when the directory
|
||||||
|
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||||
|
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||||
|
// to be skipped rather than reported under a synthesized ID.
|
||||||
|
//
|
||||||
|
// A metadata file that cannot be read or parsed is skipped without a
|
||||||
|
// warning: every caller gets metadata from vault.ListUnlockers first,
|
||||||
|
// which has already warned about that directory.
|
||||||
|
func findUnlockerIDByMetadata(
|
||||||
|
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||||
|
includeSecureEnclave bool,
|
||||||
|
) (string, error) {
|
||||||
|
files, err := afero.ReadDir(fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
// Check if this is the right unlocker by comparing metadata
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type &&
|
||||||
|
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
||||||
|
includeSecureEnclave), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockersList lists unlockers in the current vault
|
||||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
@@ -326,23 +413,58 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
currentUnlockerID = currentUnlocker.GetID()
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerMetadata, err := vlt.ListUnlockers()
|
// Get the metadata first
|
||||||
|
unlockerMetadataList, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load actual unlocker objects to get the proper IDs
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
|
|
||||||
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
for _, metadata := range unlockerMetadataList {
|
||||||
metadata := unlockerMetadata[unlockerID]
|
// Create unlocker instance to get the proper ID
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not get vault directory while listing unlockers",
|
||||||
|
"error", err)
|
||||||
|
|
||||||
unlockers = append(unlockers, UnlockerInfo{
|
continue
|
||||||
ID: unlockerID,
|
}
|
||||||
|
|
||||||
|
// Find the unlocker directory by type and created time
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
|
unlockerID, err := findUnlockerIDByMetadata(
|
||||||
|
cli.fs, unlockersDir, metadata, true,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not read unlockers directory, skipping unlocker",
|
||||||
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the proper ID using the unlocker's ID() method
|
||||||
|
var properID string
|
||||||
|
if unlockerID != "" {
|
||||||
|
properID = unlockerID
|
||||||
|
} else {
|
||||||
|
// Generate ID as fallback
|
||||||
|
properID = fmt.Sprintf("%s-%s",
|
||||||
|
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||||
|
secret.Warn("Could not create unlocker instance, using fallback ID",
|
||||||
|
"fallback_id", properID, "type", metadata.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerInfo := UnlockerInfo{
|
||||||
|
ID: properID,
|
||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
IsCurrent: unlockerID == currentUnlockerID,
|
IsCurrent: properID == currentUnlockerID,
|
||||||
})
|
}
|
||||||
|
unlockers = append(unlockers, unlockerInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -434,7 +556,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("%w: %s (supported: %s)",
|
return fmt.Errorf("%w: %s (supported: %s)",
|
||||||
errInvalidUnlockerType, unlockerType, supportedTypes)
|
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +591,7 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
}
|
}
|
||||||
@@ -489,6 +611,10 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
||||||
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
||||||
|
if runtime.GOOS != platformDarwin {
|
||||||
|
return errKeychainMacOSOnly
|
||||||
|
}
|
||||||
|
|
||||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(
|
||||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -516,6 +642,10 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
|||||||
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
||||||
// current vault
|
// current vault
|
||||||
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
||||||
|
if runtime.GOOS != platformDarwin {
|
||||||
|
return errSecureEnclaveMacOSOnly
|
||||||
|
}
|
||||||
|
|
||||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
|
||||||
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -567,7 +697,9 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this GPG key is already added
|
// Check if this GPG key is already added
|
||||||
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
expectedID := "pgp-" + fingerprint
|
||||||
|
|
||||||
|
exists, err := cli.checkUnlockerExists(vlt, expectedID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not check whether GPG key %s is already an unlocker: %w",
|
"could not check whether GPG key %s is already an unlocker: %w",
|
||||||
@@ -672,7 +804,13 @@ func (cli *Instance) findUnlockerToRemove(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(unlockers) == 1 {
|
if len(unlockers) == 1 {
|
||||||
_, found.last = unlockers[unlockerID]
|
lastID, err := findUnlockerIDByMetadata(
|
||||||
|
cli.fs, unlockersDir, unlockers[0], true)
|
||||||
|
if err != nil {
|
||||||
|
return unlockerToRemove{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found.last = lastID == unlockerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerID may instead name a directory left out of the list. If its
|
// unlockerID may instead name a directory left out of the list. If its
|
||||||
@@ -751,16 +889,16 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|||||||
return vlt.SelectUnlocker(unlockerID)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
// checkUnlockerExists reports whether the vault already has an unlocker
|
||||||
// for the GPG key with the given fingerprint. It returns an error, and no
|
// with the given ID. It returns an error, and no answer, when unlockers.d
|
||||||
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
// or an unlocker's metadata file cannot be read; the caller must then not
|
||||||
// the caller must then not create the unlocker. It reads unlockers.d itself
|
// create the unlocker. It reads unlockers.d itself because
|
||||||
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
|
// vault.ListUnlockers skips an unlocker it cannot read, which suits
|
||||||
// `unlocker list` but not this check: the skipped unlocker may be the
|
// `unlocker list` but not this check: the skipped unlocker may be the
|
||||||
// duplicate. A directory whose metadata file is missing or corrupt is not
|
// duplicate. A directory whose metadata file is missing or corrupt is not
|
||||||
// a working unlocker and is passed over.
|
// a working unlocker and is passed over.
|
||||||
func (cli *Instance) pgpUnlockerExists(
|
func (cli *Instance) checkUnlockerExists(
|
||||||
vlt *vault.Vault, fingerprint string,
|
vlt *vault.Vault, unlockerID string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -799,14 +937,14 @@ func (cli *Instance) pgpUnlockerExists(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata secret.PGPUnlockerMetadata
|
var metadata secret.UnlockerMetadata
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
|
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
||||||
@@ -48,7 +47,7 @@ func TestAddPGPUnlocker(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = vlt.AddSecret(addTestSecretName,
|
err = vlt.AddSecret(addTestSecretName,
|
||||||
@@ -99,7 +98,7 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
|||||||
|
|
||||||
err := instance.addPGPUnlocker(cmd)
|
err := instance.addPGPUnlocker(cmd)
|
||||||
|
|
||||||
require.ErrorIs(t, err, secret.ErrGPGKeyNotFound)
|
require.ErrorContains(t, err, "failed to resolve GPG key fingerprint")
|
||||||
assertDirEntries(t, base,
|
assertDirEntries(t, base,
|
||||||
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
||||||
listTestUnlockerDirOne)
|
listTestUnlockerDirOne)
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
// by its ID. These tests give the first unlocker, which sorts before the
|
// by its ID. These tests give the first unlocker, which sorts before the
|
||||||
// one the commands act on, metadata that is not JSON, and check that the
|
// one the commands act on, metadata that is not JSON, and check that the
|
||||||
// commands step past it, and that it can itself be removed by its
|
// commands step past it, and that it can itself be removed by its
|
||||||
// directory name, which `secret unlocker list` names in its warning, as can
|
// directory name, which `secret unlocker list` names in its warning. A
|
||||||
// one with no metadata file. A last test checks that an unlocker whose
|
// last test checks that an unlocker whose metadata file cannot be read
|
||||||
// metadata file cannot be read counts as the last unlocker when it is
|
// counts as the last unlocker when it is removed by its directory name.
|
||||||
// removed by its directory name.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -17,10 +16,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
||||||
@@ -46,7 +45,7 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
|||||||
fs := newCorruptUnlockerVault(t)
|
fs := newCorruptUnlockerVault(t)
|
||||||
instance, _ := newTestInstance(fs)
|
instance, _ := newTestInstance(fs)
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
|
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs,
|
current, err := afero.ReadFile(fs,
|
||||||
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
||||||
@@ -72,7 +71,7 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "the other unlocker",
|
name: "the other unlocker",
|
||||||
unlockerID: listTestUnlockerDirTwo,
|
unlockerID: "pgp-" + listTestGPGKeyID + "B",
|
||||||
wantLast: true,
|
wantLast: true,
|
||||||
wantEntries: []string{listTestUnlockerDirOne},
|
wantEntries: []string{listTestUnlockerDirOne},
|
||||||
},
|
},
|
||||||
@@ -107,34 +106,6 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
|
||||||
// of a vault with secrets, removed by its directory name when its metadata
|
// 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,
|
// file cannot be checked for or read, counts as the vault's last unlocker,
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
|
||||||
// side by side whose metadata is the same, creation time included, as
|
|
||||||
// copying an unlocker directory leaves them. It asserts that `unlocker
|
|
||||||
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
|
||||||
// give each its own ID, and that each is selected and removed by its ID
|
|
||||||
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
|
|
||||||
// get their IDs the same way.
|
|
||||||
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
vaultDir := testVaultDir(listTestVaultName)
|
|
||||||
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
|
||||||
dirNames := []string{
|
|
||||||
"passphrase-2026-10-04.12.30.00.000000000",
|
|
||||||
"passphrase-2026-10-04.12.30.00.000000000-copy",
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
|
||||||
Type: unlockerTypePassphrase,
|
|
||||||
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, dirName := range dirNames {
|
|
||||||
dir := filepath.Join(unlockersDir, dirName)
|
|
||||||
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
|
||||||
filepath.Join(dir, listTestMetadataFileName), metadata,
|
|
||||||
listTestFilePerm))
|
|
||||||
}
|
|
||||||
|
|
||||||
listed := listUnlockersJSON(t, fs)
|
|
||||||
require.Len(t, listed, len(dirNames))
|
|
||||||
|
|
||||||
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
|
|
||||||
nil, nil, "")
|
|
||||||
assert.Equal(t, dirNames, completed)
|
|
||||||
|
|
||||||
instance, cmd := newTestInstance(fs)
|
|
||||||
|
|
||||||
for i, unlocker := range listed {
|
|
||||||
assert.Equal(t, dirNames[i], unlocker.ID)
|
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
|
|
||||||
|
|
||||||
current, err := afero.ReadFile(fs,
|
|
||||||
filepath.Join(vaultDir, "current-unlocker"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, dirNames[i], string(current))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The second one first: an ID both shared would remove the first one
|
|
||||||
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
|
|
||||||
assertDirEntries(t, fs, unlockersDir, dirNames[0])
|
|
||||||
|
|
||||||
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
|
|
||||||
assertDirEntries(t, fs, unlockersDir)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
||||||
// cannot be read or used:
|
// or an unlocker's metadata in it, cannot be read while the listing is
|
||||||
|
// being rendered:
|
||||||
//
|
//
|
||||||
|
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||||
|
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||||
|
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||||
|
// still listed, with its real ID and its current-unlocker marker,
|
||||||
|
// when a later entry's scan fails.
|
||||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||||
// metadata does not stop the others from being listed.
|
// metadata does not stop the others from being listed.
|
||||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||||
// file cannot be checked for or read is left out, and the other is
|
// file cannot be checked for or read is left out, and the other is
|
||||||
// still listed.
|
// still listed.
|
||||||
|
//
|
||||||
|
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
||||||
|
// after the vault has already enumerated it. If that rescan fails the ID
|
||||||
|
// is unknowable, so the entry must be skipped: a synthesized ID matches
|
||||||
|
// no `unlocker remove` or `unlocker select` argument and would also
|
||||||
|
// suppress the current-unlocker marker.
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -21,11 +33,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,16 +48,18 @@ const (
|
|||||||
// listTestVaultName is the name of that synthetic vault.
|
// listTestVaultName is the name of that synthetic vault.
|
||||||
listTestVaultName = "default"
|
listTestVaultName = "default"
|
||||||
|
|
||||||
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
|
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
|
||||||
// in the PGP unlockers' metadata.
|
// unlocker's metadata. The unlocker's real ID is derived from it, and
|
||||||
|
// differs from the timestamp-derived fallback ID.
|
||||||
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||||
|
|
||||||
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
||||||
// directory names under unlockers.d, and so the unlockers' IDs.
|
// directory names under unlockers.d.
|
||||||
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||||
|
|
||||||
// listTestUnlockersDirName is the directory holding the unlockers.
|
// listTestUnlockersDirName is the directory the listing rescans to
|
||||||
|
// resolve unlocker IDs.
|
||||||
listTestUnlockersDirName = "unlockers.d"
|
listTestUnlockersDirName = "unlockers.d"
|
||||||
|
|
||||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||||
@@ -60,17 +74,26 @@ const (
|
|||||||
// a successful open of unlockers.d.
|
// a successful open of unlockers.d.
|
||||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||||
|
|
||||||
// unlockersDirFailFs fails every open of unlockers.d, as when the
|
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
||||||
// directory cannot be read.
|
// successfully openBudget times. This reproduces the directory becoming
|
||||||
|
// unreadable (permission change, partially restored backup, EIO) between
|
||||||
|
// the vault's own enumeration and the per-entry rescan that resolves
|
||||||
|
// unlocker IDs.
|
||||||
type unlockersDirFailFs struct {
|
type unlockersDirFailFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
|
|
||||||
|
openBudget int
|
||||||
|
opens int
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||||
if filepath.Base(name) == listTestUnlockersDirName {
|
if filepath.Base(name) == listTestUnlockersDirName {
|
||||||
|
f.opens++
|
||||||
|
if f.opens > f.openBudget {
|
||||||
return nil, errUnlockersDirUnreadable
|
return nil, errUnlockersDirUnreadable
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||||
return f.Fs.Open(name)
|
return f.Fs.Open(name)
|
||||||
@@ -119,8 +142,8 @@ func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
|||||||
return f.Fs.Stat(name)
|
return f.Fs.Stat(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
|
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||||
// metadata recording the GPG key ID keyID.
|
// yields the real ID "pgp-<keyID>".
|
||||||
func writePGPUnlocker(
|
func writePGPUnlocker(
|
||||||
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||||
createdAt time.Time, keyID string,
|
createdAt time.Time, keyID string,
|
||||||
@@ -201,6 +224,44 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
|||||||
return decoded.Unlockers
|
return decoded.Unlockers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
|
||||||
|
// which becomes unreadable after the vault enumerated it produces no rows,
|
||||||
|
// rather than rows carrying fabricated fallback IDs.
|
||||||
|
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
// Budget of one: the vault's own ListUnlockers scan succeeds, the
|
||||||
|
// per-entry rescan that resolves the ID fails.
|
||||||
|
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
|
||||||
|
assert.Empty(t, unlockers,
|
||||||
|
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
|
||||||
|
// entry survives with its real ID and current-unlocker marker when a later
|
||||||
|
// entry's rescan fails.
|
||||||
|
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 2)
|
||||||
|
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
|
||||||
|
// the second entry's rescan fails.
|
||||||
|
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
|
||||||
|
require.Len(t, unlockers, 1,
|
||||||
|
"only the entry whose directory was readable may be listed")
|
||||||
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
|
||||||
|
"the surviving row must carry the real unlocker ID")
|
||||||
|
assert.True(t, unlockers[0].IsCurrent,
|
||||||
|
"the current-unlocker marker must survive the skip")
|
||||||
|
}
|
||||||
|
|
||||||
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||||
// fully readable unlockers.d every entry is listed with its real ID.
|
// fully readable unlockers.d every entry is listed with its real ID.
|
||||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||||
@@ -211,21 +272,20 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
unlockers := listUnlockersJSON(t, base)
|
unlockers := listUnlockersJSON(t, base)
|
||||||
|
|
||||||
require.Len(t, unlockers, 2)
|
require.Len(t, unlockers, 2)
|
||||||
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
|
||||||
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
|
||||||
assert.True(t, unlockers[0].IsCurrent)
|
assert.True(t, unlockers[0].IsCurrent)
|
||||||
assert.False(t, unlockers[1].IsCurrent)
|
assert.False(t, unlockers[1].IsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
|
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
||||||
// metadata of an unknown type, are still listed, under the directory name
|
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
||||||
// like any other. The healthy unlocker is listed with its real ID.
|
|
||||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
healthyID := listTestUnlockerDirOne
|
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -240,17 +300,12 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "GPG key ID of the wrong type",
|
name: "GPG key ID of the wrong type",
|
||||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GPG key ID missing",
|
name: "GPG key ID missing",
|
||||||
metadata: `{"type": "pgp"}`,
|
metadata: `{"type": "pgp"}`,
|
||||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
wantIDs: []string{healthyID, "pgp-unknown"},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown type",
|
|
||||||
metadata: `{"type": "unknown"}`,
|
|
||||||
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +371,7 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
require.Len(t, unlockers, 1,
|
||||||
"only the unlocker with usable metadata may be listed")
|
"only the unlocker with usable metadata may be listed")
|
||||||
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
||||||
"the listed row must carry the real unlocker ID")
|
"the listed row must carry the real unlocker ID")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -290,7 +290,7 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
|||||||
writeTestSecret(t, base, vaultDir)
|
writeTestSecret(t, base, vaultDir)
|
||||||
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
||||||
|
|
||||||
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
_, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
|
||||||
|
|
||||||
require.ErrorIs(t, err, errStatFailed)
|
require.ErrorIs(t, err, errStatFailed)
|
||||||
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// usageHeading starts the usage text cobra prints after an error.
|
// usageHeading starts the usage text cobra prints after an error.
|
||||||
|
|||||||
+31
-16
@@ -10,19 +10,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for vault operations
|
// Sentinel errors for vault operations
|
||||||
var (
|
var (
|
||||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||||
|
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
|
||||||
errVaultHasLongTermKey = errors.New(
|
errVaultHasLongTermKey = errors.New(
|
||||||
"already has a long-term key configured")
|
"already has a long-term key configured")
|
||||||
errMnemonicEnvNotSet = errors.New(
|
errMnemonicEnvNotSet = errors.New(
|
||||||
@@ -249,7 +250,7 @@ func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error)
|
|||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
||||||
@@ -292,26 +293,40 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
}
|
}
|
||||||
defer cleanupPassphrase()
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
// Create the vault with its passphrase unlocker
|
// Create the vault - it will handle key derivation internally
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
|
||||||
mnemonic, passphraseBuffer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
// Get the vault metadata to retrieve the derivation index
|
||||||
|
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
||||||
|
|
||||||
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker, err := vlt.GetCurrentUnlocker()
|
// Derive the long-term key using the same index that CreateVault used
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the vault with the derived long-term key
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create passphrase-protected unlocker
|
||||||
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -352,7 +367,7 @@ func (cli *Instance) vaultImportPreflight(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
vaultName, vault.ErrVaultNotFound)
|
vaultName, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault already has a public key
|
// Check if vault already has a public key
|
||||||
@@ -380,7 +395,7 @@ func (cli *Instance) vaultImportPreflight(
|
|||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonic) {
|
if !bip39.IsMnemonicValid(mnemonic) {
|
||||||
return "", "", "", errInvalidMnemonicPhrase
|
return "", "", "", errInvalidMnemonic
|
||||||
}
|
}
|
||||||
|
|
||||||
return vaultDir, pubKeyPath, mnemonic, nil
|
return vaultDir, pubKeyPath, mnemonic, nil
|
||||||
@@ -643,7 +658,7 @@ func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
|
|||||||
|
|
||||||
if !slices.Contains(vaults, name) {
|
if !slices.Contains(vaults, name) {
|
||||||
return vaultToRemove{},
|
return vaultToRemove{},
|
||||||
fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
|
fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(vaults) == 1 {
|
if len(vaults) == 1 {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
|
|
||||||
// Sentinel errors for version operations
|
// Sentinel errors for version operations
|
||||||
var (
|
var (
|
||||||
|
errVersionNotFound = errors.New("not found for secret")
|
||||||
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found", "secret_name", secretName)
|
secret.Debug("Secret not found", "secret_name", secretName)
|
||||||
|
|
||||||
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all versions
|
// List all versions
|
||||||
@@ -288,7 +289,7 @@ func (cli *Instance) PromoteVersion(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' %w '%s'",
|
return fmt.Errorf("version '%s' %w '%s'",
|
||||||
version, vault.ErrVersionNotFound, secretName)
|
version, errVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the current symlink using the proper function
|
// Update the current symlink using the proper function
|
||||||
@@ -373,7 +374,7 @@ func (cli *Instance) findVersionToRemove(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return versionToRemove{},
|
return versionToRemove{},
|
||||||
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
@@ -385,7 +386,7 @@ func (cli *Instance) findVersionToRemove(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
|
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
|
||||||
version, vault.ErrVersionNotFound, secretName)
|
version, errVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current version
|
// Get current version
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -73,8 +73,7 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -171,7 +170,8 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
|||||||
|
|
||||||
// Try to list versions of non-existent secret
|
// Try to list versions of non-existent secret
|
||||||
err := cli.ListVersions(cmd, "nonexistent/secret")
|
err := cli.ListVersions(cmd, "nonexistent/secret")
|
||||||
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromoteVersionCommand(t *testing.T) {
|
func TestPromoteVersionCommand(t *testing.T) {
|
||||||
@@ -265,7 +265,8 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Try to promote non-existent version
|
// Try to promote non-existent version
|
||||||
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
|
||||||
require.ErrorIs(t, err, vault.ErrVersionNotFound)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSecretWithVersion(t *testing.T) {
|
func TestGetSecretWithVersion(t *testing.T) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ package macse
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -39,10 +38,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
||||||
// Returns the uncompressed public key bytes (65 bytes) and the identity hash
|
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
|
||||||
// (for deletion). If getting the public key fails, CreateKey deletes the key
|
|
||||||
// again; a failure to delete is returned along with the first error.
|
|
||||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||||
|
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||||
|
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||||
var hashBuf [hashBufferSize]C.char
|
var hashBuf [hashBufferSize]C.char
|
||||||
var errBuf [errorBufferSize]C.char
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
@@ -50,6 +49,7 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
result := C.se_create_key(cLabel,
|
result := C.se_create_key(cLabel,
|
||||||
|
&pubKeyBuf[0], &pubKeyLen,
|
||||||
&hashBuf[0], C.int(hashBufferSize),
|
&hashBuf[0], C.int(hashBufferSize),
|
||||||
&errBuf[0], C.int(errorBufferSize))
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
@@ -57,30 +57,9 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
|
||||||
h := C.GoString(&hashBuf[0])
|
h := C.GoString(&hashBuf[0])
|
||||||
|
|
||||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
|
||||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
|
||||||
|
|
||||||
result = C.se_copy_public_key(cLabel,
|
|
||||||
&pubKeyBuf[0], &pubKeyLen,
|
|
||||||
&errBuf[0], C.int(errorBufferSize))
|
|
||||||
|
|
||||||
if result != 0 {
|
|
||||||
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
|
||||||
|
|
||||||
deleteErr := DeleteKey(h)
|
|
||||||
if deleteErr != nil {
|
|
||||||
err = errors.Join(err,
|
|
||||||
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
|
||||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
|
||||||
|
|
||||||
return pk, h, nil
|
return pk, h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +83,7 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
|
||||||
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
|
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -129,8 +107,7 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
|
||||||
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
|
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
//go:build !darwin || !cgo
|
//go:build !darwin
|
||||||
|
|
||||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
|
var errNotSupported = errors.New("secure enclave is only supported on macOS")
|
||||||
|
|
||||||
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
|
// CreateKey is not supported on non-darwin platforms.
|
||||||
func CreateKey(_ string) ([]byte, string, error) {
|
func CreateKey(_ string) ([]byte, string, error) {
|
||||||
return nil, "", errNotSupported
|
return nil, "", errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
|
// Encrypt is not supported on non-darwin platforms.
|
||||||
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
|
// Decrypt is not supported on non-darwin platforms.
|
||||||
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
|
// DeleteKey is not supported on non-darwin platforms.
|
||||||
func DeleteKey(_ string) error {
|
func DeleteKey(_ string) error {
|
||||||
return errNotSupported
|
return errNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//go:build darwin && cgo
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
@@ -44,8 +45,7 @@ func TestCreateAndDeleteKey(t *testing.T) {
|
|||||||
|
|
||||||
// Verify valid uncompressed P-256 public key
|
// Verify valid uncompressed P-256 public key
|
||||||
if len(pubKey) != p256UncompressedKeySize {
|
if len(pubKey) != p256UncompressedKeySize {
|
||||||
t.Fatalf("expected public key length %d, got %d",
|
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
|
||||||
p256UncompressedKeySize, len(pubKey))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if pubKey[0] != 0x04 {
|
if pubKey[0] != 0x04 {
|
||||||
@@ -83,8 +83,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Test data simulating an age private key
|
// Test data simulating an age private key
|
||||||
plaintext := []byte("AGE-SECRET-KEY-1" +
|
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||||
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
|
||||||
|
|
||||||
// Encrypt
|
// Encrypt
|
||||||
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
|||||||
@@ -5,28 +5,18 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
||||||
// finds its identity hash. If the hash cannot be found, the key exists but
|
|
||||||
// se_create_key fails, with an error naming the label.
|
|
||||||
// label: unique identifier for the CTK identity (UTF-8 C string)
|
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||||
|
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||||
|
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||||
// hash_out: output buffer for the identity hash (for deletion)
|
// hash_out: output buffer for the identity hash (for deletion)
|
||||||
// hash_out_len: size of hash_out buffer
|
// hash_out_len: size of hash_out buffer
|
||||||
// error_out: output buffer for error message
|
// error_out: output buffer for error message
|
||||||
// error_out_len: size of error_out buffer
|
// error_out_len: size of error_out buffer
|
||||||
// Returns 0 on success, -1 on failure.
|
// Returns 0 on success, -1 on failure.
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
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,
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len);
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len) {
|
char *error_out, int error_out_len) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
@@ -86,56 +87,7 @@ int se_create_key(const char *label,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the identity hash, which deleting the key needs, by parsing
|
// Retrieve the public key from the created identity
|
||||||
// sc_auth list output
|
|
||||||
hash_out[0] = '\0';
|
|
||||||
NSTask *listTask = [[NSTask alloc] init];
|
|
||||||
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
|
||||||
listTask.arguments = @[@"list-ctk-identities"];
|
|
||||||
|
|
||||||
NSPipe *listPipe = [NSPipe pipe];
|
|
||||||
listTask.standardOutput = listPipe;
|
|
||||||
listTask.standardError = [NSPipe pipe];
|
|
||||||
|
|
||||||
if ([listTask launchAndReturnError:&nsError]) {
|
|
||||||
[listTask waitUntilExit];
|
|
||||||
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
|
||||||
NSString *listStr = [[NSString alloc] initWithData:listData
|
|
||||||
encoding:NSUTF8StringEncoding];
|
|
||||||
|
|
||||||
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
|
||||||
if ([line containsString:labelStr]) {
|
|
||||||
NSMutableArray *tokens = [NSMutableArray array];
|
|
||||||
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
|
||||||
[NSCharacterSet whitespaceCharacterSet]]) {
|
|
||||||
if (part.length > 0) {
|
|
||||||
[tokens addObject:part];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tokens.count > 1) {
|
|
||||||
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hash_out[0] == '\0') {
|
|
||||||
NSString *msg = [NSString stringWithFormat:
|
|
||||||
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
|
||||||
label];
|
|
||||||
snprintf_error(error_out, error_out_len, msg);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int se_copy_public_key(const char *label,
|
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *error_out, int error_out_len) {
|
|
||||||
@autoreleasepool {
|
|
||||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -174,6 +126,39 @@ int se_copy_public_key(const char *label,
|
|||||||
*pub_key_len = (int)length;
|
*pub_key_len = (int)length;
|
||||||
CFRelease(pubKeyData);
|
CFRelease(pubKeyData);
|
||||||
|
|
||||||
|
// Get the identity hash by parsing sc_auth list output
|
||||||
|
hash_out[0] = '\0';
|
||||||
|
NSTask *listTask = [[NSTask alloc] init];
|
||||||
|
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
listTask.arguments = @[@"list-ctk-identities"];
|
||||||
|
|
||||||
|
NSPipe *listPipe = [NSPipe pipe];
|
||||||
|
listTask.standardOutput = listPipe;
|
||||||
|
listTask.standardError = [NSPipe pipe];
|
||||||
|
|
||||||
|
if ([listTask launchAndReturnError:&nsError]) {
|
||||||
|
[listTask waitUntilExit];
|
||||||
|
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||||
|
if ([line containsString:labelStr]) {
|
||||||
|
NSMutableArray *tokens = [NSMutableArray array];
|
||||||
|
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||||
|
if (part.length > 0) {
|
||||||
|
[tokens addObject:part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.count > 1) {
|
||||||
|
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
|
|
||||||
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
|
|
||||||
const tempNamePart = ".tmp-"
|
|
||||||
|
|
||||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
||||||
// a crash at any moment, finds either the old content or the new, never a
|
// a crash at any moment, finds either the old content or the new, never a
|
||||||
// partial file. The data goes into a temporary file that afero.TempFile
|
// partial file. The data goes into a temporary file that afero.TempFile
|
||||||
@@ -22,7 +17,7 @@ const tempNamePart = ".tmp-"
|
|||||||
// temporary file is removed if any step fails.
|
// temporary file is removed if any step fails.
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
||||||
"."+filepath.Base(path)+tempNamePart+"*")
|
"."+filepath.Base(path)+".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -59,7 +54,7 @@ func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|||||||
// Its name leaves out target's, which may already be as long as a file name
|
// Its name leaves out target's, which may already be as long as a file name
|
||||||
// can be.
|
// can be.
|
||||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
||||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
|
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"failed to create temporary directory for %s: %w", target, err)
|
"failed to create temporary directory for %s: %w", target, err)
|
||||||
@@ -68,40 +63,6 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
|
|||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveLeftovers deletes from dir the temporary files of WriteFileAtomic
|
|
||||||
// and the temporary directories of TempDirFor that a command killed
|
|
||||||
// part-way left there: each entry whose name starts with "." and holds
|
|
||||||
// tempNamePart. The caller must hold the state directory lock, so that no
|
|
||||||
// running command is still using one. A dir that does not exist holds none.
|
|
||||||
func RemoveLeftovers(fs afero.Fs, dir string) error {
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if !strings.HasPrefix(name, ".") || !strings.Contains(name, tempNamePart) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
|
|
||||||
err = fs.RemoveAll(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Removed what an interrupted command left", "path", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteDir calls write to write the files of the new directory dir into a
|
// WriteDir calls write to write the files of the new directory dir into a
|
||||||
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
||||||
// neither a failure nor a crash leaves dir half-written; on a failure the
|
// neither a failure nor a crash leaves dir half-written; on a failure the
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/macse"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInjected = errors.New("injected failure")
|
var errInjected = errors.New("injected failure")
|
||||||
@@ -236,7 +235,7 @@ func newVaultWithSecret(
|
|||||||
) *vault.Vault {
|
) *vault.Vault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
buffer := memguard.NewBufferFromBytes([]byte(value))
|
||||||
@@ -353,7 +352,7 @@ func TestLongestNames(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
name := strings.Repeat("a", longestName)
|
name := strings.Repeat("a", longestName)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
|
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
value := memguard.NewBufferFromBytes([]byte("long"))
|
||||||
@@ -647,7 +646,7 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
|||||||
|
|
||||||
// No mnemonic, and no current unlocker to get the key from
|
// No mnemonic, and no current unlocker to get the key from
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
@@ -679,7 +678,7 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
|||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
@@ -728,7 +727,7 @@ func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
|||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
@@ -900,42 +899,3 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
|
||||||
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
|
||||||
// the key must be deleted again. Skipped when the add fails before that, as
|
|
||||||
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
|
||||||
// Enclave.
|
|
||||||
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
|
||||||
var seKeyLabel string
|
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
|
||||||
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
|
||||||
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
|
||||||
|
|
||||||
return errInjected
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
|
||||||
nil)
|
|
||||||
|
|
||||||
if seKeyLabel == "" {
|
|
||||||
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, errInjected)
|
|
||||||
|
|
||||||
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
|
||||||
assert.Error(t, err, "Secure Enclave key left behind")
|
|
||||||
}
|
|
||||||
|
|||||||
+19
-72
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -17,28 +16,16 @@ import (
|
|||||||
var (
|
var (
|
||||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||||
errStdinNotTerminal = errors.New(
|
errStdinNotTerminal = errors.New(
|
||||||
"stdin is not a terminal (piped input or script)")
|
"cannot read passphrase from non-terminal stdin " +
|
||||||
|
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||||
|
"environment variable or run interactively")
|
||||||
errStderrNotTerminal = errors.New(
|
errStderrNotTerminal = errors.New(
|
||||||
"stderr is not a terminal (running in non-interactive mode)")
|
"cannot prompt for passphrase: stderr is not a terminal " +
|
||||||
errNothingEntered = errors.New("nothing was entered")
|
"(running in non-interactive mode). Please set the " +
|
||||||
|
"SB_UNLOCK_PASSPHRASE environment variable")
|
||||||
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
|
||||||
// terminal to read the mnemonic from, reading it failed, or it was empty.
|
|
||||||
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
|
|
||||||
|
|
||||||
// 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
|
// EncryptToRecipient encrypts data to a recipient using age
|
||||||
// The data parameter should be a LockedBuffer for secure memory handling
|
// The data parameter should be a LockedBuffer for secure memory handling
|
||||||
func EncryptToRecipient(
|
func EncryptToRecipient(
|
||||||
@@ -115,23 +102,6 @@ func DecryptWithIdentity(
|
|||||||
return resultBuffer, nil
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
|
|
||||||
// a new locked buffer. The caller must destroy it.
|
|
||||||
//
|
|
||||||
// This is best effort. age gives the key only as a string in ordinary memory.
|
|
||||||
// The bytes of that string are moved into the buffer, which overwrites them,
|
|
||||||
// although Go otherwise never changes a string; nothing else holds this one.
|
|
||||||
// The copies age makes while building the string are left in ordinary memory.
|
|
||||||
// Avoiding those would mean encoding the key here, straight into the buffer.
|
|
||||||
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
|
|
||||||
key := id.String()
|
|
||||||
|
|
||||||
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
|
|
||||||
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
|
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes(keyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
||||||
// scrypt-based encryption. Both data and passphrase parameters should
|
// scrypt-based encryption. Both data and passphrase parameters should
|
||||||
// be LockedBuffers for secure memory handling
|
// be LockedBuffers for secure memory handling
|
||||||
@@ -153,10 +123,6 @@ func EncryptWithPassphrase(
|
|||||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ScryptWorkFactor != 0 {
|
|
||||||
recipient.SetWorkFactor(ScryptWorkFactor)
|
|
||||||
}
|
|
||||||
|
|
||||||
return EncryptToRecipient(data, recipient)
|
return EncryptToRecipient(data, recipient)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,61 +148,42 @@ func DecryptWithPassphrase(
|
|||||||
|
|
||||||
// ReadPassphrase reads a passphrase securely from the terminal without echoing
|
// ReadPassphrase reads a passphrase securely from the terminal without echoing
|
||||||
// This version is for unlocking and doesn't require confirmation
|
// This version is for unlocking and doesn't require confirmation
|
||||||
// Returns a LockedBuffer containing the passphrase for secure memory handling.
|
// Returns a LockedBuffer containing the passphrase for secure memory handling
|
||||||
// Every error it returns wraps ErrPassphraseNotRead.
|
|
||||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||||
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a
|
|
||||||
// passphrase. Every error it returns wraps ErrMnemonicNotRead.
|
|
||||||
func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
|
|
||||||
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readFromTerminal reads input from the terminal without echoing it. Every
|
|
||||||
// error it returns wraps notRead; without a terminal, the error says to set
|
|
||||||
// envVar instead.
|
|
||||||
func readFromTerminal(
|
|
||||||
prompt string, notRead error, envVar string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
// Check if stdin is a terminal
|
// Check if stdin is a terminal
|
||||||
if !term.IsTerminal(syscall.Stdin) {
|
if !term.IsTerminal(syscall.Stdin) {
|
||||||
// Not a terminal - never read secrets from piped input
|
// Not a terminal - never read passphrases from piped input
|
||||||
// for security reasons
|
// for security reasons
|
||||||
return nil, fmt.Errorf(
|
return nil, errStdinNotTerminal
|
||||||
"%w: %w. Please set the %s environment variable or run interactively",
|
|
||||||
notRead, errStdinNotTerminal, envVar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stdin is a terminal, check if stderr is also a terminal for
|
// stdin is a terminal, check if stderr is also a terminal for
|
||||||
// interactive prompting
|
// interactive prompting
|
||||||
if !term.IsTerminal(syscall.Stderr) {
|
if !term.IsTerminal(syscall.Stderr) {
|
||||||
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
return nil, errStderrNotTerminal
|
||||||
notRead, errStderrNotTerminal, envVar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both stdin and stderr are terminals - use secure password reading
|
// Both stdin and stderr are terminals - use secure password reading
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||||
|
|
||||||
input, err := term.ReadPassword(syscall.Stdin)
|
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print newline to stderr since ReadPassword doesn't echo
|
// Print newline to stderr since ReadPassword doesn't echo
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
|
|
||||||
if len(input) == 0 {
|
if len(passphrase) == 0 {
|
||||||
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
return nil, errEmptyPassphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer and copy the input
|
// Create a secure buffer and copy the passphrase
|
||||||
secureBuffer := memguard.NewBufferFromBytes(input)
|
secureBuffer := memguard.NewBufferFromBytes(passphrase)
|
||||||
|
|
||||||
// Clear the original input slice
|
// Clear the original passphrase slice
|
||||||
for i := range input {
|
for i := range passphrase {
|
||||||
input[i] = 0
|
passphrase[i] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return secureBuffer, nil
|
return secureBuffer, nil
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package secret_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
|
|
||||||
// private key, and that the identity still gives that key afterwards: the
|
|
||||||
// helper overwrites the string age returned, so age must not keep it.
|
|
||||||
func TestIdentityToLockedBuffer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
buffer := secret.IdentityToLockedBuffer(identity)
|
|
||||||
defer buffer.Destroy()
|
|
||||||
|
|
||||||
parsed, err := age.ParseX25519Identity(buffer.String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
|
|
||||||
|
|
||||||
assert.Equal(t, identity.String(), buffer.String())
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,11 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
||||||
@@ -29,43 +28,21 @@ func (v *realVault) GetDirectory() (string, error) {
|
|||||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||||
}
|
}
|
||||||
func (v *realVault) GetName() string { return v.name }
|
func (v *realVault) GetName() string { return v.name }
|
||||||
|
|
||||||
//nolint:ireturn // implements VaultInterface
|
|
||||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||||
|
|
||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
||||||
panic("not used")
|
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") }
|
||||||
//nolint:ireturn // implements VaultInterface
|
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) { panic("not used") }
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
|
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) CreatePassphraseUnlocker(
|
|
||||||
*memguard.LockedBuffer,
|
|
||||||
) (*PassphraseUnlocker, error) {
|
|
||||||
panic("not used")
|
panic("not used")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||||
// filesystem, identical to what vault.CreateVault produces.
|
// filesystem, identical to what vault.CreateVault produces.
|
||||||
func createRealVault(
|
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
|
||||||
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
|
|
||||||
) *realVault {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
@@ -78,8 +55,7 @@ func createRealVault(
|
|||||||
}
|
}
|
||||||
metaBytes, err := json.Marshal(metadata)
|
metaBytes, err := json.Marshal(metadata)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs,
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||||
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
|
||||||
|
|
||||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||||
}
|
}
|
||||||
@@ -87,9 +63,7 @@ func createRealVault(
|
|||||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||||
t.Parallel()
|
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"
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
||||||
"abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Derive expected keys at two different indices to prove they differ.
|
// Derive expected keys at two different indices to prove they differ.
|
||||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
@@ -108,7 +82,6 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
|||||||
|
|
||||||
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer result.Destroy()
|
defer result.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package secret_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||||
|
|||||||
+236
-217
@@ -1,53 +1,39 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
//
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
//nolint:revive // ALL_CAPS is intentional for this constant
|
|
||||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
|
||||||
|
|
||||||
// keychainUnlockerType is the metadata type string for keychain unlockers.
|
|
||||||
keychainUnlockerType = "keychain"
|
|
||||||
|
|
||||||
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
|
|
||||||
macOSFlag = "macos"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
var (
|
|
||||||
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
|
|
||||||
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
|
|
||||||
errUnsupportedCurrentUnlocker = errors.New(
|
|
||||||
"unsupported current unlocker type for keychain unlocker creation")
|
|
||||||
)
|
|
||||||
|
|
||||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
// Keychain item name
|
// Keychain item name
|
||||||
KeychainItemName string `json:"keychainItemName"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
@@ -59,17 +45,6 @@ type KeychainUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
|
||||||
func NewKeychainUnlocker(
|
|
||||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
|
||||||
) *KeychainUnlocker {
|
|
||||||
return &KeychainUnlocker{
|
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
DebugWith("Getting keychain unlocker identity",
|
DebugWith("Getting keychain unlocker identity",
|
||||||
@@ -77,20 +52,50 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
slog.String("unlocker_type", k.GetType()),
|
slog.String("unlocker_type", k.GetType()),
|
||||||
)
|
)
|
||||||
|
|
||||||
keychainData, err := k.readKeychainData()
|
// Step 1: Get keychain item name
|
||||||
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
||||||
}
|
}
|
||||||
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
||||||
|
|
||||||
|
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
||||||
|
|
||||||
// Step 4: Read the encrypted age private key from filesystem
|
// Step 4: Read the encrypted age private key from filesystem
|
||||||
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
||||||
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
|
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
|
||||||
|
|
||||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted age private key",
|
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||||
"error", err, "path", agePrivKeyPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -101,17 +106,12 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||||
Debug("Decrypting age private key with keychain passphrase",
|
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
||||||
"unlocker_id", k.GetID())
|
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
||||||
|
|
||||||
agePrivKeyBuffer, err := DecryptWithPassphrase(
|
|
||||||
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with keychain passphrase",
|
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
||||||
"error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
||||||
"failed to decrypt age private key with keychain passphrase: %w", err)
|
|
||||||
}
|
}
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetType implements Unlocker interface
|
// GetType implements Unlocker interface
|
||||||
func (k *KeychainUnlocker) GetType() string {
|
func (k *KeychainUnlocker) GetType() string {
|
||||||
return keychainUnlockerType
|
return "keychain"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata implements Unlocker interface
|
// GetMetadata implements Unlocker interface
|
||||||
@@ -153,9 +153,20 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
return k.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
// GetID implements Unlocker interface - generates ID from keychain item name
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
return filepath.Base(k.Directory)
|
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
||||||
|
// This matches the passphrase unlocker format
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the creation timestamp from metadata
|
||||||
|
createdAt := k.Metadata.CreatedAt
|
||||||
|
timestamp := createdAt.Format("2006-01-02.15.04")
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||||
@@ -163,105 +174,58 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
// Step 1: Get keychain item name
|
// Step 1: Get keychain item name
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name during removal",
|
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
||||||
"error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Remove from keychain
|
// Step 2: Remove from keychain
|
||||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||||
|
if err := deleteFromKeychain(keychainItemName); err != nil {
|
||||||
err = deleteFromKeychain(keychainItemName)
|
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to remove keychain item",
|
|
||||||
"error", err, "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Remove directory
|
// Step 3: Remove directory
|
||||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||||
|
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
|
||||||
err = RemoveDirAtomic(k.fs, k.Directory)
|
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to remove keychain unlocker directory",
|
|
||||||
"error", err, "directory", k.Directory)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Successfully removed keychain unlocker",
|
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||||
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
||||||
|
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||||
|
return &KeychainUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetKeychainItemName returns the keychain item name from metadata
|
// GetKeychainItemName returns the keychain item name from metadata
|
||||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||||
// Load the metadata
|
// Load the metadata
|
||||||
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var keychainMetadata KeychainUnlockerMetadata
|
var keychainMetadata KeychainUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
|
||||||
err = json.Unmarshal(metadataData, &keychainMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return keychainMetadata.KeychainItemName, nil
|
return keychainMetadata.KeychainItemName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readKeychainData reads and parses the data this unlocker keeps in the
|
|
||||||
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
|
|
||||||
// returned AgePrivKeyPassphrase.
|
|
||||||
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
|
|
||||||
// Step 1: Get keychain item name
|
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Retrieve data from keychain
|
|
||||||
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to retrieve data from keychain",
|
|
||||||
"error", err, "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugWith("Retrieved data from keychain",
|
|
||||||
slog.String("unlocker_id", k.GetID()),
|
|
||||||
slog.Int("data_length", len(keychainDataBytes)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Move the keychain data into locked memory; this wipes keychainDataBytes
|
|
||||||
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
|
||||||
defer keychainDataBuffer.Destroy()
|
|
||||||
|
|
||||||
// Step 3: Parse keychain data
|
|
||||||
keychainData, err := decodeKeychainData(keychainDataBuffer)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return keychainData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
||||||
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -283,7 +247,31 @@ func getLongTermPrivateKey(
|
|||||||
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
if mnemonic != nil {
|
if mnemonic != nil {
|
||||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the private key in a secure buffer
|
||||||
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault to access current unlocker
|
// Get the vault to access current unlocker
|
||||||
@@ -304,43 +292,34 @@ func getLongTermPrivateKey(
|
|||||||
|
|
||||||
// Get encrypted long-term key from current unlocker, handling different types
|
// Get encrypted long-term key from current unlocker, handling different types
|
||||||
var encryptedLtPrivKey []byte
|
var encryptedLtPrivKey []byte
|
||||||
|
|
||||||
switch currentUnlocker := currentUnlocker.(type) {
|
switch currentUnlocker := currentUnlocker.(type) {
|
||||||
case *PassphraseUnlocker:
|
case *PassphraseUnlocker:
|
||||||
// Read the encrypted long-term private key from passphrase unlocker
|
// Read the encrypted long-term private key from passphrase unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
||||||
"from current passphrase unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case *PGPUnlocker:
|
case *PGPUnlocker:
|
||||||
// Read the encrypted long-term private key from PGP unlocker
|
// Read the encrypted long-term private key from PGP unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
||||||
"from current PGP unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case *KeychainUnlocker:
|
case *KeychainUnlocker:
|
||||||
// Read the encrypted long-term private key from another keychain
|
// Read the encrypted long-term private key from another keychain unlocker
|
||||||
// unlocker
|
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
|
||||||
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
||||||
"from current keychain unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, errUnsupportedCurrentUnlocker
|
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt long-term private key using current unlocker
|
// Decrypt long-term private key using current unlocker
|
||||||
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
||||||
encryptedLtPrivKey, currentUnlockerIdentity)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -349,48 +328,17 @@ func getLongTermPrivateKey(
|
|||||||
return ltPrivKeyBuffer, nil
|
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
|
// 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
|
// vault. The long-term key comes from mnemonic when it is not nil, else from
|
||||||
// the current unlocker, as getLongTermPrivateKey describes.
|
// the current unlocker, as getLongTermPrivateKey describes.
|
||||||
func CreateKeychainUnlocker(
|
func CreateKeychainUnlocker(
|
||||||
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*KeychainUnlocker, error) {
|
) (*KeychainUnlocker, error) {
|
||||||
|
// Check if we're on macOS
|
||||||
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Get current vault using the GetCurrentVault function from the same package
|
// Get current vault using the GetCurrentVault function from the same package
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -427,11 +375,12 @@ func CreateKeychainUnlocker(
|
|||||||
defer agePrivKeyPassphrase.Destroy()
|
defer agePrivKeyPassphrase.Destroy()
|
||||||
|
|
||||||
// Step 3: Encrypt age private key with the generated passphrase
|
// Step 3: Encrypt age private key with the generated passphrase
|
||||||
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
// Create a secure buffer for the private key
|
||||||
|
agePrivKeyStr := ageIdentity.String()
|
||||||
|
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase)
|
||||||
agePrivKeyBuffer, agePrivKeyPassphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
}
|
}
|
||||||
@@ -444,11 +393,9 @@ func CreateKeychainUnlocker(
|
|||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 5: Encrypt long-term private key to the new age unlocker
|
// Step 5: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||||
ltPrivKeyData, ageIdentity.Recipient())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Prepare keychain data
|
// Step 6: Prepare keychain data
|
||||||
@@ -464,25 +411,12 @@ func CreateKeychainUnlocker(
|
|||||||
}
|
}
|
||||||
defer keychainDataBuffer.Destroy()
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
|
|
||||||
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Step 7: Prepare enhanced metadata
|
||||||
keychainMetadata := KeychainUnlockerMetadata{
|
keychainMetadata := KeychainUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: keychainUnlockerType,
|
Type: "keychain",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{keychainUnlockerType, macOSFlag},
|
Flags: []string{"keychain", "macos"},
|
||||||
},
|
},
|
||||||
KeychainItemName: keychainItemName,
|
KeychainItemName: keychainItemName,
|
||||||
}
|
}
|
||||||
@@ -492,49 +426,35 @@ func writeKeychainUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Write the unlocker's files, the metadata last, then store the
|
// Step 8: Write the unlocker's files and store the data in the keychain,
|
||||||
// data in the keychain
|
// the metadata last
|
||||||
stored := false
|
|
||||||
|
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
pubPath := filepath.Join(dir, "pub.txt")
|
||||||
if err != nil {
|
if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil {
|
||||||
return fmt.Errorf("failed to write age recipient: %w", err)
|
return fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
|
privPath := filepath.Join(dir, "priv.age")
|
||||||
if err != nil {
|
if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
ltKeyPath := filepath.Join(dir, "longterm.age")
|
||||||
if err != nil {
|
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
||||||
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)
|
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored = true
|
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
|
||||||
|
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
||||||
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil && stored {
|
|
||||||
deleteErr := deleteFromKeychain(keychainItemName)
|
|
||||||
if deleteErr != nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf(
|
|
||||||
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -546,15 +466,114 @@ func writeKeychainUnlocker(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateKeychainItemName validates that a keychain item name is safe for
|
// checkMacOSAvailable verifies that we're running on macOS
|
||||||
// command execution
|
func checkMacOSAvailable() error {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
||||||
func validateKeychainItemName(itemName string) error {
|
func validateKeychainItemName(itemName string) error {
|
||||||
if itemName == "" {
|
if itemName == "" {
|
||||||
return errKeychainItemNameEmpty
|
return fmt.Errorf("keychain item name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keychainItemNameRegex.MatchString(itemName) {
|
if !keychainItemNameRegex.MatchString(itemName) {
|
||||||
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
|
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||||
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
|
item.SetDescription("Secret vault keychain data")
|
||||||
|
item.SetData(data.Bytes())
|
||||||
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||||
|
|
||||||
|
// First try to delete any existing item
|
||||||
|
deleteItem := keychain.NewItem()
|
||||||
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
deleteItem.SetAccount(itemName)
|
||||||
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
|
// Add the new item
|
||||||
|
if err := keychain.AddItem(item); err != nil {
|
||||||
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||||
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := keychain.NewItem()
|
||||||
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
query.SetAccount(itemName)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
|
results, err := keychain.QueryItem(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[0].Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||||
|
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
||||||
|
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
||||||
|
func deleteFromKeychain(itemName string) error {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
|
// If the item doesn't exist, that's not an error - the goal is to ensure
|
||||||
|
// the item is gone, and it already being gone satisfies that goal.
|
||||||
|
// This is important for cleaning up unlocker directories when the keychain
|
||||||
|
// item has already been removed (e.g., manually by user, or synced vault
|
||||||
|
// from a different machine).
|
||||||
|
if err == keychain.ErrorItemNotFound {
|
||||||
|
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
//go:build darwin && !cgo
|
|
||||||
|
|
||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
)
|
|
||||||
|
|
||||||
// In a macOS build without cgo, these take the place of the functions in
|
|
||||||
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
|
|
||||||
|
|
||||||
var errKeychainNotSupported = errors.New(
|
|
||||||
"keychain unlockers need a macOS build with cgo")
|
|
||||||
|
|
||||||
// storeInKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
|
|
||||||
return errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func retrieveFromKeychain(_ string) ([]byte, error) {
|
|
||||||
return nil, errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
|
|
||||||
func deleteFromKeychain(_ string) error {
|
|
||||||
return errKeychainNotSupported
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -61,9 +60,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
return k.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID returns the unlocker ID, the name of the unlocker's directory
|
// GetID returns the unlocker ID
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
return filepath.Base(k.Directory)
|
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
//go:build darwin && cgo
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -37,8 +35,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Test 2: Retrieve data from keychain
|
// Test 2: Retrieve data from keychain
|
||||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve data from keychain")
|
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||||
assert.Equal(t, testData, string(retrievedData),
|
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
|
||||||
"Retrieved data doesn't match stored data")
|
|
||||||
|
|
||||||
// Test 3: Update existing item (store again with different data)
|
// Test 3: Update existing item (store again with different data)
|
||||||
newTestData := "updated-test-data-67890"
|
newTestData := "updated-test-data-67890"
|
||||||
@@ -51,8 +48,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Verify updated data
|
// Verify updated data
|
||||||
retrievedData, err = retrieveFromKeychain(testItemName)
|
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||||
assert.Equal(t, newTestData, string(retrievedData),
|
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
|
||||||
"Retrieved data doesn't match updated data")
|
|
||||||
|
|
||||||
// Test 4: Delete from keychain
|
// Test 4: Delete from keychain
|
||||||
err = deleteFromKeychain(testItemName)
|
err = deleteFromKeychain(testItemName)
|
||||||
@@ -72,12 +68,9 @@ func TestKeychainInvalidItemName(t *testing.T) {
|
|||||||
testData := memguard.NewBufferFromBytes([]byte("test"))
|
testData := memguard.NewBufferFromBytes([]byte("test"))
|
||||||
defer testData.Destroy()
|
defer testData.Destroy()
|
||||||
|
|
||||||
// Test an empty item name
|
|
||||||
err := storeInKeychain("", testData)
|
|
||||||
require.ErrorIs(t, err, errKeychainItemNameEmpty)
|
|
||||||
|
|
||||||
// Test invalid item names
|
// Test invalid item names
|
||||||
invalidNames := []string{
|
invalidNames := []string{
|
||||||
|
"", // Empty name
|
||||||
"test space", // Contains space
|
"test space", // Contains space
|
||||||
"test/slash", // Contains slash
|
"test/slash", // Contains slash
|
||||||
"test\\backslash", // Contains backslash
|
"test\\backslash", // Contains backslash
|
||||||
@@ -99,8 +92,8 @@ func TestKeychainInvalidItemName(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
err := storeInKeychain(name, testData)
|
err := storeInKeychain(name, testData)
|
||||||
require.ErrorIs(t, err, errInvalidKeychainItemName,
|
assert.Error(t, err, "Expected error for invalid name: %s", name)
|
||||||
"Expected error for invalid name: %s", name)
|
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test valid names (should not error on validation)
|
// Test valid names (should not error on validation)
|
||||||
@@ -130,7 +123,8 @@ func TestKeychainNilData(t *testing.T) {
|
|||||||
|
|
||||||
// Test storing nil data
|
// Test storing nil data
|
||||||
err := storeInKeychain("test-item", nil)
|
err := storeInKeychain("test-item", nil)
|
||||||
require.ErrorIs(t, err, errNilDataBuffer)
|
assert.Error(t, err, "Expected error when storing nil data")
|
||||||
|
assert.Contains(t, err.Error(), "data buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKeychainLargeData(t *testing.T) {
|
func TestKeychainLargeData(t *testing.T) {
|
||||||
@@ -186,30 +180,5 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
|||||||
// This is important for cleaning up unlocker directories when the keychain item
|
// This is important for cleaning up unlocker directories when the keychain item
|
||||||
// has already been removed (e.g., manually by user, or on a different machine)
|
// has already been removed (e.g., manually by user, or on a different machine)
|
||||||
err := deleteFromKeychain(testItemName)
|
err := deleteFromKeychain(testItemName)
|
||||||
assert.NoError(t, err,
|
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error")
|
||||||
"Deleting non-existent keychain item should not return an error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
|
||||||
// unlocker into place fail after its data is stored in the keychain: the
|
|
||||||
// keychain item must be deleted again.
|
|
||||||
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
|
||||||
testItemName := "test-secret-keychain-unlocker-cleanup"
|
|
||||||
_ = deleteFromKeychain(testItemName)
|
|
||||||
|
|
||||||
// Moving the unlocker into a read-only directory fails
|
|
||||||
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
|
||||||
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
|
||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
|
||||||
defer testBuffer.Destroy()
|
|
||||||
|
|
||||||
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
|
||||||
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
|
||||||
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
|
||||||
require.ErrorIs(t, err, os.ErrPermission,
|
|
||||||
"moving the unlocker into place should fail")
|
|
||||||
|
|
||||||
_, err = retrieveFromKeychain(testItemName)
|
|
||||||
assert.Error(t, err, "keychain item left behind")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonic is the standard BIP39 test vector mnemonic.
|
// testMnemonic is the standard BIP39 test vector mnemonic.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,11 +10,6 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrPassphraseNotRead is wrapped in every error of ReadPassphrase: there
|
|
||||||
// is no terminal to read the passphrase from, reading it failed, or it was
|
|
||||||
// empty. A passphrase unlocker that fails with it was not tried.
|
|
||||||
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
|
||||||
|
|
||||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||||
type PassphraseUnlocker struct {
|
type PassphraseUnlocker struct {
|
||||||
Directory string
|
Directory string
|
||||||
@@ -115,9 +109,12 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
// GetID implements Unlocker interface - generates ID from creation timestamp
|
||||||
func (p *PassphraseUnlocker) GetID() string {
|
func (p *PassphraseUnlocker) GetID() string {
|
||||||
return filepath.Base(p.Directory)
|
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||||
|
createdAt := p.Metadata.CreatedAt
|
||||||
|
|
||||||
|
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||||
@@ -155,7 +152,7 @@ func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return secureBuffer, nil
|
return secureBuffer, nil
|
||||||
|
|||||||
+161
-241
@@ -4,9 +4,7 @@ package secret_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,31 +15,30 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// pgpUnlockerType is the type of a PGP unlocker.
|
// Register vault with secret package for testing
|
||||||
const pgpUnlockerType = "pgp"
|
func init() {
|
||||||
|
// Register the vault.GetCurrentVault function with the secret package
|
||||||
var errNilDataBuffer = errors.New("data buffer is nil")
|
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||||
|
return vault.GetCurrentVault(fs, stateDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||||
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Create GPG config file for non-interactive operation
|
// Create GPG config file for non-interactive operation
|
||||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||||
gpgConfContent := `batch
|
gpgConfContent := `batch
|
||||||
no-tty
|
no-tty
|
||||||
pinentry-mode loopback
|
pinentry-mode loopback
|
||||||
`
|
`
|
||||||
|
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
|
||||||
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,15 +47,11 @@ pinentry-mode loopback
|
|||||||
origDecryptFunc := secret.GPGDecryptFunc
|
origDecryptFunc := secret.GPGDecryptFunc
|
||||||
|
|
||||||
// Set custom GPG functions for this test
|
// Set custom GPG functions for this test
|
||||||
secret.GPGEncryptFunc = func(
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
data *memguard.LockedBuffer, keyID string,
|
|
||||||
) ([]byte, error) {
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, errNilDataBuffer
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
}
|
}
|
||||||
|
cmd := exec.Command("gpg",
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
|
||||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -70,13 +63,11 @@ pinentry-mode loopback
|
|||||||
"-r", keyID)
|
"-r", keyID)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(data.Bytes())
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||||
|
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +75,7 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
cmd := exec.Command("gpg",
|
||||||
cmd := exec.CommandContext(t.Context(), "gpg",
|
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -95,13 +85,11 @@ pinentry-mode loopback
|
|||||||
"--decrypt")
|
"--decrypt")
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(encryptedData)
|
cmd.Stdin = bytes.NewReader(encryptedData)
|
||||||
|
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,24 +105,20 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
||||||
func runGPGWithPassphrase(
|
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
|
||||||
ctx context.Context,
|
cmdArgs := []string{
|
||||||
gnupgHome, passphrase string, args []string, input io.Reader,
|
|
||||||
) ([]byte, error) {
|
|
||||||
cmdArgs := append([]string{
|
|
||||||
"--homedir=" + gnupgHome,
|
"--homedir=" + gnupgHome,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
"--pinentry-mode", "loopback",
|
"--pinentry-mode", "loopback",
|
||||||
"--passphrase", passphrase,
|
"--passphrase", passphrase,
|
||||||
}, args...)
|
}
|
||||||
|
cmdArgs = append(cmdArgs, args...)
|
||||||
|
|
||||||
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
cmd := exec.Command("gpg", cmdArgs...)
|
||||||
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
|
|
||||||
cmd.Stdin = input
|
cmd.Stdin = input
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@@ -146,96 +130,14 @@ func runGPGWithPassphrase(
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateTestGPGKey generates a GPG key protected by passphrase in
|
|
||||||
// gnupgHomeDir and returns its key ID and fingerprint.
|
|
||||||
func generateTestGPGKey(
|
|
||||||
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Create GPG batch file for key generation
|
|
||||||
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
|
||||||
batchContent := `%echo Generating a test key
|
|
||||||
Key-Type: RSA
|
|
||||||
Key-Length: 2048
|
|
||||||
Name-Real: Test User
|
|
||||||
Name-Email: test@example.com
|
|
||||||
Expire-Date: 0
|
|
||||||
Passphrase: ` + passphrase + `
|
|
||||||
%commit
|
|
||||||
%echo Key generation completed
|
|
||||||
`
|
|
||||||
|
|
||||||
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write batch file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate GPG key with batch mode
|
|
||||||
t.Log("Generating GPG key...")
|
|
||||||
|
|
||||||
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
|
||||||
[]string{"--gen-key", batchFile}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to generate GPG key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("GPG key generated successfully")
|
|
||||||
|
|
||||||
// Get the key ID and fingerprint
|
|
||||||
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
|
||||||
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list GPG keys: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output to get key ID and fingerprint
|
|
||||||
var keyID, fingerprint string
|
|
||||||
|
|
||||||
for line := range strings.SplitSeq(string(output), "\n") {
|
|
||||||
if strings.HasPrefix(line, "sec:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
keyID = fields[4]
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(line, "fpr:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 10 && fields[9] != "" {
|
|
||||||
fingerprint = fields[9]
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keyID == "" {
|
|
||||||
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fingerprint == "" {
|
|
||||||
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Generated GPG key ID: %s", keyID)
|
|
||||||
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
|
||||||
|
|
||||||
return keyID, fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
||||||
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||||
// Check if gpg is available
|
// Check if gpg is available
|
||||||
_, err := exec.LookPath("gpg")
|
if _, err := exec.LookPath("gpg"); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
||||||
// Continue anyway to test what we can
|
// Continue anyway to test what we can
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary directory for our tests. Not t.TempDir: its longer
|
// Create a temporary directory for our tests
|
||||||
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
|
|
||||||
// macOS sets on socket paths.
|
|
||||||
//
|
|
||||||
//nolint:usetesting // see the comment above
|
|
||||||
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
@@ -244,9 +146,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Create a temporary GNUPGHOME
|
// Create a temporary GNUPGHOME
|
||||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||||
|
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
|
||||||
err = os.MkdirAll(gnupgHomeDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +159,64 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Setup non-interactive GPG with custom functions
|
// Setup non-interactive GPG with custom functions
|
||||||
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
||||||
|
|
||||||
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
|
// Create GPG batch file for key generation
|
||||||
|
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
||||||
|
batchContent := `%echo Generating a test key
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 2048
|
||||||
|
Name-Real: Test User
|
||||||
|
Name-Email: test@example.com
|
||||||
|
Expire-Date: 0
|
||||||
|
Passphrase: ` + testPassphrase + `
|
||||||
|
%commit
|
||||||
|
%echo Key generation completed
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("Failed to write batch file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GPG key with batch mode
|
||||||
|
t.Log("Generating GPG key...")
|
||||||
|
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
||||||
|
[]string{"--gen-key", batchFile}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate GPG key: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("GPG key generated successfully")
|
||||||
|
|
||||||
|
// Get the key ID and fingerprint
|
||||||
|
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
||||||
|
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list GPG keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to get key ID and fingerprint
|
||||||
|
var keyID, fingerprint string
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "sec:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
keyID = fields[4]
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, "fpr:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[9] != "" {
|
||||||
|
fingerprint = fields[9]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyID == "" {
|
||||||
|
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
||||||
|
}
|
||||||
|
if fingerprint == "" {
|
||||||
|
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
||||||
|
}
|
||||||
|
t.Logf("Generated GPG key ID: %s", keyID)
|
||||||
|
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
||||||
|
|
||||||
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
||||||
t.Setenv("GPG_AGENT_INFO", "")
|
t.Setenv("GPG_AGENT_INFO", "")
|
||||||
@@ -267,6 +224,12 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||||
|
|
||||||
@@ -276,56 +239,14 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Test creation of a PGP unlock key through a vault
|
// Test creation of a PGP unlock key through a vault
|
||||||
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
||||||
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set up key directory for individual tests
|
|
||||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
|
||||||
|
|
||||||
err = os.MkdirAll(unlockerDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up test metadata
|
|
||||||
metadata := secret.UnlockerMetadata{
|
|
||||||
Type: pgpUnlockerType,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
Flags: []string{"gpg", "encrypted"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a PGP unlocker for the remaining tests
|
|
||||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
||||||
|
|
||||||
// Test getting identity from PGP unlocker
|
|
||||||
t.Run("GetIdentity", func(t *testing.T) {
|
|
||||||
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test removing the unlocker
|
|
||||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
|
||||||
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
|
|
||||||
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
|
|
||||||
// and metadata.
|
|
||||||
func testCreatePGPUnlocker(
|
|
||||||
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Set a limited test timeout to avoid hanging
|
// Set a limited test timeout to avoid hanging
|
||||||
timer := time.AfterFunc(10*time.Second, func() {
|
timer := time.AfterFunc(30*time.Second, func() {
|
||||||
t.Fatalf("Test timed out after 10 seconds")
|
t.Fatalf("Test timed out after 30 seconds")
|
||||||
})
|
})
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
// Create a test vault directory structure
|
// Create a test vault directory structure
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -350,10 +271,7 @@ func testCreatePGPUnlocker(
|
|||||||
|
|
||||||
// Write long-term public key
|
// Write long-term public key
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||||
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)
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +281,6 @@ func testCreatePGPUnlocker(
|
|||||||
// Create a passphrase unlocker first (to have current unlocker)
|
// Create a passphrase unlocker first (to have current unlocker)
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
@@ -375,8 +292,7 @@ func testCreatePGPUnlocker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(
|
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
||||||
fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -387,91 +303,63 @@ func testCreatePGPUnlocker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the key has the correct type
|
// Check if the key has the correct type
|
||||||
if pgpUnlocker.GetType() != pgpUnlockerType {
|
if pgpUnlocker.GetType() != "pgp" {
|
||||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the ID is the name of the unlocker's directory
|
// Check if the key ID includes the GPG fingerprint
|
||||||
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
|
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
||||||
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
|
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
||||||
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
|
// Check if the key directory exists
|
||||||
|
unlockerDir := pgpUnlocker.GetDirectory()
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keyExists {
|
if !keyExists {
|
||||||
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if required files exist
|
// Check if required files exist
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
|
||||||
recipientExists, err := afero.Exists(fs, recipientPath)
|
recipientExists, err := afero.Exists(fs, recipientPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !recipientExists {
|
if !recipientExists {
|
||||||
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
|
||||||
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if private key file exists: %v", err)
|
t.Fatalf("Failed to check if private key file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !privKeyExists {
|
if !privKeyExists {
|
||||||
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataExists, err := afero.Exists(fs, metadataPath)
|
metadataExists, err := afero.Exists(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !metadataExists {
|
if !metadataExists {
|
||||||
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
|
||||||
longtermExists, err := afero.Exists(fs, longtermPath)
|
longtermExists, err := afero.Exists(fs, longtermPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !longtermExists {
|
if !longtermExists {
|
||||||
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
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
|
// Read and verify metadata
|
||||||
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read metadata: %v", err)
|
t.Fatalf("Failed to read metadata: %v", err)
|
||||||
@@ -485,28 +373,72 @@ func checkPGPUnlockerMetadata(
|
|||||||
GPGKeyID string `json:"gpgKeyId"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse metadata: %v", err)
|
t.Fatalf("Failed to parse metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Type != pgpUnlockerType {
|
if metadata.Type != "pgp" {
|
||||||
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.GPGKeyID != fingerprint {
|
if metadata.GPGKeyID != fingerprint {
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
// Set up key directory for individual tests
|
||||||
// keyID into unlockerDir and checks that unlocker decrypts it.
|
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||||
func testPGPUnlockerGetIdentity(
|
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||||
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||||
unlockerDir, keyID string,
|
}
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
|
// Set up test metadata
|
||||||
|
metadata := secret.UnlockerMetadata{
|
||||||
|
Type: "pgp",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Flags: []string{"gpg", "encrypted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:"gpgKeyId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
|
UnlockerMetadata: metadata,
|
||||||
|
GPGKeyID: fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata file
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||||
|
t.Fatalf("Failed to write metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get GPG key ID
|
||||||
|
retrievedKeyID, err := unlocker.GetGPGKeyID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get GPG key ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify key ID (should be the fingerprint)
|
||||||
|
if retrievedKeyID != fingerprint {
|
||||||
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test getting identity from PGP unlocker
|
||||||
|
t.Run("GetIdentity", func(t *testing.T) {
|
||||||
// Generate an age identity for testing
|
// Generate an age identity for testing
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -515,17 +447,13 @@ func testPGPUnlockerGetIdentity(
|
|||||||
|
|
||||||
// Write the recipient
|
// Write the recipient
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, recipientPath,
|
|
||||||
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write recipient: %v", err)
|
t.Fatalf("Failed to write recipient: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPG encrypt the private key using our custom encrypt function
|
// GPG encrypt the private key using our custom encrypt function
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
defer privKeyBuffer.Destroy()
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||||
@@ -533,9 +461,7 @@ func testPGPUnlockerGetIdentity(
|
|||||||
|
|
||||||
// Write the encrypted data to a file
|
// Write the encrypted data to a file
|
||||||
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
|
||||||
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,24 +474,18 @@ func testPGPUnlockerGetIdentity(
|
|||||||
// Verify the identity matches
|
// Verify the identity matches
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
actualPubKey := identity.Recipient().String()
|
actualPubKey := identity.Recipient().String()
|
||||||
|
|
||||||
if actualPubKey != expectedPubKey {
|
if actualPubKey != expectedPubKey {
|
||||||
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
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()
|
|
||||||
|
|
||||||
|
// Test removing the unlocker
|
||||||
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||||
// Ensure unlocker directory exists before removal
|
// Ensure unlocker directory exists before removal
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keyExists {
|
if !keyExists {
|
||||||
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
@@ -581,8 +501,8 @@ func testRemovePGPUnlocker(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyExists {
|
if keyExists {
|
||||||
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gpgNoPublicKeyStatus is the status line gpg writes when it has no key for
|
|
||||||
// the ID it was asked to list: 9 is gpg's error code for "No public key".
|
|
||||||
const gpgNoPublicKeyStatus = "[GNUPG:] ERROR keylist.getkey 9\n"
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
||||||
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
||||||
@@ -29,10 +25,6 @@ var (
|
|||||||
errNilDataBuffer = errors.New("data buffer is nil")
|
errNilDataBuffer = errors.New("data buffer is nil")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrGPGKeyNotFound is returned by ResolveGPGKeyFingerprint for a key ID
|
|
||||||
// that matches no key in the GPG keyring.
|
|
||||||
var ErrGPGKeyNotFound = errors.New("GPG key not found")
|
|
||||||
|
|
||||||
// Variables to allow overriding in tests
|
// Variables to allow overriding in tests
|
||||||
var (
|
var (
|
||||||
// GPGEncryptFunc is the function used for GPG encryption
|
// GPGEncryptFunc is the function used for GPG encryption
|
||||||
@@ -163,9 +155,21 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface: the name of the unlocker's directory
|
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
||||||
|
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
||||||
|
// directory and returns "pgp-unknown", so listing the other unlockers
|
||||||
|
// still works.
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
return filepath.Base(p.Directory)
|
// Generate ID using GPG key ID: pgp-<keyid>
|
||||||
|
gpgKeyID, err := p.GetGPGKeyID()
|
||||||
|
if err != nil {
|
||||||
|
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
||||||
|
"directory", p.Directory, "error", err)
|
||||||
|
|
||||||
|
return "pgp-unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pgp-" + gpgKeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
@@ -180,6 +184,30 @@ func (p *PGPUnlocker) Remove() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGPGKeyID returns the GPG key ID from metadata
|
||||||
|
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||||
|
// Load the metadata
|
||||||
|
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgpMetadata PGPUnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataData, &pgpMetadata)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pgpMetadata.GPGKeyID == "" {
|
||||||
|
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pgpMetadata.GPGKeyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||||
// based on hostname and time
|
// based on hostname and time
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
@@ -302,7 +330,7 @@ func encryptPGPUnlockerKeys(
|
|||||||
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
|
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
encryptedLtPrivKey, err := EncryptToRecipient(
|
encryptedLtPrivKey, err := EncryptToRecipient(
|
||||||
@@ -312,7 +340,8 @@ func encryptPGPUnlockerKeys(
|
|||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
// Use memguard to protect the private key in memory
|
||||||
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
@@ -375,20 +404,14 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use GPG to get the full fingerprint for the key. --status-fd 1 adds
|
// Use GPG to get the full fingerprint for the key
|
||||||
// gpg's status lines to the output.
|
|
||||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||||
context.Background(),
|
context.Background(),
|
||||||
"gpg", "--status-fd", "1",
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||||
"--list-keys", "--with-colons", "--fingerprint", keyID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(string(output), gpgNoPublicKeyStatus) {
|
|
||||||
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
|
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
|
||||||
@@ -48,7 +48,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
|||||||
installFakeGPG(t)
|
installFakeGPG(t)
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
@@ -87,7 +87,7 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
|
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
first, err := secret.CreatePGPUnlocker(
|
first, err := secret.CreatePGPUnlocker(
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// errSecretNotFound carries only the message tail; callers compose
|
||||||
|
// "secret <name> not found" around it so the emitted text is
|
||||||
|
// unchanged.
|
||||||
|
errSecretNotFound = errors.New("not found")
|
||||||
|
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
||||||
errGetEncryptedDataDeprecated = errors.New(
|
errGetEncryptedDataDeprecated = errors.New(
|
||||||
"GetEncryptedData is deprecated - use version-specific methods")
|
"GetEncryptedData is deprecated - use version-specific methods")
|
||||||
errGetCurrentVaultNotRegistered = errors.New(
|
errGetCurrentVaultNotRegistered = errors.New(
|
||||||
@@ -73,6 +81,73 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetValue retrieves and decrypts the current version's value, with the
|
||||||
|
// vault's long-term key derived from mnemonic when it is not nil, else
|
||||||
|
// obtained through unlocker
|
||||||
|
func (s *Secret) GetValue(
|
||||||
|
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
DebugWith("Getting secret value",
|
||||||
|
slog.String("secret_name", s.Name),
|
||||||
|
slog.String("vault_name", s.vault.GetName()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
exists, err := s.Exists()
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to check if secret exists during GetValue",
|
||||||
|
"error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
Debug("Secret not found during GetValue",
|
||||||
|
"secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||||
|
|
||||||
|
// Get current version
|
||||||
|
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create version object
|
||||||
|
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||||
|
|
||||||
|
if mnemonic != nil {
|
||||||
|
return s.getValueViaMnemonic(version, mnemonic.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||||
|
|
||||||
|
// Use the provided unlocker to get the vault's long-term private key
|
||||||
|
if unlocker == nil {
|
||||||
|
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, errUnlockerRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Successfully obtained vault's long-term key",
|
||||||
|
slog.String("secret_name", s.Name),
|
||||||
|
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use the long-term key to decrypt the version
|
||||||
|
return version.GetValue(ltIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||||
func (s *Secret) LoadMetadata() error {
|
func (s *Secret) LoadMetadata() error {
|
||||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||||
@@ -140,6 +215,124 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getValueViaMnemonic derives the vault's long-term key from the
|
||||||
|
// mnemonic and decrypts the version value with it.
|
||||||
|
func (s *Secret) getValueViaMnemonic(
|
||||||
|
version *Version, mnemonic string,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
Debug("Using mnemonic for direct long-term key derivation",
|
||||||
|
"secret_name", s.Name)
|
||||||
|
|
||||||
|
// Get vault directory to read metadata
|
||||||
|
vaultDir, err := s.vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vault metadata to get the correct derivation index
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Using vault derivation index from metadata",
|
||||||
|
slog.String("secret_name", s.Name),
|
||||||
|
slog.String("vault_name", s.vault.GetName()),
|
||||||
|
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's derivation index from metadata
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to derive long-term key from mnemonic for secret",
|
||||||
|
"error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||||
|
|
||||||
|
// Use the long-term key to decrypt the version
|
||||||
|
return version.GetValue(ltIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
||||||
|
// the vault's long-term private key.
|
||||||
|
func (s *Secret) getLongTermIdentityFromUnlocker(
|
||||||
|
unlocker Unlocker,
|
||||||
|
) (*age.X25519Identity, error) {
|
||||||
|
DebugWith("Getting vault's long-term key using unlocker",
|
||||||
|
slog.String("secret_name", s.Name),
|
||||||
|
slog.String("unlocker_type", unlocker.GetType()),
|
||||||
|
slog.String("unlocker_id", unlocker.GetID()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||||
|
unlockIdentity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to get unlocker identity",
|
||||||
|
"error", err, "secret_name", s.Name,
|
||||||
|
"unlocker_type", unlocker.GetType())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the encrypted long-term private key from the unlocker directory
|
||||||
|
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||||
|
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||||
|
|
||||||
|
encryptedLtPrivKey, err := afero.ReadFile(
|
||||||
|
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to read encrypted long-term private key",
|
||||||
|
"error", err, "path", encryptedLtPrivKeyPath)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to read encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the encrypted long-term private key using the unlocker
|
||||||
|
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||||
|
|
||||||
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to decrypt long-term private key",
|
||||||
|
"error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
// Parse the long-term private key
|
||||||
|
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||||
|
|
||||||
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to parse long-term private key",
|
||||||
|
"error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetCurrentVault gets the current vault from the file system
|
// GetCurrentVault gets the current vault from the file system
|
||||||
// This function is a wrapper around the actual implementation in the vault package
|
// This function is a wrapper around the actual implementation in the vault package
|
||||||
// and exists to break the import cycle.
|
// and exists to break the import cycle.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,9 +10,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||||
@@ -25,14 +27,6 @@ var (
|
|||||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
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
|
// MockVault is a test implementation of the VaultInterface
|
||||||
type MockVault struct {
|
type MockVault struct {
|
||||||
name string
|
name string
|
||||||
@@ -327,3 +321,46 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
t.Logf("Secret.Exists() works correctly")
|
t.Logf("Secret.Exists() works correctly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
|
||||||
|
// GetValue, given the mnemonic, derives the long-term key at the derivation
|
||||||
|
// index in the vault's metadata. At index 0 it could not decrypt the secret,
|
||||||
|
// which was encrypted to the key at index 1.
|
||||||
|
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vaultDir := "/test-config/vaults.d/test-vault"
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
|
vlt := &MockVault{
|
||||||
|
name: "test-vault",
|
||||||
|
fs: fs,
|
||||||
|
directory: vaultDir,
|
||||||
|
derivationIndex: 1,
|
||||||
|
mnemonic: mnemonic,
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
|
||||||
|
|
||||||
|
err = afero.WriteFile(
|
||||||
|
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
secretName, secretValue := "x", "value"
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName,
|
||||||
|
memguard.NewBufferFromBytes([]byte(secretValue)), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.Equal(t, secretValue, value.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/macse"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -31,7 +32,6 @@ const (
|
|||||||
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||||
type SecureEnclaveUnlockerMetadata struct {
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
SEKeyLabel string `json:"seKeyLabel"`
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
SEKeyHash string `json:"seKeyHash"`
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
}
|
}
|
||||||
@@ -43,19 +43,6 @@ type SecureEnclaveUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
|
||||||
func NewSecureEnclaveUnlocker(
|
|
||||||
fs afero.Fs,
|
|
||||||
directory string,
|
|
||||||
metadata UnlockerMetadata,
|
|
||||||
) *SecureEnclaveUnlocker {
|
|
||||||
return &SecureEnclaveUnlocker{
|
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
||||||
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
@@ -71,7 +58,6 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Read ECIES-encrypted long-term private key from disk
|
// Read ECIES-encrypted long-term private key from disk
|
||||||
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||||
|
|
||||||
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -130,9 +116,17 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
return s.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface: the name of the unlocker's directory.
|
// GetID implements Unlocker interface.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
return filepath.Base(s.Directory)
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := s.Metadata.CreatedAt
|
||||||
|
timestamp := createdAt.Format("2006-01-02.15.04")
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface.
|
// Remove implements Unlocker interface.
|
||||||
@@ -146,9 +140,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
|
|
||||||
if seKeyHash != "" {
|
if seKeyHash != "" {
|
||||||
Debug("Deleting SE key", "hash", seKeyHash)
|
Debug("Deleting SE key", "hash", seKeyHash)
|
||||||
|
if err := macse.DeleteKey(seKeyHash); err != nil {
|
||||||
err = macse.DeleteKey(seKeyHash)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||||
|
|
||||||
return fmt.Errorf("failed to delete SE key: %w", err)
|
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||||
@@ -156,9 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||||
|
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
|
||||||
err = RemoveDirAtomic(s.fs, s.Directory)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,24 +158,34 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSEKeyInfo reads the SE key label and hash from metadata.
|
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||||
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
|
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
|
||||||
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var seMetadata SecureEnclaveUnlockerMetadata
|
var seMetadata SecureEnclaveUnlockerMetadata
|
||||||
|
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
|
||||||
err = json.Unmarshal(metadataData, &seMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||||
|
func NewSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
directory string,
|
||||||
|
metadata UnlockerMetadata,
|
||||||
|
) *SecureEnclaveUnlocker {
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||||
func generateSEKeyLabel(vaultName string) (string, error) {
|
func generateSEKeyLabel(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -209,13 +209,15 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
|||||||
// using ECIES. No intermediate age keypair is used.
|
// using ECIES. No intermediate age keypair is used.
|
||||||
// The long-term key comes from mnemonic when it is not nil, else from the
|
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||||
// current unlocker, as getLongTermKeyForSE describes.
|
// current unlocker, as getLongTermKeyForSE describes.
|
||||||
// The SE key is created once the long-term key is in hand and the unlocker's
|
|
||||||
// path is known, and is deleted again if a later step fails.
|
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
stateDir string,
|
stateDir string,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
|
if err := checkMacOSAvailable(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||||
@@ -227,7 +229,16 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get the vault's long-term private key
|
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Step 2: Get the vault's long-term private key
|
||||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -237,50 +248,7 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 2: Prepare the unlocker directory's path
|
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
|
||||||
|
|
||||||
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
|
|
||||||
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
|
||||||
|
|
||||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
|
||||||
|
|
||||||
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
|
||||||
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
|
||||||
ltPrivKeyData)
|
|
||||||
if err != nil {
|
|
||||||
deleteErr := macse.DeleteKey(seKeyHash)
|
|
||||||
if deleteErr != nil {
|
|
||||||
err = errors.Join(err, fmt.Errorf(
|
|
||||||
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return unlocker, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
|
||||||
// new unlocker into unlockerDir (steps 4 and 5 of
|
|
||||||
// CreateSecureEnclaveUnlocker).
|
|
||||||
func writeSEUnlocker(
|
|
||||||
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
|
||||||
ltPrivKeyData *memguard.LockedBuffer,
|
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
|
||||||
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
|
||||||
// prepare the metadata
|
|
||||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -289,11 +257,20 @@ func writeSEUnlocker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Prepare the unlocker directory's path and metadata
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
|
||||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
Flags: []string{seUnlockerType, macOSFlag},
|
Flags: []string{seUnlockerType, "macos"},
|
||||||
},
|
},
|
||||||
SEKeyLabel: seKeyLabel,
|
SEKeyLabel: seKeyLabel,
|
||||||
SEKeyHash: seKeyHash,
|
SEKeyHash: seKeyHash,
|
||||||
@@ -306,7 +283,20 @@ func writeSEUnlocker(
|
|||||||
|
|
||||||
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
||||||
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
|
ltKeyPath := filepath.Join(dir, seLongtermFilename)
|
||||||
|
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to write SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
|
||||||
|
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -319,29 +309,6 @@ func writeSEUnlocker(
|
|||||||
}, nil
|
}, 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
|
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
|
||||||
// from mnemonic when it is not nil, else through the current unlocker, which
|
// from mnemonic when it is not nil, else through the current unlocker, which
|
||||||
// is given passphrase when it is a passphrase unlocker.
|
// is given passphrase when it is a passphrase unlocker.
|
||||||
@@ -351,7 +318,37 @@ func getLongTermKeyForSE(
|
|||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
if mnemonic != nil {
|
if mnemonic != nil {
|
||||||
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(
|
||||||
|
mnemonic.String(),
|
||||||
|
metadata.DerivationIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||||
@@ -376,7 +373,6 @@ func getLongTermKeyForSE(
|
|||||||
currentUnlocker.GetDirectory(),
|
currentUnlocker.GetDirectory(),
|
||||||
"longterm.age",
|
"longterm.age",
|
||||||
)
|
)
|
||||||
|
|
||||||
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -68,9 +67,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
return s.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID returns the unlocker ID, the name of the unlocker's directory.
|
// GetID returns the unlocker ID.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
return filepath.Base(s.Directory)
|
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove returns an error on non-Darwin platforms.
|
// Remove returns an error on non-Darwin platforms.
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
// Test GetDirectory returns the directory we passed in
|
// Test GetDirectory returns the directory we passed in
|
||||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||||
|
|
||||||
// Test GetID returns the name of the unlocker's directory
|
// Test GetID returns a formatted string with the creation timestamp
|
||||||
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
|
expectedID := "2026-01-15.10.30-secure-enclave"
|
||||||
|
assert.Equal(t, expectedID, unlocker.GetID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,14 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker"
|
dir := "/tmp/test-se-unlocker"
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
Flags: []string{seUnlockerType, "macos"},
|
Flags: []string{"secure-enclave", "macos"},
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
@@ -39,11 +35,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,23 +48,21 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
id := unlocker.GetID()
|
||||||
|
|
||||||
// The ID is the name of the unlocker's directory
|
// ID should contain the timestamp and "secure-enclave" type
|
||||||
assert.Equal(t, "test", unlocker.GetID())
|
assert.Contains(t, id, "2026-03-10.14.30")
|
||||||
|
assert.Contains(t, id, seUnlockerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
label, err := generateSEKeyLabel("test-vault")
|
label, err := generateSEKeyLabel("test-vault")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -80,8 +72,6 @@ func TestGenerateSEKeyLabel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker-missing"
|
dir := "/tmp/test-se-unlocker-missing"
|
||||||
|
|
||||||
@@ -94,12 +84,10 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||||
"seKeyHash": "abc123"
|
"seKeyHash": "abc123"
|
||||||
}`
|
}`
|
||||||
require.NoError(t, afero.WriteFile(
|
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
|
||||||
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
|
|
||||||
))
|
|
||||||
|
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: "secure-enclave",
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +96,6 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
// GetIdentity should fail because the encrypted longterm key file is missing
|
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
assert.Nil(t, identity)
|
assert.Nil(t, identity)
|
||||||
|
assert.Error(t, err)
|
||||||
var cause *os.PathError
|
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
|
||||||
|
|
||||||
require.ErrorAs(t, err, &cause)
|
|
||||||
require.ErrorIs(t, err, os.ErrNotExist)
|
|
||||||
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ type Unlocker interface {
|
|||||||
GetType() string
|
GetType() string
|
||||||
GetMetadata() UnlockerMetadata
|
GetMetadata() UnlockerMetadata
|
||||||
GetDirectory() string
|
GetDirectory() string
|
||||||
GetID() string // The name of the unlocker's directory, unique in its vault
|
GetID() string // Generate ID based on unlocker type and data
|
||||||
Remove() error // Remove the unlocker and any associated resources
|
Remove() error // Remove the unlocker and any associated resources
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported validateKeychainItemName
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,46 +7,138 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateKeychainItemName(t *testing.T) {
|
func TestValidateKeychainItemName(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
itemName string
|
itemName string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Valid cases
|
// Valid cases
|
||||||
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
|
{
|
||||||
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
|
name: "valid simple name",
|
||||||
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
|
itemName: "my-secret-key",
|
||||||
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
|
wantErr: false,
|
||||||
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
|
},
|
||||||
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
|
{
|
||||||
|
name: "valid name with dots",
|
||||||
|
itemName: "com.example.app.key",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid name with underscores",
|
||||||
|
itemName: "my_secret_key_123",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid alphanumeric",
|
||||||
|
itemName: "Secret123Key",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with hyphen at start",
|
||||||
|
itemName: "-my-key",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with dot at start",
|
||||||
|
itemName: ".hidden-key",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Invalid cases
|
// Invalid cases
|
||||||
{name: "empty item name", itemName: "", wantErr: true},
|
{
|
||||||
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
|
name: "empty item name",
|
||||||
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
|
itemName: "",
|
||||||
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
|
wantErr: true,
|
||||||
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
|
},
|
||||||
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
|
{
|
||||||
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
|
name: "item name with spaces",
|
||||||
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
|
itemName: "my secret key",
|
||||||
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
|
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 semicolon",
|
||||||
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
|
itemName: "key;rm -rf /",
|
||||||
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
|
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 pipe",
|
||||||
{name: "item name with question mark", itemName: "key?", wantErr: true},
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
err := validateKeychainItemName(tt.itemName)
|
err := validateKeychainItemName(tt.itemName)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|||||||
+10
-13
@@ -22,10 +22,10 @@ const (
|
|||||||
maxVersionsPerDay = 999
|
maxVersionsPerDay = 999
|
||||||
)
|
)
|
||||||
|
|
||||||
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
var (
|
||||||
|
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
||||||
// ErrNilValueBuffer is returned when a secret's value is given as nil.
|
errNilValueBuffer = errors.New("value buffer is nil")
|
||||||
var ErrNilValueBuffer = errors.New("value buffer is nil")
|
)
|
||||||
|
|
||||||
// VersionMetadata contains information about a secret version
|
// VersionMetadata contains information about a secret version
|
||||||
type VersionMetadata struct {
|
type VersionMetadata struct {
|
||||||
@@ -138,7 +138,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
// process dies part-way.
|
// process dies part-way.
|
||||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ErrNilValueBuffer
|
return errNilValueBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugWith("Saving secret version",
|
DebugWith("Saving secret version",
|
||||||
@@ -175,7 +175,9 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
// Store private key in memguard buffer immediately
|
||||||
|
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
|
||||||
|
[]byte(versionIdentity.String()))
|
||||||
defer versionPrivateKeyBuffer.Destroy()
|
defer versionPrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Generated version keypair",
|
DebugWith("Generated version keypair",
|
||||||
@@ -557,18 +559,13 @@ func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVersion returns the version that the "current" file points to
|
// GetCurrentVersion returns the version that the "current" file points to
|
||||||
// The file contains just the version name (e.g., "20231215.001"). If it
|
// The file contains just the version name (e.g., "20231215.001")
|
||||||
// cannot be read, the error says how to make a version current again: the
|
|
||||||
// versions themselves are not in the file.
|
|
||||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
fileData, err := afero.ReadFile(fs, currentPath)
|
fileData, err := afero.ReadFile(fs, currentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read current version file: %w; "+
|
return "", fmt.Errorf("failed to read current version file: %w", err)
|
||||||
"this file only names the current version: 'secret version list' "+
|
|
||||||
"lists the secret's versions, and 'secret version promote' makes "+
|
|
||||||
"one of them current", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
version := strings.TrimSpace(string(fileData))
|
version := strings.TrimSpace(string(fileData))
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
secretDir := "/test/secret"
|
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
|
||||||
|
|
||||||
// Create 999 versions
|
|
||||||
today := time.Now().Format("20060102")
|
|
||||||
for i := 1; i <= 999; i++ {
|
|
||||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
|
||||||
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to create one more - should fail
|
|
||||||
_, err := GenerateVersionName(fs, secretDir)
|
|
||||||
require.ErrorIs(t, err, errMaxVersionsPerDay)
|
|
||||||
}
|
|
||||||
@@ -36,16 +36,17 @@ package secret_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -126,6 +127,27 @@ func TestGenerateVersionName(t *testing.T) {
|
|||||||
assert.NotEqual(t, version1, version2)
|
assert.NotEqual(t, version1, version2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
secretDir := testSecretDir
|
||||||
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
|
// Create 999 versions
|
||||||
|
today := time.Now().Format("20060102")
|
||||||
|
for i := 1; i <= 999; i++ {
|
||||||
|
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||||
|
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create one more - should fail
|
||||||
|
_, err := secret.GenerateVersionName(fs, secretDir)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewVersion(t *testing.T) {
|
func TestNewVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,8 @@ var (
|
|||||||
// Composed as "vault <name> already exists".
|
// Composed as "vault <name> already exists".
|
||||||
ErrVaultExists = errors.New("already exists")
|
ErrVaultExists = errors.New("already exists")
|
||||||
|
|
||||||
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
|
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
||||||
// passphrase for an unlocker but no mnemonic to derive the long-term key
|
ErrNilValueBuffer = errors.New("value buffer is nil")
|
||||||
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
|
|
||||||
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
|
|
||||||
|
|
||||||
// ErrInvalidSecretName indicates a secret name that breaks the naming
|
// ErrInvalidSecretName indicates a secret name that breaks the naming
|
||||||
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
|
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
package vault_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// otherMnemonic is a valid BIP39 mnemonic other than testMnemonic.
|
|
||||||
otherMnemonic = "legal winner thank year wave sausage worth useful " +
|
|
||||||
"legal winner thank yellow"
|
|
||||||
|
|
||||||
// missingName names no vault, secret or unlocker.
|
|
||||||
missingName = "missing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newErrorTestVault creates the vault testVaultName, with the secret
|
|
||||||
// testSecretName in it, on a new in-memory filesystem.
|
|
||||||
func newErrorTestVault(t *testing.T) *vault.Vault {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(afero.NewMemMapFs(), testStateDir,
|
|
||||||
testVaultName, testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
||||||
t.Cleanup(value.Destroy)
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret(testSecretName, value, false))
|
|
||||||
|
|
||||||
return vlt
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVaultErrors checks that each failure returns its exported error,
|
|
||||||
// wrapped or not, so that errors.Is tells it apart from the others.
|
|
||||||
func TestVaultErrors(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
run func(vlt *vault.Vault) error
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{"create an existing vault", func(vlt *vault.Vault) error {
|
|
||||||
_, err := vault.CreateVault(vlt.GetFilesystem(), testStateDir,
|
|
||||||
testVaultName, nil, nil)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}, vault.ErrVaultExists},
|
|
||||||
{"select a missing vault", func(vlt *vault.Vault) error {
|
|
||||||
return vault.SelectVault(vlt.GetFilesystem(), testStateDir, missingName)
|
|
||||||
}, vault.ErrVaultNotFound},
|
|
||||||
{"add a nil value", func(vlt *vault.Vault) error {
|
|
||||||
return vlt.AddSecret(missingName, nil, false)
|
|
||||||
}, secret.ErrNilValueBuffer},
|
|
||||||
{"get a missing secret", func(vlt *vault.Vault) error {
|
|
||||||
_, err := vlt.GetSecret(missingName)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}, vault.ErrSecretNotFound},
|
|
||||||
{"copy onto an existing secret", func(vlt *vault.Vault) error {
|
|
||||||
return vlt.CopySecretAllVersions(vlt, testSecretName, testSecretName, false)
|
|
||||||
}, vault.ErrSecretExists},
|
|
||||||
{"copy a secret without versions", func(vlt *vault.Vault) error {
|
|
||||||
const versionless = "versionless"
|
|
||||||
|
|
||||||
err := vlt.GetFilesystem().MkdirAll(
|
|
||||||
filepath.Join(vaultDir, "secrets.d", versionless), secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
|
||||||
}, vault.ErrNoVersions},
|
|
||||||
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
|
||||||
return vlt.RemoveUnlocker(missingName)
|
|
||||||
}, vault.ErrUnlockerNotFound},
|
|
||||||
{"select a missing unlocker", func(vlt *vault.Vault) error {
|
|
||||||
return vlt.SelectUnlocker(missingName)
|
|
||||||
}, vault.ErrUnlockerNotFound},
|
|
||||||
{"unlocker of an unknown type", func(vlt *vault.Vault) error {
|
|
||||||
fs := vlt.GetFilesystem()
|
|
||||||
|
|
||||||
err := afero.WriteFile(fs,
|
|
||||||
filepath.Join(vaultDir, "unlockers.d", "odd", "unlocker-metadata.json"),
|
|
||||||
[]byte(`{"type":"odd"}`), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = afero.WriteFile(fs, filepath.Join(vaultDir, "current-unlocker"),
|
|
||||||
[]byte("odd"), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = vlt.GetCurrentUnlocker()
|
|
||||||
|
|
||||||
return err
|
|
||||||
}, vault.ErrUnsupportedUnlockerType},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
require.ErrorIs(t, tt.run(newErrorTestVault(t)), tt.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetSecretWithWrongMnemonic checks that getting a secret that exists,
|
|
||||||
// from a vault the given mnemonic does not open, fails with
|
|
||||||
// ErrMnemonicMismatch through GetSecret's wrapping, and not with
|
|
||||||
// ErrSecretNotFound.
|
|
||||||
func TestGetSecretWithWrongMnemonic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
created := newErrorTestVault(t)
|
|
||||||
|
|
||||||
mnemonic := memguard.NewBufferFromBytes([]byte(otherMnemonic))
|
|
||||||
t.Cleanup(mnemonic.Destroy)
|
|
||||||
|
|
||||||
vlt := vault.NewVault(created.GetFilesystem(), testStateDir, testVaultName)
|
|
||||||
vlt.SetMnemonic(mnemonic)
|
|
||||||
|
|
||||||
_, err := vlt.GetSecret(testSecretName)
|
|
||||||
require.ErrorIs(t, err, vault.ErrMnemonicMismatch)
|
|
||||||
require.NotErrorIs(t, err, vault.ErrSecretNotFound)
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,16 @@ package vault_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// deriveVaultIdentity derives the long-term identity for the given vault
|
// deriveVaultIdentity derives the long-term identity for the given vault
|
||||||
@@ -100,7 +99,7 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Create a test vault
|
// Create a test vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -148,7 +147,7 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +223,7 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create a test vault - CreateVault writes the public key derived from
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -325,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range validNames {
|
for _, name := range validNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -341,10 +340,10 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
||||||
if !errors.Is(err, vault.ErrInvalidVaultName) {
|
if err == nil {
|
||||||
t.Errorf("Expected ErrInvalidVaultName creating vault with "+
|
t.Errorf("Expected error creating vault with invalid name %q, "+
|
||||||
"invalid name %q, got %v", name, err)
|
"but got none", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create three vaults
|
// Create three vaults
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -412,12 +411,12 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
|
|
||||||
// Create two vaults - CreateVault writes the public key derived from
|
// Create two vaults - CreateVault writes the public key derived from
|
||||||
// the mnemonic
|
// the mnemonic
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnexpectedValue is returned by concurrent readers when a secret value
|
// errUnexpectedValue is returned by concurrent readers when a secret value
|
||||||
@@ -49,7 +49,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault without a long-term key, which is set up below
|
// Create vault without a long-term key, which is set up below
|
||||||
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
vault, err := CreateVault(fs, testStateDir, "test", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -320,10 +320,10 @@ func testVersionSerialLimits(
|
|||||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should fail to create 1000th version. The error is unexported in
|
// Should fail to create 1000th version
|
||||||
// package secret, whose own test checks that it is the one returned.
|
|
||||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
||||||
@@ -331,18 +331,20 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
|||||||
|
|
||||||
// Try to get non-existent version
|
// Try to get non-existent version
|
||||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
|
||||||
// Try to get version of non-existent secret
|
// Try to get version of non-existent secret
|
||||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||||
require.ErrorIs(t, err, ErrSecretNotFound)
|
require.Error(t, err)
|
||||||
|
|
||||||
// Try to add secret without force when it exists
|
// Try to add secret without force when it exists
|
||||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||||
defer failBuffer.Destroy()
|
defer failBuffer.Destroy()
|
||||||
|
|
||||||
err = vault.AddSecret(secretName, failBuffer, false)
|
err = vault.AddSecret(secretName, failBuffer, false)
|
||||||
require.ErrorIs(t, err, ErrSecretExists)
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionConcurrency tests concurrent version operations
|
// TestVersionConcurrency tests concurrent version operations
|
||||||
|
|||||||
+3
-96
@@ -1,25 +1,19 @@
|
|||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
// lockFileName is the file in the state directory that LockStateDir locks.
|
||||||
const lockFileName = "lock"
|
const lockFileName = "lock"
|
||||||
|
|
||||||
// finishedMark is what the lock file holds once the command that last held
|
|
||||||
// the lock has released it. A command killed while holding it leaves the
|
|
||||||
// file empty.
|
|
||||||
const finishedMark = "finished\n"
|
|
||||||
|
|
||||||
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
||||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
// has no file locks. Every in-memory filesystem in the process shares it.
|
||||||
//
|
//
|
||||||
@@ -31,12 +25,6 @@ var memFsLock sync.Mutex
|
|||||||
// it. While one command holds it, the next one waits here. Reads take no
|
// it. While one command holds it, the next one waits here. Reads take no
|
||||||
// lock: each file or directory a command changes is replaced in a single
|
// lock: each file or directory a command changes is replaced in a single
|
||||||
// rename, so a reader finds it as it was before or after, never half-made.
|
// rename, so a reader finds it as it was before or after, never half-made.
|
||||||
// Once it holds the lock, it empties the lock file, and the function it
|
|
||||||
// returns writes finishedMark there just before releasing the lock, so a
|
|
||||||
// command killed while holding the lock leaves the mark missing. Finding it
|
|
||||||
// missing, LockStateDir first deletes the temporary files and directories
|
|
||||||
// such a command may have left, since no command still using them can be
|
|
||||||
// running. After a command that finished, it searches nothing.
|
|
||||||
//
|
//
|
||||||
// On the real filesystem the lock is flock(2) on the file "lock" in
|
// On the real filesystem the lock is flock(2) on the file "lock" in
|
||||||
// stateDir, which the kernel releases when the process dies, so a killed
|
// stateDir, which the kernel releases when the process dies, so a killed
|
||||||
@@ -44,97 +32,16 @@ var memFsLock sync.Mutex
|
|||||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
// use has no file locks, so a process-wide mutex stands in for flock there.
|
||||||
// Any other filesystem is refused rather than left unlocked.
|
// Any other filesystem is refused rather than left unlocked.
|
||||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
||||||
var release func()
|
|
||||||
|
|
||||||
switch fs.(type) {
|
switch fs.(type) {
|
||||||
case *afero.OsFs:
|
case *afero.OsFs:
|
||||||
var err error
|
return flockStateDir(stateDir)
|
||||||
|
|
||||||
release, err = flockStateDir(stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case *afero.MemMapFs:
|
case *afero.MemMapFs:
|
||||||
memFsLock.Lock()
|
memFsLock.Lock()
|
||||||
|
|
||||||
release = memFsLock.Unlock
|
return memFsLock.Unlock, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The lock file is written in place, never replaced: a command waiting
|
|
||||||
// for flock on the old file would then take a lock nobody else checks.
|
|
||||||
lockPath := filepath.Join(stateDir, lockFileName)
|
|
||||||
|
|
||||||
mark, err := afero.ReadFile(fs, lockPath)
|
|
||||||
if err != nil || string(mark) != finishedMark {
|
|
||||||
removeLeftovers(fs, stateDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
release()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
// If this fails, the next command searches when it need not.
|
|
||||||
_ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms)
|
|
||||||
|
|
||||||
release()
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeLeftovers deletes the temporary files and directories that commands
|
|
||||||
// killed part-way left in each directory where secret.WriteFileAtomic and
|
|
||||||
// secret.TempDirFor make them: the state directory, each vault, each secret
|
|
||||||
// and each version. Unlocker directories are written whole by
|
|
||||||
// secret.WriteDir and never changed after, so they hold none. A failure is
|
|
||||||
// only warned about, and the command goes on.
|
|
||||||
func removeLeftovers(fs afero.Fs, stateDir string) {
|
|
||||||
dirs := []string{stateDir}
|
|
||||||
|
|
||||||
for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) {
|
|
||||||
dirs = append(dirs, vaultDir)
|
|
||||||
|
|
||||||
for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) {
|
|
||||||
dirs = append(dirs, secretDir)
|
|
||||||
dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dir := range dirs {
|
|
||||||
err := secret.RemoveLeftovers(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Failed to remove what an interrupted command left",
|
|
||||||
"error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subdirs returns the directories in dir: none if dir does not exist, and
|
|
||||||
// none, with a warning, if it cannot be read.
|
|
||||||
func subdirs(fs afero.Fs, dir string) []string {
|
|
||||||
entries, err := afero.ReadDir(fs, dir)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
secret.Warn("Failed to look for what an interrupted command left",
|
|
||||||
"directory", dir, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var dirs []string
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
dirs = append(dirs, filepath.Join(dir, entry.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dirs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -123,51 +121,6 @@ func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLockStateDirRemovesLeftoversOnlyAfterKill checks that taking the lock
|
|
||||||
// deletes a temporary directory a killed command left only when the last
|
|
||||||
// holder of the lock did not release it. A holder killed while it holds the
|
|
||||||
// lock leaves the lock file as it is at that moment.
|
|
||||||
func TestLockStateDirRemovesLeftoversOnlyAfterKill(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, lfs := range lockFilesystems(t) {
|
|
||||||
t.Run(lfs.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lockFile := filepath.Join(lfs.stateDir, "lock")
|
|
||||||
leftover := filepath.Join(lfs.stateDir, ".tmp-1")
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
whileHeld, err := afero.ReadFile(lfs.fs, lockFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
release()
|
|
||||||
|
|
||||||
require.NoError(t, lfs.fs.MkdirAll(leftover, secret.DirPerms))
|
|
||||||
|
|
||||||
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
release()
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(lfs.fs, leftover)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists, "searched after a holder that finished")
|
|
||||||
|
|
||||||
require.NoError(t, afero.WriteFile(lfs.fs, lockFile, whileHeld,
|
|
||||||
secret.FilePerms))
|
|
||||||
|
|
||||||
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
release()
|
|
||||||
|
|
||||||
exists, err = afero.DirExists(lfs.fs, leftover)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists, "not searched after a holder that was killed")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
||||||
// lock implementation is refused instead of being used unlocked.
|
// lock implementation is refused instead of being used unlocked.
|
||||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register the GetCurrentVault function with the secret package
|
// Register the GetCurrentVault function with the secret package
|
||||||
@@ -153,17 +152,16 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||||
// It returns the long-term key, nil when there is no mnemonic, and the
|
// It returns the derivation index, public key hash, and family hash.
|
||||||
// derivation index, public key hash, and family hash.
|
|
||||||
func processMnemonicForVault(
|
func processMnemonicForVault(
|
||||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||||
mnemonicBuffer *memguard.LockedBuffer,
|
mnemonicBuffer *memguard.LockedBuffer,
|
||||||
) (*age.X25519Identity, uint32, string, string, error) {
|
) (uint32, string, string, error) {
|
||||||
if mnemonicBuffer == nil {
|
if mnemonicBuffer == nil {
|
||||||
secret.Debug("No mnemonic given, vault created without long-term key",
|
secret.Debug("No mnemonic given, vault created without long-term key",
|
||||||
"vault", vaultName)
|
"vault", vaultName)
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return nil, 0, "", "", nil
|
return 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mnemonic := mnemonicBuffer.String()
|
mnemonic := mnemonicBuffer.String()
|
||||||
@@ -173,14 +171,13 @@ func processMnemonicForVault(
|
|||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", "",
|
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
fmt.Errorf("failed to get next derivation index: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the actual derivation index
|
// Derive the long-term key using the actual derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the public key
|
// Write the public key
|
||||||
@@ -190,8 +187,7 @@ func processMnemonicForVault(
|
|||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", "",
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
fmt.Errorf("failed to write long-term public key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
@@ -203,33 +199,24 @@ func processMnemonicForVault(
|
|||||||
// This is used to identify which vaults belong to the same mnemonic family
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", "",
|
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
fmt.Errorf("failed to derive identity for index 0: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
|
return derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault and selects it as the current vault. When
|
// CreateVault creates a new vault and selects it as the current vault. When
|
||||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||||
// long-term key until one is imported. When passphrase is not nil, the vault
|
// long-term key until one is imported. It refuses a vault that already
|
||||||
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
// exists before writing anything: creating it again would replace its keys,
|
||||||
// needs a mnemonic. It refuses a vault that already exists before writing
|
// and its secrets could no longer be decrypted. The commands that call it
|
||||||
// anything: creating it again would replace its keys, and its secrets could
|
// hold the state directory lock, so no other command can create the vault
|
||||||
// no longer be decrypted. The commands that call it hold the state directory
|
// between the check and the writes.
|
||||||
// lock, so no other command can create the vault between the check and the
|
|
||||||
// writes.
|
|
||||||
//
|
|
||||||
// The vault is written whole into a temporary directory, which is renamed
|
|
||||||
// into vaults.d only once complete, and only then selected: a crash at any
|
|
||||||
// point leaves either no vault or a complete one. The next command that
|
|
||||||
// takes the lock deletes what the crash left under a temporary name.
|
|
||||||
func CreateVault(
|
func CreateVault(
|
||||||
fs afero.Fs, stateDir string, name string,
|
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) (*Vault, error) {
|
) (*Vault, error) {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
@@ -253,19 +240,51 @@ func CreateVault(
|
|||||||
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passphrase != nil && mnemonic == nil {
|
// Create vault directory structure
|
||||||
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
|
// Create main vault directory
|
||||||
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
|
err = fs.MkdirAll(vaultDir, secret.DirPerms)
|
||||||
})
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create secrets directory
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
|
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unlockers directory
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
|
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process mnemonic if available
|
||||||
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
||||||
|
fs, stateDir, vaultDir, name, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save vault metadata
|
||||||
|
metadata := &Metadata{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
DerivationIndex: derivationIndex,
|
||||||
|
PublicKeyHash: publicKeyHash,
|
||||||
|
MnemonicFamilyHash: familyHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Select the newly created vault as current
|
// Select the newly created vault as current
|
||||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||||
|
|
||||||
@@ -283,47 +302,6 @@ func CreateVault(
|
|||||||
return vlt, nil
|
return vlt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeVaultFiles writes the files of the new vault name into vaultDir: its
|
|
||||||
// secrets and unlockers directories, its long-term public key and metadata,
|
|
||||||
// and, when passphrase is not nil, a passphrase unlocker as its current one.
|
|
||||||
func writeVaultFiles(
|
|
||||||
fs afero.Fs, stateDir, vaultDir, name string,
|
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) error {
|
|
||||||
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
|
|
||||||
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
|
|
||||||
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := &Metadata{
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
DerivationIndex: derivationIndex,
|
|
||||||
PublicKeyHash: publicKeyHash,
|
|
||||||
MnemonicFamilyHash: familyHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if passphrase == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectVault selects the given vault as the current vault
|
// SelectVault selects the given vault as the current vault
|
||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata is an alias for secret.VaultMetadata
|
// Metadata is an alias for secret.VaultMetadata
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
||||||
@@ -304,7 +304,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
@@ -321,7 +321,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
_, err = vault.CreateVault(fs, tempDir, "work", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package vault_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||||
@@ -18,7 +18,7 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -41,8 +41,10 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
require.Error(t, err,
|
||||||
"GetSecretVersion should reject malicious name: %s", name)
|
"GetSecretVersion should reject malicious name: %s", name)
|
||||||
|
require.Contains(t, err.Error(), "invalid secret name",
|
||||||
|
"error should indicate invalid name for: %s", name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,11 +57,12 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "invalid secret name")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
@@ -70,7 +73,7 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
testMnemonicBuffer(t), nil)
|
testMnemonicBuffer(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
@@ -84,8 +87,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := vlt.GetSecretObject(name)
|
_, err := vlt.GetSecretObject(name)
|
||||||
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||||
"GetSecretObject should reject: %s", name)
|
require.Contains(t, err.Error(), "invalid secret name")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user