diff --git a/AGENTS.md b/AGENTS.md index 8d95b90..8d57aed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - - **Good (modern Python 3.9+):** - ```python - def process_items(items: list[str]) -> dict[str, int]: - counts: dict[str, int] = {} - return counts - ``` - - **Avoid (triggers UP006):** - ```python - from typing import List, Dict - - def process_items(items: List[str]) -> Dict[str, int]: - counts: Dict[str, int] = {} - return counts - ``` - - For optional types, use the `|` operator instead of `Union`: - ```python - # Good - def get_value(key: str) -> str | None: - return None - - # Avoid - from typing import Optional, Union - def get_value(key: str) -> Optional[str]: - return None - ``` +1. **Type Annotations (UP006)**: Use built-in collection types directly for type + annotations instead of importing from `typing`. This avoids the UP006 linter + error. + + **Good (modern Python 3.9+):** + + ```python + def process_items(items: list[str]) -> dict[str, int]: + counts: dict[str, int] = {} + return counts + ``` + + **Avoid (triggers UP006):** + + ```python + from typing import List, Dict + + def process_items(items: List[str]) -> Dict[str, int]: + counts: Dict[str, int] = {} + return counts + ``` + + For optional types, use the `|` operator instead of `Union`: + + ```python + # Good + def get_value(key: str) -> str | None: + return None + + # Avoid + from typing import Optional, Union + def get_value(key: str) -> Optional[str]: + return None + ``` 2. **Import Organization**: Follow the standard Python import order: - - Standard library imports - - Third-party imports - - Local application imports - - Each group should be separated by a blank line. + - Standard library imports + - Third-party imports + - Local application imports + + Each group should be separated by a blank line. ## Go-Specific Guidelines -1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values. +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. diff --git a/README.md b/README.md index c746a73..7d3d10b 100644 --- a/README.md +++ b/README.md @@ -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 -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 [--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 [--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 [--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 [--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 ` / `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 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 ` -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 [--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 [--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 `: 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 `: 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 [--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 --source ` -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 [--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 [--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//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) - diff --git a/TODO.md b/TODO.md index 40eef4c..87b782e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -287,8 +280,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. @@ -310,8 +310,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). @@ -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 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 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. + - Memory security: age identity .String() creates unprotected copies of + private keys; the call sites are listed in + https://git.eeqj.de/sneak/secret/issues/38. - 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).