Compare commits
	
		
			No commits in common. "0b31fba663acb84db819f76d611253dada3068e4" and "e036d280c054da108cd043d8bab29edd58ab4fe8" have entirely different histories.
		
	
	
		
			0b31fba663
			...
			e036d280c0
		
	
		
@ -1,13 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "permissions": {
 | 
			
		||||
    "allow": [
 | 
			
		||||
      "Bash(go mod why:*)",
 | 
			
		||||
      "Bash(go list:*)",
 | 
			
		||||
      "Bash(~/go/bin/govulncheck -mode=module .)",
 | 
			
		||||
      "Bash(go test:*)",
 | 
			
		||||
      "Bash(grep:*)",
 | 
			
		||||
      "Bash(rg:*)"
 | 
			
		||||
    ],
 | 
			
		||||
    "deny": []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,3 +1,3 @@
 | 
			
		||||
EXTREMELY IMPORTANT: 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.
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,6 +1,3 @@
 | 
			
		||||
.DS_Store
 | 
			
		||||
**/.DS_Store
 | 
			
		||||
/secret
 | 
			
		||||
*.log
 | 
			
		||||
cli.test
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -63,7 +63,7 @@ Version: 2025-06-08
 | 
			
		||||
   is a bug in the test).  This is cheating, and it is bad.  You should only
 | 
			
		||||
   be modifying the test if it is incorrect or if the test is no longer
 | 
			
		||||
   relevant.  In almost all cases, you should be fixing the code that is
 | 
			
		||||
   being tested, or updating the tests to match a refactored implementation.
 | 
			
		||||
   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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								Makefile
									
									
									
									
									
								
							@ -1,7 +1,5 @@
 | 
			
		||||
default: check
 | 
			
		||||
 | 
			
		||||
build: ./secret
 | 
			
		||||
 | 
			
		||||
# Simple build (no code signing needed)
 | 
			
		||||
./secret:
 | 
			
		||||
	go build -v -o $@ cmd/secret/main.go
 | 
			
		||||
@ -11,9 +9,7 @@ vet:
 | 
			
		||||
 | 
			
		||||
test:
 | 
			
		||||
	go test -v ./...
 | 
			
		||||
 | 
			
		||||
fmt:
 | 
			
		||||
	go fmt ./...
 | 
			
		||||
	bash test_secret_manager.sh
 | 
			
		||||
 | 
			
		||||
lint:
 | 
			
		||||
	golangci-lint run --timeout 5m
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								README.md
									
									
									
									
									
								
							@ -175,16 +175,13 @@ Decrypts data using an Age key stored as a secret.
 | 
			
		||||
│   │   │   └── database%password/       # Secret: database/password
 | 
			
		||||
│   │   │       ├── versions/
 | 
			
		||||
│   │   │       └── current -> versions/20231215.001
 | 
			
		||||
│   │   ├── vault-metadata.json          # Vault metadata
 | 
			
		||||
│   │   ├── pub.age                      # Long-term public key
 | 
			
		||||
│   │   └── current-unlocker -> ../unlockers.d/passphrase
 | 
			
		||||
│   └── work/
 | 
			
		||||
│       ├── unlockers.d/
 | 
			
		||||
│       ├── secrets.d/
 | 
			
		||||
│       ├── vault-metadata.json
 | 
			
		||||
│       ├── pub.age
 | 
			
		||||
│       └── current-unlocker
 | 
			
		||||
└── currentvault -> vaults.d/default
 | 
			
		||||
├── currentvault -> vaults.d/default
 | 
			
		||||
└── configuration.json
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Key Management and Encryption Flow
 | 
			
		||||
@ -312,17 +309,11 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
 | 
			
		||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
 | 
			
		||||
- **Key Exchange**: X25519 elliptic curve Diffie-Hellman
 | 
			
		||||
- **Authentication**: Poly1305 MAC
 | 
			
		||||
- **Hashing**: Double SHA-256 for public key identification
 | 
			
		||||
 | 
			
		||||
### File Formats
 | 
			
		||||
- **Age Files**: Standard Age encryption format (.age extension)
 | 
			
		||||
- **Metadata**: JSON format with timestamps and type information
 | 
			
		||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
 | 
			
		||||
 | 
			
		||||
### Vault Management
 | 
			
		||||
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic
 | 
			
		||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
 | 
			
		||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
 | 
			
		||||
- **Configuration**: JSON configuration files
 | 
			
		||||
 | 
			
		||||
### Cross-Platform Support
 | 
			
		||||
- **macOS**: Full support including Keychain integration
 | 
			
		||||
@ -360,9 +351,8 @@ make lint     # Run linter
 | 
			
		||||
### Testing
 | 
			
		||||
The project includes comprehensive tests:
 | 
			
		||||
