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
266 lines
7.4 KiB
Go
266 lines
7.4 KiB
Go
package secret_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// testMnemonic is the standard BIP39 test vector mnemonic.
|
|
//
|
|
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
"abandon abandon abandon abandon abandon about"
|
|
|
|
// 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
|
|
}
|
|
|
|
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
|
func writeTestPublicKey(
|
|
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
|
) {
|
|
t.Helper()
|
|
|
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
|
|
|
err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write public key: %v", err)
|
|
}
|
|
|
|
// Verify the file exists
|
|
exists, err := afero.Exists(fs, pubKeyPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if public key exists: %v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
|
}
|
|
}
|
|
|
|
// writeTestPrivateKey encrypts the private key with the passphrase,
|
|
// writes it, and verifies it exists.
|
|
func writeTestPrivateKey(
|
|
t *testing.T,
|
|
fs afero.Fs,
|
|
unlockerDir string,
|
|
agePrivateKey string,
|
|
testPassphrase string,
|
|
) {
|
|
t.Helper()
|
|
|
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
|
defer privKeyBuffer.Destroy()
|
|
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphraseBuffer.Destroy()
|
|
|
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(
|
|
privKeyBuffer, passphraseBuffer)
|
|
if err != nil {
|
|
t.Fatalf("Failed to encrypt private key: %v", err)
|
|
}
|
|
|
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
|
|
|
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write encrypted private key: %v", err)
|
|
}
|
|
|
|
// Verify the file exists
|
|
exists, err := afero.Exists(fs, privKeyPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if private key exists: %v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
|
}
|
|
}
|
|
|
|
// writeTestLongTermKey encrypts the derived long-term key to the
|
|
// unlocker's recipient, writes it, and verifies it exists.
|
|
func writeTestLongTermKey(
|
|
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Derive a long-term identity from the test mnemonic
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term identity: %v", err)
|
|
}
|
|
|
|
// Encrypt long-term private key to the unlocker's recipient
|
|
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse recipient: %v", err)
|
|
}
|
|
|
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
|
defer ltPrivKeyBuffer.Destroy()
|
|
|
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
|
if err != nil {
|
|
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
|
}
|
|
|
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
|
|
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
|
}
|
|
|
|
// Verify the file exists
|
|
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
|
}
|
|
|
|
if !exists {
|
|
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
|
}
|
|
}
|
|
|
|
// newTestPassphraseUnlocker creates a temp unlocker directory and a
|
|
// passphrase unlocker with a fresh age identity for testing.
|
|
func newTestPassphraseUnlocker(
|
|
t *testing.T, fs afero.Fs,
|
|
) (*secret.PassphraseUnlocker, *age.X25519Identity, string) {
|
|
t.Helper()
|
|
|
|
// Create the directory structure in a temp dir
|
|
unlockerDir := filepath.Join(t.TempDir(), "unlocker")
|
|
|
|
err := os.MkdirAll(unlockerDir, secret.DirPerms)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
|
}
|
|
|
|
// Set up test metadata
|
|
metadata := secret.UnlockerMetadata{
|
|
Type: "passphrase",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{},
|
|
}
|
|
|
|
// Create passphrase unlocker
|
|
unlocker := secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
|
|
|
// Generate a test age identity
|
|
ageIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate age identity: %v", err)
|
|
}
|
|
|
|
return unlocker, ageIdentity, unlockerDir
|
|
}
|
|
|
|
//nolint:paralleltest // subtests share real-FS state, order matters
|
|
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|
// This test uses real filesystem
|
|
if os.Getenv("CI") == "true" {
|
|
t.Log("Running in CI environment with real filesystem")
|
|
}
|
|
|
|
// Use the real filesystem
|
|
fs := afero.NewOsFs()
|
|
|
|
// Test data
|
|
testPassphrase := "test-passphrase-123"
|
|
|
|
unlocker, ageIdentity, unlockerDir := newTestPassphraseUnlocker(t, fs)
|
|
agePrivateKey := ageIdentity.String()
|
|
agePublicKey := ageIdentity.Recipient().String()
|
|
|
|
// Test writing public key
|
|
t.Run("WritePublicKey", func(t *testing.T) {
|
|
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
|
})
|
|
|
|
// Test encrypting private key with passphrase
|
|
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
|
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
|
})
|
|
|
|
// Test writing long-term key
|
|
t.Run("WriteLongTermKey", func(t *testing.T) {
|
|
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
|
})
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
unlocker.Passphrase = passphrase
|
|
|
|
// Test getting identity with the passphrase the unlocker was given,
|
|
// twice: using it must leave it intact for the next use
|
|
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
|
|
for range 2 {
|
|
identity, err := unlocker.GetIdentity()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get identity with passphrase: %v", err)
|
|
}
|
|
|
|
// Verify the identity matches what we expect
|
|
expectedPubKey := ageIdentity.Recipient().String()
|
|
|
|
actualPubKey := identity.Recipient().String()
|
|
if actualPubKey != expectedPubKey {
|
|
t.Errorf("Public key mismatch. Expected %s, got %s",
|
|
expectedPubKey, actualPubKey)
|
|
}
|
|
}
|
|
})
|
|
|
|
unlocker.Passphrase = nil
|
|
|
|
// Test getting identity from prompt (this would require mocking the
|
|
// prompt). For real integration tests, we'd need a way to mock the
|
|
// passphrase input. Here we just verify the error is what we expect
|
|
// when no passphrase is available.
|
|
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
|
|
// This should fail since we're not in an interactive terminal
|
|
_, err := unlocker.GetIdentity()
|
|
if err == nil {
|
|
t.Errorf("Should have failed to get identity without a passphrase")
|
|
}
|
|
})
|
|
|
|
// Test removing the unlocker
|
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
|
err := unlocker.Remove()
|
|
if err != nil {
|
|
t.Fatalf("Failed to remove unlocker: %v", err)
|
|
}
|
|
|
|
// Verify the directory is gone
|
|
exists, err := afero.DirExists(fs, unlockerDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
|
}
|
|
|
|
if exists {
|
|
t.Errorf("Unlocker directory should not exist after removal")
|
|
}
|
|
})
|
|
}
|