Add the README's required sections and clear stale TODO.md items (closes #46) #103
@@ -4,154 +4,160 @@ Version: 2025-06-08
|
||||
|
||||
# Instructions and Contextual Information
|
||||
|
||||
* Be direct, robotic, expert, accurate, and professional.
|
||||
- Be direct, robotic, expert, accurate, and professional.
|
||||
|
||||
* Do not butter me up or kiss my ass.
|
||||
- Do not butter me up or kiss my ass.
|
||||
|
||||
* Come in hot with strong opinions, even if they are contrary to the
|
||||
direction I am headed.
|
||||
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||
am headed.
|
||||
|
||||
* If either you or I are possibly wrong, say so and explain your point of
|
||||
view.
|
||||
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||
|
||||
* Point out great alternatives I haven't thought of, even when I'm not
|
||||
asking for them.
|
||||
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||
for them.
|
||||
|
||||
* Treat me like the world's leading expert in every situation and every
|
||||
- Treat me like the world's leading expert in every situation and every
|
||||
conversation, and deliver the absolute best recommendations.
|
||||
|
||||
* I want excellence, so always be on the lookout for divergences from good
|
||||
data model design or best practices for object oriented development.
|
||||
- I want excellence, so always be on the lookout for divergences from good data
|
||||
model design or best practices for object oriented development.
|
||||
|
||||
* IMPORTANT: This is production code, not a research or teaching exercise.
|
||||
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||
Deliver professional-level results, not prototypes.
|
||||
|
||||
* Please read and understand the `README.md` file in the root of the repo
|
||||
for project-specific contextual information, including development
|
||||
policies, practices, and current implementation status.
|
||||
- Please read and understand the `README.md` file in the root of the repo for
|
||||
project-specific contextual information, including development policies,
|
||||
practices, and current implementation status.
|
||||
|
||||
* Be proactive in suggesting improvements or refactorings in places where we
|
||||
- Be proactive in suggesting improvements or refactorings in places where we
|
||||
diverge from best practices for clean, modular, maintainable code.
|
||||
|
||||
# Policies
|
||||
|
||||
1. Before committing, tests must pass (`make test`), linting must pass
|
||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||
`golangci-lint run`. When you think your changes are complete, rather
|
||||
than making three different tool calls to check, you can just run `make
|
||||
test && make fmt && make lint` as a single tool call which will save
|
||||
`golangci-lint run`. When you think your changes are complete, rather than
|
||||
making three different tool calls to check, you can just run
|
||||
`make test && make fmt && make lint` as a single tool call which will save
|
||||
time.
|
||||
|
||||
2. Always write a `Makefile` with the default target being `test`, and with
|
||||
a `fmt` target that formats the code. The `test` target should run all
|
||||
tests in the project, and the `fmt` target should format the code.
|
||||
`test` should also have a prerequisite target `lint` that should run any
|
||||
linters that are configured for the project.
|
||||
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||
`fmt` target that formats the code. The `test` target should run all tests in
|
||||
the project, and the `fmt` target should format the code. `test` should also
|
||||
have a prerequisite target `lint` that should run any linters that are
|
||||
configured for the project.
|
||||
|
||||
3. After each completed bugfix or feature, the code must be committed. Do
|
||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||
course.
|
||||
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||
|
||||
4. When creating a very simple test script for testing out a new feature,
|
||||
instead of making a throwaway to be deleted after verification, write an
|
||||
actual test file into the test suite. It doesn't need to be very big or
|
||||
actual test file into the test suite. It doesn't need to be very big or
|
||||
complex, but it should be a real test that can be run.
|
||||
|
||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||
tests, or change the tests specifically to make them pass (unless there
|
||||
is a bug in the test). This is cheating, and it is bad. You should only
|
||||
be modifying the test if it is incorrect or if the test is no longer
|
||||
relevant. In almost all cases, you should be fixing the code that is
|
||||
being tested, or updating the tests to match a refactored implementation.
|
||||
tests, or change the tests specifically to make them pass (unless there is a
|
||||
bug in the test). This is cheating, and it is bad. You should only be
|
||||
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||
In almost all cases, you should be fixing the code that is being tested, or
|
||||
updating the tests to match a refactored implementation.
|
||||
|
||||
6. When dealing with dates and times or timestamps, always use, display, and
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||
to see the time in a different timezone, store the user's timezone in a
|
||||
separate field and convert the UTC time to the user's timezone when
|
||||
displaying it. For internal use and internal applications and
|
||||
administrative purposes, always display UTC.
|
||||
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||
the time in a different timezone, store the user's timezone in a separate
|
||||
field and convert the UTC time to the user's timezone when displaying it. For
|
||||
internal use and internal applications and administrative purposes, always
|
||||
display UTC.
|
||||
|
||||
7. Always write tests, even if they are extremely simple and just check for
|
||||
correct syntax (ability to compile/import). If you are writing a new
|
||||
feature, write a test for it. You don't need to target complete
|
||||
coverage, but you should at least test any new functionality you add. If
|
||||
you are fixing a bug, write a test first that reproduces the bug, and
|
||||
then fix the bug in the code.
|
||||
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||
write a test for it. You don't need to target complete coverage, but you
|
||||
should at least test any new functionality you add. If you are fixing a bug,
|
||||
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||
|
||||
8. When implementing new features, be aware of potential side-effects (such
|
||||
as state files on disk, data in the database, etc.) and ensure that it is
|
||||
8. When implementing new features, be aware of potential side-effects (such as
|
||||
state files on disk, data in the database, etc.) and ensure that it is
|
||||
possible to mock or stub these side-effects in tests.
|
||||
|
||||
9. Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If stdout is not a terminal, output
|
||||
the structured logs in jsonl format.
|
||||
9. Always use structured logging. Log any relevant state/context with the
|
||||
messages (but do not log secrets). If stdout is not a terminal, output the
|
||||
structured logs in jsonl format.
|
||||
|
||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||
anywhere more than once. Always define a constant (usually at the top
|
||||
of the file) and give it a descriptive name, then use that constant in
|
||||
the code instead of the bare string or number.
|
||||
anywhere more than once. Always define a constant (usually at the top of the
|
||||
file) and give it a descriptive name, then use that constant in the code
|
||||
instead of the bare string or number.
|
||||
|
||||
11. You do not need to summarize your changes in the chat after making them.
|
||||
Making the changes and committing them is sufficient. If anything out
|
||||
of the ordinary happened, please explain it, but in the normal case
|
||||
where you found and fixed the bug, or implemented the feature, there is
|
||||
no need for the end-of-change summary.
|
||||
Making the changes and committing them is sufficient. If anything out of the
|
||||
ordinary happened, please explain it, but in the normal case where you found
|
||||
and fixed the bug, or implemented the feature, there is no need for the
|
||||
end-of-change summary.
|
||||
|
||||
12. Do not create additional files in the root directory of the project
|
||||
without asking permission first. Configuration files, documentation, and
|
||||
build files are acceptable in the root, but source code and other files
|
||||
should be organized in appropriate subdirectories.
|
||||
12. Do not create additional files in the root directory of the project without
|
||||
asking permission first. Configuration files, documentation, and build files
|
||||
are acceptable in the root, but source code and other files should be
|
||||
organized in appropriate subdirectories.
|
||||
|
||||
## 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
|
||||
error.
|
||||
|
||||
**Good (modern Python 3.9+):**
|
||||
```python
|
||||
def process_items(items: list[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
**Good (modern Python 3.9+):**
|
||||
|
||||
**Avoid (triggers UP006):**
|
||||
```python
|
||||
from typing import List, Dict
|
||||
```python
|
||||
def process_items(items: list[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
|
||||
def process_items(items: List[str]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
**Avoid (triggers UP006):**
|
||||
|
||||
For optional types, use the `|` operator instead of `Union`:
|
||||
```python
|
||||
# Good
|
||||
def get_value(key: str) -> str | None:
|
||||
return None
|
||||
```python
|
||||
from typing import List, Dict
|
||||
|
||||
# Avoid
|
||||
from typing import Optional, Union
|
||||
def get_value(key: str) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
def process_items(items: List[str]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
return counts
|
||||
```
|
||||
|
||||
For optional types, use the `|` operator instead of `Union`:
|
||||
|
||||
```python
|
||||
# Good
|
||||
def get_value(key: str) -> str | None:
|
||||
return None
|
||||
|
||||
# Avoid
|
||||
from typing import Optional, Union
|
||||
def get_value(key: str) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
|
||||
2. **Import Organization**: Follow the standard Python import order:
|
||||
- Standard library imports
|
||||
- Third-party imports
|
||||
- Local application imports
|
||||
- Standard library imports
|
||||
- Third-party 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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,72 +1,69 @@
|
||||
# secret - Local Secret Manager
|
||||
|
||||
secret is a command-line local secret manager that implements a hierarchical
|
||||
key architecture for storing and managing sensitive data. It supports
|
||||
multiple vaults, various unlock mechanisms, and provides secure storage
|
||||
using the `age` encryption library.
|
||||
## Description
|
||||
|
||||
It could be used as password manager, but was not designed as such. I
|
||||
created it to scratch an itch for a secure key/value store for replacing a
|
||||
bunch of pgp-encrypted files in a directory structure.
|
||||
`secret` is a WTFPL-licensed Go command-line local secret manager by
|
||||
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture
|
||||
for storing and managing sensitive data. It supports multiple vaults, various
|
||||
unlock mechanisms, and provides secure storage using the `age` encryption
|
||||
library.
|
||||
|
||||
## 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
|
||||
|
||||
Secret implements a three-layer key architecture:
|
||||
|
||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide
|
||||
the foundation for all encryption
|
||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys,
|
||||
supporting multiple authentication methods
|
||||
3. **Version-specific Keys**: Per-version keys that encrypt individual
|
||||
secret values
|
||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
|
||||
foundation for all encryption
|
||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
|
||||
multiple authentication methods
|
||||
3. **Version-specific Keys**: Per-version keys that encrypt individual secret
|
||||
values
|
||||
|
||||
### Version Management
|
||||
|
||||
Each secret maintains a history of versions, with each version having:
|
||||
|
||||
- Its own encryption key pair
|
||||
- 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
|
||||
- Atomic version switching via symlink updates
|
||||
|
||||
The secret's `current` file names its current version. Switching versions
|
||||
replaces that file in one rename, so it is never half-written.
|
||||
|
||||
### Vault System
|
||||
|
||||
Vaults provide logical separation of secrets, each with its own long-term
|
||||
key and unlocker set. This allows for complete isolation between different
|
||||
contexts (work, personal, projects).
|
||||
|
||||
## Installation
|
||||
|
||||
Build from source:
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd secret
|
||||
make build
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Initialize the secret manager**:
|
||||
```bash
|
||||
secret init
|
||||
```
|
||||
This creates the default vault and prompts for a BIP39 mnemonic phrase.
|
||||
|
||||
2. **Generate a mnemonic** (if needed):
|
||||
```bash
|
||||
secret generate mnemonic
|
||||
```
|
||||
|
||||
3. **Add a secret**:
|
||||
```bash
|
||||
echo "my-password" | secret add myservice/password
|
||||
```
|
||||
|
||||
4. **Retrieve a secret**:
|
||||
```bash
|
||||
secret get myservice/password
|
||||
```
|
||||
Vaults provide logical separation of secrets, each with its own long-term key
|
||||
and unlocker set. This allows for complete isolation between different contexts
|
||||
(work, personal, projects).
|
||||
|
||||
## Commands Reference
|
||||
|
||||
@@ -74,10 +71,10 @@ make build
|
||||
|
||||
`secret rm`, `secret version rm`, `secret vault remove` and
|
||||
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
|
||||
each one first asks `[y/N]`, naming exactly what it is about to remove, and
|
||||
goes ahead only on `y` or `yes`; any other answer, a bare Enter included,
|
||||
cancels and removes nothing. The question is asked only after the command's
|
||||
checks have passed, and before it changes anything.
|
||||
each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
|
||||
ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
|
||||
removes nothing. The question is asked only after the command's checks have
|
||||
passed, and before it changes anything.
|
||||
|
||||
Whether to ask is decided by stdin, where the answer is read from, so
|
||||
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
|
||||
@@ -96,6 +93,7 @@ Initializes the secret manager with a default vault. Prompts for a BIP39
|
||||
mnemonic phrase and creates the initial directory structure.
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
||||
|
||||
@@ -118,8 +116,8 @@ Switches to the specified vault for subsequent operations.
|
||||
|
||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a vault and all its secrets. It first asks
|
||||
for confirmation, naming the vault and how many secrets it holds (see
|
||||
**DANGER**: Permanently removes a vault and all its secrets. It first asks for
|
||||
confirmation, naming the vault and how many secrets it holds (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
|
||||
cannot be removed. Removing the current vault makes another vault the current
|
||||
one.
|
||||
@@ -132,58 +130,65 @@ one.
|
||||
#### `secret add <secret-name> [--force]`
|
||||
|
||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||
|
||||
- `--force, -f`: Overwrite existing secret
|
||||
|
||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/`
|
||||
are allowed, and a name must not be empty, start with `.` or `/`, end with
|
||||
`/`, contain `//`, or have `..` as a path segment.
|
||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
|
||||
allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
|
||||
contain `//`, or have `..` as a path segment.
|
||||
|
||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||
|
||||
#### `secret get <secret-name> [--version <version>]`
|
||||
|
||||
Retrieves and outputs a secret value to stdout.
|
||||
|
||||
- `--version, -v`: Get a specific version (default: current)
|
||||
|
||||
#### `secret list [filter] [--json]` / `secret ls`
|
||||
|
||||
Lists all secrets in the current vault. Optional filter for substring
|
||||
matching.
|
||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||
|
||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks
|
||||
for confirmation, naming the secret, its vault and how many versions it has
|
||||
(see [Confirmation Before Removal](#confirmation-before-removal)).
|
||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||
confirmation, naming the secret, its vault and how many versions it has (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||
|
||||
- `--force, -f`: Remove without asking
|
||||
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently
|
||||
deleted
|
||||
|
||||
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||
|
||||
Moves or renames a secret within the current vault.
|
||||
|
||||
- Fails if the destination already exists
|
||||
- Fails if the destination is the source under another name, such as `foo`
|
||||
for `Foo` on a case-insensitive filesystem (the macOS default); there, to
|
||||
change only the case of a name, move the secret to a third name first
|
||||
- Fails if the destination is the source under another name, such as `foo` for
|
||||
`Foo` on a case-insensitive filesystem (the macOS default); there, to change
|
||||
only the case of a name, move the secret to a third name first
|
||||
- Preserves all versions and metadata
|
||||
|
||||
### Version Management
|
||||
|
||||
#### `secret version list <secret-name>` / `secret version ls`
|
||||
|
||||
Lists all versions of a secret showing creation time, status, and validity period.
|
||||
Lists all versions of a secret showing creation time, status, and validity
|
||||
period.
|
||||
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
|
||||
Promotes a specific version to current by updating the symlink. Does not
|
||||
modify any timestamps, allowing for rollback scenarios.
|
||||
Promotes a specific version to current by updating the symlink. Does not modify
|
||||
any timestamps, allowing for rollback scenarios.
|
||||
|
||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
||||
for confirmation, naming the version, the secret and its vault (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||
|
||||
- `--force, -f`: Remove without asking
|
||||
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||
- Cannot remove the current version (must promote another version first)
|
||||
@@ -197,6 +202,7 @@ Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||
|
||||
Generates and stores a random secret.
|
||||
|
||||
- `--length, -l`: Length of generated secret (default: 16)
|
||||
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
||||
- `--force, -f`: Overwrite existing secret
|
||||
@@ -212,16 +218,19 @@ Lists all unlockers in the current vault with their metadata.
|
||||
Creates a new unlocker of the specified type:
|
||||
|
||||
**Types:**
|
||||
|
||||
- `passphrase`: Traditional passphrase-protected unlocker
|
||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||
- `keychain`: macOS Keychain integration (macOS only)
|
||||
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
||||
|
||||
**Options:**
|
||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
||||
|
||||
A vault has one passphrase unlocker: adding one replaces the one the vault
|
||||
has, which is removed only once the new one is the current unlocker.
|
||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
|
||||
specified)
|
||||
|
||||
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||
which is removed only once the new one is the current unlocker.
|
||||
|
||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||
|
||||
@@ -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
|
||||
that the vault then opens only with its mnemonic (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||
directory that `secret unlocker list` skips with a warning, because its
|
||||
metadata cannot be read or parsed, is removed by the directory name the
|
||||
warning gives.
|
||||
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||
|
||||
- `--force, -f`: Remove without asking, even the last unlocker
|
||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||
vault data will be PERMANENTLY INACCESSIBLE
|
||||
@@ -247,7 +256,8 @@ Selects an unlocker as the current default for operations.
|
||||
|
||||
#### `secret import <secret-name> --source <filename>`
|
||||
|
||||
Imports a secret from a file and stores it in the current vault under the given name.
|
||||
Imports a secret from a file and stores it in the current vault under the given
|
||||
name.
|
||||
|
||||
#### `secret vault import [vault-name]`
|
||||
|
||||
@@ -257,7 +267,8 @@ Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||
|
||||
#### `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]`
|
||||
|
||||
@@ -302,37 +313,43 @@ Decrypts data using an Age key stored as a secret.
|
||||
### Key Management and Encryption Flow
|
||||
|
||||
#### 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
|
||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
|
||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by
|
||||
unlockers
|
||||
|
||||
#### 2: Unlockers
|
||||
|
||||
Unlockers provide different authentication methods to access the long-term keys:
|
||||
|
||||
1. **Passphrase Unlockers**:
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
- Encrypted with user-provided passphrase
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
|
||||
2. **PGP Unlockers**:
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- Strong authentication through GPG
|
||||
|
||||
3. **Keychain Unlockers** (macOS only):
|
||||
- Stores unlock keys in macOS Keychain
|
||||
- Protected by system authentication (Touch ID, password)
|
||||
- Automatic unlocking when Keychain is unlocked
|
||||
- Cross-application integration
|
||||
- Stores unlock keys in macOS Keychain
|
||||
- Protected by system authentication (Touch ID, password)
|
||||
- Automatic unlocking when Keychain is unlocked
|
||||
- Cross-application integration
|
||||
|
||||
4. **Secure Enclave Unlockers** (macOS):
|
||||
- Hardware-backed key storage using Apple Secure Enclave
|
||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
|
||||
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
||||
- Protected by biometric authentication (Touch ID) or system password
|
||||
- Hardware-backed key storage using Apple Secure Enclave
|
||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||
Program required)
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
shell or script that sets them inherits them, `gpg` included. Set on a command
|
||||
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
||||
each one as soon as it has read it, so that the programs it runs itself, such
|
||||
as `gpg`, do not inherit it, but that erases nothing: the environment the
|
||||
process started with, and its memory, still hold the value. The interactive
|
||||
prompt, which every command except `secret vault import` offers when the
|
||||
variable is not set, is the safer default; `secret vault import` has no prompt
|
||||
and needs both variables.
|
||||
each one as soon as it has read it, so that the programs it runs itself, such as
|
||||
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||
started with, and its memory, still hold the value. The interactive prompt,
|
||||
which every command except `secret vault import` offers when the variable is not
|
||||
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||
variables.
|
||||
|
||||
## Security Features
|
||||
|
||||
### Encryption
|
||||
|
||||
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
|
||||
- Uses the [age encryption library](https://age-encryption.org/) with X25519
|
||||
keys
|
||||
- All private keys are encrypted at rest
|
||||
- No plaintext secrets stored on disk
|
||||
|
||||
@@ -382,7 +400,8 @@ and needs both variables.
|
||||
|
||||
- Hardware token support via PGP/GPG integration
|
||||
- macOS Keychain integration for system-level security
|
||||
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
|
||||
- Secure Enclave integration for hardware-backed key protection (macOS, via
|
||||
`sc_auth` / CryptoTokenKit)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -431,6 +450,7 @@ secret vault remove personal --force
|
||||
```
|
||||
|
||||
### Advanced Authentication
|
||||
|
||||
```bash
|
||||
# Add multiple unlock methods
|
||||
secret unlocker add passphrase # Password-based
|
||||
@@ -477,21 +497,27 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
## Technical Details
|
||||
|
||||
### Cryptographic Primitives
|
||||
|
||||
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
||||
- **Authentication**: Poly1305 MAC
|
||||
- **Hashing**: Double SHA-256 for public key identification
|
||||
|
||||
### File Formats
|
||||
|
||||
- **age Files**: Standard age encryption format (.age extension)
|
||||
- **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
|
||||
|
||||
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
|
||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
|
||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
||||
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||
mnemonic, and thus a unique key pair
|
||||
- **Public Key Hash**: Double SHA-256 hash of the 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
|
||||
|
||||
@@ -527,6 +553,7 @@ to add or use them.
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
@@ -534,7 +561,9 @@ make lint # Run linter
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
The project includes comprehensive tests:
|
||||
|
||||
```bash
|
||||
make test # Run all tests
|
||||
go test ./... # Unit tests
|
||||
@@ -546,61 +575,68 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
||||
This repository adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call
|
||||
them. We provide:
|
||||
development workflow, and the Makefile targets are thin shims that call them. We
|
||||
provide:
|
||||
|
||||
- `script/bootstrap` — install all dependencies (Go, Go module
|
||||
download), idempotently; golangci-lint is not installed, it runs in
|
||||
docker
|
||||
- `script/bootstrap` — install all dependencies (Go, Go module download),
|
||||
idempotently; golangci-lint is not installed, it runs in docker
|
||||
- `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`
|
||||
- `script/projectname` — output the project name (`secret`); used by
|
||||
other scripts such as `script/docker`
|
||||
- `script/build` — build the `secret` binary into the repo root, stamping
|
||||
the version (`VERSION` from the environment, else `git describe`) and
|
||||
the git commit
|
||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
||||
failure)
|
||||
- `script/lint` — run `golangci-lint` in docker only: builds
|
||||
`Dockerfile.lint`, where the linter is a build step that runs on every
|
||||
call, also on an unchanged tree
|
||||
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
|
||||
the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
|
||||
build never compiles; cgo is off, so the keychain unlocker's calls into
|
||||
the keychain (`internal/secret/keychainunlocker_cgo.go`, and
|
||||
`keychainunlocker_test.go`) and the Secure Enclave bindings
|
||||
(`internal/macse`) are not checked
|
||||
- `script/projectname` — output the project name (`secret`); used by other
|
||||
scripts such as `script/docker`
|
||||
- `script/build` — build the `secret` binary into the repo root, stamping the
|
||||
version (`VERSION` from the environment, else `git describe`) and the git
|
||||
commit
|
||||
- `script/test` — run `go vet` and the test suite (verbose rerun on failure)
|
||||
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
|
||||
where the linter is a build step that runs on every call, also on an unchanged
|
||||
tree
|
||||
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code
|
||||
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never
|
||||
compiles; cgo is off, so the keychain unlocker's calls into the keychain
|
||||
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
|
||||
and the Secure Enclave bindings (`internal/macse`) are not checked
|
||||
- `script/fmt` — format all Go code (writes)
|
||||
- `script/fmt-check` — check formatting without writing
|
||||
- `script/check` — run `script/test`, `script/lint`,
|
||||
`script/lint-darwin`, and `script/fmt-check`
|
||||
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
|
||||
`script/fmt-check`
|
||||
- `script/docker` — build the Docker image tagged with the project name
|
||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
||||
checks), with a new `CHECK_EPOCH` build argument on every run so the
|
||||
checks run again on an unchanged tree
|
||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
||||
then `script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook that
|
||||
runs `script/precommit`
|
||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
|
||||
(memguard needs mlock; the Dockerfile runs the checks), with a new
|
||||
`CHECK_EPOCH` build argument on every run so the checks run again on an
|
||||
unchanged tree
|
||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
|
||||
`script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||
`script/precommit`
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Authentication Methods**: Supports passphrase, 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
|
||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||
|
||||
# Author
|
||||
## TODO
|
||||
|
||||
Made with love and lots of expensive SOTA AI by
|
||||
[sneak](https://sneak.berlin) in Berlin in the summer of 2025.
|
||||
Open work is tracked on the
|
||||
[issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
|
||||
authoritative. The work to be done before 1.0 is the
|
||||
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
|
||||
records the steps completed so far.
|
||||
|
||||
Released as a free software gift to the world, no strings attached, under
|
||||
the [WTFPL](https://www.wtfpl.net/) license.
|
||||
## License
|
||||
|
||||
Released as a free software gift to the world, no strings attached, under the
|
||||
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
|
||||
|
||||
## Author
|
||||
|
||||
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
|
||||
in Berlin in the summer of 2025.
|
||||
|
||||
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
||||
|
||||
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
# Workflow
|
||||
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to 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)
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
- branch from `next`
|
||||
- do the Next Step: the next open issue in the `1.0.0` milestone
|
||||
- log it at the top of Completed Steps
|
||||
- commit (`TODO.md` changes in the same commit as the work)
|
||||
- push, and open a PR against `next`
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
|
||||
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
|
||||
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
|
||||
authoritative.
|
||||
|
||||
# Next Step
|
||||
|
||||
Bring the repo into policy compliance in one commit:
|
||||
|
||||
- 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.
|
||||
Take the next open issue in the `1.0.0` milestone:
|
||||
https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
@@ -299,8 +292,15 @@ Bring the repo into policy compliance in one commit:
|
||||
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||
emitting a fabricated fallback ID.
|
||||
- 2026-08-07: Added `.editorconfig`
|
||||
(https://git.eeqj.de/sneak/secret/issues/27).
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
||||
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
||||
- 2026-03-30: Added the `make fmt-check` target and
|
||||
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
||||
`Dockerfile` base images are pinned by sha256.
|
||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||
protection, plus review fixes (stub panics, derivation index, tests,
|
||||
README) on branch secure-enclave-unlocker.
|
||||
@@ -322,8 +322,6 @@ Bring the repo into policy compliance in one commit:
|
||||
|
||||
# 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
|
||||
`secret version promote` and `secret version rm`
|
||||
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||
@@ -338,34 +336,18 @@ Bring the repo into policy compliance in one commit:
|
||||
never run on them, so it would likely find more there than the line
|
||||
lengths. No macOS test runs in CI. A macOS runner would cover all of it
|
||||
(asked on https://git.eeqj.de/sneak/secret/issues/50).
|
||||
- Merge secure-enclave-unlocker to main once review is done.
|
||||
- 1.0 critical security blockers (from repo TODO.md):
|
||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
||||
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
||||
security command (keychainunlocker.go:472-476).
|
||||
- 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.
|
||||
- Memory security: age writes an identity's private key out as a string in
|
||||
ordinary memory, and the copies it makes on the way stay there
|
||||
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
||||
- Medium priority:
|
||||
- Standardize error messages; stop leaking internals.
|
||||
- Graceful handling of corrupted or missing key files with recovery
|
||||
suggestions.
|
||||
- Validate GPG key existence before creating PGP unlock keys.
|
||||
- Split oversized CLI functions.
|
||||
- mlock/munlock for sensitive allocations.
|
||||
- Cleanups: read statedir from environment or default instead of
|
||||
passing it around.
|
||||
- Enhancements: help examples, shell completion, colored output,
|
||||
--quiet flag, name suggestions on miss, audit logging, hardware
|
||||
integration tests (Keychain, GPG), naming consistency, vault
|
||||
export/import, batch operations, search, secret metadata
|
||||
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
||||
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
||||
consistency, vault export/import, batch operations, search, secret metadata
|
||||
(descriptions, tags).
|
||||
|
||||
Reference in New Issue
Block a user