```bash
 | 
			
		||||
make test     # Run all tests
 | 
			
		||||
./test_secret_manager.sh  # Full integration test suite
 | 
			
		||||
go test ./...             # Unit tests
 | 
			
		||||
go test -tags=integration -v ./internal/cli  # Integration tests
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Features
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										148
									
								
								TESTS_VERSION_SUPPORT.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								TESTS_VERSION_SUPPORT.md
									
									
									
									
									
										Normal 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 
 | 
			
		||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							@ -22,9 +22,12 @@ require (
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
 | 
			
		||||
	github.com/inconshreveable/mousetrap v1.1.0 // 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/text v0.25.0 // indirect
 | 
			
		||||
	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1 // indirect
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										10
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								go.sum
									
									
									
									
									
								
							@ -31,6 +31,7 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
 | 
			
		||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
 | 
			
		||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 | 
			
		||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 | 
			
		||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
			
		||||
@ -60,6 +61,12 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M
 | 
			
		||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 | 
			
		||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
 | 
			
		||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
 | 
			
		||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
 | 
			
		||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 | 
			
		||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 | 
			
		||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 | 
			
		||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 | 
			
		||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 | 
			
		||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 | 
			
		||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
 | 
			
		||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
 | 
			
		||||
@ -130,8 +137,9 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
 | 
			
		||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
 | 
			
		||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
 | 
			
		||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 | 
			
		||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 | 
			
		||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 | 
			
		||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 | 
			
		||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 | 
			
		||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import (
 | 
			
		||||
	"math/big"
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/vault"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
)
 | 
			
		||||
@ -31,7 +31,7 @@ func newGenerateMnemonicCmd() *cobra.Command {
 | 
			
		||||
		Long:  `Generate a cryptographically secure random BIP39 mnemonic phrase that can be used with 'secret init' or 'secret import'.`,
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GenerateMnemonic(cmd)
 | 
			
		||||
			return cli.GenerateMnemonic()
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -48,7 +48,7 @@ func newGenerateSecretCmd() *cobra.Command {
 | 
			
		||||
			force, _ := cmd.Flags().GetBool("force")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GenerateSecret(cmd, args[0], length, secretType, force)
 | 
			
		||||
			return cli.GenerateSecret(args[0], length, secretType, force)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -60,7 +60,7 @@ func newGenerateSecretCmd() *cobra.Command {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GenerateMnemonic generates a random BIP39 mnemonic phrase
 | 
			
		||||
func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
 | 
			
		||||
func (cli *CLIInstance) GenerateMnemonic() error {
 | 
			
		||||
	// Generate 128 bits of entropy for a 12-word mnemonic
 | 
			
		||||
	entropy, err := bip39.NewEntropy(128)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -74,7 +74,7 @@ func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Output mnemonic to stdout
 | 
			
		||||
	cmd.Println(mnemonic)
 | 
			
		||||
	fmt.Println(mnemonic)
 | 
			
		||||
 | 
			
		||||
	// Output helpful information to stderr
 | 
			
		||||
	fmt.Fprintln(os.Stderr, "")
 | 
			
		||||
@ -92,7 +92,7 @@ func (cli *CLIInstance) GenerateMnemonic(cmd *cobra.Command) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GenerateSecret generates a random secret and stores it in the vault
 | 
			
		||||
func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, length int, secretType string, force bool) error {
 | 
			
		||||
func (cli *CLIInstance) GenerateSecret(secretName string, length int, secretType string, force bool) error {
 | 
			
		||||
	if length < 1 {
 | 
			
		||||
		return fmt.Errorf("length must be at least 1")
 | 
			
		||||
	}
 | 
			
		||||
@ -116,16 +116,16 @@ func (cli *CLIInstance) GenerateSecret(cmd *cobra.Command, secretName string, le
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Store the secret in the vault
 | 
			
		||||
	vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	vault, err := secret.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := vlt.AddSecret(secretName, []byte(secretValue), force); err != nil {
 | 
			
		||||
	if err := vault.AddSecret(secretName, []byte(secretValue), force); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
 | 
			
		||||
	fmt.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
@ -16,23 +17,19 @@ import (
 | 
			
		||||
	"github.com/tyler-smith/go-bip39"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NewInitCmd creates the init command
 | 
			
		||||
func NewInitCmd() *cobra.Command {
 | 
			
		||||
func newInitCmd() *cobra.Command {
 | 
			
		||||
	return &cobra.Command{
 | 
			
		||||
		Use:   "init",
 | 
			
		||||
		Short: "Initialize the secrets manager",
 | 
			
		||||
		Long:  `Create the necessary directory structure for storing secrets and generate encryption keys.`,
 | 
			
		||||
		RunE:  RunInit,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RunInit is the exported function that handles the init command
 | 
			
		||||
func RunInit(cmd *cobra.Command, args []string) error {
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.Init(cmd)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Init initializes the secret manager
 | 
			
		||||
// Init initializes the secrets manager
 | 
			
		||||
func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
	secret.Debug("Starting secret manager initialization")
 | 
			
		||||
 | 
			
		||||
@ -78,18 +75,31 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
		return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set mnemonic in environment for CreateVault to use
 | 
			
		||||
	originalMnemonic := os.Getenv(secret.EnvMnemonic)
 | 
			
		||||
	os.Setenv(secret.EnvMnemonic, mnemonicStr)
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if originalMnemonic != "" {
 | 
			
		||||
			os.Setenv(secret.EnvMnemonic, originalMnemonic)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Unsetenv(secret.EnvMnemonic)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	// Calculate mnemonic hash for index tracking
 | 
			
		||||
	mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonicStr))
 | 
			
		||||
	secret.DebugWith("Calculated mnemonic hash", slog.String("hash", mnemonicHash))
 | 
			
		||||
 | 
			
		||||
	// Create the default vault - it will handle key derivation internally
 | 
			
		||||
	// Get the next available derivation index for this mnemonic
 | 
			
		||||
	derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get next derivation index", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to get next derivation index: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	secret.DebugWith("Using derivation index", slog.Uint64("index", uint64(derivationIndex)))
 | 
			
		||||
 | 
			
		||||
	// Derive long-term keypair from mnemonic with the appropriate index
 | 
			
		||||
	secret.DebugWith("Deriving long-term key from mnemonic", slog.Uint64("index", uint64(derivationIndex)))
 | 
			
		||||
	ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, derivationIndex)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to derive long-term key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate the long-term key hash
 | 
			
		||||
	ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
 | 
			
		||||
	secret.DebugWith("Calculated long-term key hash", slog.String("hash", ltKeyHash))
 | 
			
		||||
 | 
			
		||||
	// Create the default vault
 | 
			
		||||
	secret.Debug("Creating default vault")
 | 
			
		||||
	vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -97,21 +107,35 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
 | 
			
		||||
		return fmt.Errorf("failed to create default vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the vault metadata to retrieve the derivation index
 | 
			
		||||
	vaultDir := filepath.Join(stateDir, "vaults.d", "default")
 | 
			
		||||
	metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to load vault metadata", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to load vault metadata: %w", err)
 | 
			
		||||
	// Set as current vault
 | 
			
		||||
	secret.Debug("Setting default vault as current")
 | 
			
		||||
	if err := vault.SelectVault(cli.fs, cli.stateDir, "default"); err != nil {
 | 
			
		||||
		secret.Debug("Failed to select default vault", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to select default vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Derive the long-term key using the same index that CreateVault used
 | 
			
		||||
	ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to derive long-term key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	// Store long-term public key in vault
 | 
			
		||||
	vaultDir := filepath.Join(stateDir, "vaults.d", "default")
 | 
			
		||||
	ltPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
	secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir))
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), secret.FilePerms); err != nil {
 | 
			
		||||
		secret.Debug("Failed to write long-term public key", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to write long-term public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save vault metadata
 | 
			
		||||
	metadata := &vault.VaultMetadata{
 | 
			
		||||
		Name:            "default",
 | 
			
		||||
		CreatedAt:       time.Now(),
 | 
			
		||||
		DerivationIndex: derivationIndex,
 | 
			
		||||
		LongTermKeyHash: ltKeyHash,
 | 
			
		||||
		MnemonicHash:    mnemonicHash,
 | 
			
		||||
	}
 | 
			
		||||
	if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
 | 
			
		||||
		secret.Debug("Failed to save vault metadata", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to save vault metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("Saved vault metadata with derivation index and key hash")
 | 
			
		||||
 | 
			
		||||
	// Unlock the vault with the derived long-term key
 | 
			
		||||
	vlt.Unlock(ltIdentity)
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -9,6 +9,7 @@ import (
 | 
			
		||||
 | 
			
		||||
// CLIEntry is the entry point for the secret CLI application
 | 
			
		||||
func CLIEntry() {
 | 
			
		||||
	secret.Debug("CLIEntry starting - debug output is working")
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	if err := cmd.Execute(); err != nil {
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
@ -28,7 +29,7 @@ func newRootCmd() *cobra.Command {
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Adding subcommands to root command")
 | 
			
		||||
	// Add subcommands
 | 
			
		||||
	cmd.AddCommand(NewInitCmd())
 | 
			
		||||
	cmd.AddCommand(newInitCmd())
 | 
			
		||||
	cmd.AddCommand(newGenerateCmd())
 | 
			
		||||
	cmd.AddCommand(newVaultCmd())
 | 
			
		||||
	cmd.AddCommand(newAddCmd())
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,7 @@ func newGetCmd() *cobra.Command {
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			version, _ := cmd.Flags().GetString("version")
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.GetSecretWithVersion(cmd, args[0], version)
 | 
			
		||||
			return cli.GetSecretWithVersion(args[0], version)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -66,7 +66,7 @@ func newListCmd() *cobra.Command {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.ListSecrets(cmd, jsonOutput, filter)
 | 
			
		||||
			return cli.ListSecrets(jsonOutput, filter)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -85,7 +85,7 @@ func newImportCmd() *cobra.Command {
 | 
			
		||||
			force, _ := cmd.Flags().GetBool("force")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.ImportSecret(cmd, args[0], sourceFile, force)
 | 
			
		||||
			return cli.ImportSecret(args[0], sourceFile, force)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -135,18 +135,15 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetSecret retrieves and prints a secret from the current vault
 | 
			
		||||
func (cli *CLIInstance) GetSecret(cmd *cobra.Command, secretName string) error {
 | 
			
		||||
	return cli.GetSecretWithVersion(cmd, secretName, "")
 | 
			
		||||
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(cmd *cobra.Command, secretName string, version string) error {
 | 
			
		||||
	secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
 | 
			
		||||
 | 
			
		||||
func (cli *CLIInstance) GetSecretWithVersion(secretName string, version string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get current vault", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -158,20 +155,16 @@ func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName stri
 | 
			
		||||
		value, err = vlt.GetSecretVersion(secretName, version)
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get secret", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	secret.Debug("Got secret value", "valueLength", len(value))
 | 
			
		||||
 | 
			
		||||
	// Print the secret value to stdout
 | 
			
		||||
	cmd.Print(string(value))
 | 
			
		||||
	secret.Debug("Printed value to cmd")
 | 
			
		||||
	fmt.Print(string(value))
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListSecrets lists all secrets in the current vault
 | 
			
		||||
func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter string) error {
 | 
			
		||||
func (cli *CLIInstance) ListSecrets(jsonOutput bool, filter string) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -227,27 +220,27 @@ func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter
 | 
			
		||||
			return fmt.Errorf("failed to marshal JSON: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmd.Println(string(jsonBytes))
 | 
			
		||||
		fmt.Println(string(jsonBytes))
 | 
			
		||||
	} else {
 | 
			
		||||
		// Pretty table output
 | 
			
		||||
		if len(filteredSecrets) == 0 {
 | 
			
		||||
			if filter != "" {
 | 
			
		||||
				cmd.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
 | 
			
		||||
				fmt.Printf("No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
 | 
			
		||||
			} else {
 | 
			
		||||
				cmd.Println("No secrets found in current vault.")
 | 
			
		||||
				cmd.Println("Run 'secret add <name>' to create one.")
 | 
			
		||||
				fmt.Println("No secrets found in current vault.")
 | 
			
		||||
				fmt.Println("Run 'secret add <name>' to create one.")
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get current vault name for display
 | 
			
		||||
		if filter != "" {
 | 
			
		||||
			cmd.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
 | 
			
		||||
			fmt.Printf("Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
 | 
			
		||||
		} else {
 | 
			
		||||
			cmd.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
 | 
			
		||||
			fmt.Printf("Secrets in vault '%s':\n\n", vlt.GetName())
 | 
			
		||||
		}
 | 
			
		||||
		cmd.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
 | 
			
		||||
		cmd.Printf("%-40s %-20s\n", "----", "------------")
 | 
			
		||||
		fmt.Printf("%-40s %-20s\n", "NAME", "LAST UPDATED")
 | 
			
		||||
		fmt.Printf("%-40s %-20s\n", "----", "------------")
 | 
			
		||||
 | 
			
		||||
		for _, secretName := range filteredSecrets {
 | 
			
		||||
			lastUpdated := "unknown"
 | 
			
		||||
@ -255,21 +248,21 @@ func (cli *CLIInstance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter
 | 
			
		||||
				metadata := secretObj.GetMetadata()
 | 
			
		||||
				lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
 | 
			
		||||
			}
 | 
			
		||||
			cmd.Printf("%-40s %-20s\n", secretName, lastUpdated)
 | 
			
		||||
			fmt.Printf("%-40s %-20s\n", secretName, lastUpdated)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmd.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
 | 
			
		||||
		fmt.Printf("\nTotal: %d secret(s)", len(filteredSecrets))
 | 
			
		||||
		if filter != "" {
 | 
			
		||||
			cmd.Printf(" (filtered from %d)", len(secrets))
 | 
			
		||||
			fmt.Printf(" (filtered from %d)", len(secrets))
 | 
			
		||||
		}
 | 
			
		||||
		cmd.Println()
 | 
			
		||||
		fmt.Println()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ImportSecret imports a secret from a file
 | 
			
		||||
func (cli *CLIInstance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
 | 
			
		||||
func (cli *CLIInstance) ImportSecret(secretName, sourceFile string, force bool) error {
 | 
			
		||||
	// Get current vault
 | 
			
		||||
	vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -287,6 +280,6 @@ func (cli *CLIInstance) ImportSecret(cmd *cobra.Command, secretName, sourceFile
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
 | 
			
		||||
	fmt.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,58 +0,0 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
 | 
			
		||||
func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) {
 | 
			
		||||
	secret.Debug("ExecuteCommandInProcess called", "args", args)
 | 
			
		||||
 | 
			
		||||
	// Save current environment
 | 
			
		||||
	savedEnv := make(map[string]string)
 | 
			
		||||
	for k := range env {
 | 
			
		||||
		savedEnv[k] = os.Getenv(k)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set test environment
 | 
			
		||||
	for k, v := range env {
 | 
			
		||||
		os.Setenv(k, v)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create root command
 | 
			
		||||
	rootCmd := newRootCmd()
 | 
			
		||||
 | 
			
		||||
	// Capture output
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	rootCmd.SetOut(&buf)
 | 
			
		||||
	rootCmd.SetErr(&buf)
 | 
			
		||||
 | 
			
		||||
	// Set stdin if provided
 | 
			
		||||
	if stdin != "" {
 | 
			
		||||
		rootCmd.SetIn(strings.NewReader(stdin))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Set args
 | 
			
		||||
	rootCmd.SetArgs(args)
 | 
			
		||||
 | 
			
		||||
	// Execute command
 | 
			
		||||
	err := rootCmd.Execute()
 | 
			
		||||
 | 
			
		||||
	output := buf.String()
 | 
			
		||||
	secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
 | 
			
		||||
 | 
			
		||||
	// Restore environment
 | 
			
		||||
	for k, v := range savedEnv {
 | 
			
		||||
		if v == "" {
 | 
			
		||||
			os.Unsetenv(k)
 | 
			
		||||
		} else {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return output, err
 | 
			
		||||
}
 | 
			
		||||
@ -1,22 +0,0 @@
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestOutputCapture(t *testing.T) {
 | 
			
		||||
	// Test vault list command which we fixed
 | 
			
		||||
	output, err := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	assert.Contains(t, output, "Available vaults", "should capture vault list output")
 | 
			
		||||
	t.Logf("vault list output: %q", output)
 | 
			
		||||
 | 
			
		||||
	// Test help command
 | 
			
		||||
	output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	assert.NotEmpty(t, output, "help output should not be empty")
 | 
			
		||||
	t.Logf("help output length: %d", len(output))
 | 
			
		||||
}
 | 
			
		||||
@ -149,12 +149,12 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
 | 
			
		||||
			// Check if this is the right unlocker by comparing metadata
 | 
			
		||||
			metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				continue //FIXME this error needs to be handled
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var diskMetadata secret.UnlockerMetadata
 | 
			
		||||
			if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
 | 
			
		||||
				continue //FIXME this error needs to be handled
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Match by type and creation time
 | 
			
		||||
@ -177,8 +177,7 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
 | 
			
		||||
		if unlocker != nil {
 | 
			
		||||
			properID = unlocker.GetID()
 | 
			
		||||
		} else {
 | 
			
		||||
			// Generate ID as fallback
 | 
			
		||||
			properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
 | 
			
		||||
			properID = metadata.ID // fallback to metadata ID
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		unlockerInfo := UnlockerInfo{
 | 
			
		||||
@ -241,8 +240,13 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
 | 
			
		||||
			return fmt.Errorf("failed to get current vault: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// For passphrase unlockers, we don't need the vault to be unlocked
 | 
			
		||||
		// The CreatePassphraseUnlocker method will handle getting the long-term key
 | 
			
		||||
		// Try to unlock the vault if not already unlocked
 | 
			
		||||
		if vlt.Locked() {
 | 
			
		||||
			_, err := vlt.UnlockVault()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to unlock vault: %w", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if passphrase is set in environment variable
 | 
			
		||||
		var passphraseStr string
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@ func newVaultListCmd() *cobra.Command {
 | 
			
		||||
			jsonOutput, _ := cmd.Flags().GetBool("json")
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.ListVaults(cmd, jsonOutput)
 | 
			
		||||
			return cli.ListVaults(jsonOutput)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ func newVaultCreateCmd() *cobra.Command {
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.CreateVault(cmd, args[0])
 | 
			
		||||
			return cli.CreateVault(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -65,7 +65,7 @@ func newVaultSelectCmd() *cobra.Command {
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.SelectVault(cmd, args[0])
 | 
			
		||||
			return cli.SelectVault(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -83,13 +83,13 @@ func newVaultImportCmd() *cobra.Command {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			cli := NewCLIInstance()
 | 
			
		||||
			return cli.VaultImport(cmd, vaultName)
 | 
			
		||||
			return cli.VaultImport(vaultName)
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListVaults lists all available vaults
 | 
			
		||||
func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
 | 
			
		||||
func (cli *CLIInstance) ListVaults(jsonOutput bool) error {
 | 
			
		||||
	vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
@ -111,12 +111,12 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		cmd.Println(string(jsonBytes))
 | 
			
		||||
		fmt.Println(string(jsonBytes))
 | 
			
		||||
	} else {
 | 
			
		||||
		// Text output
 | 
			
		||||
		cmd.Println("Available vaults:")
 | 
			
		||||
		fmt.Println("Available vaults:")
 | 
			
		||||
		if len(vaults) == 0 {
 | 
			
		||||
			cmd.Println("  (none)")
 | 
			
		||||
			fmt.Println("  (none)")
 | 
			
		||||
		} else {
 | 
			
		||||
			// Try to get current vault for marking
 | 
			
		||||
			currentVault := ""
 | 
			
		||||
@ -126,9 +126,9 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
 | 
			
		||||
 | 
			
		||||
			for _, vaultName := range vaults {
 | 
			
		||||
				if vaultName == currentVault {
 | 
			
		||||
					cmd.Printf("  %s (current)\n", vaultName)
 | 
			
		||||
					fmt.Printf("  %s (current)\n", vaultName)
 | 
			
		||||
				} else {
 | 
			
		||||
					cmd.Printf("  %s\n", vaultName)
 | 
			
		||||
					fmt.Printf("  %s\n", vaultName)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -138,7 +138,7 @@ func (cli *CLIInstance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// CreateVault creates a new vault
 | 
			
		||||
func (cli *CLIInstance) CreateVault(cmd *cobra.Command, name string) error {
 | 
			
		||||
func (cli *CLIInstance) CreateVault(name string) error {
 | 
			
		||||
	secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
 | 
			
		||||
 | 
			
		||||
	vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
 | 
			
		||||
@ -146,22 +146,22 @@ func (cli *CLIInstance) CreateVault(cmd *cobra.Command, name string) error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Created vault '%s'\n", vlt.GetName())
 | 
			
		||||
	fmt.Printf("Created vault '%s'\n", vlt.GetName())
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SelectVault selects a vault as the current one
 | 
			
		||||
func (cli *CLIInstance) SelectVault(cmd *cobra.Command, name string) error {
 | 
			
		||||
func (cli *CLIInstance) SelectVault(name string) error {
 | 
			
		||||
	if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Selected vault '%s' as current\n", name)
 | 
			
		||||
	fmt.Printf("Selected vault '%s' as current\n", name)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// VaultImport imports a mnemonic into a specific vault
 | 
			
		||||
func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error {
 | 
			
		||||
func (cli *CLIInstance) VaultImport(vaultName string) error {
 | 
			
		||||
	secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
 | 
			
		||||
 | 
			
		||||
	// Get the specific vault by name
 | 
			
		||||
@ -181,12 +181,6 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
			
		||||
		return fmt.Errorf("vault '%s' does not exist", vaultName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if vault already has a public key
 | 
			
		||||
	pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
 | 
			
		||||
	if _, err := cli.fs.Stat(pubKeyPath); err == nil {
 | 
			
		||||
		return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get mnemonic from environment
 | 
			
		||||
	mnemonic := os.Getenv(secret.EnvMnemonic)
 | 
			
		||||
	if mnemonic == "" {
 | 
			
		||||
@ -200,8 +194,12 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
			
		||||
		return fmt.Errorf("invalid BIP39 mnemonic")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate mnemonic hash for index tracking
 | 
			
		||||
	mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonic))
 | 
			
		||||
	secret.Debug("Calculated mnemonic hash", "hash", mnemonicHash)
 | 
			
		||||
 | 
			
		||||
	// Get the next available derivation index for this mnemonic
 | 
			
		||||
	derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
 | 
			
		||||
	derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get next derivation index", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to get next derivation index: %w", err)
 | 
			
		||||
@ -215,40 +213,32 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
			
		||||
		return fmt.Errorf("failed to derive long-term key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate the long-term key hash
 | 
			
		||||
	ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
 | 
			
		||||
	secret.Debug("Calculated long-term key hash", "hash", ltKeyHash)
 | 
			
		||||
 | 
			
		||||
	// Store long-term public key in vault
 | 
			
		||||
	ltPublicKey := ltIdentity.Recipient().String()
 | 
			
		||||
	secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
 | 
			
		||||
 | 
			
		||||
	pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), 0600); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to store long-term public key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Calculate public key hash from index 0 (same for all vaults with this mnemonic)
 | 
			
		||||
	// This is used to identify which vaults belong to the same mnemonic family
 | 
			
		||||
	identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	publicKeyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
			
		||||
 | 
			
		||||
	// Load existing metadata
 | 
			
		||||
	existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// If metadata doesn't exist, create new
 | 
			
		||||
		existingMetadata = &vault.VaultMetadata{
 | 
			
		||||
	// Save vault metadata
 | 
			
		||||
	metadata := &vault.VaultMetadata{
 | 
			
		||||
		Name:            vaultName,
 | 
			
		||||
		CreatedAt:       time.Now(),
 | 
			
		||||
		DerivationIndex: derivationIndex,
 | 
			
		||||
		LongTermKeyHash: ltKeyHash,
 | 
			
		||||
		MnemonicHash:    mnemonicHash,
 | 
			
		||||
	}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update metadata with new derivation info
 | 
			
		||||
	existingMetadata.DerivationIndex = derivationIndex
 | 
			
		||||
	existingMetadata.PublicKeyHash = publicKeyHash
 | 
			
		||||
 | 
			
		||||
	if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
 | 
			
		||||
	if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
 | 
			
		||||
		secret.Debug("Failed to save vault metadata", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to save vault metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	secret.Debug("Saved vault metadata with derivation index and public key hash")
 | 
			
		||||
	secret.Debug("Saved vault metadata with derivation index and key hash")
 | 
			
		||||
 | 
			
		||||
	// Get passphrase from environment variable
 | 
			
		||||
	passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
 | 
			
		||||
@ -269,9 +259,9 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
 | 
			
		||||
		return fmt.Errorf("failed to create unlocker: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
 | 
			
		||||
	cmd.Printf("Long-term public key: %s\n", ltPublicKey)
 | 
			
		||||
	cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
 | 
			
		||||
	fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
 | 
			
		||||
	fmt.Printf("Long-term public key: %s\n", ltPublicKey)
 | 
			
		||||
	fmt.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,7 @@ func VersionCommands(cli *CLIInstance) *cobra.Command {
 | 
			
		||||
		Short: "List all versions of a secret",
 | 
			
		||||
		Args:  cobra.ExactArgs(1),
 | 
			
		||||
		RunE: func(cmd *cobra.Command, args []string) error {
 | 
			
		||||
			return cli.ListVersions(cmd, args[0])
 | 
			
		||||
			return cli.ListVersions(args[0])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -44,7 +44,7 @@ func VersionCommands(cli *CLIInstance) *cobra.Command {
 | 
			
		||||
		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(cmd, args[0], args[1])
 | 
			
		||||
			return cli.PromoteVersion(args[0], args[1])
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -53,46 +53,42 @@ func VersionCommands(cli *CLIInstance) *cobra.Command {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListVersions lists all versions of a secret
 | 
			
		||||
func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) error {
 | 
			
		||||
	secret.Debug("ListVersions called", "secret_name", secretName)
 | 
			
		||||
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 {
 | 
			
		||||
		secret.Debug("Failed to get current vault", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
		return fmt.Errorf("failed to get current vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get vault directory
 | 
			
		||||
	vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to get vault directory", "error", err)
 | 
			
		||||
		return err
 | 
			
		||||
		return fmt.Errorf("failed to get vault directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the encoded secret name
 | 
			
		||||
	encodedName := strings.ReplaceAll(secretName, "/", "%")
 | 
			
		||||
	secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
 | 
			
		||||
	// 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 {
 | 
			
		||||
		secret.Debug("Failed to check if secret exists", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to check if secret exists: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if !exists {
 | 
			
		||||
		secret.Debug("Secret not found", "secret_name", secretName)
 | 
			
		||||
		return fmt.Errorf("secret '%s' not found", secretName)
 | 
			
		||||
		return fmt.Errorf("secret %s not found", secretName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// List all versions
 | 
			
		||||
	// Get all versions
 | 
			
		||||
	versions, err := secret.ListVersions(cli.fs, secretDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		secret.Debug("Failed to list versions", "error", err)
 | 
			
		||||
		return fmt.Errorf("failed to list versions: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(versions) == 0 {
 | 
			
		||||
		cmd.Println("No versions found")
 | 
			
		||||
		fmt.Println("No versions found")
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -159,44 +155,49 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PromoteVersion promotes a specific version to current
 | 
			
		||||
func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
 | 
			
		||||
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 err
 | 
			
		||||
		return fmt.Errorf("failed to get current vault: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get vault directory
 | 
			
		||||
	vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
		return fmt.Errorf("failed to get vault directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get the encoded secret name
 | 
			
		||||
	encodedName := strings.ReplaceAll(secretName, "/", "%")
 | 
			
		||||
	secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
 | 
			
		||||
	// 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
 | 
			
		||||
	versionDir := filepath.Join(secretDir, "versions", version)
 | 
			
		||||
	exists, err := afero.DirExists(cli.fs, versionDir)
 | 
			
		||||
	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)
 | 
			
		||||
		return fmt.Errorf("version %s not found for secret %s", version, secretName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update the current symlink
 | 
			
		||||
	currentLink := filepath.Join(secretDir, "current")
 | 
			
		||||
 | 
			
		||||
	// Remove existing symlink
 | 
			
		||||
	_ = cli.fs.Remove(currentLink)
 | 
			
		||||
 | 
			
		||||
	// Create new symlink to the selected version
 | 
			
		||||
	relativePath := filepath.Join("versions", version)
 | 
			
		||||
	if err := afero.WriteFile(cli.fs, currentLink, []byte(relativePath), 0644); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to update current version: %w", err)
 | 
			
		||||
	// Update current symlink
 | 
			
		||||
	if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to promote version: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
 | 
			
		||||
	fmt.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,23 +1,8 @@
 | 
			
		||||
// Version CLI Command Tests
 | 
			
		||||
//
 | 
			
		||||
// Tests for version-related CLI commands:
 | 
			
		||||
//
 | 
			
		||||
// - 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
 | 
			
		||||
//
 | 
			
		||||
// Test Utilities:
 | 
			
		||||
// - setupTestVault(): CLI test helper for vault initialization
 | 
			
		||||
// - Uses consistent test mnemonic for reproducible testing
 | 
			
		||||
 | 
			
		||||
package cli
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
@ -77,18 +62,20 @@ func TestListVersionsCommand(t *testing.T) {
 | 
			
		||||
	err = vlt.AddSecret("test/secret", []byte("version-2"), true)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
	cmd.SetErr(&buf)
 | 
			
		||||
	// Capture output
 | 
			
		||||
	oldStdout := os.Stdout
 | 
			
		||||
	r, w, _ := os.Pipe()
 | 
			
		||||
	os.Stdout = w
 | 
			
		||||
 | 
			
		||||
	// List versions
 | 
			
		||||
	err = cli.ListVersions(cmd, "test/secret")
 | 
			
		||||
	err = cli.ListVersions("test/secret")
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	// Read output
 | 
			
		||||
	outputStr := buf.String()
 | 
			
		||||
	// 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")
 | 
			
		||||
@ -119,14 +106,8 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
 | 
			
		||||
	// Set up vault with long-term key
 | 
			
		||||
	setupTestVault(t, fs, stateDir)
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
	cmd.SetErr(&buf)
 | 
			
		||||
 | 
			
		||||
	// Try to list versions of non-existent secret
 | 
			
		||||
	err := cli.ListVersions(cmd, "nonexistent/secret")
 | 
			
		||||
	err := cli.ListVersions("nonexistent/secret")
 | 
			
		||||
	assert.Error(t, err)
 | 
			
		||||
	assert.Contains(t, err.Error(), "not found")
 | 
			
		||||
}
 | 
			
		||||
@ -166,17 +147,19 @@ func TestPromoteVersionCommand(t *testing.T) {
 | 
			
		||||
	// Promote first version
 | 
			
		||||
	firstVersion := versions[1] // Older version
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
	cmd.SetErr(&buf)
 | 
			
		||||
	// Capture output
 | 
			
		||||
	oldStdout := os.Stdout
 | 
			
		||||
	r, w, _ := os.Pipe()
 | 
			
		||||
	os.Stdout = w
 | 
			
		||||
 | 
			
		||||
	err = cli.PromoteVersion(cmd, "test/secret", firstVersion)
 | 
			
		||||
	err = cli.PromoteVersion("test/secret", firstVersion)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	// Read output
 | 
			
		||||
	outputStr := buf.String()
 | 
			
		||||
	// 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")
 | 
			
		||||
@ -203,14 +186,8 @@ func TestPromoteNonExistentVersion(t *testing.T) {
 | 
			
		||||
	err = vlt.AddSecret("test/secret", []byte("value"), false)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
	cmd.SetErr(&buf)
 | 
			
		||||
 | 
			
		||||
	// Try to promote non-existent version
 | 
			
		||||
	err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
 | 
			
		||||
	err = cli.PromoteVersion("test/secret", "20991231.999")
 | 
			
		||||
	assert.Error(t, err)
 | 
			
		||||
	assert.Contains(t, err.Error(), "not found")
 | 
			
		||||
}
 | 
			
		||||
@ -242,22 +219,33 @@ func TestGetSecretWithVersion(t *testing.T) {
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	require.Len(t, versions, 2)
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
 | 
			
		||||
	// Test getting current version (empty version string)
 | 
			
		||||
	err = cli.GetSecretWithVersion(cmd, "test/secret", "")
 | 
			
		||||
	oldStdout := os.Stdout
 | 
			
		||||
	r, w, _ := os.Pipe()
 | 
			
		||||
	os.Stdout = w
 | 
			
		||||
 | 
			
		||||
	err = cli.GetSecretWithVersion("test/secret", "")
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, "version-2", buf.String())
 | 
			
		||||
 | 
			
		||||
	w.Close()
 | 
			
		||||
	os.Stdout = oldStdout
 | 
			
		||||
	output, _ := io.ReadAll(r)
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, "version-2", string(output))
 | 
			
		||||
 | 
			
		||||
	// Test getting specific version
 | 
			
		||||
	buf.Reset()
 | 
			
		||||
	r, w, _ = os.Pipe()
 | 
			
		||||
	os.Stdout = w
 | 
			
		||||
 | 
			
		||||
	firstVersion := versions[1] // Older version
 | 
			
		||||
	err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
 | 
			
		||||
	err = cli.GetSecretWithVersion("test/secret", firstVersion)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, "version-1", buf.String())
 | 
			
		||||
 | 
			
		||||
	w.Close()
 | 
			
		||||
	os.Stdout = oldStdout
 | 
			
		||||
	output, _ = io.ReadAll(r)
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, "version-1", string(output))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestVersionCommandStructure(t *testing.T) {
 | 
			
		||||
@ -292,14 +280,8 @@ func TestListVersionsEmptyOutput(t *testing.T) {
 | 
			
		||||
	err := fs.MkdirAll(secretDir, 0755)
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	// Create a command for output capture
 | 
			
		||||
	cmd := newRootCmd()
 | 
			
		||||
	var buf bytes.Buffer
 | 
			
		||||
	cmd.SetOut(&buf)
 | 
			
		||||
	cmd.SetErr(&buf)
 | 
			
		||||
 | 
			
		||||
	// List versions - should show "No versions found"
 | 
			
		||||
	err = cli.ListVersions(cmd, "test/secret")
 | 
			
		||||
	err = cli.ListVersions("test/secret")
 | 
			
		||||
 | 
			
		||||
	// Should succeed even with no versions
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
@ -118,7 +118,7 @@ func TestDebugFunctions(t *testing.T) {
 | 
			
		||||
	initDebugLogging()
 | 
			
		||||
 | 
			
		||||
	if !IsDebugEnabled() {
 | 
			
		||||
		t.Log("Debug not enabled, but continuing with debug function tests anyway")
 | 
			
		||||
		t.Skip("Debug not enabled, skipping debug function tests")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test that debug functions don't panic and can be called
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,8 @@ import (
 | 
			
		||||
// 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"`
 | 
			
		||||
}
 | 
			
		||||
