Compare commits

...

4 Commits

Author SHA1 Message Date
ac81023ea0 add LLM instructions 2025-06-08 22:19:13 -07:00
d76a4cbf4d fix tests 2025-06-08 22:13:22 -07:00
fbda2d91af add secret versioning support 2025-06-08 22:07:19 -07:00
f59ee4d2d6 'unlock keys' renamed to 'unlockers' 2025-05-30 07:29:02 -07:00
38 changed files with 3713 additions and 2712 deletions

3
.cursorrules Normal file
View File

@ -0,0 +1,3 @@
Read and follow the policies, procedures, and instructions in the
`AGENTS.md` file in the root of the repository. Make sure you follow *all*
of the instructions meticulously.

143
AGENTS.md Normal file
View File

@ -0,0 +1,143 @@
# Policies for AI Agents
Version: 2025-06-08
# Instructions and Contextual Information
* Be direct, robotic, expert, accurate, and professional.
* 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.
* 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.
* 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.
* 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.
* 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
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
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.
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
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.
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.
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.
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.
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.
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.
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
```
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.

109
README.md
View File

@ -9,12 +9,20 @@ Secret is a modern, secure command-line secret manager that implements a hierarc
Secret implements a sophisticated three-layer key architecture:
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
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
- Encrypted metadata 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 unlock key set. This allows for complete isolation between different contexts (work, personal, projects).
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
@ -80,12 +88,21 @@ Adds a secret to the current vault. Reads the secret value from stdin.
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
- Examples: `database/password`, `api.key`, `ssh_private_key`
#### `secret get <secret-name>`
#### `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.
### Version Management
#### `secret version list <secret-name>`
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.
### Key Generation
#### `secret generate mnemonic`
@ -97,26 +114,26 @@ Generates and stores a random secret.
- `--type, -t`: Type of secret (`base58`, `alnum`)
- `--force, -f`: Overwrite existing secret
### Unlock Key Management
### Unlocker Management
#### `secret keys list [--json]`
Lists all unlock keys in the current vault with their metadata.
#### `secret unlockers list [--json]`
Lists all unlockers in the current vault with their metadata.
#### `secret keys add <type> [options]`
Creates a new unlock key of the specified type:
#### `secret unlockers add <type> [options]`
Creates a new unlocker of the specified type:
**Types:**
- `passphrase`: Traditional passphrase-protected unlock key
- `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption
**Options:**
- `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret keys rm <key-id>`
Removes an unlock key.
#### `secret unlockers rm <unlocker-id>`
Removes an unlocker.
#### `secret key select <key-id>`
Selects an unlock key as the current default for operations.
#### `secret unlocker select <unlocker-id>`
Selects an unlocker as the current default for operations.
### Import Operations
@ -142,17 +159,27 @@ Decrypts data using an Age key stored as a secret.
~/.local/share/secret/
├── vaults.d/
│ ├── default/
│ │ ├── unlock.d/
│ │ │ ├── passphrase/ # Passphrase unlock key
│ │ │ └── pgp/ # PGP unlock key
│ │ ├── 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.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001
│ │ │ └── database%password/ # Secret: database/password
│ │ └── current-unlock-key -> ../unlock.d/passphrase
│ │ │ ├── versions/
│ │ │ └── current -> versions/20231215.001
│ │ └── current-unlocker -> ../unlockers.d/passphrase
│ └── work/
│ ├── unlock.d/
│ ├── unlockers.d/
│ ├── secrets.d/
│ └── current-unlock-key
│ └── current-unlocker
├── currentvault -> vaults.d/default
└── configuration.json
```
@ -162,22 +189,22 @@ Decrypts data using an Age key stored as a secret.
#### 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
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
#### Unlock Keys
Unlock keys provide different authentication methods to access the long-term keys:
#### Unlockers
Unlockers provide different authentication methods to access the long-term keys:
1. **Passphrase Keys**:
1. **Passphrase Unlockers**:
- Encrypted with user-provided passphrase
- Stored as encrypted Age keys
- Cross-platform compatible
2. **PGP Keys**:
2. **PGP Unlockers**:
- Uses existing GPG key infrastructure
- Leverages existing key management workflows
- Strong authentication through GPG
Each vault maintains its own set of unlock keys and one long-term key. The long-term key is encrypted to each unlock key, allowing any authorized unlock key 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.
#### Secret-specific Keys
- Each secret has its own encryption key pair
@ -189,7 +216,7 @@ Each vault maintains its own set of unlock keys and one long-term key. The long-
- `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
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
## Security Features
@ -204,8 +231,10 @@ Each vault maintains its own set of unlock keys and one long-term key. The long-
- Vault isolation prevents cross-contamination
### Forward Secrecy
- Per-secret encryption keys limit exposure if compromised
- Long-term keys protected by multiple unlock key layers
- Per-version encryption keys limit exposure if compromised
- Each version is independently encrypted
- Long-term keys protected by multiple unlocker layers
- Historical versions remain encrypted with their original keys
### Hardware Integration
- Hardware token support via PGP/GPG integration
@ -238,7 +267,7 @@ secret vault create personal
# Work with work vault
secret vault select work
echo "work-db-pass" | secret add database/password
secret keys add passphrase # Add passphrase authentication
secret unlockers add passphrase # Add passphrase authentication
# Switch to personal vault
secret vault select personal
@ -251,14 +280,14 @@ secret vault list
### Advanced Authentication
```bash
# Add multiple unlock methods
secret keys add passphrase # Password-based
secret keys add pgp --keyid ABCD1234 # GPG key
secret unlockers add passphrase # Password-based
secret unlockers add pgp --keyid ABCD1234 # GPG key
# List unlock keys
secret keys list
# List unlockers
secret unlockers list
# Select a specific unlock key
secret key select <key-id>
# Select a specific unlocker
secret unlocker select <unlocker-id>
```
### Encryption/Decryption with Age Keys
@ -299,14 +328,14 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
- Supports hardware-backed authentication where available
### Best Practices
1. Use strong, unique passphrases for unlock keys
1. Use strong, unique passphrases for unlockers
2. Enable hardware authentication (Keychain, hardware tokens) when available
3. Regularly audit unlock keys and remove unused ones
3. Regularly audit unlockers 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
- Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms
@ -328,7 +357,7 @@ go test ./... # Unit tests
## Features
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlock keys
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based 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

148
TESTS_VERSION_SUPPORT.md Normal file
View File

@ -0,0 +1,148 @@
# Version Support Test Suite Documentation
This document describes the comprehensive test suite created for the versioned secrets functionality in the Secret Manager.
## Test Files Created
### 1. `internal/secret/version_test.go`
Core unit tests for version functionality:
- **TestGenerateVersionName**: Tests version name generation with date and serial format
- **TestGenerateVersionNameMaxSerial**: Tests the 999 versions per day limit
- **TestNewSecretVersion**: Tests secret version object creation
- **TestSecretVersionSave**: Tests saving a version with encryption
- **TestSecretVersionLoadMetadata**: Tests loading and decrypting version metadata
- **TestSecretVersionGetValue**: Tests retrieving and decrypting version values
- **TestListVersions**: Tests listing versions in reverse chronological order
- **TestGetCurrentVersion**: Tests retrieving the current version via symlink
- **TestSetCurrentVersion**: Tests updating the current version symlink
- **TestVersionMetadataTimestamps**: Tests timestamp pointer consistency
### 2. `internal/vault/secrets_version_test.go`
Integration tests for vault-level version operations:
- **TestVaultAddSecretCreatesVersion**: Tests that AddSecret creates proper version structure
- **TestVaultAddSecretMultipleVersions**: Tests creating multiple versions with force flag
- **TestVaultGetSecretVersion**: Tests retrieving specific versions and current version
- **TestVaultVersionTimestamps**: Tests timestamp logic (notBefore/notAfter) across versions
- **TestVaultGetNonExistentVersion**: Tests error handling for invalid versions
- **TestUpdateVersionMetadata**: Tests metadata update functionality
### 3. `internal/cli/version_test.go`
CLI command tests:
- **TestListVersionsCommand**: Tests `secret version list` command output
- **TestListVersionsNonExistentSecret**: Tests error handling for missing secrets
- **TestPromoteVersionCommand**: Tests `secret version promote` command
- **TestPromoteNonExistentVersion**: Tests error handling for invalid promotion
- **TestGetSecretWithVersion**: Tests `secret get --version` flag functionality
- **TestVersionCommandStructure**: Tests command structure and help text
- **TestListVersionsEmptyOutput**: Tests edge case with no versions
### 4. `internal/vault/integration_version_test.go`
Comprehensive integration tests:
- **TestVersionIntegrationWorkflow**: End-to-end workflow testing
- Creating initial version with proper metadata
- Creating multiple versions with timestamp updates
- Retrieving specific versions by name
- Promoting old versions to current
- Testing version serial number limits (999/day)
- Error cases and edge conditions
- **TestVersionConcurrency**: Tests concurrent read operations
- **TestVersionCompatibility**: Tests handling of legacy non-versioned secrets
## Key Test Scenarios Covered
### Version Creation
- First version gets `notBefore = epoch + 1 second`
- Subsequent versions update previous version's `notAfter` timestamp
- New version's `notBefore` equals previous version's `notAfter`
- Version names follow `YYYYMMDD.NNN` format
- Maximum 999 versions per day enforced
### Version Retrieval
- Get current version via symlink
- Get specific version by name
- Empty version parameter returns current
- Non-existent versions return appropriate errors
### Version Management
- List versions in reverse chronological order
- Promote any version to current
- Promotion doesn't modify timestamps
- Metadata remains encrypted and intact
### Data Integrity
- Each version has independent encryption keys
- Metadata encryption protects version history
- Long-term key required for all operations
- Concurrent reads handled safely
### Backward Compatibility
- Legacy secrets without versions detected
- Appropriate error messages for incompatible operations
## Test Utilities Created
### Helper Functions
- `createTestVaultWithKey()`: Sets up vault with long-term key for testing
- `setupTestVault()`: CLI test helper for vault initialization
- Mock implementations for isolated testing
### Test Environment
- Uses in-memory filesystem (afero.MemMapFs)
- Consistent test mnemonic for reproducible keys
- Proper cleanup and isolation between tests
## Running the Tests
Run all version-related tests:
```bash
go test ./internal/... -run "Test.*Version.*" -v
```
Run specific test suites:
```bash
# Core version tests
go test ./internal/secret -run "Test.*Version.*" -v
# Vault integration tests
go test ./internal/vault -run "Test.*Version.*" -v
# CLI tests
go test ./internal/cli -run "Test.*Version.*" -v
```
Run the comprehensive integration test:
```bash
go test ./internal/vault -run TestVersionIntegrationWorkflow -v
```
## Test Coverage Areas
1. **Functional Coverage**
- Version CRUD operations
- Timestamp management
- Encryption/decryption
- Symlink handling
- Error conditions
2. **Integration Coverage**
- Vault-secret interaction
- CLI-vault interaction
- End-to-end workflows
3. **Edge Cases**
- Maximum versions per day
- Empty version directories
- Missing symlinks
- Concurrent access
- Legacy compatibility
4. **Security Coverage**
- Encrypted metadata
- Key isolation per version
- Long-term key requirements

11
TODO.md
View File

@ -19,7 +19,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
- [x] **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.
- [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/`
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
- Secret files use inconsistent naming (`secret.age` vs `value.age`)
- [x] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
@ -140,7 +140,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
### Code Structure
- [ ] **51. Consistent interface implementation**: Ensure all unlock key types properly implement the UnlockKey interface.
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
@ -194,3 +194,10 @@ This document outlines the bugs, issues, and improvements that need to be addres
- Architecture/Infrastructure (51-64): Ongoing post-1.0
Total estimated time to 1.0: 5-7 weeks with focused development effort.
### Architecture Issues
- **Need to refactor unlock key hierarchy**: Current implementation has confusion between the top-level concepts. Fix in progress.
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
- Need to settle on consistent naming: "unlock keys" vs "unlockers" throughout the codebase
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.

26
go.mod
View File

@ -8,34 +8,26 @@ require (
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/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.8.4
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.38.0
golang.org/x/term v0.32.0
)
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.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/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/kr/pretty v0.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // 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
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

1197
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -74,11 +74,11 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
if os.Getenv(secret.EnvMnemonic) != "" {
secretValue, err = secretObj.GetValue(nil)
} else {
unlockKey, unlockErr := vlt.GetCurrentUnlockKey()
unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
}
secretValue, err = secretObj.GetValue(unlockKey)
secretValue, err = secretObj.GetValue(unlocker)
}
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
@ -178,11 +178,11 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
if os.Getenv(secret.EnvMnemonic) != "" {
secretValue, err = secretObj.GetValue(nil)
} else {
unlockKey, unlockErr := vlt.GetCurrentUnlockKey()
unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr)
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
}
secretValue, err = secretObj.GetValue(unlockKey)
secretValue, err = secretObj.GetValue(unlocker)
}
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)

View File

@ -140,7 +140,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Prompt for passphrase for unlock key
// Prompt for passphrase for unlocker
var passphraseStr string
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable")
@ -148,61 +148,61 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
} else {
secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
secret.Debug("Failed to read unlock passphrase", "error", err)
return fmt.Errorf("failed to read passphrase: %w", err)
}
}
// Create passphrase-protected unlock key
secret.Debug("Creating passphrase-protected unlock key")
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil {
secret.Debug("Failed to create unlock key", "error", err)
return fmt.Errorf("failed to create unlock key: %w", err)
secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlocker: %w", err)
}
// Encrypt long-term private key to the unlock key
unlockKeyDir := passphraseKey.GetDirectory()
// Encrypt long-term private key to the unlocker
unlockerDir := passphraseUnlocker.GetDirectory()
// Read unlock key public key
unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age"))
// Read unlocker public key
unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
if err != nil {
return fmt.Errorf("failed to read unlock key public key: %w", err)
return fmt.Errorf("failed to read unlocker public key: %w", err)
}
unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData))
unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
if err != nil {
return fmt.Errorf("failed to parse unlock key public key: %w", err)
return fmt.Errorf("failed to parse unlocker public key: %w", err)
}
// Encrypt long-term private key to unlock key
// Encrypt long-term private key to unlocker
ltPrivKeyData := []byte(ltIdentity.String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockRecipient)
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockerRecipient)
if err != nil {
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
// Write encrypted long-term private key
if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
if err := afero.WriteFile(cli.fs, filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltPubKey)
cmd.Printf("Unlock key ID: %s\n", passphraseKey.GetID())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
cmd.Println("\nYour secret manager is ready to use!")
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
cmd.Println("unlock keys are not required for secret operations.")
cmd.Println("unlockers are not required for secret operations.")
}
return nil
}
// readSecurePassphrase reads a passphrase securely from the terminal without echoing
// This version adds confirmation (read twice) for creating new unlock keys
// This version adds confirmation (read twice) for creating new unlockers
func readSecurePassphrase(prompt string) (string, error) {
// Get the first passphrase
passphrase1, err := secret.ReadPassphrase(prompt)

View File

@ -35,11 +35,12 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd())
cmd.AddCommand(newKeysCmd())
cmd.AddCommand(newKeyCmd())
cmd.AddCommand(newUnlockersCmd())
cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd())
cmd.AddCommand(newVersionCmd())
secret.Debug("newRootCmd completed")
return cmd

View File

