//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" // Set test environment variables t.Setenv(secret.EnvMnemonic, testMnemonic) 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) 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) 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) } }) }