add secret versioning support
This commit is contained in:
parent
f59ee4d2d6
commit
fbda2d91af
35
README.md
35
README.md
@ -10,7 +10,15 @@ Secret implements a sophisticated three-layer key architecture:
|
||||
|
||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
|
||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
|
||||
3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values
|
||||
3. **Version-specific Keys**: Per-version keys that encrypt individual secret values
|
||||
|
||||
### Version Management
|
||||
|
||||
Each secret maintains a history of versions, with each version having:
|
||||
- Its own encryption key pair
|
||||
- Encrypted metadata including creation time and validity period
|
||||
- Immutable value storage
|
||||
- Atomic version switching via symlink updates
|
||||
|
||||
### Vault System
|
||||
|
||||
@ -80,12 +88,21 @@ Adds a secret to the current vault. Reads the secret value from stdin.
|
||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||
|
||||
#### `secret get <secret-name>`
|
||||
#### `secret get <secret-name> [--version <version>]`
|
||||
Retrieves and outputs a secret value to stdout.
|
||||
- `--version, -v`: Get a specific version (default: current)
|
||||
|
||||
#### `secret list [filter] [--json]` / `secret ls`
|
||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||
|
||||
### Version Management
|
||||
|
||||
#### `secret version list <secret-name>`
|
||||
Lists all versions of a secret showing creation time, status, and validity period.
|
||||
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
Promotes a specific version to current by updating the symlink. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
|
||||
### Key Generation
|
||||
|
||||
#### `secret generate mnemonic`
|
||||
@ -147,7 +164,17 @@ Decrypts data using an Age key stored as a secret.
|
||||
│ │ │ └── pgp/ # PGP unlocker
|
||||
│ │ ├── secrets.d/
|
||||
│ │ │ ├── api%key/ # Secret: api/key
|
||||
│ │ │ │ ├── versions/
|
||||
│ │ │ │ │ ├── 20231215.001/ # Version directory
|
||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||
│ │ │ │ └── current -> versions/20231216.001
|
||||
│ │ │ └── database%password/ # Secret: database/password
|
||||
│ │ │ ├── versions/
|
||||
│ │ │ └── current -> versions/20231215.001
|
||||
│ │ └── current-unlocker -> ../unlockers.d/passphrase
|
||||
│ └── work/
|
||||
│ ├── unlockers.d/
|
||||
@ -204,8 +231,10 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
||||
- Vault isolation prevents cross-contamination
|
||||
|
||||
### Forward Secrecy
|
||||
- Per-secret encryption keys limit exposure if compromised
|
||||
- Per-version encryption keys limit exposure if compromised
|
||||
- Each version is independently encrypted
|
||||
- Long-term keys protected by multiple unlocker layers
|
||||
- Historical versions remain encrypted with their original keys
|
||||
|
||||
### Hardware Integration
|
||||
- Hardware token support via PGP/GPG integration
|
||||
|
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
|
26
go.mod
26
go.mod
@ -8,34 +8,26 @@ require (
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/spf13/afero v1.14.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c // indirect
|
||||
github.com/facebookincubator/sks v0.0.0-20250508161834-9be892919529 // indirect
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/google/btree v1.0.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.2 // indirect
|
||||
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
|
||||
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
|
||||
github.com/google/go-attestation v0.5.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tspi v0.3.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
@ -40,6 +40,7 @@ func newRootCmd() *cobra.Command {
|
||||
cmd.AddCommand(newImportCmd())
|
||||
cmd.AddCommand(newEncryptCmd())
|
||||
cmd.AddCommand(newDecryptCmd())
|
||||
cmd.AddCommand(newVersionCmd())
|
||||
|
||||
secret.Debug("newRootCmd completed")
|
||||
return cmd
|
||||
|
@ -35,15 +35,19 @@ func newAddCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func newGetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <secret-name>",
|
||||
Short: "Retrieve a secret from the vault",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
version, _ := cmd.Flags().GetString("version")
|
||||
cli := NewCLIInstance()
|
||||
return cli.GetSecret(args[0])
|
||||
return cli.GetSecretWithVersion(args[0], version)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
@ -132,6 +136,11 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
|
||||
|
||||
// GetSecret retrieves and prints a secret from the current vault
|
||||
func (cli *CLIInstance) GetSecret(secretName string) error {
|
||||
return cli.GetSecretWithVersion(secretName, "")
|
||||
}
|
||||
|
||||
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||
func (cli *CLIInstance) GetSecretWithVersion(secretName string, version string) error {
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
@ -139,7 +148,12 @@ func (cli *CLIInstance) GetSecret(secretName string) error {
|
||||
}
|
||||
|
||||
// Get the secret value
|
||||
value, err := vlt.GetSecret(secretName)
|
||||
var value []byte
|
||||
if version == "" {
|
||||
value, err = vlt.GetSecret(secretName)
|
||||
} else {
|
||||
value, err = vlt.GetSecretVersion(secretName, version)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
203
internal/cli/version.go
Normal file
203
internal/cli/version.go
Normal file
@ -0,0 +1,203 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newVersionCmd returns the version management command
|
||||
func newVersionCmd() *cobra.Command {
|
||||
cli := NewCLIInstance()
|
||||
return VersionCommands(cli)
|
||||
}
|
||||
|
||||
// VersionCommands returns the version management commands
|
||||
func VersionCommands(cli *CLIInstance) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Manage secret versions",
|
||||
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
|
||||
}
|
||||
|
||||
// List versions command
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list <secret-name>",
|
||||
Short: "List all versions of a secret",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cli.ListVersions(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
// Promote version command
|
||||
promoteCmd := &cobra.Command{
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cli.PromoteVersion(args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
versionCmd.AddCommand(listCmd, promoteCmd)
|
||||
return versionCmd
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func (cli *CLIInstance) ListVersions(secretName string) error {
|
||||
secret.Debug("Listing versions for secret", "secret_name", secretName)
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Convert secret name to storage name
|
||||
storageName := strings.ReplaceAll(secretName, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("secret %s not found", secretName)
|
||||
}
|
||||
|
||||
// Get all versions
|
||||
versions, err := secret.ListVersions(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list versions: %w", err)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
fmt.Println("No versions found")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err)
|
||||
currentVersion = ""
|
||||
}
|
||||
|
||||
// Get long-term key for decrypting metadata
|
||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Create table writer
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
||||
|
||||
// Load and display each version's metadata
|
||||
for _, version := range versions {
|
||||
sv := secret.NewSecretVersion(vlt, secretName, version)
|
||||
|
||||
// Load metadata
|
||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Debug("Failed to load version metadata", "version", version, "error", err)
|
||||
// Display version with error
|
||||
status := "error"
|
||||
if version == currentVersion {
|
||||
status = "current (error)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "expired"
|
||||
if version == currentVersion {
|
||||
status = "current"
|
||||
}
|
||||
|
||||
// Format timestamps
|
||||
createdAt := "-"
|
||||
if sv.Metadata.CreatedAt != nil {
|
||||
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notBefore := "-"
|
||||
if sv.Metadata.NotBefore != nil {
|
||||
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
notAfter := "-"
|
||||
if sv.Metadata.NotAfter != nil {
|
||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromoteVersion promotes a specific version to current
|
||||
func (cli *CLIInstance) PromoteVersion(secretName string, version string) error {
|
||||
secret.Debug("Promoting version", "secret_name", secretName, "version", version)
|
||||
|
||||
// Get current vault
|
||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Convert secret name to storage name
|
||||
storageName := strings.ReplaceAll(secretName, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("secret %s not found", secretName)
|
||||
}
|
||||
|
||||
// Check if version exists
|
||||
versionPath := filepath.Join(secretDir, "versions", version)
|
||||
exists, err = afero.DirExists(cli.fs, versionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("version %s not found for secret %s", version, secretName)
|
||||
}
|
||||
|
||||
// Update current symlink
|
||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
||||
return fmt.Errorf("failed to promote version: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
||||
return nil
|
||||
}
|
288
internal/cli/version_test.go
Normal file
288
internal/cli/version_test.go
Normal file
@ -0,0 +1,288 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/internal/vault"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to set up a vault with long-term key
|
||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select vault
|
||||
err = vault.SelectVault(fs, stateDir, "default")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestListVersionsCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// List versions
|
||||
err = cli.ListVersions("test/secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
output, _ := io.ReadAll(r)
|
||||
outputStr := string(output)
|
||||
|
||||
// Verify output contains version headers
|
||||
assert.Contains(t, outputStr, "VERSION")
|
||||
assert.Contains(t, outputStr, "CREATED")
|
||||
assert.Contains(t, outputStr, "STATUS")
|
||||
assert.Contains(t, outputStr, "NOT_BEFORE")
|
||||
assert.Contains(t, outputStr, "NOT_AFTER")
|
||||
|
||||
// Should have current status for latest version
|
||||
assert.Contains(t, outputStr, "current")
|
||||
|
||||
// Should have two version entries
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
versionLines := 0
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||
versionLines++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, versionLines)
|
||||
}
|
||||
|
||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Try to list versions of non-existent secret
|
||||
err := cli.ListVersions("nonexistent/secret")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestPromoteVersionCommand(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Current should be version-2
|
||||
value, err := vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
|
||||
// Promote first version
|
||||
firstVersion := versions[1] // Older version
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = cli.PromoteVersion("test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
output, _ := io.ReadAll(r)
|
||||
outputStr := string(output)
|
||||
|
||||
// Verify success message
|
||||
assert.Contains(t, outputStr, "Promoted version")
|
||||
assert.Contains(t, outputStr, firstVersion)
|
||||
|
||||
// Verify current is now version-1
|
||||
value, err = vlt.GetSecret("test/secret")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
}
|
||||
|
||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("value"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to promote non-existent version
|
||||
err = cli.PromoteVersion("test/secret", "20991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestGetSecretWithVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Add a secret with multiple versions
|
||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vlt.AddSecret("test/secret", []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get versions
|
||||
vaultDir, _ := vlt.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Test getting current version (empty version string)
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = cli.GetSecretWithVersion("test/secret", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
output, _ := io.ReadAll(r)
|
||||
|
||||
assert.Equal(t, "version-2", string(output))
|
||||
|
||||
// Test getting specific version
|
||||
r, w, _ = os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
firstVersion := versions[1] // Older version
|
||||
err = cli.GetSecretWithVersion("test/secret", firstVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
output, _ = io.ReadAll(r)
|
||||
|
||||
assert.Equal(t, "version-1", string(output))
|
||||
}
|
||||
|
||||
func TestVersionCommandStructure(t *testing.T) {
|
||||
// Test that version commands are properly structured
|
||||
cli := NewCLIInstance()
|
||||
cmd := VersionCommands(cli)
|
||||
|
||||
assert.Equal(t, "version", cmd.Use)
|
||||
assert.Equal(t, "Manage secret versions", cmd.Short)
|
||||
|
||||
// Check subcommands
|
||||
listCmd := cmd.Commands()[0]
|
||||
assert.Equal(t, "list <secret-name>", listCmd.Use)
|
||||
assert.Equal(t, "List all versions of a secret", listCmd.Short)
|
||||
|
||||
promoteCmd := cmd.Commands()[1]
|
||||
assert.Equal(t, "promote <secret-name> <version>", promoteCmd.Use)
|
||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||
}
|
||||
|
||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
// Set up vault with long-term key
|
||||
setupTestVault(t, fs, stateDir)
|
||||
|
||||
// Create a secret directory without versions (edge case)
|
||||
vaultDir := stateDir + "/vaults.d/default"
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions - should show "No versions found"
|
||||
err = cli.ListVersions("test/secret")
|
||||
|
||||
// Should succeed even with no versions
|
||||
assert.NoError(t, err)
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@ -62,9 +61,10 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
}
|
||||
}
|
||||
|
||||
// Save saves a secret value to the vault
|
||||
// Save is deprecated - use vault.AddSecret directly which creates versions
|
||||
// Kept for backward compatibility
|
||||
func (s *Secret) Save(value []byte, force bool) error {
|
||||
DebugWith("Saving secret",
|
||||
DebugWith("Saving secret (deprecated method)",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Int("value_length", len(value)),
|
||||
@ -81,7 +81,7 @@ func (s *Secret) Save(value []byte, force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the secret value using the provided unlocker
|
||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
@ -99,7 +99,17 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
}
|
||||
|
||||
Debug("Secret exists, proceeding with decryption", "secret_name", s.Name)
|
||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Create version object
|
||||
version := NewSecretVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
@ -114,8 +124,8 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the secret using per-secret architecture
|
||||
return s.decryptWithLongTermKey(ltIdentity)
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
@ -170,165 +180,33 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Use the long-term key to decrypt the secret using per-secret architecture
|
||||
return s.decryptWithLongTermKey(ltIdentity)
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// decryptWithLongTermKey decrypts the secret using the vault's long-term private key
|
||||
// This implements the per-secret key architecture: longterm -> secret private key -> secret value
|
||||
func (s *Secret) decryptWithLongTermKey(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
DebugWith("Decrypting secret with long-term key using per-secret architecture",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
// Step 1: Read the secret's encrypted private key from priv.age
|
||||
encryptedSecretPrivKeyPath := filepath.Join(s.Directory, "priv.age")
|
||||
Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
|
||||
|
||||
encryptedSecretPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedSecretPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret private key",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
|
||||
)
|
||||
|
||||
// Step 2: Decrypt the secret's private key using the vault's long-term private key
|
||||
Debug("Decrypting secret private key using long-term key", "secret_name", s.Name)
|
||||
secretPrivKeyData, err := DecryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the secret's private key
|
||||
Debug("Parsing secret's private key", "secret_name", s.Name)
|
||||
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse secret's private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to parse secret's private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted and parsed secret's identity",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("secret_public_key", secretIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 3: Read the secret's encrypted value from value.age
|
||||
encryptedValuePath := filepath.Join(s.Directory, "value.age")
|
||||
Debug("Reading encrypted secret value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 4: Decrypt the secret's value using the secret's private key
|
||||
Debug("Decrypting value using secret key", "secret_name", s.Name)
|
||||
decryptedValue, err := DecryptWithIdentity(encryptedValue, secretIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret value using per-secret key architecture",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// LoadMetadata loads the secret metadata from disk
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
DebugWith("Loading secret metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory for metadata loading", "error", err, "secret_name", s.Name)
|
||||
return err
|
||||
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
s.Metadata = SecretMetadata{
|
||||
Name: s.Name,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(s.Name, "/", "%")
|
||||
metadataPath := filepath.Join(vaultDir, "secrets.d", storageName, "secret-metadata.json")
|
||||
|
||||
DebugWith("Reading secret metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("metadata_path", metadataPath),
|
||||
)
|
||||
|
||||
// Read metadata file
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read secret metadata file", "error", err, "metadata_path", metadataPath)
|
||||
return fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read secret metadata file",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("metadata_size", len(metadataBytes)),
|
||||
)
|
||||
|
||||
var metadata SecretMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse secret metadata JSON", "error", err, "secret_name", s.Name)
|
||||
return fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Parsed secret metadata",
|
||||
slog.String("secret_name", metadata.Name),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Time("updated_at", metadata.UpdatedAt),
|
||||
)
|
||||
|
||||
s.Metadata = metadata
|
||||
Debug("Successfully loaded secret metadata", "secret_name", s.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
func (s *Secret) GetMetadata() SecretMetadata {
|
||||
Debug("Returning secret metadata", "secret_name", s.Name)
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetEncryptedData reads and returns the encrypted secret data
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
DebugWith("Getting encrypted secret data",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Reading encrypted secret file", "secret_path", secretPath)
|
||||
|
||||
encryptedData, err := afero.ReadFile(s.vault.GetFilesystem(), secretPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret file", "error", err, "secret_path", secretPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully read encrypted secret data",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
return encryptedData, nil
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
@ -338,22 +216,31 @@ func (s *Secret) Exists() (bool, error) {
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Checking secret file existence", "secret_path", secretPath)
|
||||
|
||||
exists, err := afero.Exists(s.vault.GetFilesystem(), secretPath)
|
||||
// Check if the secret directory exists and has a current symlink
|
||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to check secret file existence", "error", err, "secret_path", secretPath)
|
||||
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
Debug("Secret directory does not exist", "secret_dir", s.Directory)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if current symlink exists
|
||||
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("No current version found", "error", err, "secret_name", s.Name)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
DebugWith("Secret existence check result",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Bool("exists", exists),
|
||||
slog.Bool("exists", true),
|
||||
)
|
||||
|
||||
return exists, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
|
@ -3,6 +3,7 @@ package secret
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
@ -23,12 +24,33 @@ func (m *MockVault) GetDirectory() (string, error) {
|
||||
}
|
||||
|
||||
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
// Simplified implementation for testing
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", name)
|
||||
if err := m.fs.MkdirAll(secretDir, DirPerms); err != nil {
|
||||
// Create versioned structure for testing
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
|
||||
// Generate version name
|
||||
versionName, err := GenerateVersionName(m.fs, secretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return afero.WriteFile(m.fs, filepath.Join(secretDir, "value.age"), value, FilePerms)
|
||||
|
||||
// Create version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
if err := m.fs.MkdirAll(versionDir, DirPerms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted value (simplified for testing)
|
||||
if err := afero.WriteFile(m.fs, filepath.Join(versionDir, "value.age"), value, FilePerms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set current symlink
|
||||
if err := SetCurrentVersion(m.fs, secretDir, versionName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
@ -122,16 +144,30 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Verify that all expected files were created
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check value.age exists (the new per-secret key architecture format)
|
||||
secretExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(secretDir, "value.age"),
|
||||
)
|
||||
if err != nil || !secretExists {
|
||||
t.Fatalf("value.age file was not created")
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
t.Logf("All expected files created successfully")
|
||||
// Check current symlink exists
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
valueExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(versionDir, "value.age"),
|
||||
)
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
|
||||
// Create a Secret object to test with
|
||||
|
424
internal/secret/version.go
Normal file
424
internal/secret/version.go
Normal file
@ -0,0 +1,424 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// VersionMetadata contains information about a secret version
|
||||
type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
SecretName string `json:"secretName"` // Parent secret name
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
|
||||
Version string `json:"version"` // Version string (e.g., "20231215.001")
|
||||
}
|
||||
|
||||
// SecretVersion represents a version of a secret
|
||||
type SecretVersion struct {
|
||||
SecretName string
|
||||
Version string
|
||||
Directory string
|
||||
Metadata VersionMetadata
|
||||
vault VaultInterface
|
||||
}
|
||||
|
||||
// NewSecretVersion creates a new SecretVersion instance
|
||||
func NewSecretVersion(vault VaultInterface, secretName string, version string) *SecretVersion {
|
||||
DebugWith("Creating new secret version instance",
|
||||
slog.String("secret_name", secretName),
|
||||
slog.String("version", version),
|
||||
slog.String("vault_name", vault.GetName()),
|
||||
)
|
||||
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
storageName := strings.ReplaceAll(secretName, "/", "%")
|
||||
versionDir := filepath.Join(vaultDir, "secrets.d", storageName, "versions", version)
|
||||
|
||||
DebugWith("Secret version storage details",
|
||||
slog.String("secret_name", secretName),
|
||||
slog.String("version", version),
|
||||
slog.String("version_dir", versionDir),
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
return &SecretVersion{
|
||||
SecretName: secretName,
|
||||
Version: version,
|
||||
Directory: versionDir,
|
||||
vault: vault,
|
||||
Metadata: VersionMetadata{
|
||||
ID: ulid.Make().String(),
|
||||
SecretName: secretName,
|
||||
CreatedAt: &now,
|
||||
Version: version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateVersionName generates a new version name in YYYYMMDD.NNN format
|
||||
func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
today := time.Now().Format("20060102")
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Ensure versions directory exists
|
||||
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
|
||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
// Find the highest serial number for today
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read versions directory: %w", err)
|
||||
}
|
||||
|
||||
maxSerial := 0
|
||||
prefix := today + "."
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
||||
// Extract serial number
|
||||
parts := strings.Split(entry.Name(), ".")
|
||||
if len(parts) == 2 {
|
||||
var serial int
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
|
||||
if serial > maxSerial {
|
||||
maxSerial = serial
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new version name
|
||||
newSerial := maxSerial + 1
|
||||
if newSerial > 999 {
|
||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
}
|
||||
|
||||
// Save saves the version metadata and value
|
||||
func (sv *SecretVersion) Save(value []byte) error {
|
||||
DebugWith("Saving secret version",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
slog.Int("value_length", len(value)),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create version directory
|
||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Generate a new keypair for this version
|
||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||
}
|
||||
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
versionPrivateKey := versionIdentity.String()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
slog.String("public_key", versionPublicKey),
|
||||
)
|
||||
|
||||
// Step 2: Store the version's public key
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Store the encrypted value
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Get vault's long-term public key for encrypting the version's private key
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Encrypt the version's private key to the long-term public key
|
||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||
encryptedPrivKey, err := EncryptToRecipient([]byte(versionPrivateKey), ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Store the encrypted private key
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Encrypt and store metadata
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadMetadata loads and decrypts the version metadata
|
||||
func (sv *SecretVersion) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
DebugWith("Loading version metadata",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted metadata
|
||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Decrypt metadata using version key
|
||||
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Unmarshal metadata
|
||||
var metadata VersionMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
sv.Metadata = metadata
|
||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the version value
|
||||
func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
DebugWith("Getting version value",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
)
|
||||
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||
return nil, fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Decrypt value using version key
|
||||
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully retrieved version value", "version", sv.Version, "value_length", len(value))
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Check if versions directory exists
|
||||
exists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// List all version directories
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read versions directory: %w", err)
|
||||
}
|
||||
|
||||
var versions []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
versions = append(versions, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions in reverse chronological order
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
||||
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// GetCurrentVersion returns the version that the "current" symlink points to
|
||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
|
||||
// Try to read as a real symlink first
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
target, err := os.Readlink(currentPath)
|
||||
if err == nil {
|
||||
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) >= 2 && parts[0] == "versions" {
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to reading as a file (for MemMapFs testing)
|
||||
fileData, err := afero.ReadFile(fs, currentPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read current version symlink: %w", err)
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(string(fileData))
|
||||
|
||||
// Extract version from path
|
||||
parts := strings.Split(target, "/")
|
||||
if len(parts) >= 2 && parts[0] == "versions" {
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid current version symlink format: %s", target)
|
||||
}
|
||||
|
||||
// SetCurrentVersion updates the "current" symlink to point to a specific version
|
||||
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
targetPath := filepath.Join("versions", version)
|
||||
|
||||
// Remove existing symlink if it exists
|
||||
_ = fs.Remove(currentPath)
|
||||
|
||||
// Try to create a real symlink first (works on Unix systems)
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
if err := os.Symlink(targetPath, currentPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to creating a file with the target path (for MemMapFs testing)
|
||||
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to create current version symlink: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
333
internal/secret/version_test.go
Normal file
333
internal/secret/version_test.go
Normal file
@ -0,0 +1,333 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockVault implements VaultInterface for testing
|
||||
type MockVersionVault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
longTermKey *age.X25519Identity
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func TestGenerateVersionName(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// Test first version generation
|
||||
version1, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||
|
||||
// Create the version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", version1)
|
||||
err = fs.MkdirAll(versionDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
// Verify they have the same date prefix
|
||||
assert.Equal(t, version1[:8], version2[:8])
|
||||
assert.NotEqual(t, version1, version2)
|
||||
}
|
||||
|
||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Create 999 versions
|
||||
today := time.Now().Format("20060102")
|
||||
for i := 1; i <= 999; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Try to create one more - should fail
|
||||
_, err := GenerateVersionName(fs, secretDir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewSecretVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001")
|
||||
assert.NotEmpty(t, sv.Metadata.ID)
|
||||
assert.NotNil(t, sv.Metadata.CreatedAt)
|
||||
assert.Equal(t, "20231215.001", sv.Metadata.Version)
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate and store long-term public key
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
err = sv.Save(testValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify files were created
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age")))
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
sv.Metadata.NotAfter = &now
|
||||
|
||||
err = sv.Save([]byte("test-value"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify loaded metadata
|
||||
assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID)
|
||||
assert.Equal(t, sv.Metadata.SecretName, sv2.Metadata.SecretName)
|
||||
assert.Equal(t, sv.Metadata.Version, sv2.Metadata.Version)
|
||||
assert.NotNil(t, sv2.Metadata.NotBefore)
|
||||
assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix())
|
||||
assert.NotNil(t, sv2.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
|
||||
err = sv.Save(originalValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalValue, retrievedValue)
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
|
||||
// Create some versions
|
||||
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be sorted in reverse chronological order
|
||||
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
|
||||
assert.Equal(t, expected, versions)
|
||||
}
|
||||
|
||||
func TestGetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// Simulate symlink with file content (works for both OsFs and MemMapFs)
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231217.001", version)
|
||||
}
|
||||
|
||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
// Test that all timestamp fields behave consistently as pointers
|
||||
vm := VersionMetadata{
|
||||
ID: "test-id",
|
||||
SecretName: "test/secret",
|
||||
Version: "20231215.001",
|
||||
}
|
||||
|
||||
// All should be nil initially
|
||||
assert.Nil(t, vm.CreatedAt)
|
||||
assert.Nil(t, vm.NotBefore)
|
||||
assert.Nil(t, vm.NotAfter)
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
epoch := time.Unix(1, 0)
|
||||
future := now.Add(time.Hour)
|
||||
|
||||
vm.CreatedAt = &now
|
||||
vm.NotBefore = &epoch
|
||||
vm.NotAfter = &future
|
||||
|
||||
// All should be non-nil
|
||||
assert.NotNil(t, vm.CreatedAt)
|
||||
assert.NotNil(t, vm.NotBefore)
|
||||
assert.NotNil(t, vm.NotAfter)
|
||||
|
||||
// Values should match
|
||||
assert.Equal(t, now.Unix(), vm.CreatedAt.Unix())
|
||||
assert.Equal(t, int64(1), vm.NotBefore.Unix())
|
||||
assert.Equal(t, future.Unix(), vm.NotAfter.Unix())
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
return exists
|
||||
}
|
322
internal/vault/integration_version_test.go
Normal file
322
internal/vault/integration_version_test.go
Normal file
@ -0,0 +1,322 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vault, err := CreateVault(fs, stateDir, "test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault
|
||||
vault.Unlock(ltIdentity)
|
||||
|
||||
secretName := "integration/test"
|
||||
|
||||
// Step 1: Create initial version
|
||||
t.Run("create_initial_version", func(t *testing.T) {
|
||||
err := vault.AddSecret(secretName, []byte("version-1-data"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify secret can be retrieved
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify version directory structure
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 1)
|
||||
|
||||
// Verify current symlink exists
|
||||
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, versions[0], currentVersion)
|
||||
|
||||
// Verify metadata
|
||||
version := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||
assert.NotNil(t, version.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||
assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version
|
||||
})
|
||||
|
||||
// Step 2: Create second version
|
||||
var firstVersionName string
|
||||
t.Run("create_second_version", func(t *testing.T) {
|
||||
// Small delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Get first version name before creating second
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
firstVersionName = versions[0]
|
||||
|
||||
// Create second version
|
||||
err = vault.AddSecret(secretName, []byte("version-2-data"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify new value is current
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value)
|
||||
|
||||
// Verify we now have two versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 2)
|
||||
|
||||
// Verify first version metadata was updated with notAfter
|
||||
firstVersion := secret.NewSecretVersion(vault, secretName, firstVersionName)
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
|
||||
// Verify second version metadata
|
||||
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||
|
||||
// NotBefore of second should equal NotAfter of first
|
||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix())
|
||||
})
|
||||
|
||||
// Step 3: Create third version
|
||||
t.Run("create_third_version", func(t *testing.T) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err := vault.AddSecret(secretName, []byte("version-3-data"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we now have three versions
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, versions, 3)
|
||||
|
||||
// Current should be version-3
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value)
|
||||
})
|
||||
|
||||
// Step 4: Retrieve specific versions
|
||||
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 3)
|
||||
|
||||
// Get each version by its name
|
||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value1)
|
||||
|
||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2-data"), value2)
|
||||
|
||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), value3)
|
||||
|
||||
// Empty version should return current
|
||||
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||
})
|
||||
|
||||
// Step 5: Promote old version to current
|
||||
t.Run("promote_old_version", func(t *testing.T) {
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Promote the first version (oldest) to current
|
||||
oldestVersion := versions[2]
|
||||
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify current now returns the old version's value
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1-data"), value)
|
||||
|
||||
// Verify the version metadata hasn't changed
|
||||
// (promoting shouldn't modify timestamps)
|
||||
version := secret.NewSecretVersion(vault, secretName, oldestVersion)
|
||||
err = version.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter
|
||||
})
|
||||
|
||||
// Step 6: Test version limits
|
||||
t.Run("version_serial_limits", func(t *testing.T) {
|
||||
// Create a new secret for this test
|
||||
limitSecretName := "limit/test"
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||
|
||||
// Create 998 versions (we already have one from the first AddSecret)
|
||||
err := vault.AddSecret(limitSecretName, []byte("initial"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get today's date for consistent version names
|
||||
today := time.Now().Format("20060102")
|
||||
|
||||
// Manually create many versions with same date
|
||||
for i := 2; i <= 998; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
versionDir := filepath.Join(secretDir, versionName)
|
||||
err := fs.MkdirAll(versionDir, 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should be able to create one more (999)
|
||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
|
||||
|
||||
// Create the 999th version directory
|
||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to create 1000th version
|
||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
})
|
||||
|
||||
// Step 7: Test error cases
|
||||
t.Run("error_cases", func(t *testing.T) {
|
||||
// Try to get non-existent version
|
||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
|
||||
// Try to get version of non-existent secret
|
||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Try to add secret without force when it exists
|
||||
err = vault.AddSecret(secretName, []byte("should-fail"), false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
}
|
||||
|
||||
// TestVersionConcurrency tests concurrent version operations
|
||||
func TestVersionConcurrency(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "concurrent/test"
|
||||
|
||||
// Create initial version
|
||||
err := vault.AddSecret(secretName, []byte("initial"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test concurrent reads
|
||||
t.Run("concurrent_reads", func(t *testing.T) {
|
||||
done := make(chan bool, 10)
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
value, err := vault.GetSecret(secretName)
|
||||
if err != nil {
|
||||
errors <- err
|
||||
} else if string(value) != "initial" {
|
||||
errors <- fmt.Errorf("unexpected value: %s", value)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
select {
|
||||
case err := <-errors:
|
||||
t.Fatalf("concurrent read failed: %v", err)
|
||||
default:
|
||||
// No errors
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestVersionCompatibility tests that old secrets without versions still work
|
||||
func TestVersionCompatibility(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Set up vault
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Manually create an old-style secret (no versions)
|
||||
secretName := "legacy/secret"
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "legacy%secret")
|
||||
err = fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create old-style encrypted value directly in secret directory
|
||||
testValue := []byte("legacy-value")
|
||||
ltRecipient := ltIdentity.Recipient()
|
||||
encrypted, err := secret.EncryptToRecipient(testValue, ltRecipient)
|
||||
require.NoError(t, err)
|
||||
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
err = afero.WriteFile(fs, valuePath, encrypted, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fail to get with version-aware methods
|
||||
_, err = vault.GetSecret(secretName)
|
||||
assert.Error(t, err)
|
||||
|
||||
// List versions should return empty
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
}
|
@ -113,140 +113,136 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
}
|
||||
secret.Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
if exists && !force {
|
||||
// Handle existing secret case
|
||||
now := time.Now()
|
||||
var previousVersion *secret.SecretVersion
|
||||
|
||||
if exists {
|
||||
if !force {
|
||||
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
||||
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
||||
}
|
||||
|
||||
// Create secret directory
|
||||
// Get the current version to update its notAfter timestamp
|
||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err == nil && currentVersionName != "" {
|
||||
previousVersion = secret.NewSecretVersion(v, name, currentVersionName)
|
||||
// We'll need to load and update its metadata after we unlock the vault
|
||||
}
|
||||
} else {
|
||||
// Create secret directory for new secret
|
||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
||||
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||
}
|
||||
secret.Debug("Created secret directory successfully")
|
||||
}
|
||||
|
||||
// Step 1: Generate a new keypair for this secret
|
||||
secret.Debug("Generating secret-specific keypair", "secret_name", name)
|
||||
secretIdentity, err := age.GenerateX25519Identity()
|
||||
// Generate new version name
|
||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to generate secret keypair", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to generate secret keypair: %w", err)
|
||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to generate version name: %w", err)
|
||||
}
|
||||
|
||||
secretPublicKey := secretIdentity.Recipient().String()
|
||||
secretPrivateKey := secretIdentity.String()
|
||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||
|
||||
secret.DebugWith("Generated secret keypair",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretPublicKey),
|
||||
)
|
||||
// Create new version
|
||||
newVersion := secret.NewSecretVersion(v, name, versionName)
|
||||
|
||||
// Step 2: Store the secret's public key
|
||||
pubKeyPath := filepath.Join(secretDir, "pub.age")
|
||||
secret.Debug("Writing secret public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), secret.FilePerms); err != nil {
|
||||
secret.Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
|
||||
return fmt.Errorf("failed to write secret public key: %w", err)
|
||||
// Set version timestamps
|
||||
if previousVersion == nil {
|
||||
// First version: notBefore = epoch + 1 second
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
newVersion.Metadata.NotBefore = &epochPlusOne
|
||||
} else {
|
||||
// New version: notBefore = now
|
||||
newVersion.Metadata.NotBefore = &now
|
||||
|
||||
// We'll update the previous version's notAfter after we save the new version
|
||||
}
|
||||
secret.Debug("Wrote secret public key successfully")
|
||||
|
||||
// Step 3: Encrypt the secret value to the secret's public key
|
||||
secret.Debug("Encrypting secret value to secret's public key", "secret_name", name)
|
||||
encryptedValue, err := secret.EncryptToRecipient(value, secretIdentity.Recipient())
|
||||
// Save the new version
|
||||
if err := newVersion.Save(value); err != nil {
|
||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||
return fmt.Errorf("failed to save version: %w", err)
|
||||
}
|
||||
|
||||
// Update previous version if it exists
|
||||
if previousVersion != nil {
|
||||
// Get long-term key to decrypt/encrypt metadata
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to encrypt secret value", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret value: %w", err)
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Secret value encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 4: Store the encrypted secret value as value.age
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
secret.Debug("Writing encrypted secret value", "path", valuePath)
|
||||
if err := afero.WriteFile(v.fs, valuePath, encryptedValue, secret.FilePerms); err != nil {
|
||||
secret.Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
|
||||
return fmt.Errorf("failed to write encrypted secret value: %w", err)
|
||||
// Load previous version metadata
|
||||
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote encrypted secret value successfully")
|
||||
|
||||
// Step 5: Get long-term public key for encrypting the secret's private key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
secret.Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = &now
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(v.fs, ltPubKeyPath)
|
||||
// Re-save the metadata (we need to implement an update method)
|
||||
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set current symlink to new version
|
||||
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
secret.Debug("Successfully added secret version to vault", "secret_name", name, "version", versionName, "vault_name", v.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVersionMetadata updates the metadata of an existing version
|
||||
func updateVersionMetadata(fs afero.Fs, version *secret.SecretVersion, ltIdentity *age.X25519Identity) error {
|
||||
// Read the version's encrypted private key
|
||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
secret.Debug("Read long-term public key successfully", "key_length", len(ltPubKeyData))
|
||||
|
||||
secret.Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
// Decrypt version private key using long-term key
|
||||
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse long-term public key", "error", err)
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
|
||||
|
||||
// Step 6: Encrypt the secret's private key to the long-term public key
|
||||
secret.Debug("Encrypting secret private key to long-term public key", "secret_name", name)
|
||||
encryptedPrivKey, err := secret.EncryptToRecipient([]byte(secretPrivateKey), ltRecipient)
|
||||
// Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to encrypt secret private key", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret private key: %w", err)
|
||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Secret private key encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedPrivKey)),
|
||||
)
|
||||
|
||||
// Step 7: Store the encrypted secret private key as priv.age
|
||||
privKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
secret.Debug("Writing encrypted secret private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
||||
secret.Debug("Failed to write encrypted secret private key", "error", err, "path", privKeyPath)
|
||||
return fmt.Errorf("failed to write encrypted secret private key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote encrypted secret private key successfully")
|
||||
|
||||
// Step 8: Create and write metadata
|
||||
secret.Debug("Creating secret metadata")
|
||||
now := time.Now()
|
||||
metadata := SecretMetadata{
|
||||
Name: name,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
secret.DebugWith("Creating secret metadata",
|
||||
slog.String("secret_name", metadata.Name),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Time("updated_at", metadata.UpdatedAt),
|
||||
)
|
||||
|
||||
secret.Debug("Marshaling secret metadata")
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
// Marshal updated metadata
|
||||
metadataBytes, err := json.MarshalIndent(version.Metadata, "", " ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to marshal secret metadata", "error", err)
|
||||
return fmt.Errorf("failed to marshal secret metadata: %w", err)
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Marshaled secret metadata successfully")
|
||||
|
||||
metadataPath := filepath.Join(secretDir, "secret-metadata.json")
|
||||
secret.Debug("Writing secret metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
||||
secret.Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
|
||||
return fmt.Errorf("failed to write secret metadata: %w", err)
|
||||
// Encrypt metadata to the version's public key
|
||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBytes, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted metadata
|
||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote secret metadata successfully")
|
||||
|
||||
secret.Debug("Successfully added secret to vault with per-secret key architecture", "secret_name", name, "vault_name", v.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -257,11 +253,30 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||
slog.String("secret_name", name),
|
||||
)
|
||||
|
||||
// Create a secret object to handle file access
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
return v.GetSecretVersion(name, "")
|
||||
}
|
||||
|
||||
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
|
||||
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||
secret.DebugWith("Getting secret version from vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
)
|
||||
|
||||
// Get vault directory
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := secretObj.Exists()
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
@ -271,9 +286,36 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
secret.Debug("Secret exists, proceeding with vault unlock and decryption", "secret_name", name)
|
||||
// Determine which version to get
|
||||
if version == "" {
|
||||
// Get current version
|
||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
version = currentVersion
|
||||
secret.Debug("Using current version", "version", version, "secret_name", name)
|
||||
}
|
||||
|
||||
// Step 1: Unlock the vault (get long-term key in memory)
|
||||
// Create version object
|
||||
secretVersion := secret.NewSecretVersion(v, name, version)
|
||||
|
||||
// Check if version exists
|
||||
versionPath := filepath.Join(secretDir, "versions", version)
|
||||
exists, err = afero.DirExists(v.fs, versionPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
||||
}
|
||||
|
||||
secret.Debug("Version exists, proceeding with vault unlock and decryption", "version", version, "secret_name", name)
|
||||
|
||||
// Unlock the vault (get long-term key in memory)
|
||||
longTermIdentity, err := v.UnlockVault()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
||||
@ -283,18 +325,20 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||
secret.DebugWith("Successfully unlocked vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 2: Use the unlocked vault to decrypt the secret
|
||||
decryptedValue, err := v.decryptSecretWithLongTermKey(name, longTermIdentity)
|
||||
// Get the version's value
|
||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted secret with per-secret key architecture",
|
||||
secret.DebugWith("Successfully decrypted secret version",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("version", version),
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
@ -330,90 +374,6 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
return longTermIdentity, nil
|
||||
}
|
||||
|
||||
// decryptSecretWithLongTermKey decrypts a secret using the provided long-term key
|
||||
func (v *Vault) decryptSecretWithLongTermKey(name string, longTermIdentity *age.X25519Identity) ([]byte, error) {
|
||||
secret.DebugWith("Decrypting secret with long-term key",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", v.Name),
|
||||
)
|
||||
|
||||
// Get vault and secret directories
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Step 1: Read the encrypted secret private key from priv.age
|
||||
encryptedSecretPrivKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
secret.Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
|
||||
|
||||
encryptedSecretPrivKey, err := afero.ReadFile(v.fs, encryptedSecretPrivKeyPath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted secret private key",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
|
||||
)
|
||||
|
||||
// Step 2: Decrypt the secret's private key using the long-term private key
|
||||
secret.Debug("Decrypting secret private key with long-term key", "secret_name", name)
|
||||
secretPrivKeyData, err := secret.DecryptWithIdentity(encryptedSecretPrivKey, longTermIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Parse the secret's private key
|
||||
secret.Debug("Parsing secret private key", "secret_name", name)
|
||||
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
|
||||
if err != nil {
|
||||
secret.Debug("Failed to parse secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to parse secret private key: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully parsed secret identity",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 4: Read the encrypted secret value from value.age
|
||||
encryptedValuePath := filepath.Join(secretDir, "value.age")
|
||||
secret.Debug("Reading encrypted secret value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(v.fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Read encrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 5: Decrypt the secret value using the secret's private key
|
||||
secret.Debug("Decrypting secret value with secret's private key", "secret_name", name)
|
||||
decryptedValue, err := secret.DecryptWithIdentity(encryptedValue, secretIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to decrypt secret value", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
secret.DebugWith("Successfully decrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
// First check if the secret exists by checking for the metadata file
|
||||
|
282
internal/vault/secrets_version_test.go
Normal file
282
internal/vault/secrets_version_test.go
Normal file
@ -0,0 +1,282 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper function to create a vault with long-term key set up
|
||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
||||
// Set mnemonic for testing
|
||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
|
||||
// Create vault
|
||||
vault, err := CreateVault(fs, stateDir, vaultName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Derive and store long-term key from mnemonic
|
||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store long-term public key in vault
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unlock the vault with the derived key
|
||||
vault.Unlock(ltIdentity)
|
||||
|
||||
return vault
|
||||
}
|
||||
|
||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Add a secret
|
||||
secretName := "test/secret"
|
||||
secretValue := []byte("initial-value")
|
||||
|
||||
err := vault.AddSecret(secretName, secretValue, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that version directory was created
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versionsDir := secretDir + "/versions"
|
||||
|
||||
// Should have one version
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
|
||||
// Should have current symlink
|
||||
currentPath := secretDir + "/current"
|
||||
exists, err := afero.Exists(fs, currentPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Get the secret value
|
||||
retrievedValue, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, secretValue, retrievedValue)
|
||||
}
|
||||
|
||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add first version
|
||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to add again without force - should fail
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), false)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
|
||||
// Add with force - should create new version
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that we have two versions
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
versionsDir := vaultDir + "/secrets.d/test%secret/versions"
|
||||
entries, err := afero.ReadDir(fs, versionsDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 2)
|
||||
|
||||
// Current value should be version-2
|
||||
value, err := vault.GetSecret(secretName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
}
|
||||
|
||||
func TestVaultGetSecretVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add multiple versions
|
||||
err := vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Small delay to ensure different version names
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get versions list
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Get specific version (first one)
|
||||
firstVersion := versions[1] // Last in list is first created
|
||||
value, err := vault.GetSecretVersion(secretName, firstVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-1"), value)
|
||||
|
||||
// Get specific version (second one)
|
||||
secondVersion := versions[0] // First in list is most recent
|
||||
value, err = vault.GetSecretVersion(secretName, secondVersion)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
|
||||
// Get current (empty version)
|
||||
value, err = vault.GetSecretVersion(secretName, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("version-2"), value)
|
||||
}
|
||||
|
||||
func TestVaultVersionTimestamps(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
secretName := "test/secret"
|
||||
|
||||
// Add first version
|
||||
beforeFirst := time.Now()
|
||||
err = vault.AddSecret(secretName, []byte("version-1"), false)
|
||||
require.NoError(t, err)
|
||||
afterFirst := time.Now()
|
||||
|
||||
// Get first version metadata
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 1)
|
||||
|
||||
firstVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check first version timestamps
|
||||
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
||||
assert.True(t, firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
||||
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
||||
assert.Nil(t, firstVersion.Metadata.NotAfter) // Still current
|
||||
|
||||
// Add second version
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
beforeSecond := time.Now()
|
||||
err = vault.AddSecret(secretName, []byte("version-2"), true)
|
||||
require.NoError(t, err)
|
||||
afterSecond := time.Now()
|
||||
|
||||
// Get updated versions
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
// Reload first version metadata (should have notAfter now)
|
||||
firstVersion = secret.NewSecretVersion(vault, secretName, versions[1])
|
||||
err = firstVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||
assert.True(t, firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t, firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||
|
||||
// Check second version timestamps
|
||||
secondVersion := secret.NewSecretVersion(vault, secretName, versions[0])
|
||||
err = secondVersion.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||
assert.True(t, secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||
assert.True(t, secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||
}
|
||||
|
||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Add a secret
|
||||
err := vault.AddSecret("test/secret", []byte("value"), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to get non-existent version
|
||||
_, err = vault.GetSecretVersion("test/secret", "20991231.999")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestUpdateVersionMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Create vault with long-term key
|
||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
||||
|
||||
// Get long-term key
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a version manually to test updateVersionMetadata
|
||||
secretName := "test/secret"
|
||||
versionName := "20231215.001"
|
||||
version := secret.NewSecretVersion(vault, secretName, versionName)
|
||||
|
||||
// Set initial metadata
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
version.Metadata.NotBefore = &epochPlusOne
|
||||
version.Metadata.NotAfter = nil
|
||||
|
||||
// Save version
|
||||
err = version.Save([]byte("test-value"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update metadata
|
||||
version.Metadata.NotAfter = &now
|
||||
err = updateVersionMetadata(fs, version, ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load and verify
|
||||
version2 := secret.NewSecretVersion(vault, secretName, versionName)
|
||||
err = version2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, version2.Metadata.NotAfter)
|
||||
assert.Equal(t, now.Unix(), version2.Metadata.NotAfter.Unix())
|
||||
}
|
@ -629,6 +629,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" = "new-password-value" ]; then
|
||||
print_success "Retrieved correct value from specific version"
|
||||
else
|
||||
print_error "Retrieved incorrect value from specific version (expected: new-password-value, got: $VERSIONED_VALUE)"
|
||||
fi
|
||||
else
|
||||
print_error "Could not determine version to test"
|
||||
fi
|
||||
|
||||
# Test version promotion
|
||||
echo "Testing version promotion..."
|
||||
if [ -n "$FIRST_VERSION" ]; then
|
||||
echo "Running: $SECRET_BINARY version promote \"database/password\" $FIRST_VERSION"
|
||||
if $SECRET_BINARY version promote "database/password" "$FIRST_VERSION"; then
|
||||
print_success "Promoted older version to current"
|
||||
|
||||
# Verify the promoted version is now current
|
||||
PROMOTED_VALUE=$($SECRET_BINARY get "database/password" 2>/dev/null)
|
||||
if [ "$PROMOTED_VALUE" = "new-password-value" ]; then
|
||||
print_success "Promoted version is now current"
|
||||
else
|
||||
print_error "Promoted version value is incorrect"
|
||||
fi
|
||||
else
|
||||
print_error "Failed to promote version"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check version directory structure
|
||||
echo "Checking version directory structure..."
|
||||
VERSION_DIR="$TEMP_DIR/vaults.d/default/secrets.d/database%password/versions"
|
||||
if [ -d "$VERSION_DIR" ]; then
|
||||
print_success "Versions directory exists"
|
||||
|
||||
# Count version directories
|
||||
VERSION_COUNT=$(find "$VERSION_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
if [ "$VERSION_COUNT" -ge 2 ]; then
|
||||
print_success "Multiple version directories found: $VERSION_COUNT"
|
||||
else
|
||||
print_error "Expected multiple version directories, found: $VERSION_COUNT"
|
||||
fi
|
||||
|
||||
# Check for current symlink
|
||||
CURRENT_LINK="$TEMP_DIR/vaults.d/default/secrets.d/database%password/current"
|
||||
if [ -L "$CURRENT_LINK" ] || [ -f "$CURRENT_LINK" ]; then
|
||||
print_success "Current version symlink exists"
|
||||
else
|
||||
print_error "Current version symlink not found"
|
||||
fi
|
||||
else
|
||||
print_error "Versions directory not found"
|
||||
fi
|
||||
|
||||
# Final summary
|
||||
echo -e "\n${GREEN}=== Test Summary ===${NC}"
|
||||
echo -e "${GREEN}✓ Environment variable support (SB_SECRET_STATE_DIR, SB_SECRET_MNEMONIC)${NC}"
|
||||
@ -645,6 +748,7 @@ 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 +784,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