Fix integration tests: correct vault derivation index and debug test failures
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
@@ -75,31 +74,18 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
||||
}
|
||||
|
||||
// Calculate mnemonic hash for index tracking
|
||||
mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonicStr))
|
||||
secret.DebugWith("Calculated mnemonic hash", slog.String("hash", mnemonicHash))
|
||||
// Set mnemonic in environment for CreateVault to use
|
||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||
defer func() {
|
||||
if originalMnemonic != "" {
|
||||
os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||
} else {
|
||||
os.Unsetenv(secret.EnvMnemonic)
|
||||
}
|
||||
}()
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
secret.DebugWith("Using derivation index", slog.Uint64("index", uint64(derivationIndex)))
|
||||
|
||||
// Derive long-term keypair from mnemonic with the appropriate index
|
||||
secret.DebugWith("Deriving long-term key from mnemonic", slog.Uint64("index", uint64(derivationIndex)))
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, derivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
// Calculate the long-term key hash
|
||||
ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
|
||||
secret.DebugWith("Calculated long-term key hash", slog.String("hash", ltKeyHash))
|
||||
|
||||
// Create the default vault
|
||||
// Create the default vault - it will handle key derivation internally
|
||||
secret.Debug("Creating default vault")
|
||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||
if err != nil {
|
||||
@@ -107,35 +93,21 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
|
||||
return fmt.Errorf("failed to create default vault: %w", err)
|
||||
}
|
||||
|
||||
// Set as current vault
|
||||
secret.Debug("Setting default vault as current")
|
||||
if err := vault.SelectVault(cli.fs, cli.stateDir, "default"); err != nil {
|
||||
secret.Debug("Failed to select default vault", "error", err)
|
||||
return fmt.Errorf("failed to select default vault: %w", err)
|
||||
}
|
||||
|
||||
// Store long-term public key in vault
|
||||
// Get the vault metadata to retrieve the derivation index
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
secret.DebugWith("Storing long-term public key", slog.String("pubkey", ltPubKey), slog.String("vault_dir", vaultDir))
|
||||
if err := afero.WriteFile(cli.fs, filepath.Join(vaultDir, "pub.age"), []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||
secret.Debug("Failed to write long-term public key", "error", err)
|
||||
return fmt.Errorf("failed to write long-term public key: %w", err)
|
||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err)
|
||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
// Save vault metadata
|
||||
metadata := &vault.VaultMetadata{
|
||||
Name: "default",
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
LongTermKeyHash: ltKeyHash,
|
||||
MnemonicHash: mnemonicHash,
|
||||
// Derive the long-term key using the same index that CreateVault used
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key", "error", err)
|
||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Saved vault metadata with derivation index and key hash")
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
|
||||
// Unlock the vault with the derived long-term key
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
1947
internal/cli/integration_test.go
Normal file
1947
internal/cli/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -240,13 +240,8 @@ func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) er
|
||||
return fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Try to unlock the vault if not already unlocked
|
||||
if vlt.Locked() {
|
||||
_, err := vlt.UnlockVault()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unlock vault: %w", err)
|
||||
}
|
||||
}
|
||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
||||
|
||||
// Check if passphrase is set in environment variable
|
||||
var passphraseStr string
|
||||
|
||||
@@ -181,6 +181,12 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
|
||||
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
||||
}
|
||||
|
||||
// Check if vault already has a public key
|
||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
||||
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
||||
}
|
||||
|
||||
// Get mnemonic from environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
@@ -194,12 +200,8 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
|
||||
return fmt.Errorf("invalid BIP39 mnemonic")
|
||||
}
|
||||
|
||||
// Calculate mnemonic hash for index tracking
|
||||
mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonic))
|
||||
secret.Debug("Calculated mnemonic hash", "hash", mnemonicHash)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
|
||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get next derivation index", "error", err)
|
||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
@@ -213,32 +215,36 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
|
||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Calculate the long-term key hash
|
||||
ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
|
||||
secret.Debug("Calculated long-term key hash", "hash", ltKeyHash)
|
||||
|
||||
// Store long-term public key in vault
|
||||
ltPublicKey := ltIdentity.Recipient().String()
|
||||
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||
|
||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), 0600); err != nil {
|
||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||
}
|
||||
|
||||
// Save vault metadata
|
||||
metadata := &vault.VaultMetadata{
|
||||
Name: vaultName,
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: derivationIndex,
|
||||
LongTermKeyHash: ltKeyHash,
|
||||
MnemonicHash: mnemonicHash,
|
||||
// Calculate public key hash
|
||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltPublicKey))
|
||||
|
||||
// Load existing metadata
|
||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create new
|
||||
existingMetadata = &vault.VaultMetadata{
|
||||
Name: vaultName,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
|
||||
|
||||
// Update metadata with new derivation info
|
||||
existingMetadata.DerivationIndex = derivationIndex
|
||||
existingMetadata.PublicKeyHash = publicKeyHash
|
||||
|
||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
||||
secret.Debug("Failed to save vault metadata", "error", err)
|
||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
}
|
||||
secret.Debug("Saved vault metadata with derivation index and key hash")
|
||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||
|
||||
// Get passphrase from environment variable
|
||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
// Version CLI Command Tests
|
||||
//
|
||||
// Tests for version-related CLI commands:
|
||||
//
|
||||
// - TestListVersionsCommand: Tests `secret version list` command output
|
||||
// - TestListVersionsNonExistentSecret: Tests error handling for missing secrets
|
||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||
// - TestVersionCommandStructure: Tests command structure and help text
|
||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||
//
|
||||
// Test Utilities:
|
||||
// - setupTestVault(): CLI test helper for vault initialization
|
||||
// - Uses consistent test mnemonic for reproducible testing
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
|
||||
@@ -10,8 +10,7 @@ type VaultMetadata struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DerivationIndex uint32 `json:"derivation_index"`
|
||||
LongTermKeyHash string `json:"long_term_key_hash"` // Double SHA256 hash of derived long-term private key
|
||||
MnemonicHash string `json:"mnemonic_hash"` // Double SHA256 hash of mnemonic for index tracking
|
||||
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the long-term public key
|
||||
}
|
||||
|
||||
// UnlockerMetadata contains information about an unlocker
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
// Version Support Test Suite Documentation
|
||||
//
|
||||
// This file contains core unit tests for version functionality:
|
||||
//
|
||||
// - TestGenerateVersionName: Tests version name generation with date and serial format
|
||||
// - TestGenerateVersionNameMaxSerial: Tests the 999 versions per day limit
|
||||
// - TestNewSecretVersion: Tests secret version object creation
|
||||
// - TestSecretVersionSave: Tests saving a version with encryption
|
||||
// - TestSecretVersionLoadMetadata: Tests loading and decrypting version metadata
|
||||
// - TestSecretVersionGetValue: Tests retrieving and decrypting version values
|
||||
// - TestListVersions: Tests listing versions in reverse chronological order
|
||||
// - TestGetCurrentVersion: Tests retrieving the current version via symlink
|
||||
// - TestSetCurrentVersion: Tests updating the current version symlink
|
||||
// - TestVersionMetadataTimestamps: Tests timestamp pointer consistency
|
||||
//
|
||||
// Key Test Scenarios:
|
||||
// - Version Creation: First version gets notBefore = epoch + 1 second
|
||||
// - Subsequent versions update previous version's notAfter timestamp
|
||||
// - New version's notBefore equals previous version's notAfter
|
||||
// - Version names follow YYYYMMDD.NNN format
|
||||
// - Maximum 999 versions per day enforced
|
||||
//
|
||||
// Version Retrieval:
|
||||
// - Get current version via symlink
|
||||
// - Get specific version by name
|
||||
// - Empty version parameter returns current
|
||||
// - Non-existent versions return appropriate errors
|
||||
//
|
||||
// Data Integrity:
|
||||
// - Each version has independent encryption keys
|
||||
// - Metadata encryption protects version history
|
||||
// - Long-term key required for all operations
|
||||
// - Concurrent reads handled safely
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault
|
||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
@@ -114,19 +114,6 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vault
|
||||
vlt.Unlock(ltIdentity)
|
||||
|
||||
@@ -176,31 +163,18 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test vault
|
||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault: %v", err)
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic
|
||||
// Derive long-term key from mnemonic for verification
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
}
|
||||
|
||||
// Get the vault directory
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the vault is locked initially
|
||||
if !vlt.Locked() {
|
||||
t.Errorf("Vault should be locked initially")
|
||||
@@ -346,7 +320,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
t.Fatalf("Failed to create state dir: %v", err)
|
||||
}
|
||||
|
||||
// Create two vaults
|
||||
// Create two vaults - CreateVault now handles public key when mnemonic is in env
|
||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vault1: %v", err)
|
||||
@@ -358,27 +332,21 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
||||
}
|
||||
|
||||
// Derive long-term key from mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex
|
||||
ltIdentity1, err := agehd.DeriveIdentity(testMnemonic, 0) // vault1 gets index 0
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||
t.Fatalf("Failed to derive long-term key for vault1: %v", err)
|
||||
}
|
||||
|
||||
// Setup both vaults with the same long-term key
|
||||
for _, vlt := range []*vault.Vault{vault1, vault2} {
|
||||
vaultDir, err := vlt.GetDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get vault directory: %v", err)
|
||||
}
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(pubKey), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||
}
|
||||
|
||||
vlt.Unlock(ltIdentity)
|
||||
ltIdentity2, err := agehd.DeriveIdentity(testMnemonic, 1) // vault2 gets index 1
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term key for vault2: %v", err)
|
||||
}
|
||||
|
||||
// Unlock the vaults with their respective keys
|
||||
vault1.Unlock(ltIdentity1)
|
||||
vault2.Unlock(ltIdentity2)
|
||||
|
||||
// Add a secret to vault1
|
||||
secretName := "test-secret"
|
||||
secretValue := []byte("secret in vault1")
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
// Version Support Integration Tests
|
||||
//
|
||||
// Comprehensive integration tests for version functionality:
|
||||
//
|
||||
// - TestVersionIntegrationWorkflow: End-to-end workflow testing
|
||||
// - Creating initial version with proper metadata
|
||||
// - Creating multiple versions with timestamp updates
|
||||
// - Retrieving specific versions by name
|
||||
// - Promoting old versions to current
|
||||
// - Testing version serial number limits (999/day)
|
||||
// - Error cases and edge conditions
|
||||
//
|
||||
// - TestVersionConcurrency: Tests concurrent read operations
|
||||
//
|
||||
// - TestVersionCompatibility: Tests handling of legacy non-versioned secrets
|
||||
//
|
||||
// Test Environment:
|
||||
// - Uses in-memory filesystem (afero.MemMapFs)
|
||||
// - Consistent test mnemonic for reproducible keys
|
||||
// - Proper cleanup and isolation between tests
|
||||
|
||||
package vault
|
||||
|
||||
import (
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -202,13 +203,53 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||
}
|
||||
|
||||
// Save initial vault metadata (without derivation info until a mnemonic is imported)
|
||||
// Check if mnemonic is available in environment
|
||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||
var derivationIndex uint32
|
||||
var publicKeyHash string
|
||||
|
||||
if mnemonic != "" {
|
||||
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
|
||||
|
||||
// Get the next available derivation index for this mnemonic
|
||||
var err error
|
||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get next derivation index: %w", err)
|
||||
}
|
||||
|
||||
// Derive the long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Write the public key
|
||||
ltPubKey := ltIdentity.Recipient().String()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write long-term public key: %w", err)
|
||||
}
|
||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
// Compute public key hash from index 0 (same for all vaults with this mnemonic)
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
publicKeyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
} else {
|
||||
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
|
||||
// Use 0 for derivation index when no mnemonic is provided
|
||||
derivationIndex = 0
|
||||
}
|
||||
|
||||
// Save vault metadata
|
||||
metadata := &VaultMetadata{
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
DerivationIndex: 0,
|
||||
LongTermKeyHash: "", // Will be set when mnemonic is imported
|
||||
MnemonicHash: "", // Will be set when mnemonic is imported
|
||||
DerivationIndex: derivationIndex,
|
||||
PublicKeyHash: publicKeyHash,
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -24,8 +25,16 @@ func ComputeDoubleSHA256(data []byte) string {
|
||||
return hex.EncodeToString(secondHash[:])
|
||||
}
|
||||
|
||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic hash
|
||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonicHash string) (uint32, error) {
|
||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
||||
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint32, error) {
|
||||
// First, derive the public key for index 0 to get our identifier
|
||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||
}
|
||||
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||
|
||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||
|
||||
// Check if vaults directory exists
|
||||
@@ -44,9 +53,8 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonicHash string) (
|
||||
return 0, fmt.Errorf("failed to read vaults directory: %w", err)
|
||||
}
|
||||
|
||||
// Track the highest index for this mnemonic
|
||||
var highestIndex uint32 = 0
|
||||
foundMatch := false
|
||||
// Track which indices are in use for this mnemonic
|
||||
usedIndices := make(map[uint32]bool)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
@@ -67,22 +75,19 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonicHash string) (
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this vault uses the same mnemonic
|
||||
if metadata.MnemonicHash == mnemonicHash {
|
||||
foundMatch = true
|
||||
if metadata.DerivationIndex >= highestIndex {
|
||||
highestIndex = metadata.DerivationIndex
|
||||
}
|
||||
// Check if this vault uses the same mnemonic by comparing public key hashes
|
||||
if metadata.PublicKeyHash == pubKeyHash {
|
||||
usedIndices[metadata.DerivationIndex] = true
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, use the next index
|
||||
if foundMatch {
|
||||
return highestIndex + 1, nil
|
||||
// Find the first available index
|
||||
var index uint32 = 0
|
||||
for usedIndices[index] {
|
||||
index++
|
||||
}
|
||||
|
||||
// No existing vault with this mnemonic, start at 0
|
||||
return 0, nil
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// SaveVaultMetadata saves vault metadata to the vault directory
|
||||
|
||||
@@ -13,6 +13,9 @@ func TestVaultMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
stateDir := "/test/state"
|
||||
|
||||
// Test mnemonic for consistent testing
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
||||
// Test data
|
||||
data := []byte("test data")
|
||||
@@ -38,7 +41,7 @@ func TestVaultMetadata(t *testing.T) {
|
||||
|
||||
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
||||
// Test with no existing vaults
|
||||
index, err := GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
|
||||
index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
@@ -46,24 +49,36 @@ func TestVaultMetadata(t *testing.T) {
|
||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||
}
|
||||
|
||||
// Create a vault with metadata
|
||||
// Create a vault with metadata and matching public key
|
||||
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
|
||||
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for index 0
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKeyHash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
metadata1 := &VaultMetadata{
|
||||
Name: "vault1",
|
||||
DerivationIndex: 0,
|
||||
MnemonicHash: "mnemonic-hash-1",
|
||||
LongTermKeyHash: "key-hash-1",
|
||||
PublicKeyHash: pubKeyHash0,
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Next index for same mnemonic should be 1
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
@@ -72,7 +87,8 @@ func TestVaultMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
// Different mnemonic should start at 0
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-2")
|
||||
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
@@ -86,23 +102,34 @@ func TestVaultMetadata(t *testing.T) {
|
||||
t.Fatalf("Failed to create vault directory: %v", err)
|
||||
}
|
||||
|
||||
// Derive identity for index 5
|
||||
identity5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive identity: %v", err)
|
||||
}
|
||||
pubKey5 := identity5.Recipient().String()
|
||||
|
||||
// Write public key
|
||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0600); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
metadata2 := &VaultMetadata{
|
||||
Name: "vault2",
|
||||
DerivationIndex: 5,
|
||||
MnemonicHash: "mnemonic-hash-1",
|
||||
LongTermKeyHash: "key-hash-2",
|
||||
PublicKeyHash: pubKeyHash0, // Same hash since it's from the same mnemonic
|
||||
}
|
||||
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
|
||||
t.Fatalf("Failed to save metadata: %v", err)
|
||||
}
|
||||
|
||||
// Next index should be 6
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
|
||||
// Next index should be 1 (not 6) because we look for the first available slot
|
||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get derivation index: %v", err)
|
||||
}
|
||||
if index != 6 {
|
||||
t.Errorf("Expected index 6 after vault with index 5, got %d", index)
|
||||
if index != 1 {
|
||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -116,8 +143,7 @@ func TestVaultMetadata(t *testing.T) {
|
||||
metadata := &VaultMetadata{
|
||||
Name: "test-vault",
|
||||
DerivationIndex: 3,
|
||||
MnemonicHash: "test-mnemonic-hash",
|
||||
LongTermKeyHash: "test-key-hash",
|
||||
PublicKeyHash: "test-public-key-hash",
|
||||
}
|
||||
|
||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
||||
@@ -136,17 +162,12 @@ func TestVaultMetadata(t *testing.T) {
|
||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
|
||||
}
|
||||
if loaded.MnemonicHash != metadata.MnemonicHash {
|
||||
t.Errorf("MnemonicHash mismatch: expected %s, got %s", metadata.MnemonicHash, loaded.MnemonicHash)
|
||||
}
|
||||
if loaded.LongTermKeyHash != metadata.LongTermKeyHash {
|
||||
t.Errorf("LongTermKeyHash mismatch: expected %s, got %s", metadata.LongTermKeyHash, loaded.LongTermKeyHash)
|
||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s", metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Derive keys with different indices
|
||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
@@ -158,18 +179,24 @@ func TestVaultMetadata(t *testing.T) {
|
||||
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
||||
}
|
||||
|
||||
// Compute hashes
|
||||
hash0 := ComputeDoubleSHA256([]byte(identity0.String()))
|
||||
hash1 := ComputeDoubleSHA256([]byte(identity1.String()))
|
||||
// Compute public key hashes
|
||||
pubKey0 := identity0.Recipient().String()
|
||||
pubKey1 := identity1.Recipient().String()
|
||||
hash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
||||
|
||||
// Verify different indices produce different keys
|
||||
if hash0 == hash1 {
|
||||
t.Errorf("Different derivation indices should produce different keys")
|
||||
// Verify different indices produce different public keys
|
||||
if pubKey0 == pubKey1 {
|
||||
t.Errorf("Different derivation indices should produce different public keys")
|
||||
}
|
||||
|
||||
// Verify public keys are also different
|
||||
if identity0.Recipient().String() == identity1.Recipient().String() {
|
||||
t.Errorf("Different derivation indices should produce different public keys")
|
||||
// But the hash of index 0's public key should be the same for the same mnemonic
|
||||
// This is what we use as the identifier
|
||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
pubKey0Again := identity0Again.Recipient().String()
|
||||
hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||
|
||||
if hash0 != hash0Again {
|
||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,10 +63,31 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
}
|
||||
|
||||
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
|
||||
// but with additional restrictions:
|
||||
// - No leading or trailing slashes
|
||||
// - No double slashes
|
||||
// - No names starting with dots
|
||||
func isValidSecretName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for leading/trailing slashes
|
||||
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for double slashes
|
||||
if strings.Contains(name, "//") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for names starting with dot
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check the basic pattern
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
||||
return matched
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// Vault-Level Version Operation Tests
|
||||
//
|
||||
// Integration tests for vault-level version operations:
|
||||
//
|
||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper version structure
|
||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with force flag
|
||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter) across versions
|
||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||
//
|
||||
// Version Management:
|
||||
// - List versions in reverse chronological order
|
||||
// - Promote any version to current
|
||||
// - Promotion doesn't modify timestamps
|
||||
// - Metadata remains encrypted and intact
|
||||
|
||||
package vault
|
||||
|
||||
import (
|
||||
|
||||
@@ -350,18 +350,22 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to this unlocker if vault is unlocked
|
||||
if !v.Locked() {
|
||||
ltPrivKey := []byte(v.GetLongTermKey().String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
// Encrypt long-term private key to this unlocker
|
||||
// We need to get the long-term key (either from memory if unlocked, or derive it)
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
ltPrivKey := []byte(ltIdentity.String())
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Select this unlocker as current
|
||||
|
||||
@@ -65,7 +65,20 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
// Try to derive from environment mnemonic first
|
||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||
secret.Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
|
||||
// Load vault metadata to get the derivation index
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
@@ -74,6 +87,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Cache the derived key by unlocking the vault
|
||||
|
||||
Reference in New Issue
Block a user