334 lines
9.2 KiB
Go
334 lines
9.2 KiB
Go
package secret
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// MockVault is a test implementation of the VaultInterface
|
|
type MockVault struct {
|
|
name string
|
|
fs afero.Fs
|
|
directory string
|
|
derivationIndex uint32
|
|
}
|
|
|
|
func (m *MockVault) GetDirectory() (string, error) {
|
|
return m.directory, nil
|
|
}
|
|
|
|
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
|
// Create secret directory with proper storage name conversion
|
|
storageName := strings.ReplaceAll(name, "/", "%")
|
|
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
|
if err := m.fs.MkdirAll(secretDir, 0700); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create version directory with proper path
|
|
versionName := "20240101.001" // Use a fixed version name for testing
|
|
versionDir := filepath.Join(secretDir, "versions", versionName)
|
|
if err := m.fs.MkdirAll(versionDir, 0700); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Read the vault's long-term public key
|
|
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
|
|
|
// Derive long-term key using the vault's derivation index
|
|
mnemonic := os.Getenv(EnvMnemonic)
|
|
if mnemonic == "" {
|
|
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
|
}
|
|
|
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write long-term public key if it doesn't exist
|
|
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
|
pubKey := ltIdentity.Recipient().String()
|
|
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0600); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Generate version-specific keypair
|
|
versionIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write version public key
|
|
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
|
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Encrypt value to version's public key
|
|
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write encrypted value
|
|
valuePath := filepath.Join(versionDir, "value.age")
|
|
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Encrypt version private key to long-term public key
|
|
encryptedPrivKey, err := EncryptToRecipient([]byte(versionIdentity.String()), ltIdentity.Recipient())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write encrypted version private key
|
|
privKeyPath := filepath.Join(versionDir, "priv.age")
|
|
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create current symlink pointing to the version
|
|
currentLink := filepath.Join(secretDir, "current")
|
|
// For MemMapFs, write a file with the target path
|
|
if err := afero.WriteFile(m.fs, currentLink, []byte("versions/"+versionName), 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *MockVault) GetName() string {
|
|
return m.name
|
|
}
|
|
|
|
func (m *MockVault) GetFilesystem() afero.Fs {
|
|
return m.fs
|
|
}
|
|
|
|
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestPerSecretKeyFunctionality(t *testing.T) {
|
|
// Create an in-memory filesystem for testing
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Set up test environment variables
|
|
oldMnemonic := os.Getenv(EnvMnemonic)
|
|
defer func() {
|
|
if oldMnemonic == "" {
|
|
os.Unsetenv(EnvMnemonic)
|
|
} else {
|
|
os.Setenv(EnvMnemonic, oldMnemonic)
|
|
}
|
|
}()
|
|
|
|
// Set test mnemonic for direct encryption/decryption
|
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
os.Setenv(EnvMnemonic, testMnemonic)
|
|
|
|
// Set up a test vault structure
|
|
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
|
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
|
|
|
// Create vault directory structure
|
|
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault directory: %v", err)
|
|
}
|
|
|
|
// Generate a long-term keypair for the vault using the test mnemonic
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate long-term identity: %v", err)
|
|
}
|
|
|
|
// Write long-term public key
|
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
err = afero.WriteFile(
|
|
fs,
|
|
ltPubKeyPath,
|
|
[]byte(ltIdentity.Recipient().String()),
|
|
0600,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
|
}
|
|
|
|
// Set current vault
|
|
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to set current vault: %v", err)
|
|
}
|
|
|
|
// Create vault instance using the mock vault
|
|
vault := &MockVault{
|
|
name: "test-vault",
|
|
fs: fs,
|
|
directory: vaultDir,
|
|
derivationIndex: 0,
|
|
}
|
|
|
|
// Test data
|
|
secretName := "test-secret"
|
|
secretValue := []byte("this is a test secret value")
|
|
|
|
// Test AddSecret
|
|
t.Run("AddSecret", func(t *testing.T) {
|
|
err := vault.AddSecret(secretName, secretValue, false)
|
|
if err != nil {
|
|
t.Fatalf("AddSecret failed: %v", err)
|
|
}
|
|
|
|
// Verify that all expected files were created
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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
|
|
secret := NewSecret(vault, secretName)
|
|
|
|
// Test GetValue (this will need to be modified since we're using a mock vault)
|
|
t.Run("GetSecret", func(t *testing.T) {
|
|
// This test is simplified since we're not implementing the full encryption/decryption
|
|
// in the mock. We just verify the Secret object is created correctly.
|
|
if secret.Name != secretName {
|
|
t.Fatalf("Secret name doesn't match. Expected: %s, Got: %s", secretName, secret.Name)
|
|
}
|
|
|
|
if secret.vault != vault {
|
|
t.Fatalf("Secret vault reference doesn't match expected vault")
|
|
}
|
|
|
|
t.Logf("Successfully created Secret object with correct properties")
|
|
})
|
|
|
|
// Test Exists
|
|
t.Run("SecretExists", func(t *testing.T) {
|
|
exists, err := secret.Exists()
|
|
if err != nil {
|
|
t.Fatalf("Error checking if secret exists: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatalf("Secret should exist but Exists() returned false")
|
|
}
|
|
t.Logf("Secret.Exists() works correctly")
|
|
})
|
|
}
|
|
|
|
// For testing purposes only
|
|
func isValidSecretName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash
|
|
for _, char := range name {
|
|
if (char < 'a' || char > 'z') && // lowercase letters
|
|
(char < '0' || char > '9') && // numbers
|
|
char != '-' && // dash
|
|
char != '.' && // dot
|
|
char != '_' && // underscore
|
|
char != '/' { // slash
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func TestSecretNameValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
valid bool
|
|
}{
|
|
{"valid-name", true},
|
|
{"valid.name", true},
|
|
{"valid_name", true},
|
|
{"valid/path/name", true},
|
|
{"123valid", true},
|
|
{"", false},
|
|
{"Invalid-Name", false}, // uppercase not allowed
|
|
{"invalid name", false}, // space not allowed
|
|
{"invalid@name", false}, // @ not allowed
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
result := isValidSecretName(test.name)
|
|
if result != test.valid {
|
|
t.Errorf(
|
|
"isValidSecretName(%q) = %v, want %v",
|
|
test.name,
|
|
result,
|
|
test.valid,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
|
// This test demonstrates the bug where GetValue uses hardcoded index 0
|
|
// instead of the vault's actual derivation index when using environment mnemonic
|
|
|
|
// Set up test mnemonic
|
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
originalEnv := os.Getenv(EnvMnemonic)
|
|
os.Setenv(EnvMnemonic, testMnemonic)
|
|
defer os.Setenv(EnvMnemonic, originalEnv)
|
|
|
|
// Create temporary directory for vaults
|
|
fs := afero.NewOsFs()
|
|
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
_ = fs.RemoveAll(tempDir)
|
|
}()
|
|
|
|
stateDir := filepath.Join(tempDir, ".secret")
|
|
require.NoError(t, fs.MkdirAll(stateDir, 0700))
|
|
|
|
// This test is now in the integration test file where it can use real vaults
|
|
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
|
t.Log("This test demonstrates the bug in the integration test file")
|
|
}
|