@ -35,15 +35,19 @@ func newAddCmd() *cobra.Command {
}
func newGetCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "get <secret-name>",
Short: "Retrieve a secret from the vault",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
version, _ := cmd.Flags().GetString("version")
cli := NewCLIInstance()
return cli.GetSecret(args[0])
return cli.GetSecretWithVersion(args[0], version)
},
}
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
return cmd
}
func newListCmd() *cobra.Command {
@ -132,6 +136,11 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
// GetSecret retrieves and prints a secret from the current vault
func (cli *CLIInstance) GetSecret(secretName string) error {
return cli.GetSecretWithVersion(secretName, "")
}
// GetSecretWithVersion retrieves and prints a specific version of a secret
func (cli *CLIInstance) GetSecretWithVersion(secretName string, version string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@ -139,7 +148,12 @@ func (cli *CLIInstance) GetSecret(secretName string) error {
}
// Get the secret value
value, err := vlt.GetSecret(secretName)
var value []byte
if version == "" {
value, err = vlt.GetSecret(secretName)
} else {
value, err = vlt.GetSecretVersion(secretName, version)
}
if err != nil {
return err
}

View File

@ -18,29 +18,29 @@ import (
// ... existing imports ...
func newKeysCmd() *cobra.Command {
func newUnlockersCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "keys",
Short: "Manage unlock keys",
Long: `Create, list, and remove unlock keys for the current vault.`,
Use: "unlockers",
Short: "Manage unlockers",
Long: `Create, list, and remove unlockers for the current vault.`,
}
cmd.AddCommand(newKeysListCmd())
cmd.AddCommand(newKeysAddCmd())
cmd.AddCommand(newKeysRmCmd())
cmd.AddCommand(newUnlockersListCmd())
cmd.AddCommand(newUnlockersAddCmd())
cmd.AddCommand(newUnlockersRmCmd())
return cmd
}
func newKeysListCmd() *cobra.Command {
func newUnlockersListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List unlock keys in the current vault",
Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance()
return cli.KeysList(jsonOutput)
return cli.UnlockersList(jsonOutput)
},
}
@ -48,60 +48,60 @@ func newKeysListCmd() *cobra.Command {
return cmd
}
func newKeysAddCmd() *cobra.Command {
func newUnlockersAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <type>",
Short: "Add a new unlock key",
Long: `Add a new unlock key of the specified type (passphrase, keychain, pgp).`,
Short: "Add a new unlocker",
Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.KeysAdd(args[0], cmd)
return cli.UnlockersAdd(args[0], cmd)
},
}
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlock keys")
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
return cmd
}
func newKeysRmCmd() *cobra.Command {
func newUnlockersRmCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <key-id>",
Short: "Remove an unlock key",
Use: "rm <unlocker-id>",
Short: "Remove an unlocker",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.KeysRemove(args[0])
return cli.UnlockersRemove(args[0])
},
}
}
func newKeyCmd() *cobra.Command {
func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "key",
Short: "Manage current unlock key",
Long: `Select the current unlock key for operations.`,
Use: "unlocker",
Short: "Manage current unlocker",
Long: `Select the current unlocker for operations.`,
}
cmd.AddCommand(newKeySelectSubCmd())
cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd
}
func newKeySelectSubCmd() *cobra.Command {
func newUnlockerSelectSubCmd() *cobra.Command {
return &cobra.Command{
Use: "select <key-id>",
Short: "Select an unlock key as current",
Use: "select <unlocker-id>",
Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance()
return cli.KeySelect(args[0])
return cli.UnlockerSelect(args[0])
},
}
}
// KeysList lists unlock keys in the current vault
func (cli *CLIInstance) KeysList(jsonOutput bool) error {
// UnlockersList lists unlockers in the current vault
func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
@ -109,90 +109,90 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
}
// Get the metadata first
keyMetadataList, err := vlt.ListUnlockKeys()
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil {
return err
}
// Load actual unlock key objects to get the proper IDs
type KeyInfo struct {
// Load actual unlocker objects to get the proper IDs
type UnlockerInfo struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
Flags []string `json:"flags,omitempty"`
}
var keys []KeyInfo
for _, metadata := range keyMetadataList {
// Create unlock key instance to get the proper ID
var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList {
// Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory()
if err != nil {
continue
}
// Find the key directory by type and created time
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
files, err := afero.ReadDir(cli.fs, unlockKeysDir)
// Find the unlocker directory by type and created time
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil {
continue
}
var unlockKey secret.UnlockKey
var unlocker secret.Unlocker
for _, file := range files {
if !file.IsDir() {
continue
}
keyDir := filepath.Join(unlockKeysDir, file.Name())
metadataPath := filepath.Join(keyDir, "unlock-metadata.json")
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right key by comparing metadata
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockKeyMetadata
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
// Create the appropriate unlock key instance
// Create the appropriate unlocker instance
switch metadata.Type {
case "passphrase":
unlockKey = secret.NewPassphraseUnlockKey(cli.fs, keyDir, diskMetadata)
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
case "keychain":
unlockKey = secret.NewKeychainUnlockKey(cli.fs, keyDir, diskMetadata)
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp":
unlockKey = secret.NewPGPUnlockKey(cli.fs, keyDir, diskMetadata)
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
}
break
}
}
// Get the proper ID using the unlock key's ID() method
// Get the proper ID using the unlocker's ID() method
var properID string
if unlockKey != nil {
properID = unlockKey.GetID()
if unlocker != nil {
properID = unlocker.GetID()
} else {
properID = metadata.ID // fallback to metadata ID
}
keyInfo := KeyInfo{
unlockerInfo := UnlockerInfo{
ID: properID,
Type: metadata.Type,
CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags,
}
keys = append(keys, keyInfo)
unlockers = append(unlockers, unlockerInfo)
}
if jsonOutput {
// JSON output
output := map[string]interface{}{
"keys": keys,
"unlockers": unlockers,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
@ -203,36 +203,36 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
fmt.Println(string(jsonBytes))
} else {
// Pretty table output
if len(keys) == 0 {
fmt.Println("No unlock keys found in current vault.")
fmt.Println("Run 'secret keys add passphrase' to create one.")
if len(unlockers) == 0 {
fmt.Println("No unlockers found in current vault.")
fmt.Println("Run 'secret unlockers add passphrase' to create one.")
return nil
}
fmt.Printf("%-18s %-12s %-20s %s\n", "KEY ID", "TYPE", "CREATED", "FLAGS")
fmt.Printf("%-18s %-12s %-20s %s\n", "------", "----", "-------", "-----")
fmt.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
fmt.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
for _, key := range keys {
for _, unlocker := range unlockers {
flags := ""
if len(key.Flags) > 0 {
flags = strings.Join(key.Flags, ",")
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
fmt.Printf("%-18s %-12s %-20s %s\n",
key.ID,
key.Type,
key.CreatedAt.Format("2006-01-02 15:04:05"),
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags)
}
fmt.Printf("\nTotal: %d unlock key(s)\n", len(keys))
fmt.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
}
return nil
}
// KeysAdd adds a new unlock key
func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
switch keyType {
// UnlockersAdd adds a new unlocker
func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
switch unlockerType {
case "passphrase":
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@ -254,28 +254,28 @@ func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
passphraseStr = envPassphrase
} else {
// Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ")
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err)
}
}
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr)
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil {
return err
}
cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetID())
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
return nil
case "keychain":
keychainKey, err := secret.CreateKeychainUnlockKey(cli.fs, cli.stateDir)
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlock key: %w", err)
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
}
cmd.Printf("Created macOS Keychain unlock key: %s\n", keychainKey.GetID())
if keyName, err := keychainKey.GetKeychainItemName(); err == nil {
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
cmd.Printf("Keychain Item Name: %s\n", keyName)
}
return nil
@ -291,38 +291,38 @@ func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
}
pgpKey, err := secret.CreatePGPUnlockKey(cli.fs, cli.stateDir, gpgKeyID)
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
if err != nil {
return err
}
cmd.Printf("Created PGP unlock key: %s\n", pgpKey.GetID())
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
return nil
default:
return fmt.Errorf("unsupported key type: %s (supported: passphrase, keychain, pgp)", keyType)
return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
}
}
// KeysRemove removes an unlock key
func (cli *CLIInstance) KeysRemove(keyID string) error {
// UnlockersRemove removes an unlocker
func (cli *CLIInstance) UnlockersRemove(unlockerID string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
return vlt.RemoveUnlockKey(keyID)
return vlt.RemoveUnlocker(unlockerID)
}
// KeySelect selects an unlock key as current
func (cli *CLIInstance) KeySelect(keyID string) error {
// UnlockerSelect selects an unlocker as current
func (cli *CLIInstance) UnlockerSelect(unlockerID string) error {
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
return vlt.SelectUnlockKey(keyID)
return vlt.SelectUnlocker(unlockerID)
}

View File

@ -251,17 +251,17 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlock key
secret.Debug("Creating passphrase-protected unlock key")
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil {
secret.Debug("Failed to create unlock key", "error", err)
return fmt.Errorf("failed to create unlock key: %w", err)
secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlocker: %w", err)
}
fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
fmt.Printf("Long-term public key: %s\n", ltPublicKey)
fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetID())
fmt.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil
}

203
internal/cli/version.go Normal file
View File

@ -0,0 +1,203 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
// newVersionCmd returns the version management command
func newVersionCmd() *cobra.Command {
cli := NewCLIInstance()
return VersionCommands(cli)
}
// VersionCommands returns the version management commands
func VersionCommands(cli *CLIInstance) *cobra.Command {
versionCmd := &cobra.Command{
Use: "version",
Short: "Manage secret versions",
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
}
// List versions command
listCmd := &cobra.Command{
Use: "list <secret-name>",
Short: "List all versions of a secret",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.ListVersions(args[0])
},
}
// Promote version command
promoteCmd := &cobra.Command{
Use: "promote <secret-name> <version>",
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(args[0], args[1])
},
}
versionCmd.AddCommand(listCmd, promoteCmd)
return versionCmd
}
// ListVersions lists all versions of a secret
func (cli *CLIInstance) ListVersions(secretName string) error {
secret.Debug("Listing versions for secret", "secret_name", secretName)
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Convert secret name to storage name
storageName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret %s not found", secretName)
}
// Get all versions
versions, err := secret.ListVersions(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to list versions: %w", err)
}
if len(versions) == 0 {
fmt.Println("No versions found")
return nil
}
// Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err)
currentVersion = ""
}
// Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Create table writer
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
// Load and display each version's metadata
for _, version := range versions {
sv := secret.NewSecretVersion(vlt, secretName, version)
// Load metadata
if err := sv.LoadMetadata(ltIdentity); err != nil {
secret.Debug("Failed to load version metadata", "version", version, "error", err)
// Display version with error
status := "error"
if version == currentVersion {
status = "current (error)"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
continue
}
// Determine status
status := "expired"
if version == currentVersion {
status = "current"
}
// Format timestamps
createdAt := "-"
if sv.Metadata.CreatedAt != nil {
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
}
notBefore := "-"
if sv.Metadata.NotBefore != nil {
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
}
notAfter := "-"
if sv.Metadata.NotAfter != nil {
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
}
w.Flush()
return nil
}
// PromoteVersion promotes a specific version to current
func (cli *CLIInstance) PromoteVersion(secretName string, version string) error {
secret.Debug("Promoting version", "secret_name", secretName, "version", version)
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Convert secret name to storage name
storageName := strings.ReplaceAll(secretName, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret %s not found", secretName)
}
// Check if version exists
versionPath := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(cli.fs, versionPath)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return fmt.Errorf("version %s not found for secret %s", version, secretName)
}
// Update current symlink
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
return fmt.Errorf("failed to promote version: %w", err)
}
fmt.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
return nil
}

View File

