Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image). Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`). The stricter config surfaced ~1550 findings, all fixed: - `wsl_v5` (439) / `nlreturn` (24): blank-line insertions - `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged - `noinlineerr` (130): `if err := ...` split into assignment plus check - `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it - `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is` - `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq` - `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()` - `noctx` (22): `exec.CommandContext` for gpg/CLI invocations - `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint` - `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction - assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits` ## User-visible strings **None changed.** Every error message this branch composes is byte-identical to the one `main` composes. The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `vault.ErrVaultNotFound` is `"does not exist"`, composed by its caller as `vault <name> does not exist`); each such sentinel documents the message it participates in. Verified mechanically, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty. ## `unlocker list` `findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of emitting a row under a synthesized fallback ID that no `unlocker remove` or `unlocker select` can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by `internal/cli/unlockers_list_test.go`. `TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint). `make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing. Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #29 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
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.
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.
Core Architecture
Three-Layer Key Hierarchy
Secret implements a three-layer key architecture:
- Long-term Keys: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
- Unlockers: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
- 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
- Immutable value storage
- Atomic version switching via symlink updates
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:
git clone <repository>
cd secret
make build
Quick Start
-
Initialize the secret manager:
secret initThis creates the default vault and prompts for a BIP39 mnemonic phrase.
-
Generate a mnemonic (if needed):
secret generate mnemonic -
Add a secret:
echo "my-password" | secret add myservice/password -
Retrieve a secret:
secret get myservice/password
Commands Reference
Initialization
secret init
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 phraseSB_UNLOCK_PASSPHRASE: Pre-set unlock passphrase
Vault Management
secret vault list [--json] / secret vault ls
Lists all available vaults. The current vault is marked.
secret vault create <name>
Creates a new vault with the specified name.
secret vault select <name>
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. Like Unix rm,
this command does not ask for confirmation.
Requires --force if the vault contains secrets. With --force, will automatically switch to another vault if removing the current one.
--force, -f: Force removal even if vault contains secrets- NO RECOVERY: All secrets in the vault will be permanently deleted
Secret Management
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: [a-z0-9\.\-\_\/]+
- 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.
secret remove <secret-name> / secret rm ⚠️ 🛑
DANGER: Permanently removes a secret and ALL its versions. Like Unix rm, this command does not ask for confirmation.
- NO RECOVERY: Once removed, the secret cannot be recovered
- 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
- 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.
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.
secret version remove <secret-name> <version> / secret version rm ⚠️ 🛑
DANGER: Permanently removes a specific version of a secret. Like Unix
rm, this command does not ask for confirmation.
- NO RECOVERY: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first)
Key Generation
secret generate mnemonic
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
Unlocker Management
secret unlocker list [--json] / secret unlocker ls
Lists all unlockers in the current vault with their metadata.
secret unlocker add <type> [options]
Creates a new unlocker of the specified type:
Types:
passphrase: Traditional passphrase-protected unlockerpgp: Uses an existing GPG key for encryption/decryptionkeychain: 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)
secret unlocker remove <unlocker-id> [--force] / secret unlocker rm ⚠️ 🛑
DANGER: Permanently removes an unlocker. Like Unix rm, this command
does not ask for confirmation. Cannot remove the last unlocker if the vault
has secrets unless --force is used.
--force, -f: Force removal of last unlocker even if vault has secrets- CRITICAL WARNING: Without unlockers and without your mnemonic phrase, vault data will be PERMANENTLY INACCESSIBLE
- NO RECOVERY: Removing all unlockers without having your mnemonic means losing access to all secrets forever
secret unlocker select <unlocker-id>
Selects an unlocker as the current default for operations.
Import Operations
secret import <secret-name> --source <filename>
Imports a secret from a file and stores it in the current vault under the given name.
secret vault import [vault-name]
Imports a mnemonic phrase into the specified vault (defaults to "default").
Encryption Operations
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.
secret decrypt <secret-name> [--input=file] [--output=file]
Decrypts data using an Age key stored as a secret.
Storage Architecture
Directory Structure
~/.local/share/secret/
├── vaults.d/
│ ├── default/
│ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker
│ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/
│ │ │ │ │ ├── 20231215.001/ # Version directory
│ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001
│ │ │ └── database%password/ # Secret: database/password
│ │ │ ├── versions/
│ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker -> ../unlockers.d/passphrase
│ └── work/
│ ├── unlockers.d/
│ ├── secrets.d/
│ ├── vault-metadata.json
│ ├── pub.age
│ └── current-unlocker
└── currentvault -> vaults.d/default
Key Management and Encryption Flow
1: Long-term Keys
- 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
2: Unlockers
Unlockers provide different authentication methods to access the long-term keys:
-
Passphrase Unlockers:
- Encrypted with user-provided passphrase
- Stored as encrypted Age keys
- Cross-platform compatible
-
PGP Unlockers:
- Uses existing GPG key infrastructure
- Leverages existing key management workflows
- Strong authentication through GPG
-
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
-
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
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
- Each secret version has its own encryption key pair
- Private key encrypted to the vault's long-term key
- Provides forward secrecy and granular access control
Environment Variables
SB_SECRET_STATE_DIR: Custom state directory locationSB_SECRET_MNEMONIC: Pre-set mnemonic phrase (avoids interactive prompt)SB_UNLOCK_PASSPHRASE: Pre-set unlock passphrase (avoids interactive prompt)SB_GPG_KEY_ID: GPG key ID for PGP unlockers
Security Features
Encryption
- Uses the age encryption library with X25519 keys
- All private keys are encrypted at rest
- No plaintext secrets stored on disk
Access Control
- Multiple authentication methods supported
- Vault isolation prevents cross-contamination
Forward Secrecy
- Per-version encryption keys limit exposure if compromised
- Each version is independently encrypted
- Historical versions remain encrypted with their original keys
Hardware Integration
- 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)
Examples
Basic Workflow
# Initialize with a new mnemonic
secret generate mnemonic # Copy the output
secret init # Paste the mnemonic when prompted
# Add some secrets
echo "supersecret123" | secret add database/prod/password
echo "api-key-xyz" | secret add services/api/key
echo "ssh-private-key-content" | secret add ssh/servers/web01
# List and retrieve secrets
secret list
secret get database/prod/password
secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret remove ssh/servers/web01
Multi-vault Setup
# Create separate vaults for different contexts
secret vault create work
secret vault create personal
# Work with work vault
secret vault select work
echo "work-db-pass" | secret add database/password
secret unlocker add passphrase # Add passphrase authentication
# Switch to personal vault
secret vault select personal
echo "personal-email-pass" | secret add email/password
# List all vaults
secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force
Advanced Authentication
# Add multiple unlock methods
secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only)
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
# List unlockers
secret unlocker list
# Select a specific unlocker
secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
secret unlocker remove <unlocker-id>
Version Management
# List all versions of a secret
secret version list database/prod/password
# Promote an older version to current
secret version promote database/prod/password 20231215.001
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret version remove database/prod/password 20231214.001
Encryption/Decryption with Age Keys
# Generate an Age key and store it as a secret
secret generate secret encryption/mykey
# Encrypt a file using the stored key
secret encrypt encryption/mykey --input document.txt --output document.txt.age
# Decrypt the file
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 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
Cross-Platform Support
- macOS: Full support including Keychain and Secure Enclave integration
- Linux: Full support (excluding macOS-specific features)
Security Considerations
Threat Model
- Protects against unauthorized access to secret values
- Provides defense against compromise of individual components
- Supports hardware-backed authentication where available
Best Practices
- Use strong, unique passphrases for unlockers
- Enable hardware authentication (Keychain, hardware tokens) when available
- Regularly audit unlockers and remove unused ones
- Keep mnemonic phrases securely backed up offline
- Use separate vaults for different security contexts
Limitations
- Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms
Development
Building
make build # Build binary
make test # Run tests
make lint # Run linter
Testing
The project includes comprehensive tests:
make test # Run all tests
go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests
Entrypoints
This repository adheres to the
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:
script/bootstrap— install all dependencies (Go, golangci-lint, Go module download), idempotentlyscript/setup— make a fresh clone ready for development: runsscript/bootstrap, thenscript/install-precommitscript/projectname— output the project name (secret); used by other scripts such asscript/dockerscript/test— rungo vetand the test suite (verbose rerun on failure)script/lint— rungolangci-lintscript/fmt— format all Go code (writes)script/fmt-check— check formatting without writingscript/check— runscript/test,script/lint, andscript/fmt-checkscript/docker— build the Docker image tagged with the project namescript/cibuild— CI entrypoint:docker build --ulimit memlock=-1:-1 .(memguard needs mlock; the Dockerfile runs the checks)script/precommit— pre-commit checks:go mod tidyverification, thenscript/checkscript/install-precommit— install the git pre-commit hook that runsscript/precommit
Features
- 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
Made with love and lots of expensive SOTA AI by sneak in Berlin in the summer of 2025.
Released as a free software gift to the world, no strings attached, under the WTFPL license.
Contact: sneak@sneak.berlin
https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2