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. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile, and a forced add fails if another command created the secret meanwhile, rather than ask under the lock. secret.CreatePGPUnlocker now takes the vault, already unlocked. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
588 lines
16 KiB
Go
588 lines
16 KiB
Go
//go:build darwin
|
|
|
|
package secret_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
"sneak.berlin/go/secret/pkg/agehd"
|
|
)
|
|
|
|
// pgpUnlockerType is the type of a PGP unlocker.
|
|
const pgpUnlockerType = "pgp"
|
|
|
|
var errNilDataBuffer = errors.New("data buffer is nil")
|
|
|
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
|
t.Helper()
|
|
|
|
// Create GPG config file for non-interactive operation
|
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
|
gpgConfContent := `batch
|
|
no-tty
|
|
pinentry-mode loopback
|
|
`
|
|
|
|
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
|
if 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, errNilDataBuffer
|
|
}
|
|
|
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
|
cmd := exec.CommandContext(t.Context(), "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())
|
|
|
|
err := cmd.Run()
|
|
if 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) {
|
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
|
cmd := exec.CommandContext(t.Context(), "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)
|
|
|
|
err := cmd.Run()
|
|
if 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(
|
|
ctx context.Context,
|
|
gnupgHome, passphrase string, args []string, input io.Reader,
|
|
) ([]byte, error) {
|
|
cmdArgs := append([]string{
|
|
"--homedir=" + gnupgHome,
|
|
"--batch",
|
|
"--yes",
|
|
"--pinentry-mode", "loopback",
|
|
"--passphrase", passphrase,
|
|
}, args...)
|
|
|
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
|
cmd := exec.CommandContext(ctx, "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
|
|
}
|
|
|
|
// generateTestGPGKey generates a GPG key protected by passphrase in
|
|
// gnupgHomeDir and returns its key ID and fingerprint.
|
|
func generateTestGPGKey(
|
|
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
|
|
) (string, string) {
|
|
t.Helper()
|
|
|
|
// 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: ` + passphrase + `
|
|
%commit
|
|
%echo Key generation completed
|
|
`
|
|
|
|
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write batch file: %v", err)
|
|
}
|
|
|
|
// Generate GPG key with batch mode
|
|
t.Log("Generating GPG key...")
|
|
|
|
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
|
[]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(t.Context(), gnupgHomeDir, passphrase,
|
|
[]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
|
|
|
|
for line := range strings.SplitSeq(string(output), "\n") {
|
|
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)
|
|
|
|
return keyID, fingerprint
|
|
}
|
|
|
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|
// Check if gpg is available
|
|
_, err := exec.LookPath("gpg")
|
|
if 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. Not t.TempDir: its longer
|
|
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
|
|
// macOS sets on socket paths.
|
|
//
|
|
//nolint:usetesting // see the comment above
|
|
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")
|
|
|
|
err = os.MkdirAll(gnupgHomeDir, 0o700)
|
|
if 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)
|
|
|
|
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
|
|
|
|
// 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()
|
|
|
|
// 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) {
|
|
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
|
})
|
|
|
|
// Set up key directory for individual tests
|
|
unlockerDir := filepath.Join(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: pgpUnlockerType,
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{"gpg", "encrypted"},
|
|
}
|
|
|
|
// Create a PGP unlocker for the remaining tests
|
|
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
|
|
// Test getting identity from PGP unlocker
|
|
t.Run("GetIdentity", func(t *testing.T) {
|
|
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
|
})
|
|
|
|
// Test removing the unlocker
|
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
|
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
|
|
})
|
|
}
|
|
|
|
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
|
|
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
|
|
// and metadata.
|
|
func testCreatePGPUnlocker(
|
|
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Set a limited test timeout to avoid hanging
|
|
timer := time.AfterFunc(10*time.Second, func() {
|
|
t.Fatalf("Test timed out after 10 seconds")
|
|
})
|
|
defer timer.Stop()
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
// Create a test vault directory structure
|
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
|
|
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")
|
|
|
|
err = afero.WriteFile(fs, ltPubKeyPath,
|
|
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
|
|
if 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(vlt, keyID, fingerprint)
|
|
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() != pgpUnlockerType {
|
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
|
}
|
|
|
|
// Check that the ID is the name of the unlocker's directory
|
|
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
|
|
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
|
|
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
|
|
}
|
|
|
|
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
|
|
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
|
|
}
|
|
|
|
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
|
|
// its files.
|
|
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
|
|
t.Helper()
|
|
|
|
// Check if the key directory exists
|
|
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, unlockerMetadataFile)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
|
|
// unlockerDir names its type and the GPG key by fingerprint.
|
|
func checkPGPUnlockerMetadata(
|
|
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Read and verify metadata
|
|
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
|
|
|
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"`
|
|
}
|
|
|
|
err = json.Unmarshal(metadataBytes, &metadata)
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse metadata: %v", err)
|
|
}
|
|
|
|
if metadata.Type != pgpUnlockerType {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
|
// keyID into unlockerDir and checks that unlocker decrypts it.
|
|
func testPGPUnlockerGetIdentity(
|
|
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
|
unlockerDir, keyID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// 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")
|
|
|
|
err = afero.WriteFile(fs, recipientPath,
|
|
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
|
|
if 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")
|
|
|
|
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
|
|
if 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)
|
|
}
|
|
}
|
|
|
|
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
|
|
func testRemovePGPUnlocker(
|
|
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// 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)
|
|
}
|
|
}
|