@ -0,0 +1,288 @@
package cli
import (
"io"
"os"
"strings"
"testing"
"time"
"path/filepath"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to set up a vault with long-term key
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault
vlt, err := vault.CreateVault(fs, stateDir, "default")
require.NoError(t, err)
// Derive and store long-term key from mnemonic
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vlt.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Select vault
err = vault.SelectVault(fs, stateDir, "default")
require.NoError(t, err)
}
func TestListVersionsCommand(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
require.NoError(t, err)
// Capture output
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// List versions
err = cli.ListVersions("test/secret")
require.NoError(t, err)
// Restore stdout and read output
w.Close()
os.Stdout = oldStdout
output, _ := io.ReadAll(r)
outputStr := string(output)
// Verify output contains version headers
assert.Contains(t, outputStr, "VERSION")
assert.Contains(t, outputStr, "CREATED")
assert.Contains(t, outputStr, "STATUS")
assert.Contains(t, outputStr, "NOT_BEFORE")
assert.Contains(t, outputStr, "NOT_AFTER")
// Should have current status for latest version
assert.Contains(t, outputStr, "current")
// Should have two version entries
lines := strings.Split(outputStr, "\n")
versionLines := 0
for _, line := range lines {
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
versionLines++
}
}
assert.Equal(t, 2, versionLines)
}
func TestListVersionsNonExistentSecret(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Try to list versions of non-existent secret
err := cli.ListVersions("nonexistent/secret")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestPromoteVersionCommand(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
require.NoError(t, err)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Current should be version-2
value, err := vlt.GetSecret("test/secret")
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
// Promote first version
firstVersion := versions[1] // Older version
// Capture output
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err = cli.PromoteVersion("test/secret", firstVersion)
require.NoError(t, err)
// Restore stdout and read output
w.Close()
os.Stdout = oldStdout
output, _ := io.ReadAll(r)
outputStr := string(output)
// Verify success message
assert.Contains(t, outputStr, "Promoted version")
assert.Contains(t, outputStr, firstVersion)
// Verify current is now version-1
value, err = vlt.GetSecret("test/secret")
require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
}
func TestPromoteNonExistentVersion(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Add a secret
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
err = vlt.AddSecret("test/secret", []byte("value"), false)
require.NoError(t, err)
// Try to promote non-existent version
err = cli.PromoteVersion("test/secret", "20991231.999")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetSecretWithVersion(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Add a secret with multiple versions
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
require.NoError(t, err)
// Get versions
vaultDir, _ := vlt.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Test getting current version (empty version string)
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err = cli.GetSecretWithVersion("test/secret", "")
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
output, _ := io.ReadAll(r)
assert.Equal(t, "version-2", string(output))
// Test getting specific version
r, w, _ = os.Pipe()
os.Stdout = w
firstVersion := versions[1] // Older version
err = cli.GetSecretWithVersion("test/secret", firstVersion)
require.NoError(t, err)
w.Close()
os.Stdout = oldStdout
output, _ = io.ReadAll(r)
assert.Equal(t, "version-1", string(output))
}
func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured
cli := NewCLIInstance()
cmd := VersionCommands(cli)
assert.Equal(t, "version", cmd.Use)
assert.Equal(t, "Manage secret versions", cmd.Short)
// Check subcommands
listCmd := cmd.Commands()[0]
assert.Equal(t, "list <secret-name>", listCmd.Use)
assert.Equal(t, "List all versions of a secret", listCmd.Short)
promoteCmd := cmd.Commands()[1]
assert.Equal(t, "promote <secret-name> <version>", promoteCmd.Use)
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
}
func TestListVersionsEmptyOutput(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
cli := NewCLIInstanceWithStateDir(fs, stateDir)
// Set up vault with long-term key
setupTestVault(t, fs, stateDir)
// Create a secret directory without versions (edge case)
vaultDir := stateDir + "/vaults.d/default"
secretDir := vaultDir + "/secrets.d/test%secret"
err := fs.MkdirAll(secretDir, 0755)
require.NoError(t, err)
// List versions - should show "No versions found"
err = cli.ListVersions("test/secret")
// Should succeed even with no versions
assert.NoError(t, err)
}

View File

@ -15,19 +15,19 @@ import (
"github.com/spf13/afero"
)
// KeychainUnlockKeyMetadata extends UnlockKeyMetadata with keychain-specific data
type KeychainUnlockKeyMetadata struct {
UnlockKeyMetadata
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
type KeychainUnlockerMetadata struct {
UnlockerMetadata
// Age keypair information
AgePublicKey string `json:"age_public_key"`
// Keychain item name
KeychainItemName string `json:"keychain_item_name"`
}
// KeychainUnlockKey represents a macOS Keychain-protected unlock key
type KeychainUnlockKey struct {
// KeychainUnlocker represents a macOS Keychain-protected unlocker
type KeychainUnlocker struct {
Directory string
Metadata UnlockKeyMetadata
Metadata UnlockerMetadata
fs afero.Fs
}
@ -38,17 +38,17 @@ type KeychainData struct {
EncryptedLongtermKey string `json:"encrypted_longterm_key"`
}
// GetIdentity implements UnlockKey interface for Keychain-based unlock keys
func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlock key identity",
slog.String("key_id", k.GetID()),
slog.String("key_type", k.GetType()),
// GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity",
slog.String("unlocker_id", k.GetID()),
slog.String("unlocker_type", k.GetType()),
)
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name", "error", err, "key_id", k.GetID())
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
@ -61,18 +61,18 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
}
DebugWith("Retrieved data from keychain",
slog.String("key_id", k.GetID()),
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Step 3: Parse keychain data
var keychainData KeychainData
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
Debug("Failed to parse keychain data", "error", err, "key_id", k.GetID())
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
Debug("Parsed keychain data successfully", "key_id", k.GetID())
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
@ -85,61 +85,61 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
}
DebugWith("Read encrypted age private key",
slog.String("key_id", k.GetID()),
slog.String("unlocker_id", k.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "key_id", k.GetID())
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "key_id", k.GetID())
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
}
DebugWith("Successfully decrypted age private key with keychain passphrase",
slog.String("key_id", k.GetID()),
slog.String("unlocker_id", k.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", k.GetID())
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", k.GetID())
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed keychain age identity",
slog.String("key_id", k.GetID()),
slog.String("unlocker_id", k.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements UnlockKey interface
func (k *KeychainUnlockKey) GetType() string {
// GetType implements Unlocker interface
func (k *KeychainUnlocker) GetType() string {
return "keychain"
}
// GetMetadata implements UnlockKey interface
func (k *KeychainUnlockKey) GetMetadata() UnlockKeyMetadata {
// GetMetadata implements Unlocker interface
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
return k.Metadata
}
// GetDirectory implements UnlockKey interface
func (k *KeychainUnlockKey) GetDirectory() string {
// GetDirectory implements Unlocker interface
func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
}
// GetID implements UnlockKey interface
func (k *KeychainUnlockKey) GetID() string {
// GetID implements Unlocker interface
func (k *KeychainUnlocker) GetID() string {
return k.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from keychain item name
func (k *KeychainUnlockKey) ID() string {
// ID implements Unlocker interface - generates ID from keychain item name
func (k *KeychainUnlocker) ID() string {
// Generate ID using keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
@ -149,12 +149,12 @@ func (k *KeychainUnlockKey) ID() string {
return fmt.Sprintf("%s-keychain", keychainItemName)
}
// Remove implements UnlockKey interface - removes the keychain unlock key
func (k *KeychainUnlockKey) Remove() error {
// Remove implements Unlocker interface - removes the keychain unlocker
func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name during removal", "error", err, "key_id", k.GetID())
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err)
}
@ -166,19 +166,19 @@ func (k *KeychainUnlockKey) Remove() error {
}
// Step 3: Remove directory
Debug("Removing keychain unlock key directory", "directory", k.Directory)
Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := k.fs.RemoveAll(k.Directory); err != nil {
Debug("Failed to remove keychain unlock key directory", "error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlock key directory: %w", err)
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
}
Debug("Successfully removed keychain unlock key", "key_id", k.GetID(), "keychain_item", keychainItemName)
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
return nil
}
// NewKeychainUnlockKey creates a new KeychainUnlockKey instance
func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *KeychainUnlockKey {
return &KeychainUnlockKey{
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
@ -186,15 +186,15 @@ func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetad
}
// GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) {
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata
metadataPath := filepath.Join(k.Directory, "unlock-metadata.json")
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
}
var keychainMetadata KeychainUnlockKeyMetadata
var keychainMetadata KeychainUnlockerMetadata
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
}
@ -202,8 +202,8 @@ func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) {
return keychainMetadata.KeychainItemName, nil
}
// generateKeychainUnlockKeyName generates a unique name for the keychain unlock key
func generateKeychainUnlockKeyName(vaultName string) (string, error) {
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
@ -214,8 +214,8 @@ func generateKeychainUnlockKeyName(vaultName string) (string, error) {
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
}
// CreateKeychainUnlockKey creates a new keychain unlock key and stores it in the vault
func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey, error) {
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// Check if we're on macOS
if err := checkMacOSAvailable(); err != nil {
return nil, err
@ -228,23 +228,23 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
}
// Generate the keychain item name
keychainItemName, err := generateKeychainUnlockKeyName(vault.GetName())
keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
if err != nil {
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
}
// Create unlock key directory using the keychain item name as the directory name
// Create unlocker directory using the keychain item name as the directory name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", keychainItemName)
if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Step 1: Generate a new age keypair for the keychain unlock key
// Step 1: Generate a new age keypair for the keychain unlocker
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
@ -258,7 +258,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
// Step 3: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
agePubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err)
}
@ -270,7 +270,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age")
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
@ -287,61 +287,61 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlock key
currentUnlockKey, err := vault.GetCurrentUnlockKey()
// Get the vault to access current unlocker
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
// Get the current unlock key identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
// Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
}
// Get encrypted long-term key from current unlock key, handling different types
// Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) {
case *PassphraseUnlockKey:
// Read the encrypted long-term private key from passphrase unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
}
case *PGPUnlockKey:
// Read the encrypted long-term private key from PGP unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
}
case *KeychainUnlockKey:
// Read the encrypted long-term private key from another keychain unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlock key: %w", err)
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlock key type for keychain unlock key creation")
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
}
// Decrypt long-term private key using current unlock key
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
// Decrypt long-term private key using current unlocker
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Step 6: Encrypt long-term private key to the new age unlock key
// Step 6: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
@ -367,8 +367,8 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
// Generate the key ID directly using the keychain item name
keyID := fmt.Sprintf("%s-keychain", keychainItemName)
keychainMetadata := KeychainUnlockKeyMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{
keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
ID: keyID,
Type: "keychain",
CreatedAt: time.Now(),
@ -380,16 +380,16 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return &KeychainUnlockKey{
Directory: unlockKeyDir,
Metadata: keychainMetadata.UnlockKeyMetadata,
return &KeychainUnlocker{
Directory: unlockerDir,
Metadata: keychainMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
@ -398,7 +398,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
func checkMacOSAvailable() error {
cmd := exec.Command("/usr/bin/security", "help")
if err := cmd.Run(); err != nil {
return fmt.Errorf("macOS security command not available: %w (keychain unlock keys are only supported on macOS)", err)
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
}
return nil
}

View File

@ -14,8 +14,8 @@ type VaultMetadata struct {
MnemonicHash string `json:"mnemonic_hash"` // Double SHA256 hash of mnemonic for index tracking
}
// UnlockKeyMetadata contains information about an unlock key
type UnlockKeyMetadata struct {
// UnlockerMetadata contains information about an unlocker
type UnlockerMetadata struct {
ID string `json:"id"`
Type string `json:"type"` // passphrase, pgp, keychain
CreatedAt time.Time `json:"createdAt"`

View File

@ -12,7 +12,7 @@ import (
"github.com/spf13/afero"
)
func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// Skip this test if CI=true is set, as it uses real filesystem
if os.Getenv("CI") == "true" {
t.Skip("Skipping test with real filesystem in CI environment")
@ -33,21 +33,21 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
testPassphrase := "test-passphrase-123"
// Create the directory structure
keyDir := filepath.Join(tempDir, "unlock-key")
if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create key directory: %v", err)
unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err)
}
// Set up test metadata
metadata := secret.UnlockKeyMetadata{
metadata := secret.UnlockerMetadata{
ID: "test-passphrase",
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Create passphrase unlock key
unlockKey := secret.NewPassphraseUnlockKey(fs, keyDir, metadata)
// Create passphrase unlocker
unlocker := secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
// Generate a test age identity
ageIdentity, err := age.GenerateX25519Identity()
@ -59,7 +59,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Test writing public key
t.Run("WritePublicKey", func(t *testing.T) {
pubKeyPath := filepath.Join(keyDir, "pub.age")
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil {
t.Fatalf("Failed to write public key: %v", err)
}
@ -82,7 +82,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to encrypt private key: %v", err)
}
privKeyPath := filepath.Join(keyDir, "priv.age")
privKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
@ -105,7 +105,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to derive long-term identity: %v", err)
}
// Encrypt long-term private key to the unlock key's recipient
// Encrypt long-term private key to the unlocker's recipient
recipient, err := age.ParseX25519Recipient(agePublicKey)
if err != nil {
t.Fatalf("Failed to parse recipient: %v", err)
@ -117,7 +117,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to encrypt long-term private key: %v", err)
}
ltPrivKeyPath := filepath.Join(keyDir, "longterm.age")
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
}
@ -147,7 +147,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Test getting identity from environment variable
t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlockKey.GetIdentity()
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
}
@ -168,26 +168,26 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Here we'll just verify the error is what we expect when no passphrase is available
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
// This should fail since we're not in an interactive terminal
_, err := unlockKey.GetIdentity()
_, err := unlocker.GetIdentity()
if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var")
}
})
// Test removing the unlock key
t.Run("RemoveUnlockKey", func(t *testing.T) {
err := unlockKey.Remove()
// Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) {
err := unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlock key: %v", err)
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify the directory is gone
exists, err := afero.DirExists(fs, keyDir)
exists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err)
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if exists {
t.Errorf("Key directory should not exist after removal")
t.Errorf("Unlocker directory should not exist after removal")
}
})
}

View File

@ -1,150 +0,0 @@
package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"github.com/spf13/afero"
)
// PassphraseUnlockKey represents a passphrase-protected unlock key
type PassphraseUnlockKey struct {
Directory string
Metadata UnlockKeyMetadata
fs afero.Fs
Passphrase string
}
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys
func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// First check if we already have the passphrase
passphraseStr := p.Passphrase
if passphraseStr == "" {
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr = os.Getenv(EnvUnlockPassphrase)
if passphraseStr == "" {
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase
var err error
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
} else {
Debug("Using passphrase from environment", "key_id", p.GetID())
}
} else {
Debug("Using in-memory passphrase", "key_id", p.GetID())
}
// Read encrypted private key of unlock key
unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockKeyPrivPath)
if err != nil {
Debug("Failed to read passphrase unlock key private key", "error", err, "path", unlockKeyPrivPath)
return nil, fmt.Errorf("failed to read unlock key private key: %w", err)
}
DebugWith("Read encrypted passphrase unlock key",
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
// Decrypt the unlock key private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil {
Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)
}
DebugWith("Successfully decrypted unlock key private key",
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlock key identity", "key_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
if err != nil {
Debug("Failed to parse unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlock key private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements UnlockKey interface
func (p *PassphraseUnlockKey) GetType() string {
return "passphrase"
}
// GetMetadata implements UnlockKey interface
func (p *PassphraseUnlockKey) GetMetadata() UnlockKeyMetadata {
return p.Metadata
}
// GetDirectory implements UnlockKey interface
func (p *PassphraseUnlockKey) GetDirectory() string {
return p.Directory
}
// GetID implements UnlockKey interface
func (p *PassphraseUnlockKey) GetID() string {
return p.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from creation timestamp
func (p *PassphraseUnlockKey) ID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
}
// Remove implements UnlockKey interface - removes the passphrase unlock key
func (p *PassphraseUnlockKey) Remove() error {
// For passphrase keys, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove passphrase unlock key directory: %w", err)
}
return nil
}
// NewPassphraseUnlockKey creates a new PassphraseUnlockKey instance
func NewPassphraseUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PassphraseUnlockKey {
return &PassphraseUnlockKey{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreatePassphraseKey creates a new passphrase-protected unlock key
func CreatePassphraseKey(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlockKey, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseKey(passphrase)
}

View File

@ -0,0 +1,150 @@
package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"github.com/spf13/afero"
)
// PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
Passphrase string
}
// GetIdentity implements Unlocker interface for passphrase-based unlockers
func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
// First check if we already have the passphrase
passphraseStr := p.Passphrase
if passphraseStr == "" {
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr = os.Getenv(EnvUnlockPassphrase)
if passphraseStr == "" {
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase
var err error
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
} else {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
}
} else {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
}
// Read encrypted private key of unlocker
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlocker", "path", unlockerPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
if err != nil {
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
}
DebugWith("Read encrypted passphrase unlocker",
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
}
DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements Unlocker interface
func (p *PassphraseUnlocker) GetType() string {
return "passphrase"
}
// GetMetadata implements Unlocker interface
func (p *PassphraseUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements Unlocker interface
func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface
func (p *PassphraseUnlocker) GetID() string {
return p.Metadata.ID
}
// ID implements Unlocker interface - generates ID from creation timestamp
func (p *PassphraseUnlocker) ID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
}
// Remove implements Unlocker interface - removes the passphrase unlocker
func (p *PassphraseUnlocker) Remove() error {
// For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
}
return nil
}
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
return &PassphraseUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseUnlocker(passphrase)
}

View File

@ -123,7 +123,7 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
return stdout.Bytes(), nil
}
func TestPGPUnlockKeyWithRealFS(t *testing.T) {
func TestPGPUnlockerWithRealFS(t *testing.T) {
// Skip tests if gpg is not available
if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("GPG not available, skipping PGP unlock key tests")
@ -258,7 +258,7 @@ Passphrase: ` + testPassphrase + `
vaultName := "test-vault"
// Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlockKey", func(t *testing.T) {
t.Run("CreatePGPUnlocker", func(t *testing.T) {
// Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
@ -298,50 +298,50 @@ Passphrase: ` + testPassphrase + `
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlock key first (to have current unlock key)
passKey, err := vlt.CreatePassphraseKey("test-passphrase")
// Create a passphrase unlocker first (to have current unlocker)
passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
if err != nil {
t.Fatalf("Failed to create passphrase key: %v", err)
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase key was created
if passKey == nil {
t.Fatal("Passphrase key is nil")
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpKey, err := secret.CreatePGPUnlockKey(fs, stateDir, keyID)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpKey == nil {
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpKey.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpKey.GetType())
if pgpUnlocker.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check if the key ID includes the GPG key ID
if !strings.Contains(pgpKey.GetID(), keyID) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpKey.GetID(), keyID)
if !strings.Contains(pgpUnlocker.GetID(), keyID) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpUnlocker.GetID(), keyID)
}
// Check if the key directory exists
keyDir := pgpKey.GetDirectory()
keyExists, err := afero.DirExists(fs, keyDir)
unlockerDir := pgpUnlocker.GetDirectory()
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", keyDir)
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
pubKeyPath := filepath.Join(keyDir, "pub.age")
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
pubKeyExists, err := afero.Exists(fs, pubKeyPath)
if err != nil {
t.Fatalf("Failed to check if public key file exists: %v", err)
@ -350,7 +350,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key public key file does not exist: %s", pubKeyPath)
}
privKeyPath := filepath.Join(keyDir, "priv.age.gpg")
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
@ -359,7 +359,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(keyDir, "unlock-metadata.json")
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
@ -368,7 +368,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(keyDir, "longterm.age")
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
@ -405,37 +405,37 @@ Passphrase: ` + testPassphrase + `
})
// Set up key directory for individual tests
keyDir := filepath.Join(tempDir, "unlock-key")
if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create key directory: %v", err)
unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err)
}
// Set up test metadata
metadata := secret.UnlockKeyMetadata{
metadata := secret.UnlockerMetadata{
ID: fmt.Sprintf("%s-pgp", keyID),
Type: "pgp",
CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"},
}
// Create a PGP unlock key for the remaining tests
unlockKey := secret.NewPGPUnlockKey(fs, keyDir, metadata)
// Create a PGP unlocker for the remaining tests
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) {
// Create PGP metadata with GPG key ID
type PGPUnlockKeyMetadata struct {
secret.UnlockKeyMetadata
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpg_key_id"`
}
pgpMetadata := PGPUnlockKeyMetadata{
UnlockKeyMetadata: metadata,
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: keyID,
}
// Write metadata file
metadataPath := filepath.Join(keyDir, "unlock-metadata.json")
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
@ -445,7 +445,7 @@ Passphrase: ` + testPassphrase + `
}
// Get GPG key ID
retrievedKeyID, err := unlockKey.GetGPGKeyID()
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
@ -456,7 +456,7 @@ Passphrase: ` + testPassphrase + `
}
})
// Test getting identity from PGP unlock key
// Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) {
// Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
@ -465,7 +465,7 @@ Passphrase: ` + testPassphrase + `
}
// Write the public key
pubKeyPath := filepath.Join(keyDir, "pub.age")
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, pubKeyPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write public key: %v", err)
}
@ -478,13 +478,13 @@ Passphrase: ` + testPassphrase + `
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(keyDir, "priv.age.gpg")
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlockKey.GetIdentity()
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
@ -497,30 +497,30 @@ Passphrase: ` + testPassphrase + `
}
})
// Test removing the unlock key
t.Run("RemoveUnlockKey", func(t *testing.T) {
// Ensure key directory exists before removal
keyExists, err := afero.DirExists(fs, keyDir)
// Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) {
// Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err)
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Key directory does not exist: %s", keyDir)
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlock key
err = unlockKey.Remove()
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlock key: %v", err)
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, keyDir)
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err)
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Key directory still exists after removal: %s", keyDir)
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
})
}

