check / check (push) Failing after 2s
When a vault cannot be opened through its current unlocker, the error now ends by naming the vault, saying that it still opens with its mnemonic, and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set gives it a new unlocker, after 'secret vault select' when it is not the current vault. Only when the vault metadata records the key the mnemonic derives, and not when the passphrase could not be read. 'secret encrypt' and 'secret decrypt' read the key secret through vault.GetSecret, and Secret.GetValue with its helpers is removed. An unreadable 'current' file's error names 'secret version list' and 'secret version promote'. Causes stay wrapped. Model: opus-5-5
322 lines
8.7 KiB
Go
322 lines
8.7 KiB
Go
//nolint:testpackage // white-box test of unexported internals
|
|
package secret
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
|
//
|
|
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
|
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
|
|
var (
|
|
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
|
|
errNotImplementedInMock = errors.New("not implemented in mock")
|
|
)
|
|
|
|
// MockVault is a test implementation of the VaultInterface
|
|
type MockVault struct {
|
|
name string
|
|
fs afero.Fs
|
|
directory string
|
|
derivationIndex uint32
|
|
mnemonic *memguard.LockedBuffer
|
|
}
|
|
|
|
func (m *MockVault) GetDirectory() (string, error) {
|
|
return m.directory, nil
|
|
}
|
|
|
|
func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool) error {
|
|
// Create secret directory with proper storage name conversion
|
|
storageName := strings.ReplaceAll(name, "/", "%")
|
|
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
|
|
|
err := m.fs.MkdirAll(secretDir, 0o700)
|
|
if 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)
|
|
|
|
err = m.fs.MkdirAll(versionDir, 0o700)
|
|
if 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
|
|
if m.mnemonic == nil {
|
|
return errMnemonicNotSet
|
|
}
|
|
|
|
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write long-term public key if it doesn't exist
|
|
_, err = m.fs.Stat(ltPubKeyPath)
|
|
if os.IsNotExist(err) {
|
|
pubKey := ltIdentity.Recipient().String()
|
|
|
|
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
err = m.writeVersionFiles(versionDir, value, ltIdentity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create current file pointing to the version (just the version name)
|
|
currentLink := filepath.Join(secretDir, "current")
|
|
|
|
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
|
|
}
|
|
|
|
func (m *MockVault) GetName() string {
|
|
return m.name
|
|
}
|
|
|
|
//nolint:ireturn // implements VaultInterface
|
|
func (m *MockVault) GetFilesystem() afero.Fs {
|
|
return m.fs
|
|
}
|
|
|
|
//nolint:ireturn // implements VaultInterface
|
|
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
|
m.mnemonic = mnemonic
|
|
}
|
|
|
|
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
|
|
|
func (m *MockVault) CreatePassphraseUnlocker(
|
|
_ *memguard.LockedBuffer,
|
|
) (*PassphraseUnlocker, error) {
|
|
return nil, errNotImplementedInMock
|
|
}
|
|
|
|
// writeVersionFiles generates a version keypair and writes the version
|
|
// key and value files for the mock vault.
|
|
func (m *MockVault) writeVersionFiles(
|
|
versionDir string,
|
|
value *memguard.LockedBuffer,
|
|
ltIdentity *age.X25519Identity,
|
|
) error {
|
|
// Generate version-specific keypair
|
|
versionIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write version public key
|
|
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
|
|
|
err = afero.WriteFile(
|
|
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Encrypt value to version's public key (value is already a LockedBuffer)
|
|
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write encrypted value
|
|
valuePath := filepath.Join(versionDir, "value.age")
|
|
|
|
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Encrypt version private key to long-term public key
|
|
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
|
defer versionPrivKeyBuffer.Destroy()
|
|
|
|
encryptedPrivKey, err := EncryptToRecipient(
|
|
versionPrivKeyBuffer, ltIdentity.Recipient())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write encrypted version private key
|
|
privKeyPath := filepath.Join(versionDir, "priv.age")
|
|
|
|
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
|
}
|
|
|
|
// setupMockVaultDirs creates the vault directory structure, long-term
|
|
// public key, and current vault pointer for tests.
|
|
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
|
t.Helper()
|
|
|
|
// 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(testMnemonicValue, 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()),
|
|
0o600,
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// verifySecretFiles checks that AddSecret created the expected version
|
|
// files for the secret.
|
|
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
|
t.Helper()
|
|
|
|
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 file exists and points at a version
|
|
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")
|
|
}
|
|
}
|
|
|
|
//nolint:paralleltest // subtests share one vault, order matters
|
|
func TestPerSecretKeyFunctionality(t *testing.T) {
|
|
// Create an in-memory filesystem for testing
|
|
fs := afero.NewMemMapFs()
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
|
defer mnemonic.Destroy()
|
|
|
|
// Set up a test vault structure
|
|
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
|
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
|
|
|
setupMockVaultDirs(t, fs, baseDir, vaultDir)
|
|
|
|
// Create vault instance using the mock vault
|
|
vault := &MockVault{
|
|
name: "test-vault",
|
|
fs: fs,
|
|
directory: vaultDir,
|
|
derivationIndex: 0,
|
|
mnemonic: mnemonic,
|
|
}
|
|
|
|
// Test data
|
|
secretName := "test-secret"
|
|
secretValue := []byte("this is a test secret value")
|
|
|
|
// Create a secure buffer for the test value
|
|
valueBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
defer valueBuffer.Destroy()
|
|
|
|
// Test AddSecret
|
|
t.Run("AddSecret", func(t *testing.T) {
|
|
err := vault.AddSecret(secretName, valueBuffer, false)
|
|
if err != nil {
|
|
t.Fatalf("AddSecret failed: %v", err)
|
|
}
|
|
|
|
// Verify that all expected files were created
|
|
verifySecretFiles(t, fs, vaultDir, secretName)
|
|
|
|
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")
|
|
})
|
|
}
|