check / check (push) Failing after 1s
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both; CreatePGPUnlocker sets them on the vault it loads through SetMnemonic and SetUnlockPassphrase, new in VaultInterface. README warns against both variables. Model: opus-5-5
367 lines
10 KiB
Go
367 lines
10 KiB
Go
//nolint:testpackage // white-box test of unexported internals
|
|
package secret
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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")
|
|
})
|
|
}
|
|
|
|
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
|
|
// GetValue, given the mnemonic, derives the long-term key at the derivation
|
|
// index in the vault's metadata. At index 0 it could not decrypt the secret,
|
|
// which was encrypted to the key at index 1.
|
|
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
vaultDir := "/test-config/vaults.d/test-vault"
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
|
defer mnemonic.Destroy()
|
|
|
|
vlt := &MockVault{
|
|
name: "test-vault",
|
|
fs: fs,
|
|
directory: vaultDir,
|
|
derivationIndex: 1,
|
|
mnemonic: mnemonic,
|
|
}
|
|
|
|
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
|
|
require.NoError(t, err)
|
|
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
|
|
|
|
err = afero.WriteFile(
|
|
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
|
|
require.NoError(t, err)
|
|
|
|
secretName, secretValue := "x", "value"
|
|
|
|
err = vlt.AddSecret(secretName,
|
|
memguard.NewBufferFromBytes([]byte(secretValue)), false)
|
|
require.NoError(t, err)
|
|
|
|
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
|
|
require.NoError(t, err)
|
|
|
|
defer value.Destroy()
|
|
|
|
require.Equal(t, secretValue, value.String())
|
|
}
|