View File

@ -26,9 +26,9 @@ var (
GPGDecryptFunc = gpgDecryptDefault
)
// PGPUnlockKeyMetadata extends UnlockKeyMetadata with PGP-specific data
type PGPUnlockKeyMetadata struct {
UnlockKeyMetadata
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
type PGPUnlockerMetadata struct {
UnlockerMetadata
// GPG key ID used for encryption
GPGKeyID string `json:"gpg_key_id"`
// Age keypair information
@ -36,18 +36,18 @@ type PGPUnlockKeyMetadata struct {
AgeRecipient string `json:"age_recipient"`
}
// PGPUnlockKey represents a PGP-protected unlock key
type PGPUnlockKey struct {
// PGPUnlocker represents a PGP-protected unlocker
type PGPUnlocker struct {
Directory string
Metadata UnlockKeyMetadata
Metadata UnlockerMetadata
fs afero.Fs
}
// GetIdentity implements UnlockKey interface for PGP-based unlock keys
func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting PGP unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
// GetIdentity implements Unlocker interface for PGP-based unlockers
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting PGP unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
// Step 1: Read the encrypted age private key from filesystem
@ -61,61 +61,61 @@ func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) {
}
DebugWith("Read PGP-encrypted age private key",
slog.String("key_id", p.GetID()),
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 2: Decrypt the age private key using GPG
Debug("Decrypting age private key with GPG", "key_id", p.GetID())
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
if err != nil {
Debug("Failed to decrypt age private key with GPG", "error", err, "key_id", p.GetID())
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
}
DebugWith("Successfully decrypted age private key with GPG",
slog.String("key_id", p.GetID()),
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", p.GetID())
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", p.GetID())
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed PGP age identity",
slog.String("key_id", p.GetID()),
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements UnlockKey interface
func (p *PGPUnlockKey) GetType() string {
// GetType implements Unlocker interface
func (p *PGPUnlocker) GetType() string {
return "pgp"
}
// GetMetadata implements UnlockKey interface
func (p *PGPUnlockKey) GetMetadata() UnlockKeyMetadata {
// GetMetadata implements Unlocker interface
func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements UnlockKey interface
func (p *PGPUnlockKey) GetDirectory() string {
// GetDirectory implements Unlocker interface
func (p *PGPUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements UnlockKey interface
func (p *PGPUnlockKey) GetID() string {
// GetID implements Unlocker interface
func (p *PGPUnlocker) GetID() string {
return p.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from GPG key ID
func (p *PGPUnlockKey) ID() string {
// ID implements Unlocker interface - generates ID from GPG key ID
func (p *PGPUnlocker) ID() string {
// Generate ID using GPG key ID: <keyid>-pgp
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
@ -125,19 +125,19 @@ func (p *PGPUnlockKey) ID() string {
return fmt.Sprintf("%s-pgp", gpgKeyID)
}
// Remove implements UnlockKey interface - removes the PGP unlock key
func (p *PGPUnlockKey) Remove() error {
// For PGP keys, we just need to remove the directory
// Remove implements Unlocker interface - removes the PGP unlocker
func (p *PGPUnlocker) Remove() error {
// For PGP unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove PGP unlock key directory: %w", err)
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
}
return nil
}
// NewPGPUnlockKey creates a new PGPUnlockKey instance
func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PGPUnlockKey {
return &PGPUnlockKey{
// NewPGPUnlocker creates a new PGPUnlocker instance
func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
return &PGPUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
@ -145,15 +145,15 @@ func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata)
}
// GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlockKey) GetGPGKeyID() (string, error) {
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
// Load the metadata
metadataPath := filepath.Join(p.Directory, "unlock-metadata.json")
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
}
var pgpMetadata PGPUnlockKeyMetadata
var pgpMetadata PGPUnlockerMetadata
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
@ -161,8 +161,8 @@ func (p *PGPUnlockKey) GetGPGKeyID() (string, error) {
return pgpMetadata.GPGKeyID, nil
}
// generatePGPUnlockKeyName generates a unique name for the PGP unlock key based on hostname and date
func generatePGPUnlockKeyName() (string, error) {
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
@ -173,8 +173,8 @@ func generatePGPUnlockKeyName() (string, error) {
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
}
// CreatePGPUnlockKey creates a new PGP unlock key and stores it in the vault
func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlockKey, error) {
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
// Check if GPG is available
if err := checkGPGAvailable(); err != nil {
return nil, err
@ -186,24 +186,24 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the unlock key name based on hostname and date
unlockKeyName, err := generatePGPUnlockKeyName()
// Generate the unlocker name based on hostname and date
unlockerName, err := generatePGPUnlockerName()
if err != nil {
return nil, fmt.Errorf("failed to generate unlock key name: %w", err)
return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
}
// Create unlock key directory using the generated name
// Create unlocker directory using the generated name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName)
if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Step 1: Generate a new age keypair for the PGP unlock key
// Step 1: Generate a new age keypair for the PGP unlocker
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
@ -211,7 +211,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
// Step 2: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
agePubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err)
}
@ -228,54 +228,54 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlock key
currentUnlockKey, err := vault.GetCurrentUnlockKey()
// Get the vault to access current unlocker
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
// Get the current unlock key identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
// Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
}
// Get encrypted long-term key from current unlock key, handling different types
// Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) {
case *PassphraseUnlockKey:
// Read the encrypted long-term private key from passphrase unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
}
case *PGPUnlockKey:
// Read the encrypted long-term private key from PGP unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation")
return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
}
// Step 6: Decrypt long-term private key using current unlock key
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
// Step 6: Decrypt long-term private key using current unlocker
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Step 7: Encrypt long-term private key to the new age unlock key
// Step 7: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
@ -287,7 +287,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
}
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg")
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
@ -296,8 +296,8 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
// Generate the key ID directly using the GPG key ID
keyID := fmt.Sprintf("%s-pgp", gpgKeyID)
pgpMetadata := PGPUnlockKeyMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
ID: keyID,
Type: "pgp",
CreatedAt: time.Now(),
@ -310,16 +310,16 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
}
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return &PGPUnlockKey{
Directory: unlockKeyDir,
Metadata: pgpMetadata.UnlockKeyMetadata,
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}

View File

@ -1,7 +1,6 @@
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
@ -20,8 +19,8 @@ type VaultInterface interface {
AddSecret(name string, value []byte, force bool) error
GetName() string
GetFilesystem() afero.Fs
GetCurrentUnlockKey() (UnlockKey, error)
CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error)
GetCurrentUnlocker() (Unlocker, error)
CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error)
}
// Secret represents a secret in a vault
@ -62,9 +61,10 @@ func NewSecret(vault VaultInterface, name string) *Secret {
}
}
// Save saves a secret value to the vault
// Save is deprecated - use vault.AddSecret directly which creates versions
// Kept for backward compatibility
func (s *Secret) Save(value []byte, force bool) error {
DebugWith("Saving secret",
DebugWith("Saving secret (deprecated method)",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
slog.Int("value_length", len(value)),
@ -81,8 +81,8 @@ func (s *Secret) Save(value []byte, force bool) error {
return nil
}
// GetValue retrieves and decrypts the secret value using the provided unlock key
func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
// GetValue retrieves and decrypts the current version's value using the provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
@ -99,7 +99,17 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
return nil, fmt.Errorf("secret %s not found", s.Name)
}
Debug("Secret exists, proceeding with decryption", "secret_name", s.Name)
Debug("Secret exists, getting current version", "secret_name", s.Name)
// Get current version
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
// Create version object
version := NewSecretVersion(s.vault, s.Name, currentVersion)
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
@ -114,33 +124,33 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
// Use the long-term key to decrypt the secret using per-secret architecture
return s.decryptWithLongTermKey(ltIdentity)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
Debug("Using unlock key for vault access", "secret_name", s.Name)
Debug("Using unlocker for vault access", "secret_name", s.Name)
// Use the provided unlock key to get the vault's long-term private key
if unlockKey == nil {
Debug("No unlock key provided for secret decryption", "secret_name", s.Name)
return nil, fmt.Errorf("unlock key required to decrypt secret")
// Use the provided unlocker to get the vault's long-term private key
if unlocker == nil {
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
return nil, fmt.Errorf("unlocker required to decrypt secret")
}
DebugWith("Getting vault's long-term key using unlock key",
DebugWith("Getting vault's long-term key using unlocker",
slog.String("secret_name", s.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Step 1: Use the unlock key to get the vault's long-term private key
unlockIdentity, err := unlockKey.GetIdentity()
// Step 1: Use the unlocker to get the vault's long-term private key
unlockIdentity, err := unlocker.GetIdentity()
if err != nil {
Debug("Failed to get unlock key identity", "error", err, "secret_name", s.Name, "unlock_key_type", unlockKey.GetType())
return nil, fmt.Errorf("failed to get unlock key identity: %w", err)
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read the encrypted long-term private key from the unlock key directory
encryptedLtPrivKeyPath := filepath.Join(unlockKey.GetDirectory(), "longterm.age")
// Read the encrypted long-term private key from the unlocker directory
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
@ -149,8 +159,8 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
// Decrypt the encrypted long-term private key using the unlock key
Debug("Decrypting long-term private key using unlock key", "secret_name", s.Name)
// Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
@ -170,165 +180,33 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Use the long-term key to decrypt the secret using per-secret architecture
return s.decryptWithLongTermKey(ltIdentity)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
// decryptWithLongTermKey decrypts the secret using the vault's long-term private key
// This implements the per-secret key architecture: longterm -> secret private key -> secret value
func (s *Secret) decryptWithLongTermKey(ltIdentity *age.X25519Identity) ([]byte, error) {
DebugWith("Decrypting secret with long-term key using per-secret architecture",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// Step 1: Read the secret's encrypted private key from priv.age
encryptedSecretPrivKeyPath := filepath.Join(s.Directory, "priv.age")
Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
encryptedSecretPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedSecretPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
}
DebugWith("Read encrypted secret private key",
slog.String("secret_name", s.Name),
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
)
// Step 2: Decrypt the secret's private key using the vault's long-term private key
Debug("Decrypting secret private key using long-term key", "secret_name", s.Name)
secretPrivKeyData, err := DecryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt secret private key", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
}
// Parse the secret's private key
Debug("Parsing secret's private key", "secret_name", s.Name)
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
if err != nil {
Debug("Failed to parse secret's private key", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to parse secret's private key: %w", err)
}
DebugWith("Successfully decrypted and parsed secret's identity",
slog.String("secret_name", s.Name),
slog.String("secret_public_key", secretIdentity.Recipient().String()),
)
// Step 3: Read the secret's encrypted value from value.age
encryptedValuePath := filepath.Join(s.Directory, "value.age")
Debug("Reading encrypted secret value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
}
DebugWith("Read encrypted secret value",
slog.String("secret_name", s.Name),
slog.Int("encrypted_length", len(encryptedValue)),
)
// Step 4: Decrypt the secret's value using the secret's private key
Debug("Decrypting value using secret key", "secret_name", s.Name)
decryptedValue, err := DecryptWithIdentity(encryptedValue, secretIdentity)
if err != nil {
Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
}
DebugWith("Successfully decrypted secret value using per-secret key architecture",
slog.String("secret_name", s.Name),
slog.Int("decrypted_length", len(decryptedValue)),
)
return decryptedValue, nil
}
// LoadMetadata loads the secret metadata from disk
// LoadMetadata is deprecated - metadata is now per-version and encrypted
func (s *Secret) LoadMetadata() error {
DebugWith("Loading secret metadata",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
vaultDir, err := s.vault.GetDirectory()
if err != nil {
Debug("Failed to get vault directory for metadata loading", "error", err, "secret_name", s.Name)
return err
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
// For backward compatibility, we'll populate with basic info
now := time.Now()
s.Metadata = SecretMetadata{
Name: s.Name,
CreatedAt: now,
UpdatedAt: now,
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(s.Name, "/", "%")
metadataPath := filepath.Join(vaultDir, "secrets.d", storageName, "secret-metadata.json")
DebugWith("Reading secret metadata",
slog.String("secret_name", s.Name),
slog.String("metadata_path", metadataPath),
)
// Read metadata file
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
if err != nil {
Debug("Failed to read secret metadata file", "error", err, "metadata_path", metadataPath)
return fmt.Errorf("failed to read metadata: %w", err)
}
DebugWith("Read secret metadata file",
slog.String("secret_name", s.Name),
slog.Int("metadata_size", len(metadataBytes)),
)
var metadata SecretMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
Debug("Failed to parse secret metadata JSON", "error", err, "secret_name", s.Name)
return fmt.Errorf("failed to parse metadata: %w", err)
}
DebugWith("Parsed secret metadata",
slog.String("secret_name", metadata.Name),
slog.Time("created_at", metadata.CreatedAt),
slog.Time("updated_at", metadata.UpdatedAt),
)
s.Metadata = metadata
Debug("Successfully loaded secret metadata", "secret_name", s.Name)
return nil
}
// GetMetadata returns the secret metadata
// GetMetadata returns the secret metadata (deprecated)
func (s *Secret) GetMetadata() SecretMetadata {
Debug("Returning secret metadata", "secret_name", s.Name)
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
return s.Metadata
}
// GetEncryptedData reads and returns the encrypted secret data
// GetEncryptedData is deprecated - data is now stored in versions
func (s *Secret) GetEncryptedData() ([]byte, error) {
DebugWith("Getting encrypted secret data",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
secretPath := filepath.Join(s.Directory, "value.age")
Debug("Reading encrypted secret file", "secret_path", secretPath)
encryptedData, err := afero.ReadFile(s.vault.GetFilesystem(), secretPath)
if err != nil {
Debug("Failed to read encrypted secret file", "error", err, "secret_path", secretPath)
return nil, fmt.Errorf("failed to read encrypted secret: %w", err)
}
DebugWith("Successfully read encrypted secret data",
slog.String("secret_name", s.Name),
slog.Int("encrypted_length", len(encryptedData)),
)
return encryptedData, nil
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
}
// Exists checks if the secret exists on disk
@ -338,22 +216,31 @@ func (s *Secret) Exists() (bool, error) {
slog.String("vault_name", s.vault.GetName()),
)
secretPath := filepath.Join(s.Directory, "value.age")
Debug("Checking secret file existence", "secret_path", secretPath)
exists, err := afero.Exists(s.vault.GetFilesystem(), secretPath)
// Check if the secret directory exists and has a current symlink
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to check secret file existence", "error", err, "secret_path", secretPath)
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
return false, err
}
if !exists {
Debug("Secret directory does not exist", "secret_dir", s.Directory)
return false, nil
}
// Check if current symlink exists
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("No current version found", "error", err, "secret_name", s.Name)
return false, nil
}
DebugWith("Secret existence check result",
slog.String("secret_name", s.Name),
slog.Bool("exists", exists),
slog.Bool("exists", true),
)
return exists, nil
return true, nil
}
// GetCurrentVault gets the current vault from the file system

View File

@ -3,6 +3,7 @@ package secret
import (
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
@ -23,12 +24,33 @@ func (m *MockVault) GetDirectory() (string, error) {
}
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
// Simplified implementation for testing
secretDir := filepath.Join(m.directory, "secrets.d", name)
if err := m.fs.MkdirAll(secretDir, DirPerms); err != nil {
// Create versioned structure for testing
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
// Generate version name
versionName, err := GenerateVersionName(m.fs, secretDir)
if err != nil {
return err
}
return afero.WriteFile(m.fs, filepath.Join(secretDir, "value.age"), value, FilePerms)
// Create version directory
versionDir := filepath.Join(secretDir, "versions", versionName)
if err := m.fs.MkdirAll(versionDir, DirPerms); err != nil {
return err
}
// Write encrypted value (simplified for testing)
if err := afero.WriteFile(m.fs, filepath.Join(versionDir, "value.age"), value, FilePerms); err != nil {
return err
}
// Set current symlink
if err := SetCurrentVersion(m.fs, secretDir, versionName); err != nil {
return err
}
return nil
}
func (m *MockVault) GetName() string {
@ -39,11 +61,11 @@ func (m *MockVault) GetFilesystem() afero.Fs {
return m.fs
}
func (m *MockVault) GetCurrentUnlockKey() (UnlockKey, error) {
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, nil // Not needed for this test
}
func (m *MockVault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) {
func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
return nil, nil // Not needed for this test
}
@ -122,16 +144,30 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
// Verify that all expected files were created
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
// Check value.age exists (the new per-secret key architecture format)
secretExists, err := afero.Exists(
fs,
filepath.Join(secretDir, "value.age"),
)
if err != nil || !secretExists {
t.Fatalf("value.age file was not created")
// Check versions directory exists
versionsDir := filepath.Join(secretDir, "versions")
versionsDirExists, err := afero.DirExists(fs, versionsDir)
if err != nil || !versionsDirExists {
t.Fatalf("versions directory was not created")
}
t.Logf("All expected files created successfully")
// Check current symlink exists
currentVersion, err := GetCurrentVersion(fs, secretDir)
if err != nil {
t.Fatalf("Failed to get current version: %v", err)
}
// Check value.age exists in the version directory
versionDir := filepath.Join(versionsDir, currentVersion)
valueExists, err := afero.Exists(
fs,
filepath.Join(versionDir, "value.age"),
)
if err != nil || !valueExists {
t.Fatalf("value.age file was not created in version directory")
}
t.Logf("All expected files created successfully with versioning")
})
// Create a Secret object to test with

View File

@ -1,16 +0,0 @@
package secret
import (
"filippo.io/age"
)
// UnlockKey interface defines the methods all unlock key types must implement
type UnlockKey interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockKeyMetadata
GetDirectory() string
GetID() string
ID() string // Generate ID from the key's public key
Remove() error // Remove the unlock key and any associated resources
}

View File

@ -0,0 +1,16 @@
package secret
import (
"filippo.io/age"
)
// Unlocker interface defines the methods all unlocker types must implement
type Unlocker interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockerMetadata
GetDirectory() string
GetID() string
ID() string // Generate ID from the unlocker's public key
Remove() error // Remove the unlocker and any associated resources
}

424
internal/secret/version.go Normal file
View File

@ -0,0 +1,424 @@
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"filippo.io/age"
"github.com/oklog/ulid/v2"
"github.com/spf13/afero"
)
// VersionMetadata contains information about a secret version
type VersionMetadata struct {
ID string `json:"id"` // ULID
SecretName string `json:"secretName"` // Parent secret name
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
Version string `json:"version"` // Version string (e.g., "20231215.001")
}
// SecretVersion represents a version of a secret
type SecretVersion struct {
SecretName string
Version string
Directory string
Metadata VersionMetadata
vault VaultInterface
}
// NewSecretVersion creates a new SecretVersion instance
func NewSecretVersion(vault VaultInterface, secretName string, version string) *SecretVersion {
DebugWith("Creating new secret version instance",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("vault_name", vault.GetName()),
)
vaultDir, _ := vault.GetDirectory()
storageName := strings.ReplaceAll(secretName, "/", "%")
versionDir := filepath.Join(vaultDir, "secrets.d", storageName, "versions", version)
DebugWith("Secret version storage details",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("version_dir", versionDir),
)
now := time.Now()
return &SecretVersion{
SecretName: secretName,
Version: version,
Directory: versionDir,
vault: vault,
Metadata: VersionMetadata{
ID: ulid.Make().String(),
SecretName: secretName,
CreatedAt: &now,
Version: version,
},
}
}
// GenerateVersionName generates a new version name in YYYYMMDD.NNN format
func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
today := time.Now().Format("20060102")
versionsDir := filepath.Join(secretDir, "versions")
// Ensure versions directory exists
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
return "", fmt.Errorf("failed to create versions directory: %w", err)
}
// Find the highest serial number for today
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return "", fmt.Errorf("failed to read versions directory: %w", err)
}
maxSerial := 0
prefix := today + "."
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
// Extract serial number
parts := strings.Split(entry.Name(), ".")
if len(parts) == 2 {
var serial int
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
if serial > maxSerial {
maxSerial = serial
}
}
}
}
}
// Generate new version name
newSerial := maxSerial + 1
if newSerial > 999 {
return "", fmt.Errorf("exceeded maximum versions per day (999)")
}
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value
func (sv *SecretVersion) Save(value []byte) error {
DebugWith("Saving secret version",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
slog.Int("value_length", len(value)),
)
fs := sv.vault.GetFilesystem()
// Create version directory
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
}
// Step 1: Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
return fmt.Errorf("failed to generate version keypair: %w", err)
}
versionPublicKey := versionIdentity.Recipient().String()
versionPrivateKey := versionIdentity.String()
DebugWith("Generated version keypair",
slog.String("version", sv.Version),
slog.String("public_key", versionPublicKey),
)
// Step 2: Store the version's public key
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// Step 3: Encrypt the value to the version's public key
Debug("Encrypting value to version's public key", "version", sv.Version)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version value: %w", err)
}
// Step 4: Store the encrypted value
valuePath := filepath.Join(sv.Directory, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
// Step 5: Get vault's long-term public key for encrypting the version's private key
vaultDir, _ := sv.vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
if err != nil {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
}
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
if err != nil {
Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
}
// Step 6: Encrypt the version's private key to the long-term public key
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
encryptedPrivKey, err := EncryptToRecipient([]byte(versionPrivateKey), ltRecipient)
if err != nil {
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
// Step 7: Store the encrypted private key
privKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted version private key: %w", err)
}
// Step 8: Encrypt and store metadata
Debug("Encrypting version metadata", "version", sv.Version)
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
if err != nil {
Debug("Failed to marshal version metadata", "error", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
encryptedMetadata, err := EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
return nil
}
// LoadMetadata loads and decrypts the version metadata
func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
DebugWith("Loading version metadata",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Step 2: Decrypt version private key using long-term key
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted metadata
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
if err != nil {
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
}
// Step 5: Decrypt metadata using version key
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
if err != nil {
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version metadata: %w", err)
}
// Step 6: Unmarshal metadata
var metadata VersionMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
}
sv.Metadata = metadata
Debug("Successfully loaded version metadata", "version", sv.Version)
return nil
}
// GetValue retrieves and decrypts the version value
func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
DebugWith("Getting version value",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Step 2: Decrypt version private key using long-term key
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted value
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
}
// Step 5: Decrypt value using version key
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
}
Debug("Successfully retrieved version value", "version", sv.Version, "value_length", len(value))
return value, nil
}
// ListVersions lists all versions of a secret
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
versionsDir := filepath.Join(secretDir, "versions")
// Check if versions directory exists
exists, err := afero.DirExists(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to check versions directory: %w", err)
}
if !exists {
return []string{}, nil
}
// List all version directories
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to read versions directory: %w", err)
}
var versions []string
for _, entry := range entries {
if entry.IsDir() {
versions = append(versions, entry.Name())
}
}
// Sort versions in reverse chronological order
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
return versions, nil
}
// GetCurrentVersion returns the version that the "current" symlink points to
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current")
// Try to read as a real symlink first
if _, ok := fs.(*afero.OsFs); ok {
target, err := os.Readlink(currentPath)
if err == nil {
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
}
}
// Fall back to reading as a file (for MemMapFs testing)
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
return "", fmt.Errorf("failed to read current version symlink: %w", err)
}
target := strings.TrimSpace(string(fileData))
// Extract version from path
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
}
// SetCurrentVersion updates the "current" symlink to point to a specific version
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
targetPath := filepath.Join("versions", version)
// Remove existing symlink if it exists
_ = fs.Remove(currentPath)
// Try to create a real symlink first (works on Unix systems)
if _, ok := fs.(*afero.OsFs); ok {
if err := os.Symlink(targetPath, currentPath); err == nil {
return nil
}
}
// Fall back to creating a file with the target path (for MemMapFs testing)
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
return fmt.Errorf("failed to create current version symlink: %w", err)
}
return nil
}

