check / check (push) Waiting to run
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
347 行
8.4 KiB
Go
347 行
8.4 KiB
Go
package vault_test
|
|
|
|
import (
|
|
"bytes"
|
|
"path/filepath"
|
|
"slices"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// testMnemonic is the shared BIP39 test mnemonic for tests in this package.
|
|
//
|
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon about"
|
|
|
|
// Shared fixtures for tests in this package.
|
|
const (
|
|
testStateDir = "/test/state"
|
|
testVaultName = "test-vault"
|
|
testSecretName = "test-secret"
|
|
testPassphrase = "test-passphrase"
|
|
)
|
|
|
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
// destroyed when the test ends.
|
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
t.Helper()
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
t.Cleanup(mnemonic.Destroy)
|
|
|
|
return mnemonic
|
|
}
|
|
|
|
//nolint:paralleltest // order-dependent subtests forbid parallel
|
|
func TestVaultOperations(t *testing.T) {
|
|
// Use in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
t.Run("CreateVault", func(t *testing.T) {
|
|
testCreateVault(t, fs)
|
|
})
|
|
|
|
t.Run("ListVaults", func(t *testing.T) {
|
|
testListVaults(t, fs)
|
|
})
|
|
|
|
t.Run("SelectVault", func(t *testing.T) {
|
|
testSelectVault(t, fs)
|
|
})
|
|
|
|
t.Run("SecretOperations", func(t *testing.T) {
|
|
testSecretOperations(t, fs)
|
|
})
|
|
|
|
t.Run("NumSecrets", func(t *testing.T) {
|
|
testNumSecrets(t, fs)
|
|
})
|
|
|
|
t.Run("UnlockerOperations", func(t *testing.T) {
|
|
testUnlockerOperations(t, fs)
|
|
})
|
|
}
|
|
|
|
func testCreateVault(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
|
testMnemonicBuffer(t))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
if vlt.GetName() != testVaultName {
|
|
t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
|
|
}
|
|
|
|
// Check vault directory exists
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
exists, err := afero.DirExists(fs, vaultDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check vault directory: %v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Errorf("Vault directory should exist")
|
|
}
|
|
}
|
|
|
|
func testListVaults(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vaults, err := vault.ListVaults(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to list vaults: %v", err)
|
|
}
|
|
|
|
if !slices.Contains(vaults, testVaultName) {
|
|
t.Errorf("Expected to find '%s' in vault list", testVaultName)
|
|
}
|
|
}
|
|
|
|
func testSelectVault(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
err := vault.SelectVault(fs, testStateDir, testVaultName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to select vault: %v", err)
|
|
}
|
|
|
|
// Test getting current vault
|
|
currentVault, err := vault.GetCurrentVault(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current vault: %v", err)
|
|
}
|
|
|
|
if currentVault.GetName() != testVaultName {
|
|
t.Errorf("Expected current vault '%s', got '%s'",
|
|
testVaultName, currentVault.GetName())
|
|
}
|
|
}
|
|
|
|
func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current vault: %v", err)
|
|
}
|
|
|
|
// First, derive the long-term key from the test mnemonic
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
}
|
|
|
|
// Get the public key from the derived identity
|
|
ltPublicKey := ltIdentity.Recipient().String()
|
|
|
|
// Get the vault directory
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
// Write the correct public key to the pub.age file
|
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
|
|
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
|
}
|
|
|
|
// Unlock the vault with the derived identity
|
|
vlt.Unlock(ltIdentity)
|
|
|
|
// Now add a secret
|
|
secretName := "test/secret"
|
|
secretValue := []byte("test-secret-value")
|
|
expectedValue := make([]byte, len(secretValue))
|
|
copy(expectedValue, secretValue)
|
|
|
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
defer secretBuffer.Destroy()
|
|
|
|
err = vlt.AddSecret(secretName, secretBuffer, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add secret: %v", err)
|
|
}
|
|
|
|
// List secrets
|
|
secrets, err := vlt.ListSecrets()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list secrets: %v", err)
|
|
}
|
|
|
|
if !slices.Contains(secrets, secretName) {
|
|
t.Errorf("Expected to find secret '%s' in list", secretName)
|
|
}
|
|
|
|
// Get secret value
|
|
retrievedValue, err := vlt.GetSecret(secretName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get secret: %v", err)
|
|
}
|
|
defer retrievedValue.Destroy()
|
|
|
|
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
|
t.Errorf("Expected secret value '%s', got '%s'",
|
|
expectedValue, retrievedValue.Bytes())
|
|
}
|
|
}
|
|
|
|
func testNumSecrets(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current vault: %v", err)
|
|
}
|
|
|
|
numSecrets, err := vlt.NumSecrets()
|
|
if err != nil {
|
|
t.Fatalf("Failed to count secrets: %v", err)
|
|
}
|
|
|
|
// We added one secret in SecretOperations
|
|
if numSecrets != 1 {
|
|
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
|
}
|
|
}
|
|
|
|
func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current vault: %v", err)
|
|
}
|
|
|
|
// Test vault unlocking (should happen automatically via mnemonic)
|
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
if vlt.Locked() {
|
|
_, err := vlt.UnlockVault()
|
|
if err != nil {
|
|
t.Fatalf("Failed to unlock vault: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create a passphrase unlocker
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
}
|
|
|
|
// List unlockers
|
|
unlockers, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
t.Fatalf("Failed to list unlockers: %v", err)
|
|
}
|
|
|
|
if len(unlockers) == 0 {
|
|
t.Errorf("Expected at least one unlocker")
|
|
}
|
|
|
|
// Check key type
|
|
keyFound := false
|
|
|
|
for _, key := range unlockers {
|
|
if key.Type == "passphrase" {
|
|
keyFound = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !keyFound {
|
|
t.Errorf("Expected to find passphrase unlocker")
|
|
}
|
|
|
|
// Test selecting unlocker
|
|
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
|
|
if err != nil {
|
|
t.Fatalf("Failed to select unlocker: %v", err)
|
|
}
|
|
|
|
// Test getting current unlocker
|
|
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current unlocker: %v", err)
|
|
}
|
|
|
|
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
|
t.Errorf("Expected current unlocker ID '%s', got '%s'",
|
|
passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
|
}
|
|
}
|
|
|
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Use in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault
|
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
|
testMnemonicBuffer(t))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
// Create a passphrase unlocker so we have at least one valid unlocker
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
}
|
|
|
|
// Create a bogus unlocker directory with no metadata file
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
|
|
|
err = fs.MkdirAll(bogusDir, 0o700)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create bogus directory: %v", err)
|
|
}
|
|
|
|
// ListUnlockers should succeed, skipping the bogus directory
|
|
unlockers, err := vlt.ListUnlockers()
|
|
if err != nil {
|
|
t.Fatalf("ListUnlockers returned error when it should have skipped "+
|
|
"bad directory: %v", err)
|
|
}
|
|
|
|
// Should still have the valid passphrase unlocker
|
|
if len(unlockers) == 0 {
|
|
t.Errorf("Expected at least one unlocker, got none")
|
|
}
|
|
|
|
// Verify we only got the valid unlocker(s), not the bogus one
|
|
for _, u := range unlockers {
|
|
if u.Type == "" {
|
|
t.Errorf("Got unlocker with empty type, likely from bogus directory")
|
|
}
|
|
}
|
|
}
|