All checks were successful
check / check (push) Successful in 1m8s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md
458 lines
12 KiB
Go
458 lines
12 KiB
Go
package vault_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// deriveVaultIdentity derives the long-term identity for the given vault
|
|
// from testMnemonic using the derivation index stored in its metadata.
|
|
func deriveVaultIdentity(
|
|
t *testing.T, fs afero.Fs, vlt *vault.Vault,
|
|
) *age.X25519Identity {
|
|
t.Helper()
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
|
}
|
|
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
|
vaultMetadata.DerivationIndex)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
}
|
|
|
|
return ltIdentity
|
|
}
|
|
|
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
func TestVaultWithRealFilesystem(t *testing.T) {
|
|
// Create a temporary directory for our tests
|
|
tempDir := t.TempDir()
|
|
|
|
// Use the real filesystem
|
|
fs := afero.NewOsFs()
|
|
|
|
// Set test environment variables
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
|
|
// Test currentvault file handling (plain file with relative path)
|
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
|
testCurrentVaultFileHandling(t, fs, tempDir)
|
|
})
|
|
|
|
// Test secret operations with deeply nested paths
|
|
t.Run("DeepPathSecrets", func(t *testing.T) {
|
|
testDeepPathSecrets(t, fs, tempDir)
|
|
})
|
|
|
|
// Test key caching in GetOrDeriveLongTermKey
|
|
t.Run("KeyCaching", func(t *testing.T) {
|
|
testKeyCaching(t, fs, tempDir)
|
|
})
|
|
|
|
// Test vault name validation
|
|
t.Run("VaultNameValidation", func(t *testing.T) {
|
|
testVaultNameValidation(t, fs, tempDir)
|
|
})
|
|
|
|
// Test multiple vaults and switching between them
|
|
t.Run("MultipleVaults", func(t *testing.T) {
|
|
testMultipleVaults(t, fs, tempDir)
|
|
})
|
|
|
|
// Test adding a secret in one vault and verifying it's not visible in
|
|
// another
|
|
t.Run("VaultIsolation", func(t *testing.T) {
|
|
testVaultIsolation(t, fs, tempDir)
|
|
})
|
|
}
|
|
|
|
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "currentvault-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// Create a test vault
|
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
// Get the vault directory
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
// Verify the currentvault file exists and contains just the vault name
|
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
|
|
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read currentvault file: %v", err)
|
|
}
|
|
|
|
if string(currentVaultContents) != testVaultName {
|
|
t.Errorf("Expected currentvault to contain %q, got %q",
|
|
testVaultName, string(currentVaultContents))
|
|
}
|
|
|
|
// Test that ResolveVaultSymlink correctly resolves the path
|
|
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
|
}
|
|
|
|
if resolvedPath != vaultDir {
|
|
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
|
}
|
|
}
|
|
|
|
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "deep-path-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// Create a test vault - CreateVault now handles public key when
|
|
// mnemonic is in env
|
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
// Load vault metadata to get its derivation index
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
|
}
|
|
|
|
// Derive long-term key from mnemonic using the vault's derivation index
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
|
vaultMetadata.DerivationIndex)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
}
|
|
|
|
// Unlock the vault
|
|
vlt.Unlock(ltIdentity)
|
|
|
|
// Create a secret with a deeply nested path
|
|
deepPath := "api/credentials/production/database/primary"
|
|
secretValue := []byte("supersecretdbpassword")
|
|
expectedValue := make([]byte, len(secretValue))
|
|
copy(expectedValue, secretValue)
|
|
|
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
defer secretBuffer.Destroy()
|
|
|
|
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add secret with deep path: %v", err)
|
|
}
|
|
|
|
// List secrets and verify our deep path secret is there
|
|
secrets, err := vlt.ListSecrets()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list secrets: %v", err)
|
|
}
|
|
|
|
if !slices.Contains(secrets, deepPath) {
|
|
t.Errorf("Deep path secret not found in listed secrets")
|
|
}
|
|
|
|
// Retrieve the secret and verify its value
|
|
retrievedValue, err := vlt.GetSecret(deepPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
|
}
|
|
|
|
if string(retrievedValue) != string(expectedValue) {
|
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
|
string(expectedValue), string(retrievedValue))
|
|
}
|
|
}
|
|
|
|
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "key-cache-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// Create a test vault - CreateVault now handles public key when
|
|
// mnemonic is in env
|
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
// Load vault metadata to get its derivation index
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
|
}
|
|
|
|
// Derive long-term key from mnemonic for verification using the
|
|
// vault's derivation index
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
|
vaultMetadata.DerivationIndex)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
}
|
|
|
|
// Verify the vault is locked initially
|
|
if !vlt.Locked() {
|
|
t.Errorf("Vault should be locked initially")
|
|
}
|
|
|
|
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
|
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get long-term key: %v", err)
|
|
}
|
|
|
|
// Verify the vault is now unlocked
|
|
if vlt.Locked() {
|
|
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
|
}
|
|
|
|
// Second call should return the cached key without re-deriving
|
|
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get cached long-term key: %v", err)
|
|
}
|
|
|
|
// Verify both keys are the same instance
|
|
if firstKey != secondKey {
|
|
t.Errorf("Second key call should return same instance as first call")
|
|
}
|
|
|
|
// Verify the public key matches what we expect
|
|
expectedPubKey := ltIdentity.Recipient().String()
|
|
|
|
actualPubKey := firstKey.Recipient().String()
|
|
if actualPubKey != expectedPubKey {
|
|
t.Errorf("Public key mismatch. Expected %s, got %s",
|
|
expectedPubKey, actualPubKey)
|
|
}
|
|
|
|
// Now clear the key and verify it's locked again
|
|
vlt.ClearLongTermKey()
|
|
|
|
if !vlt.Locked() {
|
|
t.Errorf("Vault should be locked after clearing key")
|
|
}
|
|
|
|
// Get the key again and verify it works
|
|
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
|
}
|
|
|
|
// Verify the public key still matches
|
|
actualPubKey = thirdKey.Recipient().String()
|
|
if actualPubKey != expectedPubKey {
|
|
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
|
|
expectedPubKey, actualPubKey)
|
|
}
|
|
}
|
|
|
|
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "name-validation-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// Test valid vault names
|
|
validNames := []string{
|
|
"default",
|
|
"test-vault",
|
|
"production.vault",
|
|
"vault_123",
|
|
"a-very-long-vault-name-with-dashes",
|
|
}
|
|
|
|
for _, name := range validNames {
|
|
_, err := vault.CreateVault(fs, stateDir, name)
|
|
if err != nil {
|
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// Test invalid vault names
|
|
invalidNames := []string{
|
|
"", // Empty
|
|
"UPPERCASE", // Uppercase not allowed
|
|
"invalid/name", // Slashes not allowed in vault names
|
|
"invalid name", // Spaces not allowed
|
|
"invalid@name", // Special chars not allowed
|
|
}
|
|
|
|
for _, name := range invalidNames {
|
|
_, err := vault.CreateVault(fs, stateDir, name)
|
|
if err == nil {
|
|
t.Errorf("Expected error creating vault with invalid name %q, "+
|
|
"but got none", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// Create three vaults
|
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
|
for _, name := range vaultNames {
|
|
_, err := vault.CreateVault(fs, stateDir, name)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// List vaults and verify all three are there
|
|
vaults, err := vault.ListVaults(fs, stateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to list vaults: %v", err)
|
|
}
|
|
|
|
if len(vaults) != 3 {
|
|
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
|
}
|
|
|
|
// Test switching between vaults
|
|
for _, name := range vaultNames {
|
|
// Select the vault
|
|
err := vault.SelectVault(fs, stateDir, name)
|
|
if err != nil {
|
|
t.Fatalf("Failed to select vault %s: %v", name, err)
|
|
}
|
|
|
|
// Get current vault and verify it's the one we selected
|
|
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current vault after selecting %s: %v",
|
|
name, err)
|
|
}
|
|
|
|
if currentVault.GetName() != name {
|
|
t.Errorf("Expected current vault to be %s, got %s",
|
|
name, currentVault.GetName())
|
|
}
|
|
}
|
|
}
|
|
|
|
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|
t.Helper()
|
|
|
|
stateDir := filepath.Join(tempDir, "isolation-test")
|
|
|
|
err := os.MkdirAll(stateDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create state dir: %v", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault2: %v", err)
|
|
}
|
|
|
|
// Derive long-term keys from mnemonic
|
|
// Note: Both vaults will have different derivation indexes due to
|
|
// GetNextDerivationIndex
|
|
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
|
|
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
|
|
|
|
// Unlock the vaults with their respective keys
|
|
vault1.Unlock(ltIdentity1)
|
|
vault2.Unlock(ltIdentity2)
|
|
|
|
// Add a secret to vault1
|
|
secretValue := []byte("secret in vault1")
|
|
|
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
defer secretBuffer.Destroy()
|
|
|
|
err = vault1.AddSecret(testSecretName, secretBuffer, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add secret to vault1: %v", err)
|
|
}
|
|
|
|
// Verify the secret exists in vault1
|
|
vault1Secrets, err := vault1.ListSecrets()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
|
}
|
|
|
|
if !slices.Contains(vault1Secrets, testSecretName) {
|
|
t.Errorf("Secret not found in vault1")
|
|
}
|
|
|
|
// Verify the secret does NOT exist in vault2
|
|
vault2Secrets, err := vault2.ListSecrets()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
|
}
|
|
|
|
if slices.Contains(vault2Secrets, testSecretName) {
|
|
t.Errorf("Secret from vault1 should not be visible in vault2")
|
|
}
|
|
}
|