View File

@ -0,0 +1,333 @@
package secret
import (
"fmt"
"path/filepath"
"testing"
"time"
"filippo.io/age"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// MockVault implements VaultInterface for testing
type MockVersionVault struct {
Name string
fs afero.Fs
stateDir string
longTermKey *age.X25519Identity
}
func (m *MockVersionVault) GetDirectory() (string, error) {
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
}
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
return fmt.Errorf("not implemented in mock")
}
func (m *MockVersionVault) GetName() string {
return m.Name
}
func (m *MockVersionVault) GetFilesystem() afero.Fs {
return m.fs
}
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, fmt.Errorf("not implemented in mock")
}
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
return nil, fmt.Errorf("not implemented in mock")
}
func TestGenerateVersionName(t *testing.T) {
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
// Test first version generation
version1, err := GenerateVersionName(fs, secretDir)
require.NoError(t, err)
assert.Regexp(t, `^\d{8}\.001$`, version1)
// Create the version directory
versionDir := filepath.Join(secretDir, "versions", version1)
err = fs.MkdirAll(versionDir, 0755)
require.NoError(t, err)
// Test second version generation on same day
version2, err := GenerateVersionName(fs, secretDir)
require.NoError(t, err)
assert.Regexp(t, `^\d{8}\.002$`, version2)
// Verify they have the same date prefix
assert.Equal(t, version1[:8], version2[:8])
assert.NotEqual(t, version1, version2)
}
func TestGenerateVersionNameMaxSerial(t *testing.T) {
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
versionsDir := filepath.Join(secretDir, "versions")
// Create 999 versions
today := time.Now().Format("20060102")
for i := 1; i <= 999; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0755)
require.NoError(t, err)
}
// Try to create one more - should fail
_, err := GenerateVersionName(fs, secretDir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
}
func TestNewSecretVersion(t *testing.T) {
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: "test",
fs: fs,
stateDir: "/test",
}
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
assert.Equal(t, "test/secret", sv.SecretName)
assert.Equal(t, "20231215.001", sv.Version)
assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001")
assert.NotEmpty(t, sv.Metadata.ID)
assert.NotNil(t, sv.Metadata.CreatedAt)
assert.Equal(t, "20231215.001", sv.Metadata.Version)
}
func TestSecretVersionSave(t *testing.T) {
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: "test",
fs: fs,
stateDir: "/test",
}
// Create vault directory structure and long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0755)
require.NoError(t, err)
// Generate and store long-term public key
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Create and save a version
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
testValue := []byte("test-secret-value")
err = sv.Save(testValue)
require.NoError(t, err)
// Verify files were created
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age")))
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age")))
}
func TestSecretVersionLoadMetadata(t *testing.T) {
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: "test",
fs: fs,
stateDir: "/test",
}
// Setup vault with long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0755)
require.NoError(t, err)
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Create and save a version with custom metadata
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
now := time.Now()
epochPlusOne := time.Unix(1, 0)
sv.Metadata.NotBefore = &epochPlusOne
sv.Metadata.NotAfter = &now
err = sv.Save([]byte("test-value"))
require.NoError(t, err)
// Create new version object and load metadata
sv2 := NewSecretVersion(vault, "test/secret", "20231215.001")
err = sv2.LoadMetadata(ltIdentity)
require.NoError(t, err)
// Verify loaded metadata
assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID)
assert.Equal(t, sv.Metadata.SecretName, sv2.Metadata.SecretName)
assert.Equal(t, sv.Metadata.Version, sv2.Metadata.Version)
assert.NotNil(t, sv2.Metadata.NotBefore)
assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix())
assert.NotNil(t, sv2.Metadata.NotAfter)
}
func TestSecretVersionGetValue(t *testing.T) {
fs := afero.NewMemMapFs()
vault := &MockVersionVault{
Name: "test",
fs: fs,
stateDir: "/test",
}
// Setup vault with long-term key
vaultDir, _ := vault.GetDirectory()
err := fs.MkdirAll(vaultDir, 0755)
require.NoError(t, err)
ltIdentity, err := age.GenerateX25519Identity()
require.NoError(t, err)
vault.longTermKey = ltIdentity
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Create and save a version
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
originalValue := []byte("test-secret-value-12345")
err = sv.Save(originalValue)
require.NoError(t, err)
// Retrieve the value
retrievedValue, err := sv.GetValue(ltIdentity)
require.NoError(t, err)
assert.Equal(t, originalValue, retrievedValue)
}
func TestListVersions(t *testing.T) {
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
versionsDir := filepath.Join(secretDir, "versions")
// No versions directory
versions, err := ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Empty(t, versions)
// Create some versions
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
for _, v := range testVersions {
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0755)
require.NoError(t, err)
}
// Create a file (not directory) that should be ignored
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0600)
require.NoError(t, err)
// List versions
versions, err = ListVersions(fs, secretDir)
require.NoError(t, err)
// Should be sorted in reverse chronological order
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
assert.Equal(t, expected, versions)
}
func TestGetCurrentVersion(t *testing.T) {
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
// Simulate symlink with file content (works for both OsFs and MemMapFs)
currentPath := filepath.Join(secretDir, "current")
err := fs.MkdirAll(secretDir, 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0600)
require.NoError(t, err)
version, err := GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231216.001", version)
}
func TestSetCurrentVersion(t *testing.T) {
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
err := fs.MkdirAll(secretDir, 0755)
require.NoError(t, err)
// Set current version
err = SetCurrentVersion(fs, secretDir, "20231216.002")
require.NoError(t, err)
// Verify it was set
version, err := GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231216.002", version)
// Update to different version
err = SetCurrentVersion(fs, secretDir, "20231217.001")
require.NoError(t, err)
version, err = GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, "20231217.001", version)
}
func TestVersionMetadataTimestamps(t *testing.T) {
// Test that all timestamp fields behave consistently as pointers
vm := VersionMetadata{
ID: "test-id",
SecretName: "test/secret",
Version: "20231215.001",
}
// All should be nil initially
assert.Nil(t, vm.CreatedAt)
assert.Nil(t, vm.NotBefore)
assert.Nil(t, vm.NotAfter)
// Set timestamps
now := time.Now()
epoch := time.Unix(1, 0)
future := now.Add(time.Hour)
vm.CreatedAt = &now
vm.NotBefore = &epoch
vm.NotAfter = &future
// All should be non-nil
assert.NotNil(t, vm.CreatedAt)
assert.NotNil(t, vm.NotBefore)
assert.NotNil(t, vm.NotAfter)
// Values should match
assert.Equal(t, now.Unix(), vm.CreatedAt.Unix())
assert.Equal(t, int64(1), vm.NotBefore.Unix())
assert.Equal(t, future.Unix(), vm.NotAfter.Unix())
}
// Helper function
func fileExists(fs afero.Fs, path string) bool {
exists, _ := afero.Exists(fs, path)
return exists
}

View File

