Compare commits

...

10 Commits

Author SHA1 Message Date
9ac6fcee0c latest 2025-05-29 06:52:10 -07:00
1b8ea9695b feat: implement debug logging system (#5) - Added debug.go with structured logging using log/slog - Supports GODEBUG=berlin.sneak.pkg.secret flag - JSON output for non-TTY stderr, colorized output for TTY - Added Debug(), DebugF(), and DebugWith() functions - Early return when debug is disabled for performance - Added comprehensive tests for debug functionality - Integrated debug logging into CLI init and vault operations - Removed completed TODO item #5 2025-05-29 06:25:50 -07:00
9f0f5cc8a1 todo list items 2025-05-29 06:19:41 -07:00
89a8af2aa1 docs 2025-05-29 06:14:19 -07:00
659b5ba508 refactor: rename SEP to Keychain and reorganize import commands - Renamed sepunlock.go to keychainunlock.go - Changed all SEP types to Keychain types (SEPUnlockKey -> KeychainUnlockKey) - Updated type string from 'macos-sep' to 'keychain' - Moved 'secret import' to 'secret vault import' for mnemonic imports - Added new 'secret import <secret-name> --source <filename>' for file imports - Updated README to replace all 'Secure Enclave' references with 'macOS Keychain' - Updated directory structure diagrams and examples - Fixed linter error in MarkFlagRequired call - All tests passing, linter clean 2025-05-29 06:07:15 -07:00
bb82d10f91 fix: enable cobra usage printing after errors - Set SilenceUsage and SilenceErrors to false in root command - Addresses critical TODO item for better error handling - Users will now see command usage when commands fail 2025-05-29 05:59:29 -07:00
c526b68f58 docs: comprehensive README.md and TODO.md for 1.0 release - Updated README.md with detailed documentation of all commands, architecture, and storage system - Added comprehensive TODO.md with critical, important, and trivial items for 1.0 release - Documented three-layer key hierarchy and vault system - Included examples, security considerations, and cross-platform notes - Identified key bugs including missing cobra usage printing after errors - Categorized 50+ items by priority with timeline estimates 2025-05-29 05:58:21 -07:00
2443256338 latest, trying to get sep to work without ADP membership 2025-05-29 04:03:40 -07:00
354681b298 latest 2025-05-28 14:06:29 -07:00
efedbe405f latest 2025-05-28 07:38:07 -07:00
16 changed files with 5364 additions and 355 deletions

13
LICENSE Normal file
View File

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,10 +1,26 @@
default: test .PHONY: default build-verbose check vet test lint clean
default: check
# Check all code quality (build + vet + lint + unit tests)
check: ./secret vet lint test
./secret: cmd/secret/*.go internal/secret/*.go
go build -o $@ cmd/secret/main.go
build-verbose: cmd/secret/*.go internal/secret/*.go
go build -v -o ./secret cmd/secret/main.go
vet:
go vet ./...
test: test:
go test -v ./... go test -v ./...
@bash ./test_secret_manager.sh
lint: lint:
@echo "Running linter..."
golangci-lint run --timeout 5m golangci-lint run --timeout 5m
clean: clean:
rm -f ./secret @rm -f ./secret

443
README.md
View File

@ -1,136 +1,357 @@
# architecture # Secret - Hierarchical Secret Manager
* `secret vault` allows you to change 'vaults'. vaults are just a universe Secret is a modern, secure command-line 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.
of secrets (and a single associated long-term key). you can have multiple
vaults, each with its own long-term key and secrets. this is useful for
separating work and personal secrets, or for separating different projects
with different long-term keys.
* `secret vault list` ## Core Architecture
* `secret vault create <name>` creates a new profile
* `secret vault select <name>` selects (switches to) a profile
the first and initial vault is titled `default`. ### Three-Layer Key Hierarchy
* `secret init` initializes a new vault. this will create a new profile and Secret implements a sophisticated three-layer key architecture:
generate a new long-term keypair. the long-term keypair is used to
encrypt and decrypt secrets. the long-term keypair is stored in the
vault. the private key for the vault is encrypted to a short-term
keypair. the short-term keypair private key is encrypted to a passphrase.
to generate the long-term keypair, a random bip32 seed phrase is
generated, then the process proceeds exactly as `secret import private`.
the randomly generated bip32 seed phrase is shown to the user. 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
2. **Unlock Keys**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values
if there is already a vault, `secret init` exits with an error. ### Vault System
* `secret import [vaultname]` will derive a long-term key pair from a bip32 seed Vaults provide logical separation of secrets, each with its own long-term key and unlock key set. This allows for complete isolation between different contexts (work, personal, projects).
phrase and import it into the named vault. if no vault name is specified,
`default` is used. if the named vault already exists, it exits with
an error.
first: ## Installation
* the long term key pair will be derived in memory Build from source:
* a random short term (unlock) key pair will be derived in memory ```bash
git clone <repository>
cd secret
make build
```
then: ## Quick Start
* the long term key pair public key will be written to disk 1. **Initialize the secret manager**:
* the short term key pair public key will be written to disk ```bash
* the long term key pair private key will be encrypted to the short term secret init
public key and written to disk ```
* the short term key pair private key will be encrypted to a passphrase This creates the default vault and prompts for a BIP39 mnemonic phrase.
and written to disk
* `secret enroll sep` creates a short-term keypair inside the secure enclave 2. **Generate a mnemonic** (if needed):
of a macOS device. it will then use one of your existing short-term ```bash
keypairs to decrypt the long-term keypair, re-encrypt it to the secure secret generate mnemonic
enclave short-term keypair, and write it to disk. it requires an existing ```
vault, and errors otherwise.
* short-term keypairs are called 'unlock keys'. 3. **Add a secret**:
```bash
echo "my-password" | secret add myservice/password
```
* `secret add <secret>` adds a secret to the vault. this will generate a 4. **Retrieve a secret**:
keypair (secret-specific key) and encrypt the private portion of the ```bash
secret-specific key (called an 'unlock key') to the long-term keypair and secret get myservice/password
write it to disk. if the secret already exists it will not overwrite, but ```
will exit with an error, unless `--force`/`-f` is used. the secret
identifier is [a-z0-9\.\-\_\/]+ and is used as a storage directory name.
slashes are converted to % signs.
in a future version, overwriting a secret will cause the current secret to ## Commands Reference
get moved to a timestamped history archive.
* `secret get <secret>` retrieves a secret from the vault. this will use an ### Initialization
unlock keypair to decrypt the long-term keypair in memory, then use the
long-term keypair to decrypt the secret-specific keypair, which is then
used to decrypt the secret. the secret is then returned in plaintext on
stdout.
* `secret keys list` lists the short-term keypairs in the current vault. #### `secret init`
this will show the public keys of the short-term keypairs and their Initializes the secret manager with a default vault. Prompts for a BIP39 mnemonic phrase and creates the initial directory structure.
creation dates, as well as any flags (such as `hsm`). their identifiers
are a metahash of the public key data using the sha256 algorithm.
* `secret keys rm <keyid>` removes a short-term keypair from the vault. this will **Environment Variables:**
remove the short-term keypair from the vault, and remove the long-term - `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
keypair from the short-term keypair. - `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
* `secret keys add pgp <pgp keyid>` adds a new short-term keypair to the vault. ### Vault Management
this will generate a new short-term keypair and encrypt it to a given gpg
key, to allow unlocking a vault with an existing gpg key, for people who
use yubikeys or other gpg keys with an agent. the new short-term keypair
is randomly generated, the public key stored, and the private key encrypted
to the gpg key and stored.
* `secret key select <keyid>` selects a short-term keypair to use for #### `secret vault list [--json]`
`secret get` operations. Lists all available vaults.
# file layout #### `secret vault create <name>`
Creates a new vault with the specified name.
$BASE = ~/.config/berlin.sneak.pkg.secret (on linux per XDG) #### `secret vault select <name>`
$BASE = ~/Library/Application Support/berlin.sneak.pkg.secret (on macOS) Switches to the specified vault for subsequent operations.
$BASE/configuration.json ### Secret Management
$BASE/currentvault -> $BASE/vaults.d/default (symlink)
$BASE/vaults.d/default/
$BASE/vaults.d/default/vault-metadata.json
$BASE/vaults.d/default/pub.age
$BASE/vaults.d/default/current-unlock-key -> $BASE/vaults.d/default/unlock.d/passphrase (symlink)
$BASE/vaults.d/default/unlock.d/passphrase/unlock-metadata.json
$BASE/vaults.d/default/unlock.d/passphrase/pub.age
$BASE/vaults.d/default/unlock.d/passphrase/priv.age
$BASE/vaults.d/default/unlock.d/passphrase/longterm.age # long-term keypair, encrypted to this short-term keypair
$BASE/vaults.d/default/unlock.d/sep/unlock-metadata.json
$BASE/vaults.d/default/unlock.d/sep/pub.age
$BASE/vaults.d/default/unlock.d/sep/priv.age
$BASE/vaults.d/default/unlock.d/sep/longterm.age # long-term keypair, encrypted to this short-term keypair
$BASE/vaults.d/default/unlock.d/pgp/unlock-metadata.json
$BASE/vaults.d/default/unlock.d/pgp/pub.age
$BASE/vaults.d/default/unlock.d/pgp/priv.asc
$BASE/vaults.d/default/unlock.d/pgp/longterm.age # long-term keypair, encrypted to this short-term keypair
$BASE/vaults.d/default/secrets.d/my-tinder-password/value.age
$BASE/vaults.d/default/secrets.d/my-tinder-password/pub.age
$BASE/vaults.d/default/secrets.d/my-tinder-password/priv.age # secret-specific key, encrypted to long-term key
$BASE/vaults.d/default/secrets.d/my-tinder-password/secret-metadata.json
$BASE/vaults.d/default/secrets.d/mail%berlin.sneak.secrets.imaplogin/value.age
$BASE/vaults.d/default/secrets.d/mail%berlin.sneak.secrets.imaplogin/pub.age
$BASE/vaults.d/default/secrets.d/mail%berlin.sneak.secrets.imaplogin/priv.age
$BASE/vaults.d/default/secrets.d/mail%berlin.sneak.secrets.imaplogin/secret-metadata.json
# example configuration.json #### `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>`
Retrieves and outputs a secret value to stdout.
#### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring matching.
### 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
### Unlock Key Management
#### `secret keys list [--json]`
Lists all unlock keys in the current vault with their metadata.
#### `secret keys add <type> [options]`
Creates a new unlock key of the specified type:
**Types:**
- `passphrase`: Password-protected unlock key
- `keychain`: macOS Keychain unlock key (Touch ID/Face ID)
- `pgp`: GPG/PGP key unlock key
**Options:**
- `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret keys rm <key-id>`
Removes an unlock key from the current vault.
#### `secret key select <key-id>`
Selects an unlock key 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").
#### `secret enroll`
Enrolls a macOS Keychain unlock key for biometric authentication.
### 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
```
$BASE/ # ~/.config/berlin.sneak.pkg.secret (Linux) or ~/Library/Application Support/berlin.sneak.pkg.secret (macOS)
├── configuration.json # Global configuration
├── currentvault -> vaults.d/default # Symlink to current vault
└── vaults.d/
├── default/ # Default vault
│ ├── vault-metadata.json # Vault metadata
│ ├── pub.age # Long-term public key
│ ├── current-unlock-key -> unlock.d/passphrase # Current unlock key symlink
│ ├── unlock.d/ # Unlock keys directory
│ │ ├── passphrase/ # Passphrase unlock key
│ │ │ ├── unlock-metadata.json # Unlock key metadata
│ │ │ ├── pub.age # Unlock key public key
│ │ │ ├── priv.age # Unlock key private key (encrypted)
│ │ │ └── longterm.age # Long-term private key (encrypted to this unlock key)
│ │ ├── keychain/ # Keychain unlock key
│ │ │ ├── unlock-metadata.json
│ │ │ ├── pub.age
│ │ │ ├── priv.age
│ │ │ └── longterm.age
│ │ └── pgp/ # PGP unlock key
│ │ ├── unlock-metadata.json
│ │ ├── pub.age
│ │ ├── priv.asc # PGP-encrypted private key
│ │ └── longterm.age
│ └── secrets.d/ # Secrets directory
│ ├── my-service%password/ # Secret directory (slashes encoded as %)
│ │ ├── value.age # Encrypted secret value
│ │ ├── pub.age # Secret-specific public key
│ │ ├── priv.age # Secret-specific private key (encrypted to long-term key)
│ │ └── secret-metadata.json # Secret metadata
│ └── api%keys%production/
│ ├── value.age
│ ├── pub.age
│ ├── priv.age
│ └── secret-metadata.json
└── work/ # Additional vault
└── ... (same structure as default)
```
### Key Management and Encryption Flow
#### 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 unlock keys
#### Unlock Keys
Unlock keys provide different authentication methods to access the long-term keys:
1. **Passphrase Unlock Keys**:
- Private key encrypted using a user-provided passphrase
- Stored as encrypted Age identity in `priv.age`
2. **macOS Keychain Keys**:
- Private key stored in the macOS Keychain
- Requires biometric authentication (Touch ID/Face ID)
- Provides hardware-backed security
3. **PGP Unlock Keys**:
- Private key encrypted using an existing GPG key
- Compatible with hardware tokens (YubiKey, etc.)
- Stored as PGP-encrypted data in `priv.asc`
#### Secret-specific Keys
- Each secret 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 location
- `SB_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 unlock keys
## Security Features
### Encryption
- 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
### Access Control
- Multiple authentication methods supported
- Hierarchical key architecture provides defense in depth
- Vault isolation prevents cross-contamination
### Forward Secrecy
- Per-secret encryption keys limit exposure if compromised
- Long-term keys protected by multiple unlock key layers
### Hardware Integration
- macOS Keychain support for biometric authentication
- Hardware token support via PGP/GPG integration
## Examples
### Basic Workflow
```bash
# 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
```
### Multi-vault Setup
```bash
# 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 keys add keychain # Add Touch ID authentication
# Switch to personal vault
secret vault select personal
echo "personal-email-pass" | secret add email/password
# List all vaults
secret vault list
```
### Advanced Authentication
```bash
# Add multiple unlock methods
secret keys add passphrase # Password-based
secret keys add keychain # Touch ID (macOS only)
secret keys add pgp --keyid ABCD1234 # GPG key
# List unlock keys
secret keys list
# Select a specific unlock key
secret key select <key-id>
```
### Encryption/Decryption with Age Keys
```bash
# 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)
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
- **Authentication**: Poly1305 MAC
### File Formats
- **Age Files**: Standard Age encryption format (.age extension)
- **Metadata**: JSON format with timestamps and type information
- **Configuration**: JSON configuration files
### Cross-Platform Support
- **macOS**: Full support including Keychain integration
- **Linux**: Full support (excluding Keychain features)
- **Windows**: Basic support (filesystem operations only)
## 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
1. Use strong, unique passphrases for unlock keys
2. Enable hardware authentication (Keychain, hardware tokens) when available
3. Regularly audit unlock keys and remove unused ones
4. Keep mnemonic phrases securely backed up offline
5. Use separate vaults for different security contexts
### Limitations
- Requires access to unlock keys for secret retrieval
- Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms
## Development
### Building
```bash
make build # Build binary
make test # Run tests
make lint # Run linter
```
### Testing
The project includes comprehensive tests:
```bash
./test_secret_manager.sh # Full integration test suite
go test ./... # Unit tests
```
`json
{
"$id": "https://berlin.sneak.pkg.secret/configuration.json",
"$schema": "https://berlin.sneak.pkg.secret/configuration.schema.json",
"version": 1,
"configuration": {
"createdAt": "2025-01-01T00:00:00Z",
"requireAuth": false,
"pubKey": "<public key of the long-term keypair>",
"pubKeyFingerprint": "<fingerprint of the long-term keypair>"
}
}
`

197
TODO.md Normal file
View File

@ -0,0 +1,197 @@
# TODO for 1.0 Release
This document outlines the bugs, issues, and improvements that need to be addressed before the 1.0 release of the secret manager.
## Critical (Blockers for Release)
### Error Handling and User Experience
- [ ] **1. Inappropriate Cobra usage printing**: Commands currently print usage information for all errors, including internal program failures. Usage should only be printed when the user provides incorrect arguments or invalid commands, not when the program encounters internal errors (like file system issues, crypto failures, etc.).
- [ ] **2. Inconsistent error messages**: Error messages need standardization and should be user-friendly. Many errors currently expose internal implementation details.
- [ ] **3. Missing validation for vault names**: Vault names should be validated against a safe character set to prevent filesystem issues.
- [ ] **4. No graceful handling of corrupted state**: If key files are corrupted or missing, the tool should provide clear error messages and recovery suggestions.
### Core Functionality Bugs
- [ ] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
- [ ] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/`
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
- [ ] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
- [ ] **8. Missing current unlock key initialization**: When creating vaults, no default unlock key is selected, which can cause operations to fail.
- [ ] **9. Race conditions in file operations**: Multiple concurrent operations could corrupt the vault state due to lack of file locking.
### Security Issues
- [ ] **10. Insecure temporary file handling**: Temporary files containing sensitive data may not be properly cleaned up or secured.
- [ ] **11. Missing secure memory clearing**: Sensitive data in memory (passphrases, keys) should be cleared after use.
- [ ] **12. Weak default permissions**: Some files may be created with overly permissive default permissions.
## Important (Should be fixed before release)
### User Interface Improvements
- [ ] **13. Add confirmation prompts for destructive operations**: Operations like `keys rm` and vault deletion should require confirmation.
- [ ] **14. Improve progress indicators**: Long operations (key generation, encryption) should show progress.
- [ ] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
- [ ] **16. Add `--help` examples**: Command help should include practical examples for each operation.
### Command Implementation Gaps
- [ ] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
- [ ] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
- [ ] **19. Missing vault deletion command**: No way to delete vaults that are no longer needed.
- [ ] **20. No secret deletion command**: Missing `secret rm <secret-name>` functionality.
- [ ] **21. Missing secret history/versioning**: No way to see previous versions of secrets or restore old values.
### Configuration and Environment
- [ ] **22. Global configuration not fully implemented**: The `configuration.json` file structure exists but isn't used consistently.
- [ ] **23. Missing environment variable validation**: Environment variables should be validated for format and security.
- [ ] **24. No configuration file validation**: JSON configuration files should be validated against schemas.
### PGP Integration Issues
- [ ] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
- [ ] **26. Missing GPG agent integration**: Should detect and use existing GPG agent when available.
- [ ] **27. No validation of GPG key existence**: Should verify the specified GPG key exists before creating PGP unlock keys.
### Cross-Platform Issues
- [ ] **28. macOS Keychain error handling**: Better error messages when biometric authentication fails or isn't available.
- [ ] **29. Windows path handling**: File paths may not work correctly on Windows systems.
- [ ] **30. XDG compliance on Linux**: Should respect `XDG_CONFIG_HOME` and other XDG environment variables.
## Trivial (Nice to have)
### Code Quality
- [ ] **31. Add more comprehensive unit tests**: Current test coverage could be improved, especially for error conditions.
- [ ] **32. Reduce code duplication**: Several functions have similar patterns that could be refactored.
- [ ] **33. Improve function documentation**: Many functions lack proper Go documentation comments.
- [ ] **34. Add static analysis**: Integrate tools like `staticcheck`, `golangci-lint` with more linters.
### Performance Optimizations
- [ ] **35. Cache unlock key operations**: Avoid re-reading unlock key metadata on every operation.
- [ ] **36. Optimize file I/O**: Batch file operations where possible to reduce syscalls.
- [ ] **37. Add connection pooling for HSM operations**: For hardware security module operations.
### User Experience Enhancements
- [ ] **38. Add shell completion**: Bash/Zsh completion for commands and secret names.
- [ ] **39. Colored output**: Use colors to improve readability of lists and error messages.
- [ ] **40. Add `--quiet` flag**: Option to suppress non-essential output.
- [ ] **41. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
### Additional Features
- [ ] **42. Secret templates**: Predefined templates for common secret types (database URLs, API keys, etc.).
- [ ] **43. Bulk operations**: Import/export multiple secrets at once.
- [ ] **44. Secret sharing**: Secure sharing of secrets between vaults or users.
- [ ] **45. Audit logging**: Log all secret access and modifications.
- [ ] **46. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
### Documentation
- [ ] **47. Man pages**: Generate and install proper Unix man pages.
- [ ] **48. API documentation**: Document the internal API for potential library use.
- [ ] **49. Migration guide**: Document how to migrate from other secret managers.
- [ ] **50. Security audit documentation**: Document security assumptions and threat model.
## Architecture Improvements
### Code Structure
- [ ] **51. Consistent interface implementation**: Ensure all unlock key types properly implement the UnlockKey interface.
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
- [ ] **53. Improved error types**: Create specific error types instead of using generic `fmt.Errorf`.
### Testing Infrastructure
- [ ] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
- [ ] **55. Integration test isolation**: Tests should not affect each other or the host system.
- [ ] **56. Performance benchmarks**: Add benchmarks for crypto operations and file I/O.
## Technical Debt
- [ ] **57. Remove unused code**: Clean up any dead code or unused imports.
- [ ] **58. Standardize JSON schemas**: Create proper JSON schemas for all configuration files.
- [ ] **59. Improve error propagation**: Many functions swallow important context in error messages.
- [ ] **60. Consistent naming conventions**: Some variables and functions use inconsistent naming.
## Development Workflow
- [ ] **61. Add pre-commit hooks**: Ensure code quality and formatting before commits.
- [ ] **62. Continuous integration**: Set up CI/CD pipeline with automated testing.
- [ ] **63. Release automation**: Automate the build and release process.
- [ ] **64. Dependency management**: Regular updates and security scanning of dependencies.
---
## Priority Assessment
**Critical items** (1-12) block the 1.0 release and must be fixed for basic functionality and security.
**Important items** (13-30) should be addressed for a polished user experience but don't block the release.
**Trivial items** (31-50) are enhancements that can be addressed in future releases.
**Architecture and Infrastructure** (51-64) are longer-term improvements for maintainability and development workflow.
## Estimated Timeline
- Critical (1-12): 2-3 weeks
- Important (13-30): 3-4 weeks
- Trivial (31-50): Ongoing post-1.0
- Architecture/Infrastructure (51-64): Ongoing post-1.0
Total estimated time to 1.0: 5-7 weeks with focused development effort.

36
go.mod
View File

@ -2,20 +2,40 @@ module git.eeqj.de/sneak/secret
go 1.24.1 go 1.24.1
require github.com/spf13/cobra v1.9.1 require (
filippo.io/age v1.2.1
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.38.0
)
require ( require (
filippo.io/age v1.2.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
github.com/btcsuite/btcd/btcutil v1.1.6 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c // indirect
github.com/facebookincubator/sks v0.0.0-20250508161834-9be892919529 // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2 // indirect
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
) )

1210
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,223 +0,0 @@
package agehd
import (
"bytes"
"io"
"testing"
"filippo.io/age"
)
const (
mnemonic = "abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon about"
// Test xprv from BIP85 test vectors
testXPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
)
func TestEncryptDecrypt(t *testing.T) {
id, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive: %v", err)
}
t.Logf("secret: %s", id.String())
t.Logf("recipient: %s", id.Recipient().String())
var ct bytes.Buffer
w, err := age.Encrypt(&ct, id.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, "hello world"); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), id)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := string(dec); got != "hello world" {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeriveIdentityFromXPRV(t *testing.T) {
id, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive from xprv: %v", err)
}
t.Logf("xprv secret: %s", id.String())
t.Logf("xprv recipient: %s", id.Recipient().String())
// Test encryption/decryption with xprv-derived identity
var ct bytes.Buffer
w, err := age.Encrypt(&ct, id.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, "hello from xprv"); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), id)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := string(dec); got != "hello from xprv" {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeterministicDerivation(t *testing.T) {
// Test that the same mnemonic and index always produce the same identity
id1, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices produce different identities
id3, err := DeriveIdentity(mnemonic, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("Index 0: %s", id1.String())
t.Logf("Index 1: %s", id3.String())
}
func TestDeterministicXPRVDerivation(t *testing.T) {
// Test that the same xprv and index always produce the same identity
id1, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("xprv identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices with same xprv produce different identities
id3, err := DeriveIdentityFromXPRV(testXPRV, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("XPRV Index 0: %s", id1.String())
t.Logf("XPRV Index 1: %s", id3.String())
}
func TestMnemonicVsXPRVConsistency(t *testing.T) {
// Test that deriving from mnemonic and from the corresponding xprv produces the same result
// Note: This test is removed because the test mnemonic and test xprv are from different sources
// and are not expected to produce the same results.
t.Skip("Skipping consistency test - test mnemonic and xprv are from different sources")
}
func TestEntropyLength(t *testing.T) {
// Test that DeriveEntropy returns exactly 32 bytes
entropy, err := DeriveEntropy(mnemonic, 0)
if err != nil {
t.Fatalf("derive entropy: %v", err)
}
if len(entropy) != 32 {
t.Fatalf("expected 32 bytes of entropy, got %d", len(entropy))
}
t.Logf("Entropy (32 bytes): %x", entropy)
// Test that DeriveEntropyFromXPRV returns exactly 32 bytes
entropyXPRV, err := DeriveEntropyFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive entropy from xprv: %v", err)
}
if len(entropyXPRV) != 32 {
t.Fatalf("expected 32 bytes of entropy from xprv, got %d", len(entropyXPRV))
}
t.Logf("XPRV Entropy (32 bytes): %x", entropyXPRV)
// Note: We don't compare the entropy values since the test mnemonic and test xprv
// are from different sources and should produce different entropy values.
}
func TestIdentityFromEntropy(t *testing.T) {
// Test that IdentityFromEntropy works with custom entropy
entropy := make([]byte, 32)
for i := range entropy {
entropy[i] = byte(i)
}
id, err := IdentityFromEntropy(entropy)
if err != nil {
t.Fatalf("identity from entropy: %v", err)
}
t.Logf("Custom entropy identity: %s", id.String())
// Test that it rejects wrong-sized entropy
_, err = IdentityFromEntropy(entropy[:31])
if err == nil {
t.Fatalf("expected error for 31-byte entropy")
}
// Create a 33-byte slice to test rejection
entropy33 := make([]byte, 33)
copy(entropy33, entropy)
_, err = IdentityFromEntropy(entropy33)
if err == nil {
t.Fatalf("expected error for 33-byte entropy")
}
}
func TestInvalidXPRV(t *testing.T) {
// Test with invalid xprv
_, err := DeriveIdentityFromXPRV("invalid-xprv", 0)
if err == nil {
t.Fatalf("expected error for invalid xprv")
}
t.Logf("Got expected error for invalid xprv: %v", err)
}

1568
internal/secret/cli.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/internal/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@ -61,7 +61,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/internal/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@ -87,7 +87,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/internal/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
@ -114,7 +114,7 @@ import (
"fmt" "fmt"
"log" "log"
"git.eeqj.de/sneak/secret/internal/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {

View File

@ -13,7 +13,7 @@ import (
"strings" "strings"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/bip85" "git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"

934
pkg/agehd/agehd_test.go Normal file
View File

@ -0,0 +1,934 @@
package agehd
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"strings"
"testing"
"filippo.io/age"
"github.com/tyler-smith/go-bip39"
)
const (
mnemonic = "abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon about"
// Test xprv from BIP85 test vectors
testXPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// Additional test mnemonics for comprehensive testing
testMnemonic12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testMnemonic15 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testMnemonic18 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testMnemonic21 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testMnemonic24 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
// Test messages used throughout the tests
testMessageHelloWorld = "hello world"
testMessageHelloFromXPRV = "hello from xprv"
testMessageGeneric = "test message"
testMessageBoundary = "boundary test"
testMessageBenchmark = "benchmark test message"
testMessageLargePattern = "A"
// Error messages for validation
errorMsgNeed32Bytes = "need 32-byte scalar, got"
errorMsgInvalidXPRV = "invalid-xprv"
// Test constants for various scenarios
testSkipMessage = "Skipping consistency test - test mnemonic and xprv are from different sources"
// Numeric constants for testing
testNumGoroutines = 10
testNumIterations = 100
// Large data test constants
testDataSizeMegabyte = 1024 * 1024 // 1 MB
)
func TestEncryptDecrypt(t *testing.T) {
id, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive: %v", err)
}
t.Logf("secret: %s", id.String())
t.Logf("recipient: %s", id.Recipient().String())
var ct bytes.Buffer
w, err := age.Encrypt(&ct, id.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, testMessageHelloWorld); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), id)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := string(dec); got != testMessageHelloWorld {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeriveIdentityFromXPRV(t *testing.T) {
id, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive from xprv: %v", err)
}
t.Logf("xprv secret: %s", id.String())
t.Logf("xprv recipient: %s", id.Recipient().String())
// Test encryption/decryption with xprv-derived identity
var ct bytes.Buffer
w, err := age.Encrypt(&ct, id.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, testMessageHelloFromXPRV); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), id)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := string(dec); got != testMessageHelloFromXPRV {
t.Fatalf("round-trip mismatch: %q", got)
}
}
func TestDeterministicDerivation(t *testing.T) {
// Test that the same mnemonic and index always produce the same identity
id1, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices produce different identities
id3, err := DeriveIdentity(mnemonic, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("Index 0: %s", id1.String())
t.Logf("Index 1: %s", id3.String())
}
func TestDeterministicXPRVDerivation(t *testing.T) {
// Test that the same xprv and index always produce the same identity
id1, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 1: %v", err)
}
id2, err := DeriveIdentityFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive 2: %v", err)
}
if id1.String() != id2.String() {
t.Fatalf("xprv identities should be deterministic: %s != %s", id1.String(), id2.String())
}
// Test that different indices with same xprv produce different identities
id3, err := DeriveIdentityFromXPRV(testXPRV, 1)
if err != nil {
t.Fatalf("derive 3: %v", err)
}
if id1.String() == id3.String() {
t.Fatalf("different indices should produce different identities")
}
t.Logf("XPRV Index 0: %s", id1.String())
t.Logf("XPRV Index 1: %s", id3.String())
}
func TestMnemonicVsXPRVConsistency(t *testing.T) {
// Test that deriving from mnemonic and from the corresponding xprv produces the same result
// Note: This test is removed because the test mnemonic and test xprv are from different sources
// and are not expected to produce the same results.
t.Skip(testSkipMessage)
}
func TestEntropyLength(t *testing.T) {
// Test that DeriveEntropy returns exactly 32 bytes
entropy, err := DeriveEntropy(mnemonic, 0)
if err != nil {
t.Fatalf("derive entropy: %v", err)
}
if len(entropy) != 32 {
t.Fatalf("expected 32 bytes of entropy, got %d", len(entropy))
}
t.Logf("Entropy (32 bytes): %x", entropy)
// Test that DeriveEntropyFromXPRV returns exactly 32 bytes
entropyXPRV, err := DeriveEntropyFromXPRV(testXPRV, 0)
if err != nil {
t.Fatalf("derive entropy from xprv: %v", err)
}
if len(entropyXPRV) != 32 {
t.Fatalf("expected 32 bytes of entropy from xprv, got %d", len(entropyXPRV))
}
t.Logf("XPRV Entropy (32 bytes): %x", entropyXPRV)
// Note: We don't compare the entropy values since the test mnemonic and test xprv
// are from different sources and should produce different entropy values.
}
func TestIdentityFromEntropy(t *testing.T) {
// Test that IdentityFromEntropy works with custom entropy
entropy := make([]byte, 32)
for i := range entropy {
entropy[i] = byte(i)
}
id, err := IdentityFromEntropy(entropy)
if err != nil {
t.Fatalf("identity from entropy: %v", err)
}
t.Logf("Custom entropy identity: %s", id.String())
// Test that it rejects wrong-sized entropy
_, err = IdentityFromEntropy(entropy[:31])
if err == nil {
t.Fatalf("expected error for 31-byte entropy")
}
// Create a 33-byte slice to test rejection
entropy33 := make([]byte, 33)
copy(entropy33, entropy)
_, err = IdentityFromEntropy(entropy33)
if err == nil {
t.Fatalf("expected error for 33-byte entropy")
}
}
func TestInvalidXPRV(t *testing.T) {
// Test with invalid xprv
_, err := DeriveIdentityFromXPRV(errorMsgInvalidXPRV, 0)
if err == nil {
t.Fatalf("expected error for invalid xprv")
}
t.Logf("Got expected error for invalid xprv: %v", err)
}
// TestClampFunction tests the RFC-7748 clamping function
func TestClampFunction(t *testing.T) {
tests := []struct {
name string
input []byte
expected []byte
}{
{
name: "all zeros",
input: make([]byte, 32),
expected: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64},
},
{
name: "all ones",
input: bytes.Repeat([]byte{255}, 32),
expected: append([]byte{248}, append(bytes.Repeat([]byte{255}, 30), 127)...),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := make([]byte, 32)
copy(input, tt.input)
clamp(input)
// Check specific bits that should be clamped
if input[0]&7 != 0 {
t.Errorf("first byte should have bottom 3 bits cleared, got %08b", input[0])
}
if input[31]&128 != 0 {
t.Errorf("last byte should have top bit cleared, got %08b", input[31])
}
if input[31]&64 == 0 {
t.Errorf("last byte should have second-to-top bit set, got %08b", input[31])
}
})
}
}
// TestIdentityFromEntropyEdgeCases tests edge cases for IdentityFromEntropy
func TestIdentityFromEntropyEdgeCases(t *testing.T) {
tests := []struct {
name string
entropy []byte
expectError bool
errorMsg string
}{
{
name: "nil entropy",
entropy: nil,
expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
},
{
name: "empty entropy",
entropy: []byte{},
expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
},
{
name: "too short entropy",
entropy: make([]byte, 31),
expectError: true,
errorMsg: errorMsgNeed32Bytes + " 31",
},
{
name: "too long entropy",
entropy: make([]byte, 33),
expectError: true,
errorMsg: errorMsgNeed32Bytes + " 33",
},
{
name: "valid 32-byte entropy",
entropy: make([]byte, 32),
expectError: false,
},
{
name: "random valid entropy",
entropy: func() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err) // In test context, panic is acceptable for setup failures
}
return b
}(),
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identity, err := IdentityFromEntropy(tt.entropy)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
} else if !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error())
}
if identity != nil {
t.Errorf("expected nil identity on error, got %v", identity)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if identity == nil {
t.Errorf("expected valid identity, got nil")
}
}
})
}
}
// TestDeriveEntropyInvalidMnemonic tests error handling for invalid mnemonics
func TestDeriveEntropyInvalidMnemonic(t *testing.T) {
tests := []struct {
name string
mnemonic string
}{
{
name: "empty mnemonic",
mnemonic: "",
},
{
name: "single word",
mnemonic: "abandon",
},
{
name: "invalid word",
mnemonic: "invalid word sequence that does not exist in bip39",
},
{
name: "wrong word count",
mnemonic: "abandon abandon abandon abandon abandon",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Note: BIP39 library is quite permissive and doesn't validate
// mnemonic words strictly, so we mainly test that the function
// doesn't panic and produces some result
entropy, err := DeriveEntropy(tt.mnemonic, 0)
if err != nil {
t.Logf("Got error for invalid mnemonic %q: %v", tt.name, err)
} else {
if len(entropy) != 32 {
t.Errorf("expected 32 bytes even for invalid mnemonic, got %d", len(entropy))
}
t.Logf("Invalid mnemonic %q produced entropy: %x", tt.name, entropy)
}
})
}
}
// TestDeriveEntropyFromXPRVInvalidInputs tests error handling for invalid XPRVs
func TestDeriveEntropyFromXPRVInvalidInputs(t *testing.T) {
tests := []struct {
name string
xprv string
expectError bool
}{
{
name: "empty xprv",
xprv: "",
expectError: true,
},
{
name: "invalid base58",
xprv: "invalid-base58-string-!@#$%",
expectError: true,
},
{
name: "wrong prefix",
xprv: "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
expectError: true,
},
{
name: "truncated xprv",
xprv: "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLj",
expectError: true,
},
{
name: "valid xprv",
xprv: testXPRV,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entropy, err := DeriveEntropyFromXPRV(tt.xprv, 0)
if tt.expectError {
if err == nil {
t.Errorf("expected error for invalid xprv %q", tt.name)
} else {
t.Logf("Got expected error for %q: %v", tt.name, err)
}
} else {
if err != nil {
t.Errorf("unexpected error for valid xprv: %v", err)
}
if len(entropy) != 32 {
t.Errorf("expected 32 bytes of entropy, got %d", len(entropy))
}
}
})
}
}
// TestDifferentMnemonicLengths tests derivation with different mnemonic lengths
func TestDifferentMnemonicLengths(t *testing.T) {
mnemonics := map[string]string{
"12 words": testMnemonic12,
"15 words": testMnemonic15,
"18 words": testMnemonic18,
"21 words": testMnemonic21,
"24 words": testMnemonic24,
}
for name, mnemonic := range mnemonics {
t.Run(name, func(t *testing.T) {
identity, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("failed to derive identity from %s: %v", name, err)
}
// Test that we can encrypt/decrypt
var ct bytes.Buffer
w, err := age.Encrypt(&ct, identity.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, testMessageGeneric); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), identity)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(dec) != testMessageGeneric {
t.Fatalf("round-trip failed for %s", name)
}
t.Logf("%s identity: %s", name, identity.String())
})
}
}
// TestIndexBoundaries tests derivation with various index values
func TestIndexBoundaries(t *testing.T) {
indices := []uint32{
0, // minimum
1, // basic
100, // moderate
1000, // larger
0x7FFFFFFF, // maximum hardened index
0xFFFFFFFF, // maximum uint32
}
for _, index := range indices {
t.Run(fmt.Sprintf("index_%d", index), func(t *testing.T) {
identity, err := DeriveIdentity(mnemonic, index)
if err != nil {
t.Fatalf("failed to derive identity at index %d: %v", index, err)
}
// Verify the identity is valid by testing encryption/decryption
var ct bytes.Buffer
w, err := age.Encrypt(&ct, identity.Recipient())
if err != nil {
t.Fatalf("encrypt init at index %d: %v", index, err)
}
if _, err = io.WriteString(w, testMessageBoundary); err != nil {
t.Fatalf("write at index %d: %v", index, err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close at index %d: %v", index, err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), identity)
if err != nil {
t.Fatalf("decrypt init at index %d: %v", index, err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read at index %d: %v", index, err)
}
if string(dec) != testMessageBoundary {
t.Fatalf("round-trip failed at index %d", index)
}
t.Logf("Index %d identity: %s", index, identity.String())
})
}
}
// TestEntropyUniqueness tests that different inputs produce different entropy
func TestEntropyUniqueness(t *testing.T) {
// Test different indices with same mnemonic
entropy1, err := DeriveEntropy(mnemonic, 0)
if err != nil {
t.Fatalf("derive entropy 1: %v", err)
}
entropy2, err := DeriveEntropy(mnemonic, 1)
if err != nil {
t.Fatalf("derive entropy 2: %v", err)
}
if bytes.Equal(entropy1, entropy2) {
t.Fatalf("different indices should produce different entropy")
}
// Test different mnemonics with same index
entropy3, err := DeriveEntropy(testMnemonic24, 0)
if err != nil {
t.Fatalf("derive entropy 3: %v", err)
}
if bytes.Equal(entropy1, entropy3) {
t.Fatalf("different mnemonics should produce different entropy")
}
t.Logf("Entropy uniqueness verified across indices and mnemonics")
}
// TestConcurrentDerivation tests that derivation is safe for concurrent use
func TestConcurrentDerivation(t *testing.T) {
results := make(chan string, testNumGoroutines*testNumIterations)
errors := make(chan error, testNumGoroutines*testNumIterations)
for i := 0; i < testNumGoroutines; i++ {
go func(goroutineID int) {
for j := 0; j < testNumIterations; j++ {
identity, err := DeriveIdentity(mnemonic, uint32(j))
if err != nil {
errors <- err
return
}
results <- identity.String()
}
}(i)
}
// Collect results
resultMap := make(map[string]int)
for i := 0; i < testNumGoroutines*testNumIterations; i++ {
select {
case result := <-results:
resultMap[result]++
case err := <-errors:
t.Fatalf("concurrent derivation error: %v", err)
}
}
// Verify that each index produced the same result across all goroutines
expectedResults := testNumGoroutines
for result, count := range resultMap {
if count != expectedResults {
t.Errorf("result %s appeared %d times, expected %d", result, count, expectedResults)
}
}
t.Logf("Concurrent derivation test passed with %d unique results", len(resultMap))
}
// Benchmark tests
func BenchmarkDeriveIdentity(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := DeriveIdentity(mnemonic, uint32(i%1000))
if err != nil {
b.Fatalf("derive identity: %v", err)
}
}
}
func BenchmarkDeriveIdentityFromXPRV(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := DeriveIdentityFromXPRV(testXPRV, uint32(i%1000))
if err != nil {
b.Fatalf("derive identity from xprv: %v", err)
}
}
}
func BenchmarkDeriveEntropy(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := DeriveEntropy(mnemonic, uint32(i%1000))
if err != nil {
b.Fatalf("derive entropy: %v", err)
}
}
}
func BenchmarkIdentityFromEntropy(b *testing.B) {
entropy := make([]byte, 32)
if _, err := rand.Read(entropy); err != nil {
b.Fatalf("failed to generate random entropy: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := IdentityFromEntropy(entropy)
if err != nil {
b.Fatalf("identity from entropy: %v", err)
}
}
}
func BenchmarkEncryptDecrypt(b *testing.B) {
identity, err := DeriveIdentity(mnemonic, 0)
if err != nil {
b.Fatalf("derive identity: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var ct bytes.Buffer
w, err := age.Encrypt(&ct, identity.Recipient())
if err != nil {
b.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, testMessageBenchmark); err != nil {
b.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
b.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), identity)
if err != nil {
b.Fatalf("decrypt init: %v", err)
}
_, err = io.ReadAll(r)
if err != nil {
b.Fatalf("read: %v", err)
}
}
}
// TestConstants verifies the hardcoded constants
func TestConstants(t *testing.T) {
if purpose != 83696968 {
t.Errorf("purpose constant mismatch: expected 83696968, got %d", purpose)
}
if vendorID != 592366788 {
t.Errorf("vendorID constant mismatch: expected 592366788, got %d", vendorID)
}
if appID != 733482323 {
t.Errorf("appID constant mismatch: expected 733482323, got %d", appID)
}
if hrp != "age-secret-key-" {
t.Errorf("hrp constant mismatch: expected 'age-secret-key-', got %q", hrp)
}
}
// TestIdentityStringFormat tests that generated identities have the correct format
func TestIdentityStringFormat(t *testing.T) {
identity, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive identity: %v", err)
}
secretKey := identity.String()
recipient := identity.Recipient().String()
// Check secret key format
if !strings.HasPrefix(secretKey, "AGE-SECRET-KEY-") {
t.Errorf("secret key should start with 'AGE-SECRET-KEY-', got: %s", secretKey)
}
// Check recipient format
if !strings.HasPrefix(recipient, "age1") {
t.Errorf("recipient should start with 'age1', got: %s", recipient)
}
// Check that they're different
if secretKey == recipient {
t.Errorf("secret key and recipient should be different")
}
t.Logf("Secret key format: %s", secretKey)
t.Logf("Recipient format: %s", recipient)
}
// TestLargeMessageEncryption tests encryption/decryption of larger messages
func TestLargeMessageEncryption(t *testing.T) {
identity, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive identity: %v", err)
}
// Test with different message sizes
sizes := []int{1, 100, 1024, 10240, 100000}
for _, size := range sizes {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
message := strings.Repeat(testMessageLargePattern, size)
var ct bytes.Buffer
w, err := age.Encrypt(&ct, identity.Recipient())
if err != nil {
t.Fatalf("encrypt init: %v", err)
}
if _, err = io.WriteString(w, message); err != nil {
t.Fatalf("write: %v", err)
}
if err = w.Close(); err != nil {
t.Fatalf("encrypt close: %v", err)
}
r, err := age.Decrypt(bytes.NewReader(ct.Bytes()), identity)
if err != nil {
t.Fatalf("decrypt init: %v", err)
}
dec, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(dec) != message {
t.Fatalf("message size %d: round-trip failed", size)
}
t.Logf("Successfully encrypted/decrypted %d byte message", size)
})
}
}
// TestRandomMnemonicDeterministicGeneration tests that:
// 1. A random mnemonic generates the same keys deterministically
// 2. Large data (1MB) can be encrypted and decrypted successfully
func TestRandomMnemonicDeterministicGeneration(t *testing.T) {
// Generate a random mnemonic using the BIP39 library
entropy := make([]byte, 32) // 256 bits for 24-word mnemonic
if _, err := rand.Read(entropy); err != nil {
t.Fatalf("failed to generate random entropy: %v", err)
}
randomMnemonic, err := bip39.NewMnemonic(entropy)
if err != nil {
t.Fatalf("failed to generate random mnemonic: %v", err)
}
t.Logf("Generated random mnemonic: %s", randomMnemonic)
// Test index for key derivation
testIndex := uint32(42)
// Generate the first identity
identity1, err := DeriveIdentity(randomMnemonic, testIndex)
if err != nil {
t.Fatalf("failed to derive first identity: %v", err)
}
// Generate the second identity with the same mnemonic and index
identity2, err := DeriveIdentity(randomMnemonic, testIndex)
if err != nil {
t.Fatalf("failed to derive second identity: %v", err)
}
// Verify that both private keys are identical
privateKey1 := identity1.String()
privateKey2 := identity2.String()
if privateKey1 != privateKey2 {
t.Fatalf("private keys should be identical:\nFirst: %s\nSecond: %s", privateKey1, privateKey2)
}
// Verify that both public keys (recipients) are identical
publicKey1 := identity1.Recipient().String()
publicKey2 := identity2.Recipient().String()
if publicKey1 != publicKey2 {
t.Fatalf("public keys should be identical:\nFirst: %s\nSecond: %s", publicKey1, publicKey2)
}
t.Logf("✓ Deterministic generation verified")
t.Logf("Private key: %s", privateKey1)
t.Logf("Public key: %s", publicKey1)
// Generate 1 MB of random data for encryption test
testData := make([]byte, testDataSizeMegabyte)
if _, err := rand.Read(testData); err != nil {
t.Fatalf("failed to generate random test data: %v", err)
}
t.Logf("Generated %d bytes of random test data", len(testData))
// Encrypt the data using the public key (recipient)
var ciphertext bytes.Buffer
encryptor, err := age.Encrypt(&ciphertext, identity1.Recipient())
if err != nil {
t.Fatalf("failed to create encryptor: %v", err)
}
_, err = encryptor.Write(testData)
if err != nil {
t.Fatalf("failed to write data to encryptor: %v", err)
}
err = encryptor.Close()
if err != nil {
t.Fatalf("failed to close encryptor: %v", err)
}
t.Logf("✓ Encrypted %d bytes into %d bytes of ciphertext", len(testData), ciphertext.Len())
// Decrypt the data using the private key
decryptor, err := age.Decrypt(bytes.NewReader(ciphertext.Bytes()), identity1)
if err != nil {
t.Fatalf("failed to create decryptor: %v", err)
}
decryptedData, err := io.ReadAll(decryptor)
if err != nil {
t.Fatalf("failed to read decrypted data: %v", err)
}
t.Logf("✓ Decrypted %d bytes", len(decryptedData))
// Verify that the decrypted data matches the original
if len(decryptedData) != len(testData) {
t.Fatalf("decrypted data length mismatch: expected %d, got %d", len(testData), len(decryptedData))
}
if !bytes.Equal(testData, decryptedData) {
t.Fatalf("decrypted data does not match original data")
}
t.Logf("✓ Large data encryption/decryption test passed successfully")
// Additional verification: test with the second identity (should work identically)
var ciphertext2 bytes.Buffer
encryptor2, err := age.Encrypt(&ciphertext2, identity2.Recipient())
if err != nil {
t.Fatalf("failed to create second encryptor: %v", err)
}
_, err = encryptor2.Write(testData)
if err != nil {
t.Fatalf("failed to write data to second encryptor: %v", err)
}
err = encryptor2.Close()
if err != nil {
t.Fatalf("failed to close second encryptor: %v", err)
}
// Decrypt with the second identity
decryptor2, err := age.Decrypt(bytes.NewReader(ciphertext2.Bytes()), identity2)
if err != nil {
t.Fatalf("failed to create second decryptor: %v", err)
}
decryptedData2, err := io.ReadAll(decryptor2)
if err != nil {
t.Fatalf("failed to read second decrypted data: %v", err)
}
if !bytes.Equal(testData, decryptedData2) {
t.Fatalf("second decrypted data does not match original data")
}
t.Logf("✓ Cross-verification with second identity successful")
}

View File

@ -17,7 +17,7 @@ BIP85 enables a variety of use cases:
```go ```go
import ( import (
"fmt" "fmt"
"git.eeqj.de/sneak/secret/internal/bip85" "git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
) )
@ -140,7 +140,7 @@ The implementation is also compatible with the Python reference implementation's
Run the tests with verbose output to see the test vectors and results: Run the tests with verbose output to see the test vectors and results:
``` ```
go test -v git.eeqj.de/sneak/secret/internal/bip85 go test -v git.eeqj.de/sneak/secret/pkg/bip85
``` ```
## References ## References

1061
test_secret_manager.sh Executable file

File diff suppressed because it is too large Load Diff