check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. Under the lock, unlocker add fails if the vault was removed, or created again with another key, and refuses a GPG key added meanwhile; a forced add of a secret created meanwhile lets go of the lock, gets the key and locks again. secret.CreatePGPUnlocker takes the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone. Not done: secret move between vaults still unlocks both under the lock (#189). Model: opus-5-5
324 lines
8.7 KiB
Go
324 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"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/secret/pkg/agehd"
|
|
)
|
|
|
|
// 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")
|
|
)
|
|
|
|
// TestMain makes passphrase encryption in the tests cheap; see
|
|
// ScryptWorkFactor.
|
|
func TestMain(m *testing.M) {
|
|
ScryptWorkFactor = 1
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// 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) 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")
|
|
})
|
|
}
|