@ -0,0 +1,322 @@
package vault
import (
"fmt"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVersionIntegrationWorkflow tests the complete version workflow
func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault
vault, err := CreateVault(fs, stateDir, "test")
require.NoError(t, err)
// Derive and store long-term key from mnemonic
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Unlock the vault
vault.Unlock(ltIdentity)
secretName := "integration/test"
// Step 1: Create initial version
t.Run("create_initial_version", func(t *testing.T) {
err := vault.AddSecret(secretName, []byte("version-1-data"), false)
require.NoError(t, err)
// Verify secret can be retrieved
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
// Verify version directory structure
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 1)
// Verify current symlink exists
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
require.NoError(t, err)
assert.Equal(t, versions[0], currentVersion)
// Verify metadata
version := secret.NewSecretVersion(vault, secretName, versions[0])
err = version.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, version.Metadata.CreatedAt)
assert.NotNil(t, version.Metadata.NotBefore)
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version
})
// Step 2: Create second version
var firstVersionName string
t.Run("create_second_version", func(t *testing.T) {
// Small delay to ensure different timestamps
time.Sleep(10 * time.Millisecond)
// Get first version name before creating second
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
firstVersionName = versions[0]
// Create second version
err = vault.AddSecret(secretName, []byte("version-2-data"), true)
require.NoError(t, err)
// Verify new value is current
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value)
// Verify we now have two versions
versions, err = secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 2)
// Verify first version metadata was updated with notAfter
firstVersion := secret.NewSecretVersion(vault, secretName, firstVersionName)
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, firstVersion.Metadata.NotAfter)
// Verify second version metadata
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
err = secondVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, secondVersion.Metadata.NotBefore)
assert.Nil(t, secondVersion.Metadata.NotAfter)
// NotBefore of second should equal NotAfter of first
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix())
})
// Step 3: Create third version
t.Run("create_third_version", func(t *testing.T) {
time.Sleep(10 * time.Millisecond)
err := vault.AddSecret(secretName, []byte("version-3-data"), true)
require.NoError(t, err)
// Verify we now have three versions
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Len(t, versions, 3)
// Current should be version-3
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value)
})
// Step 4: Retrieve specific versions
t.Run("retrieve_specific_versions", func(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 3)
// Get each version by its name
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value1)
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value2)
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value3)
// Empty version should return current
valueCurrent, err := vault.GetSecretVersion(secretName, "")
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), valueCurrent)
})
// Step 5: Promote old version to current
t.Run("promote_old_version", func(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
// Promote the first version (oldest) to current
oldestVersion := versions[2]
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
require.NoError(t, err)
// Verify current now returns the old version's value
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
// Verify the version metadata hasn't changed
// (promoting shouldn't modify timestamps)
version := secret.NewSecretVersion(vault, secretName, oldestVersion)
err = version.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter
})
// Step 6: Test version limits
t.Run("version_serial_limits", func(t *testing.T) {
// Create a new secret for this test
limitSecretName := "limit/test"
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
// Create 998 versions (we already have one from the first AddSecret)
err := vault.AddSecret(limitSecretName, []byte("initial"), false)
require.NoError(t, err)
// Get today's date for consistent version names
today := time.Now().Format("20060102")
// Manually create many versions with same date
for i := 2; i <= 998; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
versionDir := filepath.Join(secretDir, versionName)
err := fs.MkdirAll(versionDir, 0755)
require.NoError(t, err)
}
// Should be able to create one more (999)
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
// Create the 999th version directory
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0755)
require.NoError(t, err)
// Should fail to create 1000th version
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
assert.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
})
// Step 7: Test error cases
t.Run("error_cases", func(t *testing.T) {
// Try to get non-existent version
_, err := vault.GetSecretVersion(secretName, "99991231.999")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
// Try to get version of non-existent secret
_, err = vault.GetSecretVersion("nonexistent/secret", "")
assert.Error(t, err)
// Try to add secret without force when it exists
err = vault.AddSecret(secretName, []byte("should-fail"), false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
})
}
// TestVersionConcurrency tests concurrent version operations
func TestVersionConcurrency(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Set up vault
vault := createTestVaultWithKey(t, fs, stateDir, "test")
secretName := "concurrent/test"
// Create initial version
err := vault.AddSecret(secretName, []byte("initial"), false)
require.NoError(t, err)
// Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) {
done := make(chan bool, 10)
errors := make(chan error, 10)
for i := 0; i < 10; i++ {
go func() {
value, err := vault.GetSecret(secretName)
if err != nil {
errors <- err
} else if string(value) != "initial" {
errors <- fmt.Errorf("unexpected value: %s", value)
}
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
// Check for errors
select {
case err := <-errors:
t.Fatalf("concurrent read failed: %v", err)
default:
// No errors
}
})
}
// TestVersionCompatibility tests that old secrets without versions still work
func TestVersionCompatibility(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Set up vault
vault := createTestVaultWithKey(t, fs, stateDir, "test")
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
// Manually create an old-style secret (no versions)
secretName := "legacy/secret"
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
err = fs.MkdirAll(secretDir, 0755)
require.NoError(t, err)
// Create old-style encrypted value directly in secret directory
testValue := []byte("legacy-value")
ltRecipient := ltIdentity.Recipient()
encrypted, err := secret.EncryptToRecipient(testValue, ltRecipient)
require.NoError(t, err)
valuePath := filepath.Join(secretDir, "value.age")
err = afero.WriteFile(fs, valuePath, encrypted, 0600)
require.NoError(t, err)
// Should fail to get with version-aware methods
_, err = vault.GetSecret(secretName)
assert.Error(t, err)
// List versions should return empty
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
assert.Empty(t, versions)
}

View File

@ -196,10 +196,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
}
// Create unlock keys directory
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
if err := fs.MkdirAll(unlockKeysDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock keys directory: %w", err)
// Create unlockers directory
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
}
// Save initial vault metadata (without derivation info until a mnemonic is imported)

View File

@ -13,7 +13,7 @@ import (
// Alias the metadata types from secret package for convenience
type VaultMetadata = secret.VaultMetadata
type UnlockKeyMetadata = secret.UnlockKeyMetadata
type UnlockerMetadata = secret.UnlockerMetadata
type SecretMetadata = secret.SecretMetadata
type Configuration = secret.Configuration

View File

@ -113,140 +113,136 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
}
secret.Debug("Secret existence check complete", "exists", exists)
if exists && !force {
// Handle existing secret case
now := time.Now()
var previousVersion *secret.SecretVersion
if exists {
if !force {
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
}
// Create secret directory
// Get the current version to update its notAfter timestamp
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
if err == nil && currentVersionName != "" {
previousVersion = secret.NewSecretVersion(v, name, currentVersionName)
// We'll need to load and update its metadata after we unlock the vault
}
} else {
// Create secret directory for new secret
secret.Debug("Creating secret directory", "secret_dir", secretDir)
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to create secret directory: %w", err)
}
secret.Debug("Created secret directory successfully")
}
// Step 1: Generate a new keypair for this secret
secret.Debug("Generating secret-specific keypair", "secret_name", name)
secretIdentity, err := age.GenerateX25519Identity()
// Generate new version name
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to generate secret keypair", "error", err, "secret_name", name)
return fmt.Errorf("failed to generate secret keypair: %w", err)
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
return fmt.Errorf("failed to generate version name: %w", err)
}
secretPublicKey := secretIdentity.Recipient().String()
secretPrivateKey := secretIdentity.String()
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
secret.DebugWith("Generated secret keypair",
slog.String("secret_name", name),
slog.String("public_key", secretPublicKey),
)
// Create new version
newVersion := secret.NewSecretVersion(v, name, versionName)
// Step 2: Store the secret's public key
pubKeyPath := filepath.Join(secretDir, "pub.age")
secret.Debug("Writing secret public key", "path", pubKeyPath)
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), secret.FilePerms); err != nil {
secret.Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write secret public key: %w", err)
// Set version timestamps
if previousVersion == nil {
// First version: notBefore = epoch + 1 second
epochPlusOne := time.Unix(1, 0)
newVersion.Metadata.NotBefore = &epochPlusOne
} else {
// New version: notBefore = now
newVersion.Metadata.NotBefore = &now
// We'll update the previous version's notAfter after we save the new version
}
secret.Debug("Wrote secret public key successfully")
// Step 3: Encrypt the secret value to the secret's public key
secret.Debug("Encrypting secret value to secret's public key", "secret_name", name)
encryptedValue, err := secret.EncryptToRecipient(value, secretIdentity.Recipient())
// Save the new version
if err := newVersion.Save(value); err != nil {
secret.Debug("Failed to save new version", "error", err, "version", versionName)
return fmt.Errorf("failed to save version: %w", err)
}
// Update previous version if it exists
if previousVersion != nil {
// Get long-term key to decrypt/encrypt metadata
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to encrypt secret value", "error", err, "secret_name", name)
return fmt.Errorf("failed to encrypt secret value: %w", err)
secret.Debug("Failed to get long-term key for metadata update", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
secret.DebugWith("Secret value encrypted",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedValue)),
)
// Step 4: Store the encrypted secret value as value.age
valuePath := filepath.Join(secretDir, "value.age")
secret.Debug("Writing encrypted secret value", "path", valuePath)
if err := afero.WriteFile(v.fs, valuePath, encryptedValue, secret.FilePerms); err != nil {
secret.Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted secret value: %w", err)
// Load previous version metadata
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
secret.Debug("Wrote encrypted secret value successfully")
// Step 5: Get long-term public key for encrypting the secret's private key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
secret.Debug("Reading long-term public key", "path", ltPubKeyPath)
// Update notAfter timestamp
previousVersion.Metadata.NotAfter = &now
ltPubKeyData, err := afero.ReadFile(v.fs, ltPubKeyPath)
// Re-save the metadata (we need to implement an update method)
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
secret.Debug("Failed to update previous version metadata", "error", err)
return fmt.Errorf("failed to update previous version metadata: %w", err)
}
}
// Set current symlink to new version
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
secret.Debug("Failed to set current version", "error", err, "version", versionName)
return fmt.Errorf("failed to set current version: %w", err)
}
secret.Debug("Successfully added secret version to vault", "secret_name", name, "version", versionName, "vault_name", v.Name)
return nil
}
// updateVersionMetadata updates the metadata of an existing version
func updateVersionMetadata(fs afero.Fs, version *secret.SecretVersion, ltIdentity *age.X25519Identity) error {
// Read the version's encrypted private key
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
secret.Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
secret.Debug("Read long-term public key successfully", "key_length", len(ltPubKeyData))
secret.Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
// Decrypt version private key using long-term key
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
secret.Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
secret.DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
// Step 6: Encrypt the secret's private key to the long-term public key
secret.Debug("Encrypting secret private key to long-term public key", "secret_name", name)
encryptedPrivKey, err := secret.EncryptToRecipient([]byte(secretPrivateKey), ltRecipient)
// Parse version private key
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
if err != nil {
secret.Debug("Failed to encrypt secret private key", "error", err, "secret_name", name)
return fmt.Errorf("failed to encrypt secret private key: %w", err)
return fmt.Errorf("failed to parse version private key: %w", err)
}
secret.DebugWith("Secret private key encrypted",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedPrivKey)),
)
// Step 7: Store the encrypted secret private key as priv.age
privKeyPath := filepath.Join(secretDir, "priv.age")
secret.Debug("Writing encrypted secret private key", "path", privKeyPath)
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
secret.Debug("Failed to write encrypted secret private key", "error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted secret private key: %w", err)
}
secret.Debug("Wrote encrypted secret private key successfully")
// Step 8: Create and write metadata
secret.Debug("Creating secret metadata")
now := time.Now()
metadata := SecretMetadata{
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
secret.DebugWith("Creating secret metadata",
slog.String("secret_name", metadata.Name),
slog.Time("created_at", metadata.CreatedAt),
slog.Time("updated_at", metadata.UpdatedAt),
)
secret.Debug("Marshaling secret metadata")
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
// Marshal updated metadata
metadataBytes, err := json.MarshalIndent(version.Metadata, "", " ")
if err != nil {
secret.Debug("Failed to marshal secret metadata", "error", err)
return fmt.Errorf("failed to marshal secret metadata: %w", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
secret.Debug("Marshaled secret metadata successfully")
metadataPath := filepath.Join(secretDir, "secret-metadata.json")
secret.Debug("Writing secret metadata", "path", metadataPath)
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
secret.Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write secret metadata: %w", err)
// Encrypt metadata to the version's public key
encryptedMetadata, err := secret.EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
if err != nil {
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
// Write encrypted metadata
metadataPath := filepath.Join(version.Directory, "metadata.age")
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
secret.Debug("Wrote secret metadata successfully")
secret.Debug("Successfully added secret to vault with per-secret key architecture", "secret_name", name, "vault_name", v.Name)
return nil
}
@ -257,11 +253,30 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
slog.String("secret_name", name),
)
// Create a secret object to handle file access
secretObj := secret.NewSecret(v, name)
return v.GetSecretVersion(name, "")
}
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
secret.DebugWith("Getting secret version from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
)
// Get vault directory
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return nil, err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret exists
exists, err := secretObj.Exists()
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
@ -271,9 +286,36 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
return nil, fmt.Errorf("secret %s not found", name)
}
secret.Debug("Secret exists, proceeding with vault unlock and decryption", "secret_name", name)
// Determine which version to get
if version == "" {
// Get current version
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
version = currentVersion
secret.Debug("Using current version", "version", version, "secret_name", name)
}
// Step 1: Unlock the vault (get long-term key in memory)
// Create version object
secretVersion := secret.NewSecretVersion(v, name, version)
// Check if version exists
versionPath := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(v.fs, versionPath)
if err != nil {
secret.Debug("Failed to check if version exists", "error", err, "version", version)
return nil, fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
secret.Debug("Version not found", "version", version, "secret_name", name)
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
}
secret.Debug("Version exists, proceeding with vault unlock and decryption", "version", version, "secret_name", name)
// Unlock the vault (get long-term key in memory)
longTermIdentity, err := v.UnlockVault()
if err != nil {
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
@ -283,18 +325,20 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
secret.DebugWith("Successfully unlocked vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.String("version", version),
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
)
// Step 2: Use the unlocked vault to decrypt the secret
decryptedValue, err := v.decryptSecretWithLongTermKey(name, longTermIdentity)
// Get the version's value
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
if err != nil {
secret.Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt version: %w", err)
}
secret.DebugWith("Successfully decrypted secret with per-secret key architecture",
secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(decryptedValue)),
)
@ -330,90 +374,6 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
return longTermIdentity, nil
}
// decryptSecretWithLongTermKey decrypts a secret using the provided long-term key
func (v *Vault) decryptSecretWithLongTermKey(name string, longTermIdentity *age.X25519Identity) ([]byte, error) {
secret.DebugWith("Decrypting secret with long-term key",
slog.String("secret_name", name),
slog.String("vault_name", v.Name),
)
// Get vault and secret directories
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return nil, err
}
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Step 1: Read the encrypted secret private key from priv.age
encryptedSecretPrivKeyPath := filepath.Join(secretDir, "priv.age")
secret.Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
encryptedSecretPrivKey, err := afero.ReadFile(v.fs, encryptedSecretPrivKeyPath)
if err != nil {
secret.Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
}
secret.DebugWith("Read encrypted secret private key",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
)
// Step 2: Decrypt the secret's private key using the long-term private key
secret.Debug("Decrypting secret private key with long-term key", "secret_name", name)
secretPrivKeyData, err := secret.DecryptWithIdentity(encryptedSecretPrivKey, longTermIdentity)
if err != nil {
secret.Debug("Failed to decrypt secret private key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
}
// Step 3: Parse the secret's private key
secret.Debug("Parsing secret private key", "secret_name", name)
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
if err != nil {
secret.Debug("Failed to parse secret private key", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to parse secret private key: %w", err)
}
secret.DebugWith("Successfully parsed secret identity",
slog.String("secret_name", name),
slog.String("public_key", secretIdentity.Recipient().String()),
)
// Step 4: Read the encrypted secret value from value.age
encryptedValuePath := filepath.Join(secretDir, "value.age")
secret.Debug("Reading encrypted secret value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(v.fs, encryptedValuePath)
if err != nil {
secret.Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
}
secret.DebugWith("Read encrypted secret value",
slog.String("secret_name", name),
slog.Int("encrypted_length", len(encryptedValue)),
)
// Step 5: Decrypt the secret value using the secret's private key
secret.Debug("Decrypting secret value with secret's private key", "secret_name", name)
decryptedValue, err := secret.DecryptWithIdentity(encryptedValue, secretIdentity)
if err != nil {
secret.Debug("Failed to decrypt secret value", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
}
secret.DebugWith("Successfully decrypted secret value",
slog.String("secret_name", name),
slog.Int("decrypted_length", len(decryptedValue)),
)
return decryptedValue, nil
}
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
// First check if the secret exists by checking for the metadata file

View File

@ -0,0 +1,282 @@
package vault
import (
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to create a vault with long-term key set up
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
// Create vault
vault, err := CreateVault(fs, stateDir, vaultName)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
require.NoError(t, err)
// Store long-term public key in vault
vaultDir, _ := vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
require.NoError(t, err)
// Unlock the vault with the derived key
vault.Unlock(ltIdentity)
return vault
}
func TestVaultAddSecretCreatesVersion(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
// Add a secret
secretName := "test/secret"
secretValue := []byte("initial-value")
err := vault.AddSecret(secretName, secretValue, false)
require.NoError(t, err)
// Check that version directory was created
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versionsDir := secretDir + "/versions"
// Should have one version
entries, err := afero.ReadDir(fs, versionsDir)
require.NoError(t, err)
assert.Len(t, entries, 1)
// Should have current symlink
currentPath := secretDir + "/current"
exists, err := afero.Exists(fs, currentPath)
require.NoError(t, err)
assert.True(t, exists)
// Get the secret value
retrievedValue, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, secretValue, retrievedValue)
}
func TestVaultAddSecretMultipleVersions(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
secretName := "test/secret"
// Add first version
err := vault.AddSecret(secretName, []byte("version-1"), false)
require.NoError(t, err)
// Try to add again without force - should fail
err = vault.AddSecret(secretName, []byte("version-2"), false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
// Add with force - should create new version
err = vault.AddSecret(secretName, []byte("version-2"), true)
require.NoError(t, err)
// Check that we have two versions
vaultDir, _ := vault.GetDirectory()
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
entries, err := afero.ReadDir(fs, versionsDir)
require.NoError(t, err)
assert.Len(t, entries, 2)
// Current value should be version-2
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
}
func TestVaultGetSecretVersion(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
secretName := "test/secret"
// Add multiple versions
err := vault.AddSecret(secretName, []byte("version-1"), false)
require.NoError(t, err)
// Small delay to ensure different version names
time.Sleep(10 * time.Millisecond)
err = vault.AddSecret(secretName, []byte("version-2"), true)
require.NoError(t, err)
// Get versions list
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Get specific version (first one)
firstVersion := versions[1] // Last in list is first created
value, err := vault.GetSecretVersion(secretName, firstVersion)
require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
// Get specific version (second one)
secondVersion := versions[0] // First in list is most recent
value, err = vault.GetSecretVersion(secretName, secondVersion)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
// Get current (empty version)
value, err = vault.GetSecretVersion(secretName, "")
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
}
func TestVaultVersionTimestamps(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
// Get long-term key
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
secretName := "test/secret"
// Add first version
beforeFirst := time.Now()
err = vault.AddSecret(secretName, []byte("version-1"), false)
require.NoError(t, err)
afterFirst := time.Now()
// Get first version metadata
vaultDir, _ := vault.GetDirectory()
secretDir := vaultDir + "/secrets.d/test%secret"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 1)
firstVersion := secret.NewSecretVersion(vault, secretName, versions[0])
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
// Check first version timestamps
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
assert.True(t, firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
assert.True(t, firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
assert.NotNil(t, firstVersion.Metadata.NotBefore)
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
// Add second version
time.Sleep(10 * time.Millisecond)
beforeSecond := time.Now()
err = vault.AddSecret(secretName, []byte("version-2"), true)
require.NoError(t, err)
afterSecond := time.Now()
// Get updated versions
versions, err = secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// Reload first version metadata (should have notAfter now)
firstVersion = secret.NewSecretVersion(vault, secretName, versions[1])
err = firstVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, firstVersion.Metadata.NotAfter)
assert.True(t, firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
assert.True(t, firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
// Check second version timestamps
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
err = secondVersion.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, secondVersion.Metadata.NotBefore)
assert.True(t, secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
assert.True(t, secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
func TestVaultGetNonExistentVersion(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
// Add a secret
err := vault.AddSecret("test/secret", []byte("value"), false)
require.NoError(t, err)
// Try to get non-existent version
_, err = vault.GetSecretVersion("test/secret", "20991231.999")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestUpdateVersionMetadata(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault with long-term key
vault := createTestVaultWithKey(t, fs, stateDir, "test")
// Get long-term key
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
// Create a version manually to test updateVersionMetadata
secretName := "test/secret"
versionName := "20231215.001"
version := secret.NewSecretVersion(vault, secretName, versionName)
// Set initial metadata
now := time.Now()
epochPlusOne := time.Unix(1, 0)
version.Metadata.NotBefore = &epochPlusOne
version.Metadata.NotAfter = nil
// Save version
err = version.Save([]byte("test-value"))
require.NoError(t, err)
// Update metadata
version.Metadata.NotAfter = &now
err = updateVersionMetadata(fs, version, ltIdentity)
require.NoError(t, err)
// Load and verify
version2 := secret.NewSecretVersion(vault, secretName, versionName)
err = version2.LoadMetadata(ltIdentity)
require.NoError(t, err)
assert.NotNil(t, version2.Metadata.NotAfter)
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
}

View File

@ -1,376 +0,0 @@
package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
// GetCurrentUnlockKey returns the current unlock key for this vault
func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
secret.DebugWith("Getting current unlock key", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name)
return nil, err
}
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Check if the symlink exists
_, err = v.fs.Stat(currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
// Resolve the symlink to get the target directory
var unlockKeyDir string
if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlock key symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockKeyDir, err = ResolveVaultSymlink(v.fs, currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
}
} else {
secret.Debug("Reading unlock key path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes))
}
secret.DebugWith("Resolved unlock key directory",
slog.String("unlock_key_dir", unlockKeyDir),
slog.String("vault_name", v.Name),
)
// Read unlock key metadata
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
secret.Debug("Reading unlock key metadata", "path", metadataPath)
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read unlock key metadata: %w", err)
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
secret.Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err)
}
secret.DebugWith("Parsed unlock key metadata",
slog.String("key_id", metadata.ID),
slog.String("key_type", metadata.Type),
slog.Time("created_at", metadata.CreatedAt),
slog.Any("flags", metadata.Flags),
)
// Create unlock key instance using direct constructors with filesystem
var unlockKey secret.UnlockKey
// Convert our metadata to secret.UnlockKeyMetadata
secretMetadata := secret.UnlockKeyMetadata(metadata)
switch metadata.Type {
case "passphrase":
secret.Debug("Creating passphrase unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata)
case "pgp":
secret.Debug("Creating PGP unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewPGPUnlockKey(v.fs, unlockKeyDir, secretMetadata)
case "keychain":
secret.Debug("Creating keychain unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewKeychainUnlockKey(v.fs, unlockKeyDir, secretMetadata)
default:
secret.Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID)
return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
secret.DebugWith("Successfully created unlock key instance",
slog.String("key_type", unlockKey.GetType()),
slog.String("key_id", unlockKey.GetID()),
slog.String("vault_name", v.Name),
)
return unlockKey, nil
}
// ListUnlockKeys returns a list of available unlock keys for this vault
func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// Check if unlock keys directory exists
exists, err := afero.DirExists(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err)
}
if !exists {
return []UnlockKeyMetadata{}, nil
}
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var keys []UnlockKeyMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
continue
}
if !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
keys = append(keys, metadata)
}
}
return keys, nil
}
// RemoveUnlockKey removes an unlock key from this vault
func (v *Vault) RemoveUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the key directory and create the unlock key instance
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var unlockKey secret.UnlockKey
var keyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
keyDir = filepath.Join(unlockKeysDir, file.Name())
// Convert our metadata to secret.UnlockKeyMetadata
secretMetadata := secret.UnlockKeyMetadata(metadata)
// Create the appropriate unlock key instance
switch metadata.Type {
case "passphrase":
unlockKey = secret.NewPassphraseUnlockKey(v.fs, keyDir, secretMetadata)
case "pgp":
unlockKey = secret.NewPGPUnlockKey(v.fs, keyDir, secretMetadata)
case "keychain":
unlockKey = secret.NewKeychainUnlockKey(v.fs, keyDir, secretMetadata)
default:
return fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
break
}
}
}
if unlockKey == nil {
return fmt.Errorf("unlock key with ID %s not found", keyID)
}
// Use the unlock key's Remove method
return unlockKey.Remove()
}
// SelectUnlockKey selects an unlock key as current for this vault
func (v *Vault) SelectUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlock key directory by ID
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d to find the key
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var targetKeyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
targetKeyDir = filepath.Join(unlockKeysDir, file.Name())
break
}
}
}
if targetKeyDir == "" {
return fmt.Errorf("unlock key with ID %s not found", keyID)
}
// Create/update current unlock key symlink
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Remove existing symlink if it exists
if exists, _ := afero.Exists(v.fs, currentUnlockKeyPath); exists {
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
secret.Debug("Failed to remove existing unlock key symlink", "error", err, "path", currentUnlockKeyPath)
}
}
// Create new symlink
return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), secret.FilePerms)
}
// CreatePassphraseKey creates a new passphrase-protected unlock key
func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlockKey, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Create unlock key directory with timestamp
timestamp := time.Now().Format("2006-01-02.15.04")
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase")
if err := v.fs.MkdirAll(unlockKeyDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
}
// Generate new age keypair for unlock key
unlockIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlock key: %w", err)
}
// Write public key
pubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key public key: %w", err)
}
// Encrypt private key with passphrase
privKeyData := []byte(unlockIdentity.String())
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt unlock key private key: %w", err)
}
// Write encrypted private key
privKeyPath := filepath.Join(unlockKeyDir, "priv.age")
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted unlock key private key: %w", err)
}
// Create metadata
keyID := fmt.Sprintf("%s-passphrase", timestamp)
metadata := UnlockKeyMetadata{
ID: keyID,
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Write metadata
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
}
// Encrypt long-term private key to this unlock key if vault is unlocked
if !v.Locked() {
ltPrivKey := []byte(v.GetLongTermKey().String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
}
// Select this unlock key as current
if err := v.SelectUnlockKey(keyID); err != nil {
return nil, fmt.Errorf("failed to select new unlock key: %w", err)
}
// Convert our metadata to secret.UnlockKeyMetadata for the constructor
secretMetadata := secret.UnlockKeyMetadata(metadata)
return secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata), nil
}

