Add the README's required sections and clear stale TODO.md items (closes #46)
check / check (push) Failing after 2s

README gains Description, Getting Started, Rationale, Design, TODO and
License sections; its first sentence names the licence and author.
Installation and Quick Start become Getting Started; Core Architecture
becomes Design, whose two false version bullets (symlink switching,
unencrypted metadata) are corrected. README and AGENTS.md are wrapped
to prettier's settings.

TODO.md: Workflow and Next Step point at the 1.0.0 milestone and the
next branch, the old Next Step's four finished items move to Completed
Steps with their dates, and Future Steps loses the items already done.

Model: opus-5-5
This commit is contained in:
2026-10-04 16:09:57 +00:00
committed by clawbot
parent 017b8d73bf
commit 734a651be4
3 changed files with 309 additions and 285 deletions
+98 -92
View File
@@ -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
error.
**Good (modern Python 3.9+):** **Good (modern Python 3.9+):**
```python
def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
return counts
```
**Avoid (triggers UP006):** ```python
```python def process_items(items: list[str]) -> dict[str, int]:
from typing import List, Dict counts: dict[str, int] = {}
return counts
```
def process_items(items: List[str]) -> Dict[str, int]: **Avoid (triggers UP006):**
counts: Dict[str, int] = {}
return counts
```
For optional types, use the `|` operator instead of `Union`: ```python
```python from typing import List, Dict
# Good
def get_value(key: str) -> str | None:
return None
# Avoid def process_items(items: List[str]) -> Dict[str, int]:
from typing import Optional, Union counts: Dict[str, int] = {}
def get_value(key: str) -> Optional[str]: return counts
return None ```
```
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: 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.
+183 -147
View File
@@ -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)
+22 -40
View File
@@ -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
@@ -287,8 +280,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.
@@ -310,8 +310,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).
@@ -326,34 +324,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 identity .String() creates unprotected copies of
(pgpunlocker.go:323-327); data.String() passed unescaped to the private keys; the call sites are listed in
security command (keychainunlocker.go:472-476). https://git.eeqj.de/sneak/secret/issues/38.
- Memory security: age identity .String() creates unprotected
copies (keychainunlocker.go:356, pgpunlocker.go:256,
version.go:155); age secret key held in a plain string in
cli/crypto.go:86,91,113; 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).