Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947f9b8411 | ||
|
|
015730fb05 | ||
|
|
1d7f78fd0d |
@@ -4,154 +4,160 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
# Instructions and Contextual Information
|
# Instructions and Contextual Information
|
||||||
|
|
||||||
* Be direct, robotic, expert, accurate, and professional.
|
- Be direct, robotic, expert, accurate, and professional.
|
||||||
|
|
||||||
* Do not butter me up or kiss my ass.
|
- Do not butter me up or kiss my ass.
|
||||||
|
|
||||||
* Come in hot with strong opinions, even if they are contrary to the
|
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||||
direction I am headed.
|
am headed.
|
||||||
|
|
||||||
* If either you or I are possibly wrong, say so and explain your point of
|
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||||
view.
|
|
||||||
|
|
||||||
* Point out great alternatives I haven't thought of, even when I'm not
|
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||||
asking for them.
|
for them.
|
||||||
|
|
||||||
* Treat me like the world's leading expert in every situation and every
|
- Treat me like the world's leading expert in every situation and every
|
||||||
conversation, and deliver the absolute best recommendations.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
* I want excellence, so always be on the lookout for divergences from good
|
- I want excellence, so always be on the lookout for divergences from good data
|
||||||
data model design or best practices for object oriented development.
|
model design or best practices for object oriented development.
|
||||||
|
|
||||||
* IMPORTANT: This is production code, not a research or teaching exercise.
|
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||||
Deliver professional-level results, not prototypes.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
* Please read and understand the `README.md` file in the root of the repo
|
- Please read and understand the `README.md` file in the root of the repo for
|
||||||
for project-specific contextual information, including development
|
project-specific contextual information, including development policies,
|
||||||
policies, practices, and current implementation status.
|
practices, and current implementation status.
|
||||||
|
|
||||||
* Be proactive in suggesting improvements or refactorings in places where we
|
- Be proactive in suggesting improvements or refactorings in places where we
|
||||||
diverge from best practices for clean, modular, maintainable code.
|
diverge from best practices for clean, modular, maintainable code.
|
||||||
|
|
||||||
# Policies
|
# Policies
|
||||||
|
|
||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
1. Before committing, tests must pass (`make test`), linting must pass
|
||||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. When you think your changes are complete, rather
|
`golangci-lint run`. When you think your changes are complete, rather than
|
||||||
than making three different tool calls to check, you can just run `make
|
making three different tool calls to check, you can just run
|
||||||
test && make fmt && make lint` as a single tool call which will save
|
`make test && make fmt && make lint` as a single tool call which will save
|
||||||
time.
|
time.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with
|
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||||
a `fmt` target that formats the code. The `test` target should run all
|
`fmt` target that formats the code. The `test` target should run all tests in
|
||||||
tests in the project, and the `fmt` target should format the code.
|
the project, and the `fmt` target should format the code. `test` should also
|
||||||
`test` should also have a prerequisite target `lint` that should run any
|
have a prerequisite target `lint` that should run any linters that are
|
||||||
linters that are configured for the project.
|
configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do
|
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||||
course.
|
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
4. When creating a very simple test script for testing out a new feature,
|
||||||
instead of making a throwaway to be deleted after verification, write an
|
instead of making a throwaway to be deleted after verification, write an
|
||||||
actual test file into the test suite. It doesn't need to be very big or
|
actual test file into the test suite. It doesn't need to be very big or
|
||||||
complex, but it should be a real test that can be run.
|
complex, but it should be a real test that can be run.
|
||||||
|
|
||||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
tests, or change the tests specifically to make them pass (unless there
|
tests, or change the tests specifically to make them pass (unless there is a
|
||||||
is a bug in the test). This is cheating, and it is bad. You should only
|
bug in the test). This is cheating, and it is bad. You should only be
|
||||||
be modifying the test if it is incorrect or if the test is no longer
|
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||||
relevant. In almost all cases, you should be fixing the code that is
|
In almost all cases, you should be fixing the code that is being tested, or
|
||||||
being tested, or updating the tests to match a refactored implementation.
|
updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
6. When dealing with dates and times or timestamps, always use, display, and
|
6. When dealing with dates and times or timestamps, always use, display, and
|
||||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||||
to see the time in a different timezone, store the user's timezone in a
|
the time in a different timezone, store the user's timezone in a separate
|
||||||
separate field and convert the UTC time to the user's timezone when
|
field and convert the UTC time to the user's timezone when displaying it. For
|
||||||
displaying it. For internal use and internal applications and
|
internal use and internal applications and administrative purposes, always
|
||||||
administrative purposes, always display UTC.
|
display UTC.
|
||||||
|
|
||||||
7. Always write tests, even if they are extremely simple and just check for
|
7. Always write tests, even if they are extremely simple and just check for
|
||||||
correct syntax (ability to compile/import). If you are writing a new
|
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||||
feature, write a test for it. You don't need to target complete
|
write a test for it. You don't need to target complete coverage, but you
|
||||||
coverage, but you should at least test any new functionality you add. If
|
should at least test any new functionality you add. If you are fixing a bug,
|
||||||
you are fixing a bug, write a test first that reproduces the bug, and
|
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||||
then fix the bug in the code.
|
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such
|
8. When implementing new features, be aware of potential side-effects (such as
|
||||||
as state files on disk, data in the database, etc.) and ensure that it is
|
state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If stdout is not a terminal, output
|
messages (but do not log secrets). If stdout is not a terminal, output the
|
||||||
the structured logs in jsonl format.
|
structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top
|
anywhere more than once. Always define a constant (usually at the top of the
|
||||||
of the file) and give it a descriptive name, then use that constant in
|
file) and give it a descriptive name, then use that constant in the code
|
||||||
the code instead of the bare string or number.
|
instead of the bare string or number.
|
||||||
|
|
||||||
11. You do not need to summarize your changes in the chat after making them.
|
11. You do not need to summarize your changes in the chat after making them.
|
||||||
Making the changes and committing them is sufficient. If anything out
|
Making the changes and committing them is sufficient. If anything out of the
|
||||||
of the ordinary happened, please explain it, but in the normal case
|
ordinary happened, please explain it, but in the normal case where you found
|
||||||
where you found and fixed the bug, or implemented the feature, there is
|
and fixed the bug, or implemented the feature, there is no need for the
|
||||||
no need for the end-of-change summary.
|
end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project
|
12. Do not create additional files in the root directory of the project without
|
||||||
without asking permission first. Configuration files, documentation, and
|
asking permission first. Configuration files, documentation, and build files
|
||||||
build files are acceptable in the root, but source code and other files
|
are acceptable in the root, but source code and other files should be
|
||||||
should be organized in appropriate subdirectories.
|
organized in appropriate subdirectories.
|
||||||
|
|
||||||
## Python-Specific Guidelines
|
## Python-Specific Guidelines
|
||||||
|
|
||||||
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error.
|
1. **Type Annotations (UP006)**: Use built-in collection types directly for type
|
||||||
|
annotations instead of importing from `typing`. This avoids the UP006 linter
|
||||||
**Good (modern Python 3.9+):**
|
error.
|
||||||
```python
|
|
||||||
def process_items(items: list[str]) -> dict[str, int]:
|
**Good (modern Python 3.9+):**
|
||||||
counts: dict[str, int] = {}
|
|
||||||
return counts
|
```python
|
||||||
```
|
def process_items(items: list[str]) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
**Avoid (triggers UP006):**
|
return counts
|
||||||
```python
|
```
|
||||||
from typing import List, Dict
|
|
||||||
|
**Avoid (triggers UP006):**
|
||||||
def process_items(items: List[str]) -> Dict[str, int]:
|
|
||||||
counts: Dict[str, int] = {}
|
```python
|
||||||
return counts
|
from typing import List, Dict
|
||||||
```
|
|
||||||
|
def process_items(items: List[str]) -> Dict[str, int]:
|
||||||
For optional types, use the `|` operator instead of `Union`:
|
counts: Dict[str, int] = {}
|
||||||
```python
|
return counts
|
||||||
# Good
|
```
|
||||||
def get_value(key: str) -> str | None:
|
|
||||||
return None
|
For optional types, use the `|` operator instead of `Union`:
|
||||||
|
|
||||||
# Avoid
|
```python
|
||||||
from typing import Optional, Union
|
# Good
|
||||||
def get_value(key: str) -> Optional[str]:
|
def get_value(key: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
```
|
|
||||||
|
# Avoid
|
||||||
|
from typing import Optional, Union
|
||||||
|
def get_value(key: str) -> Optional[str]:
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
2. **Import Organization**: Follow the standard Python import order:
|
2. **Import Organization**: Follow the standard Python import order:
|
||||||
- Standard library imports
|
- Standard library imports
|
||||||
- Third-party imports
|
- Third-party imports
|
||||||
- Local application imports
|
- Local application imports
|
||||||
|
|
||||||
Each group should be separated by a blank line.
|
Each group should be separated by a blank line.
|
||||||
|
|
||||||
## Go-Specific Guidelines
|
## Go-Specific Guidelines
|
||||||
|
|
||||||
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
|
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
|
||||||
|
errors via return values.
|
||||||
|
|
||||||
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
|
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
|
||||||
|
errors, not crash.
|
||||||
|
|
||||||
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
||||||
|
|
||||||
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
|
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix
|
||||||
|
the code.
|
||||||
|
|
||||||
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
|
5. **All PRs must pass `make check` with zero failures.** No exceptions, no
|
||||||
|
"pre-existing issue" excuses.
|
||||||
|
|
||||||
6. **Pin external dependencies by commit hash**, not mutable tags.
|
6. **Pin external dependencies by commit hash**, not mutable tags.
|
||||||
|
|||||||
@@ -1,72 +1,69 @@
|
|||||||
# secret - Local Secret Manager
|
# secret - Local Secret Manager
|
||||||
|
|
||||||
secret is a command-line local secret manager that implements a hierarchical
|
## Description
|
||||||
key architecture for storing and managing sensitive data. It supports
|
|
||||||
multiple vaults, various unlock mechanisms, and provides secure storage
|
|
||||||
using the `age` encryption library.
|
|
||||||
|
|
||||||
It could be used as password manager, but was not designed as such. I
|
`secret` is a WTFPL-licensed Go command-line local secret manager by
|
||||||
created it to scratch an itch for a secure key/value store for replacing a
|
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture
|
||||||
bunch of pgp-encrypted files in a directory structure.
|
for storing and managing sensitive data. It supports multiple vaults, various
|
||||||
|
unlock mechanisms, and provides secure storage using the `age` encryption
|
||||||
|
library.
|
||||||
|
|
||||||
## Core Architecture
|
## Getting Started
|
||||||
|
|
||||||
|
Build from source, then install the binary as `~/bin/secret`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.eeqj.de/sneak/secret.git
|
||||||
|
cd secret
|
||||||
|
make build # writes the binary to ./secret
|
||||||
|
make install # builds it and copies it to ~/bin/secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a mnemonic, create the default vault, then store and read a secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secret generate mnemonic # prints a new BIP39 mnemonic; write it down
|
||||||
|
secret init # asks for that mnemonic and an unlocker passphrase
|
||||||
|
echo "my-password" | secret add myservice/password
|
||||||
|
secret get myservice/password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
I created `secret` to scratch an itch: I wanted a secure key/value store to
|
||||||
|
replace a bunch of PGP-encrypted files in a directory structure. It could be
|
||||||
|
used as a password manager, but was not designed as one.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
### Three-Layer Key Hierarchy
|
### Three-Layer Key Hierarchy
|
||||||
|
|
||||||
Secret implements a three-layer key architecture:
|
Secret implements a three-layer key architecture:
|
||||||
|
|
||||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide
|
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
|
||||||
the foundation for all encryption
|
foundation for all encryption
|
||||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys,
|
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
|
||||||
supporting multiple authentication methods
|
multiple authentication methods
|
||||||
3. **Version-specific Keys**: Per-version keys that encrypt individual
|
3. **Version-specific Keys**: Per-version keys that encrypt individual secret
|
||||||
secret values
|
values
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
Each secret maintains a history of versions, with each version having:
|
Each secret maintains a history of versions, with each version having:
|
||||||
|
|
||||||
- Its own encryption key pair
|
- Its own encryption key pair
|
||||||
- Metadata (unencrypted) including creation time and validity period
|
- Metadata including creation time and validity period, encrypted to the
|
||||||
|
version's key pair
|
||||||
- Immutable value storage
|
- Immutable value storage
|
||||||
- Atomic version switching via symlink updates
|
|
||||||
|
The secret's `current` file names its current version. Switching versions
|
||||||
|
replaces that file in one rename, so it is never half-written.
|
||||||
|
|
||||||
### Vault System
|
### Vault System
|
||||||
|
|
||||||
Vaults provide logical separation of secrets, each with its own long-term
|
Vaults provide logical separation of secrets, each with its own long-term key
|
||||||
key and unlocker set. This allows for complete isolation between different
|
and unlocker set. This allows for complete isolation between different contexts
|
||||||
contexts (work, personal, projects).
|
(work, personal, projects).
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Build from source:
|
|
||||||
```bash
|
|
||||||
git clone <repository>
|
|
||||||
cd secret
|
|
||||||
make build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Initialize the secret manager**:
|
|
||||||
```bash
|
|
||||||
secret init
|
|
||||||
```
|
|
||||||
This creates the default vault and prompts for a BIP39 mnemonic phrase.
|
|
||||||
|
|
||||||
2. **Generate a mnemonic** (if needed):
|
|
||||||
```bash
|
|
||||||
secret generate mnemonic
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Add a secret**:
|
|
||||||
```bash
|
|
||||||
echo "my-password" | secret add myservice/password
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Retrieve a secret**:
|
|
||||||
```bash
|
|
||||||
secret get myservice/password
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands Reference
|
## Commands Reference
|
||||||
|
|
||||||
@@ -74,10 +71,10 @@ make build
|
|||||||
|
|
||||||
`secret rm`, `secret version rm`, `secret vault remove` and
|
`secret rm`, `secret version rm`, `secret vault remove` and
|
||||||
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
|
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
|
||||||
each one first asks `[y/N]`, naming exactly what it is about to remove, and
|
each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
|
||||||
goes ahead only on `y` or `yes`; any other answer, a bare Enter included,
|
ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
|
||||||
cancels and removes nothing. The question is asked only after the command's
|
removes nothing. The question is asked only after the command's checks have
|
||||||
checks have passed, and before it changes anything.
|
passed, and before it changes anything.
|
||||||
|
|
||||||
Whether to ask is decided by stdin, where the answer is read from, so
|
Whether to ask is decided by stdin, where the answer is read from, so
|
||||||
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
|
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
|
||||||
@@ -96,6 +93,7 @@ Initializes the secret manager with a default vault. Prompts for a BIP39
|
|||||||
mnemonic phrase and creates the initial directory structure.
|
mnemonic phrase and creates the initial directory structure.
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
|
|
||||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
||||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
||||||
|
|
||||||
@@ -118,8 +116,8 @@ Switches to the specified vault for subsequent operations.
|
|||||||
|
|
||||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a vault and all its secrets. It first asks
|
**DANGER**: Permanently removes a vault and all its secrets. It first asks for
|
||||||
for confirmation, naming the vault and how many secrets it holds (see
|
confirmation, naming the vault and how many secrets it holds (see
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
|
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
|
||||||
cannot be removed. Removing the current vault makes another vault the current
|
cannot be removed. Removing the current vault makes another vault the current
|
||||||
one.
|
one.
|
||||||
@@ -132,58 +130,65 @@ one.
|
|||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
|
|
||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||||
|
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
|
|
||||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/`
|
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
|
||||||
are allowed, and a name must not be empty, start with `.` or `/`, end with
|
allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
|
||||||
`/`, contain `//`, or have `..` as a path segment.
|
contain `//`, or have `..` as a path segment.
|
||||||
|
|
||||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||||
|
|
||||||
#### `secret get <secret-name> [--version <version>]`
|
#### `secret get <secret-name> [--version <version>]`
|
||||||
|
|
||||||
Retrieves and outputs a secret value to stdout.
|
Retrieves and outputs a secret value to stdout.
|
||||||
|
|
||||||
- `--version, -v`: Get a specific version (default: current)
|
- `--version, -v`: Get a specific version (default: current)
|
||||||
|
|
||||||
#### `secret list [filter] [--json]` / `secret ls`
|
#### `secret list [filter] [--json]` / `secret ls`
|
||||||
|
|
||||||
Lists all secrets in the current vault. Optional filter for substring
|
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||||
matching.
|
|
||||||
|
|
||||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks
|
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||||
for confirmation, naming the secret, its vault and how many versions it has
|
confirmation, naming the secret, its vault and how many versions it has (see
|
||||||
(see [Confirmation Before Removal](#confirmation-before-removal)).
|
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||||
|
|
||||||
- `--force, -f`: Remove without asking
|
- `--force, -f`: Remove without asking
|
||||||
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently
|
||||||
|
deleted
|
||||||
|
|
||||||
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||||
|
|
||||||
Moves or renames a secret within the current vault.
|
Moves or renames a secret within the current vault.
|
||||||
|
|
||||||
- Fails if the destination already exists
|
- Fails if the destination already exists
|
||||||
- Fails if the destination is the source under another name, such as `foo`
|
- Fails if the destination is the source under another name, such as `foo` for
|
||||||
for `Foo` on a case-insensitive filesystem (the macOS default); there, to
|
`Foo` on a case-insensitive filesystem (the macOS default); there, to change
|
||||||
change only the case of a name, move the secret to a third name first
|
only the case of a name, move the secret to a third name first
|
||||||
- Preserves all versions and metadata
|
- Preserves all versions and metadata
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
#### `secret version list <secret-name>` / `secret version ls`
|
#### `secret version list <secret-name>` / `secret version ls`
|
||||||
|
|
||||||
Lists all versions of a secret showing creation time, status, and validity period.
|
Lists all versions of a secret showing creation time, status, and validity
|
||||||
|
period.
|
||||||
|
|
||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
|
|
||||||
Promotes a specific version to current by updating the symlink. Does not
|
Promotes a specific version to current by updating the symlink. Does not modify
|
||||||
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` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
||||||
for confirmation, naming the version, the secret and its vault (see
|
for confirmation, naming the version, the secret and its vault (see
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)).
|
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||||
|
|
||||||
- `--force, -f`: Remove without asking
|
- `--force, -f`: Remove without asking
|
||||||
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||||
- Cannot remove the current version (must promote another version first)
|
- Cannot remove the current version (must promote another version first)
|
||||||
@@ -197,6 +202,7 @@ Generates a cryptographically secure BIP39 mnemonic phrase.
|
|||||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||||
|
|
||||||
Generates and stores a random secret.
|
Generates and stores a random secret.
|
||||||
|
|
||||||
- `--length, -l`: Length of generated secret (default: 16)
|
- `--length, -l`: Length of generated secret (default: 16)
|
||||||
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
@@ -212,16 +218,19 @@ Lists all unlockers in the current vault with their metadata.
|
|||||||
Creates a new unlocker of the specified type:
|
Creates a new unlocker of the specified type:
|
||||||
|
|
||||||
**Types:**
|
**Types:**
|
||||||
|
|
||||||
- `passphrase`: Traditional passphrase-protected unlocker
|
- `passphrase`: Traditional passphrase-protected unlocker
|
||||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||||
- `keychain`: macOS Keychain integration (macOS only)
|
- `keychain`: macOS Keychain integration (macOS only)
|
||||||
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
|
||||||
|
|
||||||
A vault has one passphrase unlocker: adding one replaces the one the vault
|
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
|
||||||
has, which is removed only once the new one is the current unlocker.
|
specified)
|
||||||
|
|
||||||
|
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||||
|
which is removed only once the new one is the current unlocker.
|
||||||
|
|
||||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
|
|
||||||
@@ -230,9 +239,9 @@ naming the unlocker and its vault and saying whether it is the vault's last
|
|||||||
unlocker; for the last one it says how many secrets the vault holds and warns
|
unlocker; for the last one it says how many secrets the vault holds and warns
|
||||||
that the vault then opens only with its mnemonic (see
|
that the vault then opens only with its mnemonic (see
|
||||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||||
directory that `secret unlocker list` skips with a warning, because its
|
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||||
metadata cannot be read or parsed, is removed by the directory name the
|
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||||
warning gives.
|
|
||||||
- `--force, -f`: Remove without asking, even the last unlocker
|
- `--force, -f`: Remove without asking, even the last unlocker
|
||||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||||
vault data will be PERMANENTLY INACCESSIBLE
|
vault data will be PERMANENTLY INACCESSIBLE
|
||||||
@@ -247,7 +256,8 @@ Selects an unlocker as the current default for operations.
|
|||||||
|
|
||||||
#### `secret import <secret-name> --source <filename>`
|
#### `secret import <secret-name> --source <filename>`
|
||||||
|
|
||||||
Imports a secret from a file and stores it in the current vault under the given name.
|
Imports a secret from a file and stores it in the current vault under the given
|
||||||
|
name.
|
||||||
|
|
||||||
#### `secret vault import [vault-name]`
|
#### `secret vault import [vault-name]`
|
||||||
|
|
||||||
@@ -257,7 +267,8 @@ Imports a mnemonic phrase into the specified vault (defaults to "default").
|
|||||||
|
|
||||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
|
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
|
||||||
|
generates a new Age key.
|
||||||
|
|
||||||
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
@@ -302,37 +313,43 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
### Key Management and Encryption Flow
|
### Key Management and Encryption Flow
|
||||||
|
|
||||||
#### 1: Long-term Keys
|
#### 1: Long-term Keys
|
||||||
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
|
|
||||||
|
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
|
||||||
|
deterministic (HD) key derivation
|
||||||
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
||||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
|
- **Storage**: Public key stored as `pub.age`, private key encrypted by
|
||||||
|
unlockers
|
||||||
|
|
||||||
#### 2: Unlockers
|
#### 2: Unlockers
|
||||||
|
|
||||||
Unlockers provide different authentication methods to access the long-term keys:
|
Unlockers provide different authentication methods to access the long-term keys:
|
||||||
|
|
||||||
1. **Passphrase Unlockers**:
|
1. **Passphrase Unlockers**:
|
||||||
- Encrypted with user-provided passphrase
|
- Encrypted with user-provided passphrase
|
||||||
- Stored as encrypted Age keys
|
- Stored as encrypted Age keys
|
||||||
- Cross-platform compatible
|
- Cross-platform compatible
|
||||||
|
|
||||||
2. **PGP Unlockers**:
|
2. **PGP Unlockers**:
|
||||||
- Uses existing GPG key infrastructure
|
- Uses existing GPG key infrastructure
|
||||||
- Leverages existing key management workflows
|
- Leverages existing key management workflows
|
||||||
- Strong authentication through GPG
|
- Strong authentication through GPG
|
||||||
|
|
||||||
3. **Keychain Unlockers** (macOS only):
|
3. **Keychain Unlockers** (macOS only):
|
||||||
- Stores unlock keys in macOS Keychain
|
- Stores unlock keys in macOS Keychain
|
||||||
- Protected by system authentication (Touch ID, password)
|
- Protected by system authentication (Touch ID, password)
|
||||||
- Automatic unlocking when Keychain is unlocked
|
- Automatic unlocking when Keychain is unlocked
|
||||||
- Cross-application integration
|
- Cross-application integration
|
||||||
|
|
||||||
4. **Secure Enclave Unlockers** (macOS):
|
4. **Secure Enclave Unlockers** (macOS):
|
||||||
- Hardware-backed key storage using Apple Secure Enclave
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
|
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||||
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
Program required)
|
||||||
- Protected by biometric authentication (Touch ID) or system password
|
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
||||||
|
- Protected by biometric authentication (Touch ID) or system password
|
||||||
|
|
||||||
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
|
Each vault maintains its own set of unlockers and one long-term key. The
|
||||||
|
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
||||||
|
access vault secrets.
|
||||||
|
|
||||||
#### 3: Secret-specific Keys
|
#### 3: Secret-specific Keys
|
||||||
|
|
||||||
@@ -352,18 +369,19 @@ they hold. Other processes running as the same user can read a process's
|
|||||||
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
||||||
shell or script that sets them inherits them, `gpg` included. Set on a command
|
shell or script that sets them inherits them, `gpg` included. Set on a command
|
||||||
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
||||||
each one as soon as it has read it, so that the programs it runs itself, such
|
each one as soon as it has read it, so that the programs it runs itself, such as
|
||||||
as `gpg`, do not inherit it, but that erases nothing: the environment the
|
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||||
process started with, and its memory, still hold the value. The interactive
|
started with, and its memory, still hold the value. The interactive prompt,
|
||||||
prompt, which every command except `secret vault import` offers when the
|
which every command except `secret vault import` offers when the variable is not
|
||||||
variable is not set, is the safer default; `secret vault import` has no prompt
|
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||||
and needs both variables.
|
variables.
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
|
- Uses the [age encryption library](https://age-encryption.org/) with X25519
|
||||||
|
keys
|
||||||
- All private keys are encrypted at rest
|
- All private keys are encrypted at rest
|
||||||
- No plaintext secrets stored on disk
|
- No plaintext secrets stored on disk
|
||||||
|
|
||||||
@@ -382,7 +400,8 @@ and needs both variables.
|
|||||||
|
|
||||||
- Hardware token support via PGP/GPG integration
|
- Hardware token support via PGP/GPG integration
|
||||||
- macOS Keychain integration for system-level security
|
- macOS Keychain integration for system-level security
|
||||||
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
|
- Secure Enclave integration for hardware-backed key protection (macOS, via
|
||||||
|
`sc_auth` / CryptoTokenKit)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -431,6 +450,7 @@ secret vault remove personal --force
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Authentication
|
### Advanced Authentication
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add multiple unlock methods
|
# Add multiple unlock methods
|
||||||
secret unlocker add passphrase # Password-based
|
secret unlocker add passphrase # Password-based
|
||||||
@@ -477,21 +497,27 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
## Technical Details
|
## Technical Details
|
||||||
|
|
||||||
### Cryptographic Primitives
|
### Cryptographic Primitives
|
||||||
|
|
||||||
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
||||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
||||||
- **Authentication**: Poly1305 MAC
|
- **Authentication**: Poly1305 MAC
|
||||||
- **Hashing**: Double SHA-256 for public key identification
|
- **Hashing**: Double SHA-256 for public key identification
|
||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
|
|
||||||
- **age Files**: Standard age encryption format (.age extension)
|
- **age Files**: Standard age encryption format (.age extension)
|
||||||
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
||||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
- **Vault Metadata**: JSON containing vault name, creation time, derivation
|
||||||
|
index, and public key hash
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
|
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
|
mnemonic, and thus a unique key pair
|
||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies
|
||||||
|
vaults from the same mnemonic
|
||||||
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
||||||
|
automatically derived
|
||||||
|
|
||||||
### Cross-Platform Support
|
### Cross-Platform Support
|
||||||
|
|
||||||
@@ -527,6 +553,7 @@ to add or use them.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # Build binary
|
make build # Build binary
|
||||||
make test # Run tests
|
make test # Run tests
|
||||||
@@ -534,7 +561,9 @@ make lint # Run linter
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
The project includes comprehensive tests:
|
The project includes comprehensive tests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests
|
||||||
go test ./... # Unit tests
|
go test ./... # Unit tests
|
||||||
@@ -546,61 +575,68 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
them. We provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (Go, Go module
|
- `script/bootstrap` — install all dependencies (Go, Go module download),
|
||||||
download), idempotently; golangci-lint is not installed, it runs in
|
idempotently; golangci-lint is not installed, it runs in docker
|
||||||
docker
|
|
||||||
- `script/setup` — make a fresh clone ready for development: runs
|
- `script/setup` — make a fresh clone ready for development: runs
|
||||||
`script/bootstrap`, then `script/install-precommit`
|
`script/bootstrap`, then `script/install-precommit`
|
||||||
- `script/projectname` — output the project name (`secret`); used by
|
- `script/projectname` — output the project name (`secret`); used by other
|
||||||
other scripts such as `script/docker`
|
scripts such as `script/docker`
|
||||||
- `script/build` — build the `secret` binary into the repo root, stamping
|
- `script/build` — build the `secret` binary into the repo root, stamping the
|
||||||
the version (`VERSION` from the environment, else `git describe`) and
|
version (`VERSION` from the environment, else `git describe`) and the git
|
||||||
the git commit
|
commit
|
||||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
- `script/test` — run `go vet` and the test suite (verbose rerun on failure)
|
||||||
failure)
|
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
|
||||||
- `script/lint` — run `golangci-lint` in docker only: builds
|
where the linter is a build step that runs on every call, also on an unchanged
|
||||||
`Dockerfile.lint`, where the linter is a build step that runs on every
|
tree
|
||||||
call, also on an unchanged tree
|
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code
|
||||||
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
|
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never
|
||||||
the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
|
compiles; cgo is off, so the keychain unlocker's calls into the keychain
|
||||||
build never compiles; cgo is off, so the keychain unlocker's calls into
|
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
|
||||||
the keychain (`internal/secret/keychainunlocker_cgo.go`, and
|
and the Secure Enclave bindings (`internal/macse`) are not checked
|
||||||
`keychainunlocker_test.go`) and the Secure Enclave bindings
|
|
||||||
(`internal/macse`) are not checked
|
|
||||||
- `script/fmt` — format all Go code (writes)
|
- `script/fmt` — format all Go code (writes)
|
||||||
- `script/fmt-check` — check formatting without writing
|
- `script/fmt-check` — check formatting without writing
|
||||||
- `script/check` — run `script/test`, `script/lint`,
|
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
|
||||||
`script/lint-darwin`, and `script/fmt-check`
|
`script/fmt-check`
|
||||||
- `script/docker` — build the Docker image tagged with the project name
|
- `script/docker` — build the Docker image tagged with the project name
|
||||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
- `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
|
||||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
(memguard needs mlock; the Dockerfile runs the checks), with a new
|
||||||
checks), with a new `CHECK_EPOCH` build argument on every run so the
|
`CHECK_EPOCH` build argument on every run so the checks run again on an
|
||||||
checks run again on an unchanged tree
|
unchanged tree
|
||||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
|
||||||
then `script/check`
|
`script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
runs `script/precommit`
|
`script/precommit`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
|
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
|
||||||
|
and Secure Enclave unlockers
|
||||||
- **Vault Isolation**: Complete separation between different vaults
|
- **Vault Isolation**: Complete separation between different vaults
|
||||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||||
|
|
||||||
# Author
|
## TODO
|
||||||
|
|
||||||
Made with love and lots of expensive SOTA AI by
|
Open work is tracked on the
|
||||||
[sneak](https://sneak.berlin) in Berlin in the summer of 2025.
|
[issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
|
||||||
|
authoritative. The work to be done before 1.0 is the
|
||||||
|
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
|
||||||
|
records the steps completed so far.
|
||||||
|
|
||||||
Released as a free software gift to the world, no strings attached, under
|
## License
|
||||||
the [WTFPL](https://www.wtfpl.net/) license.
|
|
||||||
|
Released as a free software gift to the world, no strings attached, under the
|
||||||
|
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
|
||||||
|
in Berlin in the summer of 2025.
|
||||||
|
|
||||||
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `main`)
|
- branch from `next`
|
||||||
* do the work in Next Step
|
- do the Next Step: the next open issue in the `1.0.0` milestone
|
||||||
* move Next Step to the top of Completed Steps
|
- log it at the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
- push, and open a PR against `next`
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
|
||||||
* push
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
|
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
|
||||||
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
|
authoritative.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Bring the repo into policy compliance in one commit:
|
Take the next open issue in the `1.0.0` milestone:
|
||||||
|
https://git.eeqj.de/sneak/secret/milestone/12
|
||||||
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
|
|
||||||
docker already exist).
|
|
||||||
- Add REPO_POLICIES.md and .editorconfig.
|
|
||||||
- Add .gitea/workflows/check.yml running make check.
|
|
||||||
- Verify Dockerfile base images are pinned by sha256.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
@@ -31,12 +24,31 @@ Bring the repo into policy compliance in one commit:
|
|||||||
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
|
||||||
Secure Enclave key, so that a wrong passphrase creates none, and deletes 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.
|
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
|
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
|
||||||
among them, before it stores the item in the keychain, and deletes the item
|
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
|
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:
|
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 Secure Enclave one in a build with cgo on a Mac with a Secure Enclave,
|
||||||
the keychain one in a build with cgo.
|
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
|
- 2026-10-04: An age identity's private key goes into a locked buffer
|
||||||
through `secret.IdentityToLockedBuffer` everywhere
|
through `secret.IdentityToLockedBuffer` everywhere
|
||||||
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
|
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
|
||||||
@@ -247,15 +259,10 @@ Bring the repo into policy compliance in one commit:
|
|||||||
cross-vault copies are built in a temporary directory and renamed
|
cross-vault copies are built in a temporary directory and renamed
|
||||||
into place, and removals rename out of the way first, so a version
|
into place, and removals rename out of the way first, so a version
|
||||||
or secret is never half-added and never half-removed. An
|
or secret is never half-added and never half-removed. An
|
||||||
interrupted command can still leave:
|
interrupted command can still leave, from `init` or `vault create`
|
||||||
- from `init` or `vault create` killed after the passphrase prompt
|
killed after the passphrase prompt but before the unlocker is
|
||||||
but before the unlocker is written, a vault with no unlocker,
|
written, a vault with no unlocker, which `vault create` has already
|
||||||
which `vault create` has already made the current vault;
|
made the current vault.
|
||||||
- data under a `.tmp-` name in the state directory: a secret,
|
|
||||||
version or unlocker being added, or the secret, version, unlocker
|
|
||||||
or vault being removed, encrypted keys included. Nothing deletes
|
|
||||||
it; it must be deleted by hand
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/75).
|
|
||||||
- 2026-10-03: The checks run before changing a vault now stop with an
|
- 2026-10-03: The checks run before changing a vault now stop with an
|
||||||
error naming the path and cause when they cannot read what they
|
error naming the path and cause when they cannot read what they
|
||||||
inspect, instead of reading the failure as "nothing there": the
|
inspect, instead of reading the failure as "nothing there": the
|
||||||
@@ -311,8 +318,15 @@ Bring the repo into policy compliance in one commit:
|
|||||||
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||||
skips an unreadable `unlockers.d` entry with a warning instead of
|
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||||
emitting a fabricated fallback ID.
|
emitting a fabricated fallback ID.
|
||||||
|
- 2026-08-07: Added `.editorconfig`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/27).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
|
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
||||||
|
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
||||||
|
- 2026-03-30: Added the `make fmt-check` target and
|
||||||
|
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
||||||
|
`Dockerfile` base images are pinned by sha256.
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||||
protection, plus review fixes (stub panics, derivation index, tests,
|
protection, plus review fixes (stub panics, derivation index, tests,
|
||||||
README) on branch secure-enclave-unlocker.
|
README) on branch secure-enclave-unlocker.
|
||||||
@@ -334,8 +348,6 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Compliance (after Next Step lands): keep main green under the new
|
|
||||||
.gitea workflow; run make check before every merge.
|
|
||||||
- Implement version-number shell completion for the second arg of
|
- Implement version-number shell completion for the second arg of
|
||||||
`secret version promote` and `secret version rm`
|
`secret version promote` and `secret version rm`
|
||||||
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||||
@@ -350,34 +362,18 @@ Bring the repo into policy compliance in one commit:
|
|||||||
never run on them, so it would likely find more there than the line
|
never run on them, so it would likely find more there than the line
|
||||||
lengths. No macOS test runs in CI. A macOS runner would cover all of it
|
lengths. No macOS test runs in CI. A macOS runner would cover all of it
|
||||||
(asked on https://git.eeqj.de/sneak/secret/issues/50).
|
(asked on https://git.eeqj.de/sneak/secret/issues/50).
|
||||||
- Merge secure-enclave-unlocker to main once review is done.
|
|
||||||
- 1.0 critical security blockers (from repo TODO.md):
|
- 1.0 critical security blockers (from repo TODO.md):
|
||||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
- Memory security: age writes an identity's private key out as a string in
|
||||||
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
ordinary memory, and the copies it makes on the way stay there
|
||||||
security command (keychainunlocker.go:472-476).
|
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
||||||
- Memory security: age writes an identity's private key out as a
|
|
||||||
string in ordinary memory, and the copies it makes on the way stay
|
|
||||||
there (`secret.IdentityToLockedBuffer` overwrites only the string
|
|
||||||
itself); private keys exposed via buffer.Bytes() to GPGEncryptFunc
|
|
||||||
and EncryptWithPassphrase.
|
|
||||||
- Input validation: no maximum secret size (DoS).
|
|
||||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
|
||||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
|
||||||
- High priority:
|
|
||||||
- Secure temporary file handling and cleanup.
|
|
||||||
- Initialize a default unlock key at vault creation.
|
|
||||||
- Add secret rm and vault deletion commands.
|
|
||||||
- Medium priority:
|
- Medium priority:
|
||||||
- Standardize error messages; stop leaking internals.
|
- Standardize error messages; stop leaking internals.
|
||||||
- Graceful handling of corrupted or missing key files with recovery
|
- Graceful handling of corrupted or missing key files with recovery
|
||||||
suggestions.
|
suggestions.
|
||||||
- Validate GPG key existence before creating PGP unlock keys.
|
|
||||||
- Split oversized CLI functions.
|
- Split oversized CLI functions.
|
||||||
- mlock/munlock for sensitive allocations.
|
|
||||||
- Cleanups: read statedir from environment or default instead of
|
- Cleanups: read statedir from environment or default instead of
|
||||||
passing it around.
|
passing it around.
|
||||||
- Enhancements: help examples, shell completion, colored output,
|
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
||||||
--quiet flag, name suggestions on miss, audit logging, hardware
|
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
||||||
integration tests (Keychain, GPG), naming consistency, vault
|
consistency, vault export/import, batch operations, search, secret metadata
|
||||||
export/import, batch operations, search, secret metadata
|
|
||||||
(descriptions, tags).
|
(descriptions, tags).
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLeftoversRemovedByNextChangingCommand is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
|
||||||
|
// killed part-way leaves in each directory where secret.TempDirFor and
|
||||||
|
// secret.WriteFileAtomic make temporary entries: a temporary directory
|
||||||
|
// holding a vault, secret, unlocker or version being added or removed, and
|
||||||
|
// a temporary file beside a file being replaced. `secret list` must leave
|
||||||
|
// them all, and the next command that takes the state directory lock, here
|
||||||
|
// `secret vault select` of the vault already current, must delete exactly
|
||||||
|
// them: a vault named like a temporary directory stays. The copy has no
|
||||||
|
// lock file yet, so that command, as after a killed one, finds no mark that
|
||||||
|
// the last holder of the lock finished.
|
||||||
|
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil)
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -362,7 +362,6 @@ func requireWaitsForLock(
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
||||||
before := stateDirModTimes(t, fs)
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
release, err := vault.LockStateDir(fs, testStateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -372,6 +371,9 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ 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()
|
||||||
|
|
||||||
@@ -102,6 +104,10 @@ func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if path == testStateDir+"/lock" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
tree[path+"/"] = ""
|
tree[path+"/"] = ""
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
// 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. A
|
// directory name, which `secret unlocker list` names in its warning, as can
|
||||||
// last test checks that an unlocker whose metadata file cannot be read
|
// one with no metadata file. A last test checks that an unlocker whose
|
||||||
// counts as the last unlocker when it is removed by its directory name.
|
// metadata file cannot be read counts as the last unlocker when it is
|
||||||
|
// removed by its directory name.
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -106,6 +107,34 @@ 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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package macse
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -39,10 +40,9 @@ 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,7 +50,6 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
result := C.se_create_key(cLabel,
|
result := C.se_create_key(cLabel,
|
||||||
&pubKeyBuf[0], &pubKeyLen,
|
|
||||||
&hashBuf[0], C.int(hashBufferSize),
|
&hashBuf[0], C.int(hashBufferSize),
|
||||||
&errBuf[0], C.int(errorBufferSize))
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
@@ -58,9 +57,29 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h := C.GoString(&hashBuf[0])
|
||||||
|
|
||||||
|
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||||
|
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||||
|
|
||||||
|
result = C.se_copy_public_key(cLabel,
|
||||||
|
&pubKeyBuf[0], &pubKeyLen,
|
||||||
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
|
||||||
|
deleteErr := DeleteKey(h)
|
||||||
|
if deleteErr != nil {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:nlreturn // CGo result extraction
|
//nolint:nlreturn // CGo result extraction
|
||||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||||
h := C.GoString(&hashBuf[0])
|
|
||||||
|
|
||||||
return pk, h, nil
|
return pk, h, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,30 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
||||||
|
// finds its identity hash. If the hash cannot be found, the key exists but
|
||||||
|
// se_create_key fails, with an error naming the label.
|
||||||
// label: unique identifier for the CTK identity (UTF-8 C string)
|
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||||
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
|
||||||
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
|
||||||
// hash_out: output buffer for the identity hash (for deletion)
|
// hash_out: output buffer for the identity hash (for deletion)
|
||||||
// hash_out_len: size of hash_out buffer
|
// hash_out_len: size of hash_out buffer
|
||||||
// error_out: output buffer for error message
|
// error_out: output buffer for error message
|
||||||
// error_out_len: size of error_out buffer
|
// error_out_len: size of error_out buffer
|
||||||
// Returns 0 on success, -1 on failure.
|
// Returns 0 on success, -1 on failure.
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len);
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
// se_copy_public_key copies the public key of a CTK identity.
|
||||||
|
// label: label of the CTK identity
|
||||||
|
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||||
|
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_copy_public_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||||
// label: label of the CTK identity whose public key to use
|
// label: label of the CTK identity whose public key to use
|
||||||
// plaintext: data to encrypt
|
// plaintext: data to encrypt
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len) {
|
char *error_out, int error_out_len) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve the public key from the created identity
|
// Get the identity hash, which deleting the key needs, by parsing
|
||||||
|
// sc_auth list output
|
||||||
|
hash_out[0] = '\0';
|
||||||
|
NSTask *listTask = [[NSTask alloc] init];
|
||||||
|
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
listTask.arguments = @[@"list-ctk-identities"];
|
||||||
|
|
||||||
|
NSPipe *listPipe = [NSPipe pipe];
|
||||||
|
listTask.standardOutput = listPipe;
|
||||||
|
listTask.standardError = [NSPipe pipe];
|
||||||
|
|
||||||
|
if ([listTask launchAndReturnError:&nsError]) {
|
||||||
|
[listTask waitUntilExit];
|
||||||
|
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||||
|
if ([line containsString:labelStr]) {
|
||||||
|
NSMutableArray *tokens = [NSMutableArray array];
|
||||||
|
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||||
|
if (part.length > 0) {
|
||||||
|
[tokens addObject:part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.count > 1) {
|
||||||
|
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash_out[0] == '\0') {
|
||||||
|
NSString *msg = [NSString stringWithFormat:
|
||||||
|
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
||||||
|
label];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_copy_public_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
|
|||||||
*pub_key_len = (int)length;
|
*pub_key_len = (int)length;
|
||||||
CFRelease(pubKeyData);
|
CFRelease(pubKeyData);
|
||||||
|
|
||||||
// Get the identity hash by parsing sc_auth list output
|
|
||||||
hash_out[0] = '\0';
|
|
||||||
NSTask *listTask = [[NSTask alloc] init];
|
|
||||||
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
|
||||||
listTask.arguments = @[@"list-ctk-identities"];
|
|
||||||
|
|
||||||
NSPipe *listPipe = [NSPipe pipe];
|
|
||||||
listTask.standardOutput = listPipe;
|
|
||||||
listTask.standardError = [NSPipe pipe];
|
|
||||||
|
|
||||||
if ([listTask launchAndReturnError:&nsError]) {
|
|
||||||
[listTask waitUntilExit];
|
|
||||||
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
|
||||||
NSString *listStr = [[NSString alloc] initWithData:listData
|
|
||||||
encoding:NSUTF8StringEncoding];
|
|
||||||
|
|
||||||
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
|
||||||
if ([line containsString:labelStr]) {
|
|
||||||
NSMutableArray *tokens = [NSMutableArray array];
|
|
||||||
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
|
||||||
[NSCharacterSet whitespaceCharacterSet]]) {
|
|
||||||
if (part.length > 0) {
|
|
||||||
[tokens addObject:part];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tokens.count > 1) {
|
|
||||||
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ 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
|
||||||
@@ -17,7 +22,7 @@ import (
|
|||||||
// temporary file is removed if any step fails.
|
// temporary file is removed if any step fails.
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
||||||
"."+filepath.Base(path)+".tmp-*")
|
"."+filepath.Base(path)+tempNamePart+"*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -54,7 +59,7 @@ func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|||||||
// Its name leaves out target's, which may already be as long as a file name
|
// Its name leaves out target's, which may already be as long as a file name
|
||||||
// can be.
|
// can be.
|
||||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
||||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"failed to create temporary directory for %s: %w", target, err)
|
"failed to create temporary directory for %s: %w", target, err)
|
||||||
@@ -63,6 +68,40 @@ 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
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ 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 only once everything that does not need it has
|
// The SE key is created once the long-term key is in hand and the unlocker's
|
||||||
// succeeded, and is deleted again if writing the unlocker fails.
|
// 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,
|
||||||
|
|||||||
+95
-2
@@ -1,6 +1,7 @@
|
|||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,6 +15,11 @@ import (
|
|||||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
// lockFileName is the file in the state directory that LockStateDir locks.
|
||||||
const lockFileName = "lock"
|
const lockFileName = "lock"
|
||||||
|
|
||||||
|
// finishedMark is what the lock file holds once the command that last held
|
||||||
|
// the lock has released it. A command killed while holding it leaves the
|
||||||
|
// file empty.
|
||||||
|
const finishedMark = "finished\n"
|
||||||
|
|
||||||
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
||||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
// has no file locks. Every in-memory filesystem in the process shares it.
|
||||||
//
|
//
|
||||||
@@ -25,6 +31,12 @@ var memFsLock sync.Mutex
|
|||||||
// it. While one command holds it, the next one waits here. Reads take no
|
// it. While one command holds it, the next one waits here. Reads take no
|
||||||
// lock: each file or directory a command changes is replaced in a single
|
// lock: each file or directory a command changes is replaced in a single
|
||||||
// rename, so a reader finds it as it was before or after, never half-made.
|
// rename, so a reader finds it as it was before or after, never half-made.
|
||||||
|
// Once it holds the lock, it empties the lock file, and the function it
|
||||||
|
// returns writes finishedMark there just before releasing the lock, so a
|
||||||
|
// command killed while holding the lock leaves the mark missing. Finding it
|
||||||
|
// missing, LockStateDir first deletes the temporary files and directories
|
||||||
|
// such a command may have left, since no command still using them can be
|
||||||
|
// running. After a command that finished, it searches nothing.
|
||||||
//
|
//
|
||||||
// On the real filesystem the lock is flock(2) on the file "lock" in
|
// On the real filesystem the lock is flock(2) on the file "lock" in
|
||||||
// stateDir, which the kernel releases when the process dies, so a killed
|
// stateDir, which the kernel releases when the process dies, so a killed
|
||||||
@@ -32,16 +44,97 @@ var memFsLock sync.Mutex
|
|||||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
// use has no file locks, so a process-wide mutex stands in for flock there.
|
||||||
// Any other filesystem is refused rather than left unlocked.
|
// Any other filesystem is refused rather than left unlocked.
|
||||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
||||||
|
var release func()
|
||||||
|
|
||||||
switch fs.(type) {
|
switch fs.(type) {
|
||||||
case *afero.OsFs:
|
case *afero.OsFs:
|
||||||
return flockStateDir(stateDir)
|
var err error
|
||||||
|
|
||||||
|
release, err = flockStateDir(stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
case *afero.MemMapFs:
|
case *afero.MemMapFs:
|
||||||
memFsLock.Lock()
|
memFsLock.Lock()
|
||||||
|
|
||||||
return memFsLock.Unlock, nil
|
release = memFsLock.Unlock
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The lock file is written in place, never replaced: a command waiting
|
||||||
|
// for flock on the old file would then take a lock nobody else checks.
|
||||||
|
lockPath := filepath.Join(stateDir, lockFileName)
|
||||||
|
|
||||||
|
mark, err := afero.ReadFile(fs, lockPath)
|
||||||
|
if err != nil || string(mark) != finishedMark {
|
||||||
|
removeLeftovers(fs, stateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
// If this fails, the next command searches when it need not.
|
||||||
|
_ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms)
|
||||||
|
|
||||||
|
release()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeLeftovers deletes the temporary files and directories that commands
|
||||||
|
// killed part-way left in each directory where secret.WriteFileAtomic and
|
||||||
|
// secret.TempDirFor make them: the state directory, each vault, each secret
|
||||||
|
// and each version. Unlocker directories are written whole by
|
||||||
|
// secret.WriteDir and never changed after, so they hold none. A failure is
|
||||||
|
// only warned about, and the command goes on.
|
||||||
|
func removeLeftovers(fs afero.Fs, stateDir string) {
|
||||||
|
dirs := []string{stateDir}
|
||||||
|
|
||||||
|
for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) {
|
||||||
|
dirs = append(dirs, vaultDir)
|
||||||
|
|
||||||
|
for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) {
|
||||||
|
dirs = append(dirs, secretDir)
|
||||||
|
dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
err := secret.RemoveLeftovers(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Failed to remove what an interrupted command left",
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subdirs returns the directories in dir: none if dir does not exist, and
|
||||||
|
// none, with a warning, if it cannot be read.
|
||||||
|
func subdirs(fs afero.Fs, dir string) []string {
|
||||||
|
entries, err := afero.ReadDir(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
secret.Warn("Failed to look for what an interrupted command left",
|
||||||
|
"directory", dir, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
dirs = append(dirs, filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -121,6 +123,51 @@ func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLockStateDirRemovesLeftoversOnlyAfterKill checks that taking the lock
|
||||||
|
// deletes a temporary directory a killed command left only when the last
|
||||||
|
// holder of the lock did not release it. A holder killed while it holds the
|
||||||
|
// lock leaves the lock file as it is at that moment.
|
||||||
|
func TestLockStateDirRemovesLeftoversOnlyAfterKill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, lfs := range lockFilesystems(t) {
|
||||||
|
t.Run(lfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lockFile := filepath.Join(lfs.stateDir, "lock")
|
||||||
|
leftover := filepath.Join(lfs.stateDir, ".tmp-1")
|
||||||
|
|
||||||
|
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
whileHeld, err := afero.ReadFile(lfs.fs, lockFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
require.NoError(t, lfs.fs.MkdirAll(leftover, secret.DirPerms))
|
||||||
|
|
||||||
|
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(lfs.fs, leftover)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "searched after a holder that finished")
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(lfs.fs, lockFile, whileHeld,
|
||||||
|
secret.FilePerms))
|
||||||
|
|
||||||
|
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(lfs.fs, leftover)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "not searched after a holder that was killed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
||||||
// lock implementation is refused instead of being used unlocked.
|
// lock implementation is refused instead of being used unlocked.
|
||||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user