376
internal/vault/unlockers.go Normal file
View File

@ -0,0 +1,376 @@
package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
// GetCurrentUnlocker returns the current unlocker for this vault
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
return nil, err
}
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Check if the symlink exists
_, err = v.fs.Stat(currentUnlockerPath)
if err != nil {
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
}
// Resolve the symlink to get the target directory
var unlockerDir string
if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlocker symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath)
return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err)
}
} else {
secret.Debug("Reading unlocker path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
}
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
}
secret.DebugWith("Resolved unlocker directory",
slog.String("unlocker_dir", unlockerDir),
slog.String("vault_name", v.Name),
)
// Read unlocker metadata
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
secret.Debug("Reading unlocker metadata", "path", metadataPath)
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
}
secret.DebugWith("Parsed unlocker metadata",
slog.String("unlocker_id", metadata.ID),
slog.String("unlocker_type", metadata.Type),
slog.Time("created_at", metadata.CreatedAt),
slog.Any("flags", metadata.Flags),
)
// Create unlocker instance using direct constructors with filesystem
var unlocker secret.Unlocker
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
switch metadata.Type {
case "passphrase":
secret.Debug("Creating passphrase unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata)
case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata)
default:
secret.Debug("Unsupported unlocker type", "type", metadata.Type, "unlocker_id", metadata.ID)
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
}
secret.DebugWith("Successfully created unlocker instance",
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
slog.String("vault_name", v.Name),
)
return unlocker, nil
}
// ListUnlockers returns a list of available unlockers for this vault
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Check if unlockers directory exists
exists, err := afero.DirExists(v.fs, unlockersDir)
if err != nil {
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
}
if !exists {
return []UnlockerMetadata{}, nil
}
// List directories in unlockers.d
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var unlockers []UnlockerMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
continue
}
if !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
unlockers = append(unlockers, metadata)
}
}
return unlockers, nil
}
// RemoveUnlocker removes an unlocker from this vault
func (v *Vault) RemoveUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlocker directory and create the unlocker instance
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// List directories in unlockers.d
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return fmt.Errorf("failed to read unlockers directory: %w", err)
}
var unlocker secret.Unlocker
var unlockerDirPath string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == unlockerID {
unlockerDirPath = filepath.Join(unlockersDir, file.Name())
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
// Create the appropriate unlocker instance
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
default:
return fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
}
break
}
}
}
if unlocker == nil {
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
}
// Use the unlocker's Remove method
return unlocker.Remove()
}
// SelectUnlocker selects an unlocker as current for this vault
func (v *Vault) SelectUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlocker directory by ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// List directories in unlockers.d to find the unlocker
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return fmt.Errorf("failed to read unlockers directory: %w", err)
}
var targetUnlockerDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == unlockerID {
targetUnlockerDir = filepath.Join(unlockersDir, file.Name())
break
}
}
}
if targetUnlockerDir == "" {
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
}
// Create/update current unlocker symlink
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Remove existing symlink if it exists
if exists, _ := afero.Exists(v.fs, currentUnlockerPath); exists {
if err := v.fs.Remove(currentUnlockerPath); err != nil {
secret.Debug("Failed to remove existing unlocker symlink", "error", err, "path", currentUnlockerPath)
}
}
// Create new symlink
return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms)
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Create unlocker directory with timestamp
timestamp := time.Now().Format("2006-01-02.15.04")
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
}
// Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
}
// Encrypt private key with passphrase
privKeyData := []byte(unlockerIdentity.String())
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
}
// Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
}
// Create metadata
unlockerID := fmt.Sprintf("%s-passphrase", timestamp)
metadata := UnlockerMetadata{
ID: unlockerID,
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Write metadata
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
// Encrypt long-term private key to this unlocker if vault is unlocked
if !v.Locked() {
ltPrivKey := []byte(v.GetLongTermKey().String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
}
// Select this unlocker as current
if err := v.SelectUnlocker(unlockerID); err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
// Convert our metadata to secret.UnlockerMetadata for the constructor
secretMetadata := secret.UnlockerMetadata(metadata)
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata), nil
}

View File

