Fix integration tests: correct vault derivation index and debug test failures

This commit is contained in:
2025-06-09 04:54:45 -07:00
parent e036d280c0
commit 02be4b2a55
21 changed files with 2461 additions and 1191 deletions

View File

@@ -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")

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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

View File

@@ -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")
}
})
}

View File

@@ -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
}

View File

@@ -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 (

View File

@@ -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

View File

@@ -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