@ -131,14 +133,18 @@ func (k *KeychainUnlocker) GetDirectory() string {
 | 
			
		||||
	return k.Directory
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetID implements Unlocker interface - generates ID from keychain item name
 | 
			
		||||
// GetID implements Unlocker interface
 | 
			
		||||
func (k *KeychainUnlocker) GetID() string {
 | 
			
		||||
	return k.Metadata.ID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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 {
 | 
			
		||||
		// The vault metadata is corrupt - this is a fatal error
 | 
			
		||||
		// We cannot continue with a fallback ID as that would mask data corruption
 | 
			
		||||
		panic(fmt.Sprintf("Keychain unlocker metadata is corrupt or missing keychain item name: %v", err))
 | 
			
		||||
		// Fallback to metadata ID if we can't read the keychain item name
 | 
			
		||||
		return k.Metadata.ID
 | 
			
		||||
	}
 | 
			
		||||
	return fmt.Sprintf("%s-keychain", keychainItemName)
 | 
			
		||||
}
 | 
			
		||||
@ -250,11 +256,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
 | 
			
		||||
		return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 3: Store age recipient as plaintext
 | 
			
		||||
	ageRecipient := ageIdentity.Recipient().String()
 | 
			
		||||
	recipientPath := filepath.Join(unlockerDir, "pub.txt")
 | 
			
		||||
	if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write age recipient: %w", err)
 | 
			
		||||
	// Step 3: Store age public key as plaintext
 | 
			
		||||
	agePublicKeyString := ageIdentity.Recipient().String()
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 4: Encrypt age private key with the generated passphrase and store on disk
 | 
			
		||||
@ -342,7 +348,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
 | 
			
		||||
 | 
			
		||||
	// Step 7: Prepare keychain data
 | 
			
		||||
	keychainData := KeychainData{
 | 
			
		||||
		AgePublicKey:         ageRecipient,
 | 
			
		||||
		AgePublicKey:         agePublicKeyString,
 | 
			
		||||
		AgePrivKeyPassphrase: agePrivKeyPassphrase,
 | 
			
		||||
		EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
 | 
			
		||||
	}
 | 
			
		||||
@ -358,12 +364,17 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 9: Create and write enhanced metadata
 | 
			
		||||
	// Generate the key ID directly using the keychain item name
 | 
			
		||||
	keyID := fmt.Sprintf("%s-keychain", keychainItemName)
 | 
			
		||||
 | 
			
		||||
	keychainMetadata := KeychainUnlockerMetadata{
 | 
			
		||||
		UnlockerMetadata: UnlockerMetadata{
 | 
			
		||||
			ID:        keyID,
 | 
			
		||||
			Type:      "keychain",
 | 
			
		||||
			CreatedAt: time.Now(),
 | 
			
		||||
			Flags:     []string{"keychain", "macos"},
 | 
			
		||||
		},
 | 
			
		||||
		AgePublicKey:     agePublicKeyString,
 | 
			
		||||
		KeychainItemName: keychainItemName,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,14 +6,17 @@ import (
 | 
			
		||||
 | 
			
		||||
// VaultMetadata contains information about a vault
 | 
			
		||||
type VaultMetadata struct {
 | 
			
		||||
	Name            string    `json:"name"`
 | 
			
		||||
	CreatedAt       time.Time `json:"createdAt"`
 | 
			
		||||
	Description     string    `json:"description,omitempty"`
 | 
			
		||||
	DerivationIndex uint32    `json:"derivation_index"`
 | 
			
		||||
	PublicKeyHash   string    `json:"public_key_hash,omitempty"` // Double SHA256 hash of the long-term public key
 | 
			
		||||
	LongTermKeyHash string    `json:"long_term_key_hash"` // Double SHA256 hash of derived long-term private key
 | 
			
		||||
	MnemonicHash    string    `json:"mnemonic_hash"`      // Double SHA256 hash of mnemonic for index tracking
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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"`
 | 
			
		||||
	Flags     []string  `json:"flags,omitempty"`
 | 
			
		||||
@ -21,6 +24,7 @@ type UnlockerMetadata struct {
 | 
			
		||||
 | 
			
		||||
// SecretMetadata contains information about a secret
 | 
			
		||||
type SecretMetadata struct {
 | 
			
		||||
	Name      string    `json:"name"`
 | 
			
		||||
	CreatedAt time.Time `json:"createdAt"`
 | 
			
		||||
	UpdatedAt time.Time `json:"updatedAt"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -13,9 +13,9 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
 | 
			
		||||
	// This test uses real filesystem
 | 
			
		||||
	// Skip this test if CI=true is set, as it uses real filesystem
 | 
			
		||||
	if os.Getenv("CI") == "true" {
 | 
			
		||||
		t.Log("Running in CI environment with real filesystem")
 | 
			
		||||
		t.Skip("Skipping test with real filesystem in CI environment")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create a temporary directory for our tests
 | 
			
		||||
@ -40,6 +40,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Set up test metadata
 | 
			
		||||
	metadata := secret.UnlockerMetadata{
 | 
			
		||||
		ID:        "test-passphrase",
 | 
			
		||||
		Type:      "passphrase",
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		Flags:     []string{},
 | 
			
		||||
 | 
			
		||||
@ -107,8 +107,13 @@ func (p *PassphraseUnlocker) GetDirectory() string {
 | 
			
		||||
	return p.Directory
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetID implements Unlocker interface - generates ID from creation timestamp
 | 
			
		||||
// 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"))
 | 
			
		||||
 | 
			
		||||
@ -124,10 +124,9 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPGPUnlockerWithRealFS(t *testing.T) {
 | 
			
		||||
	// Check if gpg is available
 | 
			
		||||
	// Skip tests if gpg is not available
 | 
			
		||||
	if _, err := exec.LookPath("gpg"); err != nil {
 | 
			
		||||
		t.Log("GPG not available, PGP unlock key tests may not fully function")
 | 
			
		||||
		// Continue anyway to test what we can
 | 
			
		||||
		t.Skip("GPG not available, skipping PGP unlock key tests")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create a temporary directory for our tests
 | 
			
		||||
@ -342,13 +341,13 @@ Passphrase: ` + testPassphrase + `
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if required files exist
 | 
			
		||||
		recipientPath := filepath.Join(unlockerDir, "pub.txt")
 | 
			
		||||
		recipientExists, err := afero.Exists(fs, recipientPath)
 | 
			
		||||
		pubKeyPath := filepath.Join(unlockerDir, "pub.age")
 | 
			
		||||
		pubKeyExists, err := afero.Exists(fs, pubKeyPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to check if recipient file exists: %v", err)
 | 
			
		||||
			t.Fatalf("Failed to check if public key file exists: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if !recipientExists {
 | 
			
		||||
			t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
 | 
			
		||||
		if !pubKeyExists {
 | 
			
		||||
			t.Errorf("PGP unlock key public key file does not exist: %s", pubKeyPath)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
 | 
			
		||||
@ -413,6 +412,7 @@ Passphrase: ` + testPassphrase + `
 | 
			
		||||
 | 
			
		||||
	// Set up test metadata
 | 
			
		||||
	metadata := secret.UnlockerMetadata{
 | 
			
		||||
		ID:        fmt.Sprintf("%s-pgp", keyID),
 | 
			
		||||
		Type:      "pgp",
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		Flags:     []string{"gpg", "encrypted"},
 | 
			
		||||
@ -464,10 +464,10 @@ Passphrase: ` + testPassphrase + `
 | 
			
		||||
			t.Fatalf("Failed to generate age identity: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Write the recipient
 | 
			
		||||
		recipientPath := filepath.Join(unlockerDir, "pub.txt")
 | 
			
		||||
		if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write recipient: %v", err)
 | 
			
		||||
		// Write the public key
 | 
			
		||||
		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)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// GPG encrypt the private key using our custom encrypt function
 | 
			
		||||
 | 
			
		||||
@ -31,6 +31,9 @@ type PGPUnlockerMetadata struct {
 | 
			
		||||
	UnlockerMetadata
 | 
			
		||||
	// GPG key ID used for encryption
 | 
			
		||||
	GPGKeyID string `json:"gpg_key_id"`
 | 
			
		||||
	// Age keypair information
 | 
			
		||||
	AgePublicKey string `json:"age_public_key"`
 | 
			
		||||
	AgeRecipient string `json:"age_recipient"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PGPUnlocker represents a PGP-protected unlocker
 | 
			
		||||
@ -106,14 +109,18 @@ func (p *PGPUnlocker) GetDirectory() string {
 | 
			
		||||
	return p.Directory
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetID implements Unlocker interface - generates ID from GPG key ID
 | 
			
		||||
// GetID implements Unlocker interface
 | 
			
		||||
func (p *PGPUnlocker) GetID() string {
 | 
			
		||||
	return p.Metadata.ID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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 {
 | 
			
		||||
		// The vault metadata is corrupt - this is a fatal error
 | 
			
		||||
		// We cannot continue with a fallback ID as that would mask data corruption
 | 
			
		||||
		panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
 | 
			
		||||
		// Fallback to metadata ID if we can't read the GPG key ID
 | 
			
		||||
		return p.Metadata.ID
 | 
			
		||||
	}
 | 
			
		||||
	return fmt.Sprintf("%s-pgp", gpgKeyID)
 | 
			
		||||
}
 | 
			
		||||
@ -202,11 +209,11 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
 | 
			
		||||
		return nil, fmt.Errorf("failed to generate age keypair: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 2: Store age recipient as plaintext
 | 
			
		||||
	ageRecipient := ageIdentity.Recipient().String()
 | 
			
		||||
	recipientPath := filepath.Join(unlockerDir, "pub.txt")
 | 
			
		||||
	if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to write age recipient: %w", err)
 | 
			
		||||
	// Step 2: Store age public key as plaintext
 | 
			
		||||
	agePublicKeyString := ageIdentity.Recipient().String()
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 3: Get or derive the long-term private key
 | 
			
		||||
@ -286,13 +293,19 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Step 9: Create and write enhanced metadata
 | 
			
		||||
	// Generate the key ID directly using the GPG key ID
 | 
			
		||||
	keyID := fmt.Sprintf("%s-pgp", gpgKeyID)
 | 
			
		||||
 | 
			
		||||
	pgpMetadata := PGPUnlockerMetadata{
 | 
			
		||||
		UnlockerMetadata: UnlockerMetadata{
 | 
			
		||||
			ID:        keyID,
 | 
			
		||||
			Type:      "pgp",
 | 
			
		||||
			CreatedAt: time.Now(),
 | 
			
		||||
			Flags:     []string{"gpg", "encrypted"},
 | 
			
		||||
		},
 | 
			
		||||
		GPGKeyID:     gpgKeyID,
 | 
			
		||||
		AgePublicKey: agePublicKeyString,
 | 
			
		||||
		AgeRecipient: ageIdentity.Recipient().String(),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	metadataBytes, err := json.MarshalIndent(pgpMetadata, "", "  ")
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
@ -55,6 +54,7 @@ func NewSecret(vault VaultInterface, name string) *Secret {
 | 
			
		||||
		Directory: secretDir,
 | 
			
		||||
		vault:     vault,
 | 
			
		||||
		Metadata: SecretMetadata{
 | 
			
		||||
			Name:      name,
 | 
			
		||||
			CreatedAt: time.Now(),
 | 
			
		||||
			UpdatedAt: time.Now(),
 | 
			
		||||
		},
 | 
			
		||||
@ -115,35 +115,8 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
 | 
			
		||||
	if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
 | 
			
		||||
		Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name)
 | 
			
		||||
 | 
			
		||||
		// Get vault directory to read metadata
 | 
			
		||||
		vaultDir, err := s.vault.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
 | 
			
		||||
			return nil, fmt.Errorf("failed to get vault directory: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Load vault metadata to get the correct derivation index
 | 
			
		||||
		metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
 | 
			
		||||
		metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
 | 
			
		||||
			return nil, fmt.Errorf("failed to read vault metadata: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var metadata VaultMetadata
 | 
			
		||||
		if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
 | 
			
		||||
			Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
 | 
			
		||||
			return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		DebugWith("Using vault derivation index from metadata",
 | 
			
		||||
			slog.String("secret_name", s.Name),
 | 
			
		||||
			slog.String("vault_name", s.vault.GetName()),
 | 
			
		||||
			slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		// Use mnemonic with the vault's derivation index from metadata
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
 | 
			
		||||
		// Use mnemonic directly to derive long-term key
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
 | 
			
		||||
			return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
			
		||||
@ -217,6 +190,7 @@ func (s *Secret) LoadMetadata() error {
 | 
			
		||||
	// For backward compatibility, we'll populate with basic info
 | 
			
		||||
	now := time.Now()
 | 
			
		||||
	s.Metadata = SecretMetadata{
 | 
			
		||||
		Name:      s.Name,
 | 
			
		||||
		CreatedAt: now,
 | 
			
		||||
		UpdatedAt: now,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
@ -10,7 +9,6 @@ import (
 | 
			
		||||
	"filippo.io/age"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
	"github.com/stretchr/testify/require"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// MockVault is a test implementation of the VaultInterface
 | 
			
		||||
@ -18,7 +16,7 @@ type MockVault struct {
 | 
			
		||||
	name       string
 | 
			
		||||
	fs         afero.Fs
 | 
			
		||||
	directory  string
 | 
			
		||||
	derivationIndex uint32
 | 
			
		||||
	longTermID *age.X25519Identity
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockVault) GetDirectory() (string, error) {
 | 
			
		||||
@ -26,82 +24,29 @@ func (m *MockVault) GetDirectory() (string, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
 | 
			
		||||
	// Create secret directory with proper storage name conversion
 | 
			
		||||
	// Create versioned structure for testing
 | 
			
		||||
	storageName := strings.ReplaceAll(name, "/", "%")
 | 
			
		||||
	secretDir := filepath.Join(m.directory, "secrets.d", storageName)
 | 
			
		||||
	if err := m.fs.MkdirAll(secretDir, 0700); err != nil {
 | 
			
		||||
 | 
			
		||||
	// Generate version name
 | 
			
		||||
	versionName, err := GenerateVersionName(m.fs, secretDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create version directory with proper path
 | 
			
		||||
	versionName := "20240101.001" // Use a fixed version name for testing
 | 
			
		||||
	// Create version directory
 | 
			
		||||
	versionDir := filepath.Join(secretDir, "versions", versionName)
 | 
			
		||||
	if err := m.fs.MkdirAll(versionDir, 0700); err != nil {
 | 
			
		||||
	if err := m.fs.MkdirAll(versionDir, DirPerms); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Read the vault's long-term public key
 | 
			
		||||
	ltPubKeyPath := filepath.Join(m.directory, "pub.age")
 | 
			
		||||
 | 
			
		||||
	// Derive long-term key using the vault's derivation index
 | 
			
		||||
	mnemonic := os.Getenv(EnvMnemonic)
 | 
			
		||||
	if mnemonic == "" {
 | 
			
		||||
		return fmt.Errorf("SB_SECRET_MNEMONIC not set")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
	// Write encrypted value (simplified for testing)
 | 
			
		||||
	if err := afero.WriteFile(m.fs, filepath.Join(versionDir, "value.age"), value, FilePerms); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write long-term public key if it doesn't exist
 | 
			
		||||
	if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
 | 
			
		||||
		pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0600); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Generate version-specific keypair
 | 
			
		||||
	versionIdentity, err := age.GenerateX25519Identity()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write version public key
 | 
			
		||||
	pubKeyPath := filepath.Join(versionDir, "pub.age")
 | 
			
		||||
	if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0600); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt value to version's public key
 | 
			
		||||
	encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted value
 | 
			
		||||
	valuePath := filepath.Join(versionDir, "value.age")
 | 
			
		||||
	if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0600); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt version private key to long-term public key
 | 
			
		||||
	encryptedPrivKey, err := EncryptToRecipient([]byte(versionIdentity.String()), ltIdentity.Recipient())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write encrypted version private key
 | 
			
		||||
	privKeyPath := filepath.Join(versionDir, "priv.age")
 | 
			
		||||
	if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create current symlink pointing to the version
 | 
			
		||||
	currentLink := filepath.Join(secretDir, "current")
 | 
			
		||||
	// For MemMapFs, write a file with the target path
 | 
			
		||||
	if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0600); err != nil {
 | 
			
		||||
	// Set current symlink
 | 
			
		||||
	if err := SetCurrentVersion(m.fs, secretDir, versionName); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -117,11 +62,11 @@ func (m *MockVault) GetFilesystem() afero.Fs {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
 | 
			
		||||
	return nil, nil
 | 
			
		||||
	return nil, nil // Not needed for this test
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
 | 
			
		||||
	return nil, nil
 | 
			
		||||
	return nil, nil // Not needed for this test
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPerSecretKeyFunctionality(t *testing.T) {
 | 
			
		||||
@ -182,7 +127,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
 | 
			
		||||
		name:       "test-vault",
 | 
			
		||||
		fs:         fs,
 | 
			
		||||
		directory:  vaultDir,
 | 
			
		||||
		derivationIndex: 0,
 | 
			
		||||
		longTermID: ltIdentity,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test data
 | 
			
		||||
@ -305,29 +250,3 @@ func TestSecretNameValidation(t *testing.T) {
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
 | 
			
		||||
	// This test demonstrates the bug where GetValue uses hardcoded index 0
 | 
			
		||||
	// instead of the vault's actual derivation index when using environment mnemonic
 | 
			
		||||
 | 
			
		||||
	// Set up test mnemonic
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
	originalEnv := os.Getenv(EnvMnemonic)
 | 
			
		||||
	os.Setenv(EnvMnemonic, testMnemonic)
 | 
			
		||||
	defer os.Setenv(EnvMnemonic, originalEnv)
 | 
			
		||||
 | 
			
		||||
	// Create temporary directory for vaults
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
	tempDir, err := afero.TempDir(fs, "", "secret-test-")
 | 
			
		||||
	require.NoError(t, err)
 | 
			
		||||
	defer func() {
 | 
			
		||||
		_ = fs.RemoveAll(tempDir)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	stateDir := filepath.Join(tempDir, ".secret")
 | 
			
		||||
	require.NoError(t, fs.MkdirAll(stateDir, 0700))
 | 
			
		||||
 | 
			
		||||
	// This test is now in the integration test file where it can use real vaults
 | 
			
		||||
	// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
 | 
			
		||||
	t.Log("This test demonstrates the bug in the integration test file")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ type Unlocker interface {
 | 
			
		||||
	GetType() string
 | 
			
		||||
	GetMetadata() UnlockerMetadata
 | 
			
		||||
	GetDirectory() string
 | 
			
		||||
	GetID() string // Generate ID based on unlocker type and data
 | 
			
		||||
	GetID() string
 | 
			
		||||
	ID() string    // Generate ID from the unlocker's public key
 | 
			
		||||
	Remove() error // Remove the unlocker and any associated resources
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -18,9 +18,11 @@ import (
 | 
			
		||||
// 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
 | 
			
		||||
@ -58,7 +60,9 @@ func NewSecretVersion(vault VaultInterface, secretName string, version string) *
 | 
			
		||||
		vault:      vault,
 | 
			
		||||
		Metadata: VersionMetadata{
 | 
			
		||||
			ID:         ulid.Make().String(),
 | 
			
		||||
			SecretName: secretName,
 | 
			
		||||
			CreatedAt:  &now,
 | 
			
		||||
			Version:    version,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,37 +1,3 @@
 | 
			
		||||
// Version Support Test Suite Documentation
 | 
			
		||||
//
 | 
			
		||||
// This file contains 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
 | 
			
		||||
//
 | 
			
		||||
// Key Test Scenarios:
 | 
			
		||||
// - 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
 | 
			
		||||
//
 | 
			
		||||
// Data Integrity:
 | 
			
		||||
// - Each version has independent encryption keys
 | 
			
		||||
// - Metadata encryption protects version history
 | 
			
		||||
// - Long-term key required for all operations
 | 
			
		||||
// - Concurrent reads handled safely
 | 
			
		||||
 | 
			
		||||
package secret
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
@ -136,6 +102,7 @@ func TestNewSecretVersion(t *testing.T) {
 | 
			
		||||
	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) {
 | 
			
		||||
@ -212,6 +179,8 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// 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)
 | 
			
		||||
@ -328,6 +297,8 @@ 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
 | 
			
		||||
 | 
			
		||||
@ -102,26 +102,29 @@ func TestVaultWithRealFilesystem(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a test vault - CreateVault now handles public key when mnemonic is in env
 | 
			
		||||
		// Create a test vault
 | 
			
		||||
		vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Load vault metadata to get its derivation index
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the vault directory
 | 
			
		||||
		vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to load vault metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic using the vault's derivation index
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		// Write long-term public key
 | 
			
		||||
		ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Unlock the vault
 | 
			
		||||
@ -173,26 +176,29 @@ func TestVaultWithRealFilesystem(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a test vault - CreateVault now handles public key when mnemonic is in env
 | 
			
		||||
		// Create a test vault
 | 
			
		||||
		vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Load vault metadata to get its derivation index
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Get the vault directory
 | 
			
		||||
		vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to load vault metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic for verification using the vault's derivation index
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		// Write long-term public key
 | 
			
		||||
		ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Verify the vault is locked initially
 | 
			
		||||
@ -340,7 +346,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to create state dir: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create two vaults - CreateVault now handles public key when mnemonic is in env
 | 
			
		||||
		// Create two vaults
 | 
			
		||||
		vault1, err := vault.CreateVault(fs, stateDir, "vault1")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault1: %v", err)
 | 
			
		||||
@ -352,42 +358,27 @@ func TestVaultWithRealFilesystem(t *testing.T) {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive long-term key from mnemonic
 | 
			
		||||
		// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex
 | 
			
		||||
 | 
			
		||||
		// Load vault1 metadata to get its derivation index
 | 
			
		||||
		vault1Dir, err := vault1.GetDirectory()
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault1 directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		vault1Metadata, err := vault.LoadVaultMetadata(fs, vault1Dir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to load vault1 metadata: %v", err)
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ltIdentity1, err := agehd.DeriveIdentity(testMnemonic, vault1Metadata.DerivationIndex)
 | 
			
		||||
		// Setup both vaults with the same long-term key
 | 
			
		||||
		for _, vlt := range []*vault.Vault{vault1, vault2} {
 | 
			
		||||
			vaultDir, err := vlt.GetDirectory()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key for vault1: %v", err)
 | 
			
		||||
				t.Fatalf("Failed to get vault directory: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		// Load vault2 metadata to get its derivation index
 | 
			
		||||
		vault2Dir, err := vault2.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get vault2 directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		vault2Metadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to load vault2 metadata: %v", err)
 | 
			
		||||
			ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
			pubKey := ltIdentity.Recipient().String()
 | 
			
		||||
			if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
 | 
			
		||||
				t.Fatalf("Failed to write long-term public key: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		ltIdentity2, err := agehd.DeriveIdentity(testMnemonic, vault2Metadata.DerivationIndex)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive long-term key for vault2: %v", err)
 | 
			
		||||
			vlt.Unlock(ltIdentity)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Unlock the vaults with their respective keys
 | 
			
		||||
		vault1.Unlock(ltIdentity1)
 | 
			
		||||
		vault2.Unlock(ltIdentity2)
 | 
			
		||||
 | 
			
		||||
		// Add a secret to vault1
 | 
			
		||||
		secretName := "test-secret"
 | 
			
		||||
		secretValue := []byte("secret in vault1")
 | 
			
		||||
 | 
			
		||||
@ -1,24 +1,3 @@
 | 
			
		||||
// Version Support Integration Tests
 | 
			
		||||
//
 | 
			
		||||
// Comprehensive integration tests for version functionality:
 | 
			
		||||
//
 | 
			
		||||
// - 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
 | 
			
		||||
//
 | 
			
		||||
// Test Environment:
 | 
			
		||||
// - Uses in-memory filesystem (afero.MemMapFs)
 | 
			
		||||
// - Consistent test mnemonic for reproducible keys
 | 
			
		||||
// - Proper cleanup and isolation between tests
 | 
			
		||||
 | 
			
		||||
package vault
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ import (
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -203,53 +202,13 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
 | 
			
		||||
		return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if mnemonic is available in environment
 | 
			
		||||
	mnemonic := os.Getenv(secret.EnvMnemonic)
 | 
			
		||||
	var derivationIndex uint32
 | 
			
		||||
	var publicKeyHash string
 | 
			
		||||
 | 
			
		||||
	if mnemonic != "" {
 | 
			
		||||
		secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
 | 
			
		||||
 | 
			
		||||
		// Get the next available derivation index for this mnemonic
 | 
			
		||||
		var err error
 | 
			
		||||
		derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to get next derivation index: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive the long-term key using the actual derivation index
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to derive long-term key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Write the public key
 | 
			
		||||
		ltPubKey := ltIdentity.Recipient().String()
 | 
			
		||||
		ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
 | 
			
		||||
		if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to write long-term public key: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
 | 
			
		||||
 | 
			
		||||
		// Compute public key hash from index 0 (same for all vaults with this mnemonic)
 | 
			
		||||
		// This is used to identify which vaults belong to the same mnemonic family
 | 
			
		||||
		identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
		publicKeyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
			
		||||
	} else {
 | 
			
		||||
		secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
 | 
			
		||||
		// Use 0 for derivation index when no mnemonic is provided
 | 
			
		||||
		derivationIndex = 0
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save vault metadata
 | 
			
		||||
	// Save initial vault metadata (without derivation info until a mnemonic is imported)
 | 
			
		||||
	metadata := &VaultMetadata{
 | 
			
		||||
		Name:            name,
 | 
			
		||||
		CreatedAt:       time.Now(),
 | 
			
		||||
		DerivationIndex: derivationIndex,
 | 
			
		||||
		PublicKeyHash:   publicKeyHash,
 | 
			
		||||
		DerivationIndex: 0,
 | 
			
		||||
		LongTermKeyHash: "", // Will be set when mnemonic is imported
 | 
			
		||||
		MnemonicHash:    "", // Will be set when mnemonic is imported
 | 
			
		||||
	}
 | 
			
		||||
	if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to save vault metadata: %w", err)
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ import (
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -25,16 +24,8 @@ func ComputeDoubleSHA256(data []byte) string {
 | 
			
		||||
	return hex.EncodeToString(secondHash[:])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
 | 
			
		||||
// by deriving the public key for index 0 and using its hash to identify related vaults
 | 
			
		||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint32, error) {
 | 
			
		||||
	// First, derive the public key for index 0 to get our identifier
 | 
			
		||||
	identity0, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
			
		||||
 | 
			
		||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic hash
 | 
			
		||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonicHash string) (uint32, error) {
 | 
			
		||||
	vaultsDir := filepath.Join(stateDir, "vaults.d")
 | 
			
		||||
 | 
			
		||||
	// Check if vaults directory exists
 | 
			
		||||
@ -53,8 +44,9 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
 | 
			
		||||
		return 0, fmt.Errorf("failed to read vaults directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Track which indices are in use for this mnemonic
 | 
			
		||||
	usedIndices := make(map[uint32]bool)
 | 
			
		||||
	// Track the highest index for this mnemonic
 | 
			
		||||
	var highestIndex uint32 = 0
 | 
			
		||||
	foundMatch := false
 | 
			
		||||
 | 
			
		||||
	for _, entry := range entries {
 | 
			
		||||
		if !entry.IsDir() {
 | 
			
		||||
@ -75,19 +67,22 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check if this vault uses the same mnemonic by comparing public key hashes
 | 
			
		||||
		if metadata.PublicKeyHash == pubKeyHash {
 | 
			
		||||
			usedIndices[metadata.DerivationIndex] = true
 | 
			
		||||
		// Check if this vault uses the same mnemonic
 | 
			
		||||
		if metadata.MnemonicHash == mnemonicHash {
 | 
			
		||||
			foundMatch = true
 | 
			
		||||
			if metadata.DerivationIndex >= highestIndex {
 | 
			
		||||
				highestIndex = metadata.DerivationIndex
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Find the first available index
 | 
			
		||||
	var index uint32 = 0
 | 
			
		||||
	for usedIndices[index] {
 | 
			
		||||
		index++
 | 
			
		||||
	// If we found a match, use the next index
 | 
			
		||||
	if foundMatch {
 | 
			
		||||
		return highestIndex + 1, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return index, nil
 | 
			
		||||
	// No existing vault with this mnemonic, start at 0
 | 
			
		||||
	return 0, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SaveVaultMetadata saves vault metadata to the vault directory
 | 
			
		||||
 | 
			
		||||
@ -5,8 +5,6 @@ import (
 | 
			
		||||
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"git.eeqj.de/sneak/secret/pkg/agehd"
 | 
			
		||||
	"github.com/spf13/afero"
 | 
			
		||||
)
 | 
			
		||||
@ -15,9 +13,6 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
	fs := afero.NewMemMapFs()
 | 
			
		||||
	stateDir := "/test/state"
 | 
			
		||||
 | 
			
		||||
	// Test mnemonic for consistent testing
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	t.Run("ComputeDoubleSHA256", func(t *testing.T) {
 | 
			
		||||
		// Test data
 | 
			
		||||
		data := []byte("test data")
 | 
			
		||||
@ -43,7 +38,7 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	t.Run("GetNextDerivationIndex", func(t *testing.T) {
 | 
			
		||||
		// Test with no existing vaults
 | 
			
		||||
		index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic)
 | 
			
		||||
		index, err := GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get derivation index: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
@ -51,35 +46,24 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
			t.Errorf("Expected index 0 for first vault, got %d", index)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Create a vault with metadata and matching public key
 | 
			
		||||
		// Create a vault with metadata
 | 
			
		||||
		vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
 | 
			
		||||
		if err := fs.MkdirAll(vaultDir, 0700); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to create vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive identity for index 0
 | 
			
		||||
		identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive identity: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		pubKey0 := identity0.Recipient().String()
 | 
			
		||||
		pubKeyHash0 := ComputeDoubleSHA256([]byte(pubKey0))
 | 
			
		||||
 | 
			
		||||
		// Write public key
 | 
			
		||||
		if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0600); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		metadata1 := &VaultMetadata{
 | 
			
		||||
			Name:            "vault1",
 | 
			
		||||
			DerivationIndex: 0,
 | 
			
		||||
			PublicKeyHash:   pubKeyHash0,
 | 
			
		||||
			MnemonicHash:    "mnemonic-hash-1",
 | 
			
		||||
			LongTermKeyHash: "key-hash-1",
 | 
			
		||||
		}
 | 
			
		||||
		if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to save metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Next index for same mnemonic should be 1
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get derivation index: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
@ -88,8 +72,7 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Different mnemonic should start at 0
 | 
			
		||||
		differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-2")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get derivation index: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
@ -103,33 +86,23 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to create vault directory: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Derive identity for index 5
 | 
			
		||||
		identity5, err := agehd.DeriveIdentity(testMnemonic, 5)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive identity: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		pubKey5 := identity5.Recipient().String()
 | 
			
		||||
 | 
			
		||||
		// Write public key
 | 
			
		||||
		if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0600); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to write public key: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		metadata2 := &VaultMetadata{
 | 
			
		||||
			Name:            "vault2",
 | 
			
		||||
			DerivationIndex: 5,
 | 
			
		||||
			PublicKeyHash:   pubKeyHash0, // Same hash since it's from the same mnemonic
 | 
			
		||||
			MnemonicHash:    "mnemonic-hash-1",
 | 
			
		||||
			LongTermKeyHash: "key-hash-2",
 | 
			
		||||
		}
 | 
			
		||||
		if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
 | 
			
		||||
			t.Fatalf("Failed to save metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Next index should be 1 (not 6) because we look for the first available slot
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
 | 
			
		||||
		// Next index should be 6
 | 
			
		||||
		index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to get derivation index: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
		if index != 1 {
 | 
			
		||||
			t.Errorf("Expected index 1 (first available), got %d", index)
 | 
			
		||||
		if index != 6 {
 | 
			
		||||
			t.Errorf("Expected index 6 after vault with index 5, got %d", index)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
@ -141,8 +114,10 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
		// Create and save metadata
 | 
			
		||||
		metadata := &VaultMetadata{
 | 
			
		||||
			Name:            "test-vault",
 | 
			
		||||
			DerivationIndex: 3,
 | 
			
		||||
			PublicKeyHash:   "test-public-key-hash",
 | 
			
		||||
			MnemonicHash:    "test-mnemonic-hash",
 | 
			
		||||
			LongTermKeyHash: "test-key-hash",
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
 | 
			
		||||
@ -155,15 +130,23 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to load metadata: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if loaded.Name != metadata.Name {
 | 
			
		||||
			t.Errorf("Name mismatch: expected %s, got %s", metadata.Name, loaded.Name)
 | 
			
		||||
		}
 | 
			
		||||
		if loaded.DerivationIndex != metadata.DerivationIndex {
 | 
			
		||||
			t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
 | 
			
		||||
		}
 | 
			
		||||
		if loaded.PublicKeyHash != metadata.PublicKeyHash {
 | 
			
		||||
			t.Errorf("PublicKeyHash mismatch: expected %s, got %s", metadata.PublicKeyHash, loaded.PublicKeyHash)
 | 
			
		||||
		if loaded.MnemonicHash != metadata.MnemonicHash {
 | 
			
		||||
			t.Errorf("MnemonicHash mismatch: expected %s, got %s", metadata.MnemonicHash, loaded.MnemonicHash)
 | 
			
		||||
		}
 | 
			
		||||
		if loaded.LongTermKeyHash != metadata.LongTermKeyHash {
 | 
			
		||||
			t.Errorf("LongTermKeyHash mismatch: expected %s, got %s", metadata.LongTermKeyHash, loaded.LongTermKeyHash)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
 | 
			
		||||
		testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
		// Derive keys with different indices
 | 
			
		||||
		identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@ -175,237 +158,18 @@ func TestVaultMetadata(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Failed to derive identity with index 1: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Compute public key hashes
 | 
			
		||||
		pubKey0 := identity0.Recipient().String()
 | 
			
		||||
		pubKey1 := identity1.Recipient().String()
 | 
			
		||||
		hash0 := ComputeDoubleSHA256([]byte(pubKey0))
 | 
			
		||||
		// Compute hashes
 | 
			
		||||
		hash0 := ComputeDoubleSHA256([]byte(identity0.String()))
 | 
			
		||||
		hash1 := ComputeDoubleSHA256([]byte(identity1.String()))
 | 
			
		||||
 | 
			
		||||
		// Verify different indices produce different public keys
 | 
			
		||||
		if pubKey0 == pubKey1 {
 | 
			
		||||
			t.Errorf("Different derivation indices should produce different public keys")
 | 
			
		||||
		// Verify different indices produce different keys
 | 
			
		||||
		if hash0 == hash1 {
 | 
			
		||||
			t.Errorf("Different derivation indices should produce different keys")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// But the hash of index 0's public key should be the same for the same mnemonic
 | 
			
		||||
		// This is what we use as the identifier
 | 
			
		||||
		identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
		pubKey0Again := identity0Again.Recipient().String()
 | 
			
		||||
		hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again))
 | 
			
		||||
 | 
			
		||||
		if hash0 != hash0Again {
 | 
			
		||||
			t.Errorf("Same mnemonic should produce same public key hash for index 0")
 | 
			
		||||
		// Verify public keys are also different
 | 
			
		||||
		if identity0.Recipient().String() == identity1.Recipient().String() {
 | 
			
		||||
			t.Errorf("Different derivation indices should produce different public keys")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPublicKeyHashConsistency(t *testing.T) {
 | 
			
		||||
	// Use the same test mnemonic that the integration test uses
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	// Derive identity from index 0 multiple times
 | 
			
		||||
	identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to derive first identity: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	identity2, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to derive second identity: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify identities are the same
 | 
			
		||||
	if identity1.Recipient().String() != identity2.Recipient().String() {
 | 
			
		||||
		t.Errorf("Identity derivation is not deterministic")
 | 
			
		||||
		t.Logf("First:  %s", identity1.Recipient().String())
 | 
			
		||||
		t.Logf("Second: %s", identity2.Recipient().String())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Compute public key hashes
 | 
			
		||||
	hash1 := ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
 | 
			
		||||
	hash2 := ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
 | 
			
		||||
 | 
			
		||||
	// Verify hashes are the same
 | 
			
		||||
	if hash1 != hash2 {
 | 
			
		||||
		t.Errorf("Public key hash computation is not deterministic")
 | 
			
		||||
		t.Logf("First hash:  %s", hash1)
 | 
			
		||||
		t.Logf("Second hash: %s", hash2)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("Test mnemonic public key hash (index 0): %s", hash1)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestSampleHashCalculation(t *testing.T) {
 | 
			
		||||
	// Test with the exact mnemonic from integration test if available
 | 
			
		||||
	// We'll also test with a few different mnemonics to make sure they produce different hashes
 | 
			
		||||
	mnemonics := []string{
 | 
			
		||||
		"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
 | 
			
		||||
		"legal winner thank year wave sausage worth useful legal winner thank yellow",
 | 
			
		||||
		"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i, mnemonic := range mnemonics {
 | 
			
		||||
		identity, err := agehd.DeriveIdentity(mnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
 | 
			
		||||
		t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
 | 
			
		||||
		t.Logf("  Recipient: %s", identity.Recipient().String())
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWorkflowMismatch(t *testing.T) {
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	// Create a temporary directory for testing
 | 
			
		||||
	tempDir := t.TempDir()
 | 
			
		||||
	fs := afero.NewOsFs()
 | 
			
		||||
 | 
			
		||||
	// Test Case 1: Create vault WITH mnemonic (like init command)
 | 
			
		||||
	t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
 | 
			
		||||
	_, err := CreateVault(fs, tempDir, "default")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create vault with mnemonic: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Load metadata for vault1
 | 
			
		||||
	vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
 | 
			
		||||
	metadata1, err := LoadVaultMetadata(fs, vault1Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load vault1 metadata: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
 | 
			
		||||
		metadata1.DerivationIndex, metadata1.PublicKeyHash)
 | 
			
		||||
 | 
			
		||||
	// Test Case 2: Create vault WITHOUT mnemonic, then import (like work vault)
 | 
			
		||||
	t.Setenv("SB_SECRET_MNEMONIC", "")
 | 
			
		||||
	_, err = CreateVault(fs, tempDir, "work")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to create vault without mnemonic: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
 | 
			
		||||
 | 
			
		||||
	// Simulate the vault import process
 | 
			
		||||
	t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
 | 
			
		||||
 | 
			
		||||
	// Get the next available derivation index for this mnemonic
 | 
			
		||||
	derivationIndex, err := GetNextDerivationIndex(fs, tempDir, testMnemonic)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to get next derivation index: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("Next derivation index for import: %d", derivationIndex)
 | 
			
		||||
 | 
			
		||||
	// Calculate public key hash from index 0 (same as in VaultImport)
 | 
			
		||||
	identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to derive identity for index 0: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	publicKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
 | 
			
		||||
 | 
			
		||||
	// Load existing metadata and update it (same as in VaultImport)
 | 
			
		||||
	existingMetadata, err := LoadVaultMetadata(fs, vault2Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load existing metadata: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update metadata with new derivation info
 | 
			
		||||
	existingMetadata.DerivationIndex = derivationIndex
 | 
			
		||||
	existingMetadata.PublicKeyHash = publicKeyHash
 | 
			
		||||
 | 
			
		||||
	if err := SaveVaultMetadata(fs, vault2Dir, existingMetadata); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to save vault metadata: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Load updated metadata for vault2
 | 
			
		||||
	metadata2, err := LoadVaultMetadata(fs, vault2Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load vault2 metadata: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
 | 
			
		||||
		metadata2.DerivationIndex, metadata2.PublicKeyHash)
 | 
			
		||||
 | 
			
		||||
	// Verify that both vaults have the same public key hash
 | 
			
		||||
	if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
 | 
			
		||||
		t.Errorf("Public key hashes don't match!")
 | 
			
		||||
		t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
 | 
			
		||||
		t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
 | 
			
		||||
	} else {
 | 
			
		||||
		t.Logf("SUCCESS: Both vaults have the same public key hash: %s", metadata1.PublicKeyHash)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestReverseEngineerHash(t *testing.T) {
 | 
			
		||||
	// This is the hash that the work vault is getting in the failing test
 | 
			
		||||
	wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
 | 
			
		||||
	correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
 | 
			
		||||
 | 
			
		||||
	// Test mnemonic from integration test
 | 
			
		||||
	testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
 | 
			
		||||
 | 
			
		||||
	// Calculate hash for test mnemonic
 | 
			
		||||
	identity, err := agehd.DeriveIdentity(testMnemonic, 0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to derive identity: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	calculatedHash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
 | 
			
		||||
	t.Logf("Test mnemonic hash: %s", calculatedHash)
 | 
			
		||||
 | 
			
		||||
	if calculatedHash == correctHash {
 | 
			
		||||
		t.Logf("✓ Test mnemonic produces the correct hash")
 | 
			
		||||
	} else {
 | 
			
		||||
		t.Errorf("✗ Test mnemonic does not produce the correct hash")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if calculatedHash == wrongHash {
 | 
			
		||||
		t.Logf("✗ Test mnemonic unexpectedly produces the wrong hash")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Let's try some other possibilities - maybe there's a string normalization issue?
 | 
			
		||||
	variations := []string{
 | 
			
		||||
		"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
 | 
			
		||||
		" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ",
 | 
			
		||||
		"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n",
 | 
			
		||||
		strings.TrimSpace("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i, variation := range variations {
 | 
			
		||||
		identity, err := agehd.DeriveIdentity(variation, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Logf("Variation %d failed: %v", i, err)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
 | 
			
		||||
		t.Logf("Variation %d hash: %s", i, hash)
 | 
			
		||||
 | 
			
		||||
		if hash == wrongHash {
 | 
			
		||||
			t.Logf("✗ Found variation that produces wrong hash: '%s'", variation)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Maybe let's try an empty mnemonic or something else?
 | 
			
		||||
	emptyMnemonics := []string{
 | 
			
		||||
		"",
 | 
			
		||||
		" ",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i, emptyMnemonic := range emptyMnemonics {
 | 
			
		||||
		identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
 | 
			
		||||
		t.Logf("Empty mnemonic %d hash: %s", i, hash)
 | 
			
		||||
 | 
			
		||||
		if hash == wrongHash {
 | 
			
		||||
			t.Logf("✗ Empty mnemonic produces wrong hash!")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -63,31 +63,10 @@ func (v *Vault) ListSecrets() ([]string, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
 | 
			
		||||
// but with additional restrictions:
 | 
			
		||||
// - No leading or trailing slashes
 | 
			
		||||
// - No double slashes
 | 
			
		||||
// - No names starting with dots
 | 
			
		||||
func isValidSecretName(name string) bool {
 | 
			
		||||
	if name == "" {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for leading/trailing slashes
 | 
			
		||||
	if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for double slashes
 | 
			
		||||
	if strings.Contains(name, "//") {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check for names starting with dot
 | 
			
		||||
	if strings.HasPrefix(name, ".") {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check the basic pattern
 | 
			
		||||
	matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
 | 
			
		||||
	return matched
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,20 +1,3 @@
 | 
			
		||||
// Vault-Level Version Operation Tests
 | 
			
		||||
//
 | 
			
		||||
// 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
 | 
			
		||||
//
 | 
			
		||||
// Version Management:
 | 
			
		||||
// - List versions in reverse chronological order
 | 
			
		||||
// - Promote any version to current
 | 
			
		||||
// - Promotion doesn't modify timestamps
 | 
			
		||||
// - Metadata remains encrypted and intact
 | 
			
		||||
 | 
			
		||||
package vault
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
 | 
			
		||||
@ -75,6 +75,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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),
 | 
			
		||||
@ -86,16 +87,16 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
 | 
			
		||||
	secretMetadata := secret.UnlockerMetadata(metadata)
 | 
			
		||||
	switch metadata.Type {
 | 
			
		||||
	case "passphrase":
 | 
			
		||||
		secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
 | 
			
		||||
		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_type", metadata.Type)
 | 
			
		||||
		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_type", metadata.Type)
 | 
			
		||||
		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)
 | 
			
		||||
		secret.Debug("Unsupported unlocker type", "type", metadata.Type, "unlocker_id", metadata.ID)
 | 
			
		||||
		return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -139,20 +140,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
 | 
			
		||||
			metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
 | 
			
		||||
			exists, err := afero.Exists(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
			if !exists {
 | 
			
		||||
				return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var metadata UnlockerMetadata
 | 
			
		||||
			if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
 | 
			
		||||
				return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			unlockers = append(unlockers, metadata)
 | 
			
		||||
@ -185,45 +186,37 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
 | 
			
		||||
			// Read metadata file
 | 
			
		||||
			metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
 | 
			
		||||
			exists, err := afero.Exists(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
			}
 | 
			
		||||
			if !exists {
 | 
			
		||||
				// Skip directories without metadata - they might not be unlockers
 | 
			
		||||
			if err != nil || !exists {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var metadata UnlockerMetadata
 | 
			
		||||
			if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				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
 | 
			
		||||
			var tempUnlocker secret.Unlocker
 | 
			
		||||
				switch metadata.Type {
 | 
			
		||||
				case "passphrase":
 | 
			
		||||
				tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
					unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
				case "pgp":
 | 
			
		||||
				tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
					unlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
				case "keychain":
 | 
			
		||||
				tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
					unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
				default:
 | 
			
		||||
				continue
 | 
			
		||||
					return fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
			// Check if this unlocker's ID matches
 | 
			
		||||
			if tempUnlocker.GetID() == unlockerID {
 | 
			
		||||
				unlocker = tempUnlocker
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -259,45 +252,22 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
 | 
			
		||||
			// Read metadata file
 | 
			
		||||
			metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
 | 
			
		||||
			exists, err := afero.Exists(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
			}
 | 
			
		||||
			if !exists {
 | 
			
		||||
				// Skip directories without metadata - they might not be unlockers
 | 
			
		||||
			if err != nil || !exists {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var metadata UnlockerMetadata
 | 
			
		||||
			if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
 | 
			
		||||
				return fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			unlockerDirPath := filepath.Join(unlockersDir, file.Name())
 | 
			
		||||
 | 
			
		||||
			// Convert our metadata to secret.UnlockerMetadata
 | 
			
		||||
			secretMetadata := secret.UnlockerMetadata(metadata)
 | 
			
		||||
 | 
			
		||||
			// Create the appropriate unlocker instance
 | 
			
		||||
			var tempUnlocker secret.Unlocker
 | 
			
		||||
			switch metadata.Type {
 | 
			
		||||
			case "passphrase":
 | 
			
		||||
				tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
			case "pgp":
 | 
			
		||||
				tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
			case "keychain":
 | 
			
		||||
				tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
 | 
			
		||||
			default:
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Check if this unlocker's ID matches
 | 
			
		||||
			if tempUnlocker.GetID() == unlockerID {
 | 
			
		||||
				targetUnlockerDir = unlockerDirPath
 | 
			
		||||
			if metadata.ID == unlockerID {
 | 
			
		||||
				targetUnlockerDir = filepath.Join(unlockersDir, file.Name())
 | 
			
		||||
				break
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -311,11 +281,9 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
 | 
			
		||||
	currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
 | 
			
		||||
 | 
			
		||||
	// Remove existing symlink if it exists
 | 
			
		||||
	if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err)
 | 
			
		||||
	} else if exists {
 | 
			
		||||
	if exists, _ := afero.Exists(v.fs, currentUnlockerPath); exists {
 | 
			
		||||
		if err := v.fs.Remove(currentUnlockerPath); err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to remove existing unlocker symlink: %w", err)
 | 
			
		||||
			secret.Debug("Failed to remove existing unlocker symlink", "error", err, "path", currentUnlockerPath)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -330,7 +298,8 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
 | 
			
		||||
		return nil, fmt.Errorf("failed to get vault directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create unlocker directory
 | 
			
		||||
	// 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)
 | 
			
		||||
@ -362,7 +331,9 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create metadata
 | 
			
		||||
	unlockerID := fmt.Sprintf("%s-passphrase", timestamp)
 | 
			
		||||
	metadata := UnlockerMetadata{
 | 
			
		||||
		ID:        unlockerID,
 | 
			
		||||
		Type:      "passphrase",
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		Flags:     []string{},
 | 
			
		||||
@ -379,14 +350,9 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
 | 
			
		||||
		return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Encrypt long-term private key to this unlocker
 | 
			
		||||
	// We need to get the long-term key (either from memory if unlocked, or derive it)
 | 
			
		||||
	ltIdentity, err := v.GetOrDeriveLongTermKey()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to get long-term key: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ltPrivKey := []byte(ltIdentity.String())
 | 
			
		||||
	// 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)
 | 
			
		||||
@ -396,17 +362,15 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
 | 
			
		||||
		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)
 | 
			
		||||
 | 
			
		||||
	// Create the unlocker instance
 | 
			
		||||
	unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
 | 
			
		||||
 | 
			
		||||
	// Select this unlocker as current
 | 
			
		||||
	if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to select new unlocker: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return unlocker, nil
 | 
			
		||||
	return secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -65,20 +65,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
 | 
			
		||||
	// Try to derive from environment mnemonic first
 | 
			
		||||
	if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
 | 
			
		||||
		secret.Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
 | 
			
		||||
 | 
			
		||||
		// Load vault metadata to get the derivation index
 | 
			
		||||
		vaultDir, err := v.GetDirectory()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, fmt.Errorf("failed to get vault directory: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		metadata, err := LoadVaultMetadata(v.fs, vaultDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
 | 
			
		||||
			return nil, fmt.Errorf("failed to load vault metadata: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
 | 
			
		||||
		ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
 | 
			
		||||
			return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
 | 
			
		||||
@ -87,7 +74,6 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
 | 
			
		||||
		secret.DebugWith("Successfully derived long-term key from mnemonic",
 | 
			
		||||
			slog.String("vault_name", v.Name),
 | 
			
		||||
			slog.String("public_key", ltIdentity.Recipient().String()),
 | 
			
		||||
			slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		// Cache the derived key by unlocking the vault
 | 
			
		||||
 | 
			
		||||
@ -39,7 +39,7 @@ const (
 | 
			
		||||
	errorMsgInvalidXPRV = "invalid-xprv"
 | 
			
		||||
 | 
			
		||||
	// Test constants for various scenarios
 | 
			
		||||
	// Removed testSkipMessage as tests are no longer skipped
 | 
			
		||||
	testSkipMessage = "Skipping consistency test - test mnemonic and xprv are from different sources"
 | 
			
		||||
 | 
			
		||||
	// Numeric constants for testing
 | 
			
		||||
	testNumGoroutines = 10
 | 
			
		||||
@ -133,11 +133,7 @@ func TestDeterministicDerivation(t *testing.T) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if id1.String() != id2.String() {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"identities should be deterministic: %s != %s",
 | 
			
		||||
			id1.String(),
 | 
			
		||||
			id2.String(),
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("identities should be deterministic: %s != %s", id1.String(), id2.String())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test that different indices produce different identities
 | 
			
		||||
@ -167,11 +163,7 @@ func TestDeterministicXPRVDerivation(t *testing.T) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if id1.String() != id2.String() {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"xprv identities should be deterministic: %s != %s",
 | 
			
		||||
			id1.String(),
 | 
			
		||||
			id2.String(),
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("xprv identities should be deterministic: %s != %s", id1.String(), id2.String())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test that different indices with same xprv produce different identities
 | 
			
		||||
@ -189,8 +181,10 @@ func TestDeterministicXPRVDerivation(t *testing.T) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestMnemonicVsXPRVConsistency(t *testing.T) {
 | 
			
		||||
	// FIXME This test is missing!
 | 
			
		||||
 | 
			
		||||
	// Test that deriving from mnemonic and from the corresponding xprv produces the same result
 | 
			
		||||
	// Note: This test is removed because the test mnemonic and test xprv are from different sources
 | 
			
		||||
	// and are not expected to produce the same results.
 | 
			
		||||
	t.Skip(testSkipMessage)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEntropyLength(t *testing.T) {
 | 
			
		||||
@ -213,10 +207,7 @@ func TestEntropyLength(t *testing.T) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(entropyXPRV) != 32 {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"expected 32 bytes of entropy from xprv, got %d",
 | 
			
		||||
			len(entropyXPRV),
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("expected 32 bytes of entropy from xprv, got %d", len(entropyXPRV))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("XPRV Entropy (32 bytes): %x", entropyXPRV)
 | 
			
		||||
@ -274,47 +265,12 @@ func TestClampFunction(t *testing.T) {
 | 
			
		||||
		{
 | 
			
		||||
			name:     "all zeros",
 | 
			
		||||
			input:    make([]byte, 32),
 | 
			
		||||
			expected: []byte{
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				0,
 | 
			
		||||
				64,
 | 
			
		||||
			},
 | 
			
		||||
			expected: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "all ones",
 | 
			
		||||
			input:    bytes.Repeat([]byte{255}, 32),
 | 
			
		||||
			expected: append(
 | 
			
		||||
				[]byte{248},
 | 
			
		||||
				append(bytes.Repeat([]byte{255}, 30), 127)...),
 | 
			
		||||
			expected: append([]byte{248}, append(bytes.Repeat([]byte{255}, 30), 127)...),
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -326,22 +282,13 @@ func TestClampFunction(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
			// Check specific bits that should be clamped
 | 
			
		||||
			if input[0]&7 != 0 {
 | 
			
		||||
				t.Errorf(
 | 
			
		||||
					"first byte should have bottom 3 bits cleared, got %08b",
 | 
			
		||||
					input[0],
 | 
			
		||||
				)
 | 
			
		||||
				t.Errorf("first byte should have bottom 3 bits cleared, got %08b", input[0])
 | 
			
		||||
			}
 | 
			
		||||
			if input[31]&128 != 0 {
 | 
			
		||||
				t.Errorf(
 | 
			
		||||
					"last byte should have top bit cleared, got %08b",
 | 
			
		||||
					input[31],
 | 
			
		||||
				)
 | 
			
		||||
				t.Errorf("last byte should have top bit cleared, got %08b", input[31])
 | 
			
		||||
			}
 | 
			
		||||
			if input[31]&64 == 0 {
 | 
			
		||||
				t.Errorf(
 | 
			
		||||
					"last byte should have second-to-top bit set, got %08b",
 | 
			
		||||
					input[31],
 | 
			
		||||
				)
 | 
			
		||||
				t.Errorf("last byte should have second-to-top bit set, got %08b", input[31])
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
@ -389,9 +336,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
 | 
			
		||||
			entropy: func() []byte {
 | 
			
		||||
				b := make([]byte, 32)
 | 
			
		||||
				if _, err := rand.Read(b); err != nil {
 | 
			
		||||
					panic(
 | 
			
		||||
						err,
 | 
			
		||||
					) // In test context, panic is acceptable for setup failures
 | 
			
		||||
					panic(err) // In test context, panic is acceptable for setup failures
 | 
			
		||||
				}
 | 
			
		||||
				return b
 | 
			
		||||
			}(),
 | 
			
		||||
@ -410,10 +355,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
 | 
			
		||||
					t.Errorf("expected error containing %q, got %q", tt.errorMsg, err.Error())
 | 
			
		||||
				}
 | 
			
		||||
				if identity != nil {
 | 
			
		||||
					t.Errorf(
 | 
			
		||||
						"expected nil identity on error, got %v",
 | 
			
		||||
						identity,
 | 
			
		||||
					)
 | 
			
		||||
					t.Errorf("expected nil identity on error, got %v", identity)
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				if err != nil {
 | 
			
		||||
@ -588,11 +530,7 @@ func TestIndexBoundaries(t *testing.T) {
 | 
			
		||||
		t.Run(fmt.Sprintf("index_%d", index), func(t *testing.T) {
 | 
			
		||||
			identity, err := DeriveIdentity(mnemonic, index)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf(
 | 
			
		||||
					"failed to derive identity at index %d: %v",
 | 
			
		||||
					index,
 | 
			
		||||
					err,
 | 
			
		||||
				)
 | 
			
		||||
				t.Fatalf("failed to derive identity at index %d: %v", index, err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Verify the identity is valid by testing encryption/decryption
 | 
			
		||||
@ -689,19 +627,11 @@ func TestConcurrentDerivation(t *testing.T) {
 | 
			
		||||
	expectedResults := testNumGoroutines
 | 
			
		||||
	for result, count := range resultMap {
 | 
			
		||||
		if count != expectedResults {
 | 
			
		||||
			t.Errorf(
 | 
			
		||||
				"result %s appeared %d times, expected %d",
 | 
			
		||||
				result,
 | 
			
		||||
				count,
 | 
			
		||||
				expectedResults,
 | 
			
		||||
			)
 | 
			
		||||
			t.Errorf("result %s appeared %d times, expected %d", result, count, expectedResults)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf(
 | 
			
		||||
		"Concurrent derivation test passed with %d unique results",
 | 
			
		||||
		len(resultMap),
 | 
			
		||||
	)
 | 
			
		||||
	t.Logf("Concurrent derivation test passed with %d unique results", len(resultMap))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Benchmark tests
 | 
			
		||||
@ -781,28 +711,16 @@ func BenchmarkEncryptDecrypt(b *testing.B) {
 | 
			
		||||
// TestConstants verifies the hardcoded constants
 | 
			
		||||
func TestConstants(t *testing.T) {
 | 
			
		||||
	if purpose != 83696968 {
 | 
			
		||||
		t.Errorf(
 | 
			
		||||
			"purpose constant mismatch: expected 83696968, got %d",
 | 
			
		||||
			purpose,
 | 
			
		||||
		)
 | 
			
		||||
		t.Errorf("purpose constant mismatch: expected 83696968, got %d", purpose)
 | 
			
		||||
	}
 | 
			
		||||
	if vendorID != 592366788 {
 | 
			
		||||
		t.Errorf(
 | 
			
		||||
			"vendorID constant mismatch: expected 592366788, got %d",
 | 
			
		||||
			vendorID,
 | 
			
		||||
		)
 | 
			
		||||
		t.Errorf("vendorID constant mismatch: expected 592366788, got %d", vendorID)
 | 
			
		||||
	}
 | 
			
		||||
	if appID != 733482323 {
 | 
			
		||||
		t.Errorf(
 | 
			
		||||
			"appID constant mismatch: expected 733482323, got %d",
 | 
			
		||||
			appID,
 | 
			
		||||
		)
 | 
			
		||||
		t.Errorf("appID constant mismatch: expected 733482323, got %d", appID)
 | 
			
		||||
	}
 | 
			
		||||
	if hrp != "age-secret-key-" {
 | 
			
		||||
		t.Errorf(
 | 
			
		||||
			"hrp constant mismatch: expected 'age-secret-key-', got %q",
 | 
			
		||||
			hrp,
 | 
			
		||||
		)
 | 
			
		||||
		t.Errorf("hrp constant mismatch: expected 'age-secret-key-', got %q", hrp)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -818,10 +736,7 @@ func TestIdentityStringFormat(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Check secret key format
 | 
			
		||||
	if !strings.HasPrefix(secretKey, "AGE-SECRET-KEY-") {
 | 
			
		||||
		t.Errorf(
 | 
			
		||||
			"secret key should start with 'AGE-SECRET-KEY-', got: %s",
 | 
			
		||||
			secretKey,
 | 
			
		||||
		)
 | 
			
		||||
		t.Errorf("secret key should start with 'AGE-SECRET-KEY-', got: %s", secretKey)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check recipient format
 | 
			
		||||
@ -918,22 +833,14 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) {
 | 
			
		||||
	privateKey1 := identity1.String()
 | 
			
		||||
	privateKey2 := identity2.String()
 | 
			
		||||
	if privateKey1 != privateKey2 {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"private keys should be identical:\nFirst:  %s\nSecond: %s",
 | 
			
		||||
			privateKey1,
 | 
			
		||||
			privateKey2,
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("private keys should be identical:\nFirst:  %s\nSecond: %s", privateKey1, privateKey2)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify that both public keys (recipients) are identical
 | 
			
		||||
	publicKey1 := identity1.Recipient().String()
 | 
			
		||||
	publicKey2 := identity2.Recipient().String()
 | 
			
		||||
	if publicKey1 != publicKey2 {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"public keys should be identical:\nFirst:  %s\nSecond: %s",
 | 
			
		||||
			publicKey1,
 | 
			
		||||
			publicKey2,
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("public keys should be identical:\nFirst:  %s\nSecond: %s", publicKey1, publicKey2)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf("✓ Deterministic generation verified")
 | 
			
		||||
@ -965,17 +872,10 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) {
 | 
			
		||||
		t.Fatalf("failed to close encryptor: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t.Logf(
 | 
			
		||||
		"✓ Encrypted %d bytes into %d bytes of ciphertext",
 | 
			
		||||
		len(testData),
 | 
			
		||||
		ciphertext.Len(),
 | 
			
		||||
	)
 | 
			
		||||
	t.Logf("✓ Encrypted %d bytes into %d bytes of ciphertext", len(testData), ciphertext.Len())
 | 
			
		||||
 | 
			
		||||
	// Decrypt the data using the private key
 | 
			
		||||
	decryptor, err := age.Decrypt(
 | 
			
		||||
		bytes.NewReader(ciphertext.Bytes()),
 | 
			
		||||
		identity1,
 | 
			
		||||
	)
 | 
			
		||||
	decryptor, err := age.Decrypt(bytes.NewReader(ciphertext.Bytes()), identity1)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create decryptor: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
@ -989,11 +889,7 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	// Verify that the decrypted data matches the original
 | 
			
		||||
	if len(decryptedData) != len(testData) {
 | 
			
		||||
		t.Fatalf(
 | 
			
		||||
			"decrypted data length mismatch: expected %d, got %d",
 | 
			
		||||
			len(testData),
 | 
			
		||||
			len(decryptedData),
 | 
			
		||||
		)
 | 
			
		||||
		t.Fatalf("decrypted data length mismatch: expected %d, got %d", len(testData), len(decryptedData))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !bytes.Equal(testData, decryptedData) {
 | 
			
		||||
@ -1020,10 +916,7 @@ func TestRandomMnemonicDeterministicGeneration(t *testing.T) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Decrypt with the second identity
 | 
			
		||||
	decryptor2, err := age.Decrypt(
 | 
			
		||||
		bytes.NewReader(ciphertext2.Bytes()),
 | 
			
		||||
		identity2,
 | 
			
		||||
	)
 | 
			
		||||
	decryptor2, err := age.Decrypt(bytes.NewReader(ciphertext2.Bytes()), identity2)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create second decryptor: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -354,30 +354,46 @@ else
 | 
			
		||||
    print_error "Failed to list secrets"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Test 7: Secret management without mnemonic (traditional unlocker approach)
 | 
			
		||||
print_step "7" "Testing traditional unlocker approach"
 | 
			
		||||
# Test 7: Testing vault operations with different unlockers
 | 
			
		||||
print_step "7" "Testing vault operations with passphrase unlocker"
 | 
			
		||||
 | 
			
		||||
# Create a new vault without mnemonic
 | 
			
		||||
# Create a new vault for unlocker testing
 | 
			
		||||
echo "Running: $SECRET_BINARY vault create traditional"
 | 
			
		||||
$SECRET_BINARY vault create traditional
 | 
			
		||||
 | 
			
		||||
# Add a secret using traditional unlocker approach
 | 
			
		||||
echo "Adding secret using traditional unlocker..."
 | 
			
		||||
# Import mnemonic into the traditional vault (required for versioned secrets)
 | 
			
		||||
echo "Importing mnemonic into traditional vault..."
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
echo "Running: $SECRET_BINARY vault import traditional"
 | 
			
		||||
if $SECRET_BINARY vault import traditional; then
 | 
			
		||||
    print_success "Imported mnemonic into traditional vault"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to import mnemonic into traditional vault"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
 | 
			
		||||
# Now add a secret using the vault with unlocker
 | 
			
		||||
echo "Adding secret to vault with 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"
 | 
			
		||||
    print_success "Added secret to vault with unlocker"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to add secret with traditional approach"
 | 
			
		||||
    print_error "Failed to add secret to vault with unlocker"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Retrieve secret using traditional unlocker approach
 | 
			
		||||
echo "Retrieving secret using traditional unlocker approach..."
 | 
			
		||||
echo "Running: $SECRET_BINARY get traditional/secret"
 | 
			
		||||
# Retrieve secret using passphrase (temporarily unset mnemonic to test unlocker)
 | 
			
		||||
echo "Retrieving secret from vault with unlocker..."
 | 
			
		||||
TEMP_MNEMONIC="$SB_SECRET_MNEMONIC"
 | 
			
		||||
unset SB_SECRET_MNEMONIC
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
echo "Running: $SECRET_BINARY get traditional/secret (using passphrase unlocker)"
 | 
			
		||||
if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then
 | 
			
		||||
    print_success "Retrieved: $RETRIEVED"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Failed to retrieve secret with traditional approach"
 | 
			
		||||
    print_error "Failed to retrieve secret from vault with unlocker"
 | 
			
		||||
fi
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEMP_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
# Test 8: Advanced unlocker management
 | 
			
		||||
print_step "8" "Testing advanced unlocker management"
 | 
			
		||||
@ -414,6 +430,10 @@ fi
 | 
			
		||||
# Test 9: Secret name validation and edge cases
 | 
			
		||||
print_step "9" "Testing secret name validation and edge cases"
 | 
			
		||||
 | 
			
		||||
# Switch back to default vault for name validation tests
 | 
			
		||||
echo "Switching back to default vault..."
 | 
			
		||||
$SECRET_BINARY vault select default
 | 
			
		||||
 | 
			
		||||
# Test valid names
 | 
			
		||||
VALID_NAMES=("valid-name" "valid.name" "valid_name" "valid/path/name" "123valid" "a" "very-long-name-with-many-parts/and/paths")
 | 
			
		||||
for name in "${VALID_NAMES[@]}"; do
 | 
			
		||||
@ -543,15 +563,37 @@ if [ -d "$TEMP_DIR/vaults.d/default/secrets.d" ]; then
 | 
			
		||||
    if [ -d "$SECRET_DIR" ]; then
 | 
			
		||||
        print_success "Secret directory exists: database%password"
 | 
			
		||||
        
 | 
			
		||||
        # Check required files for per-secret key architecture
 | 
			
		||||
        FILES=("value.age" "pub.age" "priv.age" "secret-metadata.json")
 | 
			
		||||
        for file in "${FILES[@]}"; do
 | 
			
		||||
            if [ -f "$SECRET_DIR/$file" ]; then
 | 
			
		||||
                print_success "Required file exists: $file"
 | 
			
		||||
        # Check for versions directory and current symlink
 | 
			
		||||
        if [ -d "$SECRET_DIR/versions" ]; then
 | 
			
		||||
            print_success "Versions directory exists"
 | 
			
		||||
        else
 | 
			
		||||
                print_error "Required file missing: $file"
 | 
			
		||||
            print_error "Versions directory missing"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        if [ -L "$SECRET_DIR/current" ] || [ -f "$SECRET_DIR/current" ]; then
 | 
			
		||||
            print_success "Current version symlink exists"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Current version symlink missing"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        # Check version directory structure
 | 
			
		||||
        LATEST_VERSION=$(ls -1 "$SECRET_DIR/versions" 2>/dev/null | sort -r | head -n1)
 | 
			
		||||
        if [ -n "$LATEST_VERSION" ]; then
 | 
			
		||||
            VERSION_DIR="$SECRET_DIR/versions/$LATEST_VERSION"
 | 
			
		||||
            print_success "Found version directory: $LATEST_VERSION"
 | 
			
		||||
            
 | 
			
		||||
            # Check required files in version directory
 | 
			
		||||
            VERSION_FILES=("value.age" "pub.age" "priv.age" "metadata.age")
 | 
			
		||||
            for file in "${VERSION_FILES[@]}"; do
 | 
			
		||||
                if [ -f "$VERSION_DIR/$file" ]; then
 | 
			
		||||
                    print_success "Version file exists: $file"
 | 
			
		||||
                else
 | 
			
		||||
                    print_error "Version file missing: $file"
 | 
			
		||||
                fi
 | 
			
		||||
            done
 | 
			
		||||
        else
 | 
			
		||||
            print_error "No version directories found"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Secret directory not found"
 | 
			
		||||
    fi
 | 
			
		||||
@ -608,14 +650,26 @@ export SB_SECRET_STATE_DIR="$TEMP_DIR"
 | 
			
		||||
# Test 14: Mixed approach compatibility
 | 
			
		||||
print_step "14" "Testing mixed approach compatibility"
 | 
			
		||||
 | 
			
		||||
# Verify mnemonic can access traditional secrets
 | 
			
		||||
# Switch to traditional vault and test access with passphrase
 | 
			
		||||
echo "Switching to traditional vault..."
 | 
			
		||||
$SECRET_BINARY vault select traditional
 | 
			
		||||
 | 
			
		||||
# Verify passphrase can access traditional vault secrets
 | 
			
		||||
unset SB_SECRET_MNEMONIC
 | 
			
		||||
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
 | 
			
		||||
RETRIEVED_MIXED=$($SECRET_BINARY get "traditional/secret" 2>/dev/null)
 | 
			
		||||
if [ "$RETRIEVED_MIXED" = "traditional-secret-value" ]; then
 | 
			
		||||
    print_success "Mnemonic can access traditional secrets"
 | 
			
		||||
unset SB_UNLOCK_PASSPHRASE
 | 
			
		||||
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
 | 
			
		||||
 | 
			
		||||
if [ "$RETRIEVED_MIXED" = "traditional-secret" ]; then
 | 
			
		||||
    print_success "Passphrase unlocker can access vault secrets"
 | 
			
		||||
else
 | 
			
		||||
    print_error "Mnemonic cannot access traditional secrets"
 | 
			
		||||
    print_error "Failed to access secret from traditional vault (expected: traditional-secret, got: $RETRIEVED_MIXED)"
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
# Switch back to default vault
 | 
			
		||||
$SECRET_BINARY vault select default
 | 
			
		||||
 | 
			
		||||
# Test without mnemonic but with unlocker
 | 
			
		||||
echo "Testing mnemonic-created vault access..."
 | 
			
		||||
echo "Testing traditional unlocker access to mnemonic-created secrets..."
 | 
			
		||||
@ -629,6 +683,109 @@ fi
 | 
			
		||||
# 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" = "my-super-secret-password" ]; then
 | 
			
		||||
        print_success "Retrieved correct value from specific version"
 | 
			
		||||
    else
 | 
			
		||||
        print_error "Retrieved incorrect value from specific version (expected: my-super-secret-password, 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" = "my-super-secret-password" ]; then
 | 
			
		||||
            print_success "Promoted version is now current"
 | 
			
		||||
        else
 | 
			
		||||
            print_error "Promoted version value is incorrect (expected: my-super-secret-password, got: $PROMOTED_VALUE)"
 | 
			
		||||
        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}"
 | 
			
		||||
@ -638,13 +795,14 @@ echo -e "${GREEN}✓ Import functionality with environment variable combinations
 | 
			
		||||
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${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}✓ Vault operations with passphrase unlocker${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}"
 | 
			
		||||
 | 
			
		||||
@ -680,8 +838,13 @@ 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\""
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user