@ -83,32 +83,32 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return ltIdentity, nil
}
// No mnemonic available, try to use current unlock key
secret.Debug("No mnemonic available, using current unlock key to unlock vault", "vault_name", v.Name)
// No mnemonic available, try to use current unlocker
secret.Debug("No mnemonic available, using current unlocker to unlock vault", "vault_name", v.Name)
// Get current unlock key
unlockKey, err := v.GetCurrentUnlockKey()
// Get current unlocker
unlocker, err := v.GetCurrentUnlocker()
if err != nil {
secret.Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
secret.DebugWith("Retrieved current unlock key for vault unlock",
secret.DebugWith("Retrieved current unlocker for vault unlock",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Get unlock key identity
unlockIdentity, err := unlockKey.GetIdentity()
// Get unlocker identity
unlockerIdentity, err := unlocker.GetIdentity()
if err != nil {
secret.Debug("Failed to get unlock key identity", "error", err, "unlock_key_type", unlockKey.GetType())
return nil, fmt.Errorf("failed to get unlock key identity: %w", err)
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read encrypted long-term private key from unlock key directory
unlockKeyDir := unlockKey.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
// Read encrypted long-term private key from unlocker directory
unlockerDir := unlocker.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
@ -119,21 +119,21 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.DebugWith("Read encrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
)
// Decrypt long-term private key using unlock key
secret.Debug("Decrypting long-term private key with unlock key", "unlock_key_type", unlockKey.GetType())
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
// Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlock_key_type", unlockKey.GetType())
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", len(ltPrivKeyData)),
)
@ -145,15 +145,15 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
secret.DebugWith("Successfully obtained long-term identity via unlock key",
secret.DebugWith("Successfully obtained long-term identity via unlocker",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Cache the derived key by unlocking the vault
v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via unlock key", "vault_name", v.Name, "unlock_key_type", unlockKey.GetType())
secret.Debug("Vault is unlocked (lt key in memory) via unlocker", "vault_name", v.Name, "unlocker_type", unlocker.GetType())
return ltIdentity, nil
}

View File

@ -174,8 +174,8 @@ func TestVaultOperations(t *testing.T) {
}
})
// Test unlock key operations
t.Run("UnlockKeyOperations", func(t *testing.T) {
// Test unlocker operations
t.Run("UnlockerOperations", func(t *testing.T) {
vlt, err := GetCurrentVault(fs, stateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
@ -189,25 +189,25 @@ func TestVaultOperations(t *testing.T) {
}
}
// Create a passphrase unlock key
passphraseKey, err := vlt.CreatePassphraseKey("test-passphrase")
// Create a passphrase unlocker
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
if err != nil {
t.Fatalf("Failed to create passphrase key: %v", err)
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// List unlock keys
keys, err := vlt.ListUnlockKeys()
// List unlockers
unlockers, err := vlt.ListUnlockers()
if err != nil {
t.Fatalf("Failed to list unlock keys: %v", err)
t.Fatalf("Failed to list unlockers: %v", err)
}
if len(keys) == 0 {
t.Errorf("Expected at least one unlock key")
if len(unlockers) == 0 {
t.Errorf("Expected at least one unlocker")
}
// Check key type
keyFound := false
for _, key := range keys {
for _, key := range unlockers {
if key.Type == "passphrase" {
keyFound = true
break
@ -215,23 +215,23 @@ func TestVaultOperations(t *testing.T) {
}
if !keyFound {
t.Errorf("Expected to find passphrase unlock key")
t.Errorf("Expected to find passphrase unlocker")
}
// Test selecting unlock key
err = vlt.SelectUnlockKey(passphraseKey.GetID())
// Test selecting unlocker
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
if err != nil {
t.Fatalf("Failed to select unlock key: %v", err)
t.Fatalf("Failed to select unlocker: %v", err)
}
// Test getting current unlock key
currentKey, err := vlt.GetCurrentUnlockKey()
// Test getting current unlocker
currentUnlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
t.Fatalf("Failed to get current unlock key: %v", err)
t.Fatalf("Failed to get current unlocker: %v", err)
}
if currentKey.GetID() != passphraseKey.GetID() {
t.Errorf("Expected current unlock key ID '%s', got '%s'", passphraseKey.GetID(), currentKey.GetID())
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID())
}
})
}

View File

@ -104,8 +104,19 @@ echo " SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
# Test 2: Initialize the secret manager (should create default vault)
print_step "2" "Initializing secret manager (creates default vault)"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
echo " SB_UNLOCK_PASSPHRASE=$SB_UNLOCK_PASSPHRASE"
# Verify environment variables are exported and visible to subprocesses
echo "Verifying environment variables are exported:"
env | grep -E "^SB_" || true
echo "Running: $SECRET_BINARY init"
if $SECRET_BINARY init; then
# Run with explicit environment to ensure variables are passed
if SB_SECRET_STATE_DIR="$SB_SECRET_STATE_DIR" \
SB_SECRET_MNEMONIC="$SB_SECRET_MNEMONIC" \
SB_UNLOCK_PASSPHRASE="$SB_UNLOCK_PASSPHRASE" \
GODEBUG="$GODEBUG" \
$SECRET_BINARY init </dev/null; then
print_success "Secret manager initialized with default vault"
else
print_error "Failed to initialize secret manager"
@ -229,42 +240,45 @@ fi
reset_state
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 5: Unlock key management
print_step "5" "Testing unlock key management"
# Test 5: Unlocker management
print_step "5" "Testing unlocker management"
# Initialize to create default vault
# Initialize with mnemonic and passphrase
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
echo "Running: $SECRET_BINARY init (with SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set)"
if $SECRET_BINARY init; then
print_success "Initialized for unlock key testing"
print_success "Initialized for unlocker testing"
else
print_error "Failed to initialize for unlock key testing"
print_error "Failed to initialize for unlocker testing"
fi
# Create passphrase-protected unlock key
echo "Creating passphrase-protected unlock key..."
echo "Running: $SECRET_BINARY keys add passphrase (with SB_UNLOCK_PASSPHRASE set)"
if $SECRET_BINARY keys add passphrase; then
print_success "Created passphrase-protected unlock key"
# Create passphrase-protected unlocker
echo "Creating passphrase-protected unlocker..."
echo "Running: $SECRET_BINARY unlockers add passphrase (with SB_UNLOCK_PASSPHRASE set)"
if $SECRET_BINARY unlockers add passphrase; then
print_success "Created passphrase-protected unlocker"
else
print_error "Failed to create passphrase-protected unlock key"
print_error "Failed to create passphrase-protected unlocker"
exit 1
fi
unset SB_UNLOCK_PASSPHRASE
# List unlock keys
echo "Listing unlock keys..."
echo "Running: $SECRET_BINARY keys list"
if $SECRET_BINARY keys list; then
KEYS=$($SECRET_BINARY keys list)
echo "Available unlock keys: $KEYS"
print_success "Listed unlock keys"
# List unlockers
echo "Listing unlockers..."
echo "Running: $SECRET_BINARY unlockers list"
if $SECRET_BINARY unlockers list; then
UNLOCKERS=$($SECRET_BINARY unlockers list)
echo "Available unlockers: $UNLOCKERS"
print_success "Listed unlockers"
else
print_error "Failed to list unlock keys"
print_error "Failed to list unlockers"
exit 1
fi
# Test 6: Secret management with mnemonic (keyless operation)
print_step "6" "Testing mnemonic-based secret operations (keyless)"
# Add secrets using mnemonic (no unlock key required)
# Add secrets using mnemonic (no unlocker required)
echo "Adding secrets using mnemonic-based long-term key..."
# Test secret 1
@ -340,66 +354,59 @@ else
print_error "Failed to list secrets"
fi
# Test 7: Secret management without mnemonic (traditional unlock key approach)
print_step "7" "Testing traditional unlock key approach"
# Test 7: Secret management without mnemonic (traditional unlocker approach)
print_step "7" "Testing traditional unlocker approach"
# Temporarily unset mnemonic to test traditional approach
unset SB_SECRET_MNEMONIC
# Create a new vault without mnemonic
echo "Running: $SECRET_BINARY vault create traditional"
$SECRET_BINARY vault create traditional
# Add a secret using traditional unlock key approach
echo "Adding secret using traditional unlock key..."
echo "Running: echo \"traditional-secret-value\" | $SECRET_BINARY add \"traditional/secret\""
if echo "traditional-secret-value" | $SECRET_BINARY add "traditional/secret"; then
print_success "Added secret using traditional approach: traditional/secret"
# Add a secret using traditional unlocker approach
echo "Adding secret using traditional unlocker..."
echo "Running: echo 'traditional-secret' | $SECRET_BINARY add traditional/secret"
if echo "traditional-secret" | $SECRET_BINARY add traditional/secret; then
print_success "Added secret with traditional approach"
else
print_error "Failed to add secret using traditional approach"
print_error "Failed to add secret with traditional approach"
fi
# Retrieve secret using traditional unlock key approach
echo "Retrieving secret using traditional unlock key approach..."
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
RETRIEVED_TRADITIONAL=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
unset SB_UNLOCK_PASSPHRASE
if [ "$RETRIEVED_TRADITIONAL" = "traditional-secret-value" ]; then
print_success "Retrieved and verified traditional secret: traditional/secret"
# Retrieve secret using traditional unlocker approach
echo "Retrieving secret using traditional unlocker approach..."
echo "Running: $SECRET_BINARY get traditional/secret"
if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then
print_success "Retrieved: $RETRIEVED"
else
print_error "Failed to retrieve or verify traditional secret"
print_error "Failed to retrieve secret with traditional approach"
fi
# Re-enable mnemonic for remaining tests
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 8: Advanced unlocker management
print_step "8" "Testing advanced unlocker management"
# Test 8: Advanced unlock key management
print_step "8" "Testing advanced unlock key management"
# Test Secure Enclave (macOS only)
if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Testing Secure Enclave unlock key creation..."
echo "Running: $SECRET_BINARY enroll sep"
if $SECRET_BINARY enroll sep; then
print_success "Created Secure Enclave unlock key"
if [ "$PLATFORM" = "darwin" ]; then
# macOS only: Test Secure Enclave
echo "Testing Secure Enclave unlocker creation..."
if $SECRET_BINARY unlockers add sep; then
print_success "Created Secure Enclave unlocker"
else
print_warning "Secure Enclave unlock key creation not yet implemented"
print_warning "Secure Enclave unlocker creation not yet implemented"
fi
else
print_warning "Secure Enclave only available on macOS"
fi
# Get current unlock key ID for testing
echo "Getting current unlock key for testing..."
echo "Running: $SECRET_BINARY keys list"
if $SECRET_BINARY keys list; then
CURRENT_KEY_ID=$($SECRET_BINARY keys list | head -n1 | awk '{print $1}')
if [ -n "$CURRENT_KEY_ID" ]; then
print_success "Found unlock key ID: $CURRENT_KEY_ID"
# Get current unlocker ID for testing
echo "Getting current unlocker for testing..."
echo "Running: $SECRET_BINARY unlockers list"
if $SECRET_BINARY unlockers list; then
CURRENT_UNLOCKER_ID=$($SECRET_BINARY unlockers list | head -n1 | awk '{print $1}')
if [ -n "$CURRENT_UNLOCKER_ID" ]; then
print_success "Found unlocker ID: $CURRENT_UNLOCKER_ID"
# Test key selection
echo "Testing unlock key selection..."
echo "Running: $SECRET_BINARY key select $CURRENT_KEY_ID"
if $SECRET_BINARY key select "$CURRENT_KEY_ID"; then
print_success "Selected unlock key: $CURRENT_KEY_ID"
# Test unlocker selection
echo "Testing unlocker selection..."
echo "Running: $SECRET_BINARY unlocker select $CURRENT_UNLOCKER_ID"
if $SECRET_BINARY unlocker select "$CURRENT_UNLOCKER_ID"; then
print_success "Selected unlocker: $CURRENT_UNLOCKER_ID"
else
print_warning "Unlock key selection not yet implemented"
print_warning "Unlocker selection not yet implemented"
fi
fi
fi
@ -609,21 +616,122 @@ else
print_error "Mnemonic cannot access traditional secrets"
fi
# Test without mnemonic but with unlock key
unset SB_SECRET_MNEMONIC
echo "Testing traditional unlock key access to mnemonic-created secrets..."
echo "Running: $SECRET_BINARY get \"database/password\" (with SB_UNLOCK_PASSPHRASE set)"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
if $SECRET_BINARY get "database/password"; then
print_success "Traditional unlock key can access mnemonic-created secrets"
# Test without mnemonic but with unlocker
echo "Testing mnemonic-created vault access..."
echo "Testing traditional unlocker access to mnemonic-created secrets..."
echo "Running: $SECRET_BINARY get test/seed (with mnemonic set)"
if RETRIEVED=$($SECRET_BINARY get test/seed 2>&1); then
print_success "Traditional unlocker can access mnemonic-created secrets"
else
print_warning "Traditional unlock key cannot access mnemonic-created secrets (may need implementation)"
print_warning "Traditional unlocker cannot access mnemonic-created secrets (may need implementation)"
fi
unset SB_UNLOCK_PASSPHRASE
# Re-enable mnemonic for final tests
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 15: Version management
print_step "15" "Testing version management"
# Switch back to default vault for version testing
echo "Switching to default vault for version testing..."
echo "Running: $SECRET_BINARY vault select default"
$SECRET_BINARY vault select default
# Test listing versions of a secret
echo "Listing versions of database/password..."
echo "Running: $SECRET_BINARY version list \"database/password\""
if $SECRET_BINARY version list "database/password"; then
print_success "Listed versions of database/password"
else
print_error "Failed to list versions of database/password"
fi
# Add a new version of an existing secret
echo "Adding new version of database/password..."
echo "Running: echo \"version-2-password\" | $SECRET_BINARY add \"database/password\" --force"
if echo "version-2-password" | $SECRET_BINARY add "database/password" --force; then
print_success "Added new version of database/password"
# List versions again to see both
echo "Running: $SECRET_BINARY version list \"database/password\""
if $SECRET_BINARY version list "database/password"; then
print_success "Listed versions after adding new version"
else
print_error "Failed to list versions after adding new version"
fi
else
print_error "Failed to add new version of database/password"
fi
# Get current version (should be the latest)
echo "Getting current version of database/password..."
CURRENT_VALUE=$($SECRET_BINARY get "database/password" 2>/dev/null)
if [ "$CURRENT_VALUE" = "version-2-password" ]; then
print_success "Current version has correct value"
else
print_error "Current version has incorrect value"
fi
# Get specific version by capturing version from list output
echo "Getting specific version of database/password..."
VERSIONS=$($SECRET_BINARY version list "database/password" | grep -E '^[0-9]{8}\.[0-9]{3}' | awk '{print $1}')
FIRST_VERSION=$(echo "$VERSIONS" | tail -n1)
if [ -n "$FIRST_VERSION" ]; then
echo "Running: $SECRET_BINARY get --version $FIRST_VERSION \"database/password\""
VERSIONED_VALUE=$($SECRET_BINARY get --version "$FIRST_VERSION" "database/password" 2>/dev/null)
if [ "$VERSIONED_VALUE" = "new-password-value" ]; then
print_success "Retrieved correct value from specific version"
else
print_error "Retrieved incorrect value from specific version (expected: new-password-value, got: $VERSIONED_VALUE)"
fi
else
print_error "Could not determine version to test"
fi
# Test version promotion
echo "Testing version promotion..."
if [ -n "$FIRST_VERSION" ]; then
echo "Running: $SECRET_BINARY version promote \"database/password\" $FIRST_VERSION"
if $SECRET_BINARY version promote "database/password" "$FIRST_VERSION"; then
print_success "Promoted older version to current"
# Verify the promoted version is now current
PROMOTED_VALUE=$($SECRET_BINARY get "database/password" 2>/dev/null)
if [ "$PROMOTED_VALUE" = "new-password-value" ]; then
print_success "Promoted version is now current"
else
print_error "Promoted version value is incorrect"
fi
else
print_error "Failed to promote version"
fi
fi
# Check version directory structure
echo "Checking version directory structure..."
VERSION_DIR="$TEMP_DIR/vaults.d/default/secrets.d/database%password/versions"
if [ -d "$VERSION_DIR" ]; then
print_success "Versions directory exists"
# Count version directories
VERSION_COUNT=$(find "$VERSION_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)
if [ "$VERSION_COUNT" -ge 2 ]; then
print_success "Multiple version directories found: $VERSION_COUNT"
else
print_error "Expected multiple version directories, found: $VERSION_COUNT"
fi
# Check for current symlink
CURRENT_LINK="$TEMP_DIR/vaults.d/default/secrets.d/database%password/current"
if [ -L "$CURRENT_LINK" ] || [ -f "$CURRENT_LINK" ]; then
print_success "Current version symlink exists"
else
print_error "Current version symlink not found"
fi
else
print_error "Versions directory not found"
fi
# Final summary
echo -e "\n${GREEN}=== Test Summary ===${NC}"
echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}"
@ -631,15 +739,16 @@ echo -e "${GREEN}✓ Secret manager initialization${NC}"
echo -e "${GREEN}✓ Vault management (create, list, select)${NC}"
echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}"
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}"
echo -e "${GREEN}✓ Unlock key management (passphrase, PGP, SEP)${NC}"
echo -e "${GREEN}Mnemonic-based secret operations (keyless)${NC}"
echo -e "${GREEN}✓ Traditional unlock key operations${NC}"
echo -e "${GREEN}✓ Unlocker management (passphrase, PGP, SEP)${NC}"
echo -e "${GREEN}Secret generation and storage${NC}"
echo -e "${GREEN}✓ Traditional unlocker operations${NC}"
echo -e "${GREEN}✓ Secret name validation${NC}"
echo -e "${GREEN}✓ Overwrite protection and force flag${NC}"
echo -e "${GREEN}✓ Cross-vault operations${NC}"
echo -e "${GREEN}✓ Per-secret key file structure${NC}"
echo -e "${GREEN}✓ Mixed approach compatibility${NC}"
echo -e "${GREEN}✓ Error handling${NC}"
echo -e "${GREEN}✓ Version management (list, get, promote)${NC}"
echo -e "\n${GREEN}🎉 Comprehensive test completed with environment variable automation!${NC}"
@ -662,21 +771,26 @@ echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
echo "secret vault import work"
echo ""
echo -e "${YELLOW}# Unlock key management:${NC}"
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
echo "secret keys add passphrase"
echo "secret keys add pgp <gpg-key-id>"
echo "secret enroll sep # macOS only"
echo "secret keys list"
echo "secret key select <key-id>"
echo "secret keys rm <key-id>"
echo -e "${YELLOW}# Unlocker management:${NC}"
echo "$SECRET_BINARY unlockers add <type> # Add unlocker (passphrase, pgp, keychain)"
echo "$SECRET_BINARY unlockers add passphrase"
echo "$SECRET_BINARY unlockers add pgp <gpg-key-id>"
echo "$SECRET_BINARY unlockers add keychain # macOS only"
echo "$SECRET_BINARY unlockers list # List all unlockers"
echo "$SECRET_BINARY unlocker select <unlocker-id> # Select current unlocker"
echo "$SECRET_BINARY unlockers rm <unlocker-id> # Remove unlocker"
echo ""
echo -e "${YELLOW}# Secret management:${NC}"
echo "echo \"my-secret\" | secret add \"app/password\""
echo "echo \"my-secret\" | secret add \"app/password\" --force"
echo "secret get \"app/password\""
echo "secret get --version 20231215.001 \"app/password\""
echo "secret list"
echo ""
echo -e "${YELLOW}# Version management:${NC}"
echo "secret version list \"app/password\""
echo "secret version promote \"app/password\" 20231215.001"
echo ""
echo -e "${YELLOW}# Cross-vault operations:${NC}"
echo "secret vault select work"
echo "echo \"work-secret\" | secret add \"work/database\""