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. Nothing below the command reads the environment. README warns against both variables. Model: opus-5-5
509 lines
15 KiB
Go
509 lines
15 KiB
Go
//go:build darwin
|
|
|
|
package secret_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"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"
|
|
)
|
|
|
|
// Register vault with secret package for testing
|
|
func init() {
|
|
// Register the vault.GetCurrentVault function with the secret package
|
|
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
|
return vault.GetCurrentVault(fs, stateDir)
|
|
})
|
|
}
|
|
|
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
|
// Create GPG config file for non-interactive operation
|
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
|
gpgConfContent := `batch
|
|
no-tty
|
|
pinentry-mode loopback
|
|
`
|
|
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
|
|
t.Fatalf("Failed to write GPG config file: %v", err)
|
|
}
|
|
|
|
// Create a test-specific GPG implementation
|
|
origEncryptFunc := secret.GPGEncryptFunc
|
|
origDecryptFunc := secret.GPGDecryptFunc
|
|
|
|
// Set custom GPG functions for this test
|
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
|
if data == nil {
|
|
return nil, fmt.Errorf("data buffer is nil")
|
|
}
|
|
cmd := exec.Command("gpg",
|
|
"--homedir", gnupgHomeDir,
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode", "loopback",
|
|
"--passphrase", passphrase,
|
|
"--trust-model", "always",
|
|
"--armor",
|
|
"--encrypt",
|
|
"-r", keyID)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
|
cmd := exec.Command("gpg",
|
|
"--homedir", gnupgHomeDir,
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode", "loopback",
|
|
"--passphrase", passphrase,
|
|
"--quiet",
|
|
"--decrypt")
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
cmd.Stdin = bytes.NewReader(encryptedData)
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
|
}
|
|
|
|
// Create a secure buffer for the decrypted data
|
|
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
|
|
}
|
|
|
|
// Restore original functions after test
|
|
t.Cleanup(func() {
|
|
secret.GPGEncryptFunc = origEncryptFunc
|
|
secret.GPGDecryptFunc = origDecryptFunc
|
|
})
|
|
}
|
|
|
|
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
|
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
|
|
cmdArgs := []string{
|
|
"--homedir=" + gnupgHome,
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode", "loopback",
|
|
"--passphrase", passphrase,
|
|
}
|
|
cmdArgs = append(cmdArgs, args...)
|
|
|
|
cmd := exec.Command("gpg", cmdArgs...)
|
|
cmd.Stdin = input
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GPG command failed: %w\nStderr: %s", err, stderr.String())
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|
// Check if gpg is available
|
|
if _, err := exec.LookPath("gpg"); err != nil {
|
|
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
|
// Continue anyway to test what we can
|
|
}
|
|
|
|
// Create a temporary directory for our tests
|
|
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
|
|
|
|
// Create a temporary GNUPGHOME
|
|
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
|
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
|
|
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
|
}
|
|
|
|
// Set new GNUPGHOME
|
|
t.Setenv("GNUPGHOME", gnupgHomeDir)
|
|
|
|
// Test passphrase for GPG key
|
|
testPassphrase := "test123"
|
|
|
|
// Setup non-interactive GPG with custom functions
|
|
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
|
|
|
// Create GPG batch file for key generation
|
|
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
|
batchContent := `%echo Generating a test key
|
|
Key-Type: RSA
|
|
Key-Length: 2048
|
|
Name-Real: Test User
|
|
Name-Email: test@example.com
|
|
Expire-Date: 0
|
|
Passphrase: ` + testPassphrase + `
|
|
%commit
|
|
%echo Key generation completed
|
|
`
|
|
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
|
|
t.Fatalf("Failed to write batch file: %v", err)
|
|
}
|
|
|
|
// Generate GPG key with batch mode
|
|
t.Log("Generating GPG key...")
|
|
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
|
[]string{"--gen-key", batchFile}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate GPG key: %v", err)
|
|
}
|
|
t.Log("GPG key generated successfully")
|
|
|
|
// Get the key ID and fingerprint
|
|
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
|
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to list GPG keys: %v", err)
|
|
}
|
|
|
|
// Parse output to get key ID and fingerprint
|
|
var keyID, fingerprint string
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "sec:") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= 5 {
|
|
keyID = fields[4]
|
|
}
|
|
} else if strings.HasPrefix(line, "fpr:") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= 10 && fields[9] != "" {
|
|
fingerprint = fields[9]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if keyID == "" {
|
|
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
|
}
|
|
if fingerprint == "" {
|
|
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
|
}
|
|
t.Logf("Generated GPG key ID: %s", keyID)
|
|
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
|
|
|
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
|
t.Setenv("GPG_AGENT_INFO", "")
|
|
|
|
// Use the real filesystem
|
|
fs := afero.NewOsFs()
|
|
|
|
// Test data
|
|
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
defer mnemonic.Destroy()
|
|
|
|
// Set test environment variables
|
|
t.Setenv(secret.EnvGPGKeyID, keyID)
|
|
|
|
// Set up vault structure for testing
|
|
stateDir := tempDir
|
|
vaultName := "test-vault"
|
|
|
|
// Test creation of a PGP unlock key through a vault
|
|
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
|
// Set a limited test timeout to avoid hanging
|
|
timer := time.AfterFunc(30*time.Second, func() {
|
|
t.Fatalf("Test timed out after 30 seconds")
|
|
})
|
|
defer timer.Stop()
|
|
|
|
// Create a test vault directory structure
|
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create vault: %v", err)
|
|
}
|
|
|
|
// Set the current vault
|
|
err = vault.SelectVault(fs, stateDir, vaultName)
|
|
if err != nil {
|
|
t.Fatalf("Failed to select vault: %v", err)
|
|
}
|
|
|
|
// Derive long-term key from mnemonic
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
}
|
|
|
|
// Get the vault directory
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get vault directory: %v", err)
|
|
}
|
|
|
|
// Write long-term public key
|
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
|
}
|
|
|
|
// Unlock the vault
|
|
vlt.Unlock(ltIdentity)
|
|
|
|
// Create a passphrase unlocker first (to have current unlocker)
|
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
|
defer passphraseBuffer.Destroy()
|
|
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
}
|
|
|
|
// Verify passphrase unlocker was created
|
|
if passUnlocker == nil {
|
|
t.Fatal("Passphrase unlocker is nil")
|
|
}
|
|
|
|
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
|
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
|
}
|
|
|
|
// Verify the PGP unlock key was created
|
|
if pgpUnlocker == nil {
|
|
t.Fatal("PGP unlock key is nil")
|
|
}
|
|
|
|
// Check if the key has the correct type
|
|
if pgpUnlocker.GetType() != "pgp" {
|
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
|
}
|
|
|
|
// Check if the key ID includes the GPG fingerprint
|
|
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
|
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
|
}
|
|
|
|
// Check if the key directory exists
|
|
unlockerDir := pgpUnlocker.GetDirectory()
|
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
|
}
|
|
if !keyExists {
|
|
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
|
}
|
|
|
|
// Check if required files exist
|
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
recipientExists, err := afero.Exists(fs, recipientPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
|
}
|
|
if !recipientExists {
|
|
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
|
}
|
|
|
|
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
|
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if private key file exists: %v", err)
|
|
}
|
|
if !privKeyExists {
|
|
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
|
}
|
|
|
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
metadataExists, err := afero.Exists(fs, metadataPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
|
}
|
|
if !metadataExists {
|
|
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
|
}
|
|
|
|
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
|
longtermExists, err := afero.Exists(fs, longtermPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
|
}
|
|
if !longtermExists {
|
|
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
|
}
|
|
|
|
// Read and verify metadata
|
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read metadata: %v", err)
|
|
}
|
|
|
|
var metadata struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Flags []string `json:"flags"`
|
|
GPGKeyID string `json:"gpgKeyId"`
|
|
}
|
|
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
t.Fatalf("Failed to parse metadata: %v", err)
|
|
}
|
|
|
|
if metadata.Type != "pgp" {
|
|
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
|
}
|
|
|
|
if metadata.GPGKeyID != fingerprint {
|
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
|
}
|
|
})
|
|
|
|
// Set up key directory for individual tests
|
|
unlockerDir := filepath.Join(tempDir, "unlocker")
|
|
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
|
}
|
|
|
|
// Set up test metadata
|
|
metadata := secret.UnlockerMetadata{
|
|
Type: "pgp",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{"gpg", "encrypted"},
|
|
}
|
|
|
|
// Create a PGP unlocker for the remaining tests
|
|
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
|
|
// Test getting GPG key ID
|
|
t.Run("GetGPGKeyID", func(t *testing.T) {
|
|
// Create PGP metadata with GPG key ID
|
|
type PGPUnlockerMetadata struct {
|
|
secret.UnlockerMetadata
|
|
GPGKeyID string `json:"gpgKeyId"`
|
|
}
|
|
|
|
pgpMetadata := PGPUnlockerMetadata{
|
|
UnlockerMetadata: metadata,
|
|
GPGKeyID: fingerprint,
|
|
}
|
|
|
|
// Write metadata file
|
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal metadata: %v", err)
|
|
}
|
|
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
|
t.Fatalf("Failed to write metadata: %v", err)
|
|
}
|
|
|
|
// Get GPG key ID
|
|
retrievedKeyID, err := unlocker.GetGPGKeyID()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get GPG key ID: %v", err)
|
|
}
|
|
|
|
// Verify key ID (should be the fingerprint)
|
|
if retrievedKeyID != fingerprint {
|
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
|
}
|
|
})
|
|
|
|
// Test getting identity from PGP unlocker
|
|
t.Run("GetIdentity", func(t *testing.T) {
|
|
// Generate an age identity for testing
|
|
ageIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate age identity: %v", err)
|
|
}
|
|
|
|
// Write the recipient
|
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
|
t.Fatalf("Failed to write recipient: %v", err)
|
|
}
|
|
|
|
// GPG encrypt the private key using our custom encrypt function
|
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
|
defer privKeyBuffer.Destroy()
|
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
|
}
|
|
|
|
// Write the encrypted data to a file
|
|
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
|
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
|
|
t.Fatalf("Failed to write encrypted private key: %v", err)
|
|
}
|
|
|
|
// Now try to get the identity - this will use our custom GPGDecryptFunc
|
|
identity, err := unlocker.GetIdentity()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get identity: %v", err)
|
|
}
|
|
|
|
// Verify the identity matches
|
|
expectedPubKey := ageIdentity.Recipient().String()
|
|
actualPubKey := identity.Recipient().String()
|
|
if actualPubKey != expectedPubKey {
|
|
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
|
}
|
|
})
|
|
|
|
// Test removing the unlocker
|
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
|
// Ensure unlocker directory exists before removal
|
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
|
}
|
|
if !keyExists {
|
|
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
|
}
|
|
|
|
// Remove unlocker
|
|
err = unlocker.Remove()
|
|
if err != nil {
|
|
t.Fatalf("Failed to remove unlocker: %v", err)
|
|
}
|
|
|
|
// Verify directory is gone
|
|
keyExists, err = afero.DirExists(fs, unlockerDir)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
|
}
|
|
if keyExists {
|
|
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
|
}
|
|
})
|
|
}
|