check / check (push) Waiting to run
Every gpg run now has a one-minute deadline (gpgTimeout) on top of its caller's context and is killed when either ends. A timeout is reported as "gpg timed out" under the failing operation instead of "signal: killed". Only gpg itself is killed; WaitDelay (one second) stops the run from waiting on a process gpg left behind that still holds its output, such as a wrapper script that does not exec the real gpg. Builder.Build and Checker.ExtractEmbeddedSigningKeyFP take a context, so ToManifest's context now reaches signing and the contextcheck suppression calling signing non-cancellable is gone. Manifest loading takes no context, so its signature check is bounded by the timeout alone. Model: opus-5-5
473 lines
14 KiB
Go
473 lines
14 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
|
// Returns the key ID and the GPG home directory; callers must point
|
|
// GNUPGHOME at the returned directory (via t.Setenv) before using the
|
|
// gpg helpers under test.
|
|
func testGPGEnv(t *testing.T) (GPGKeyID, string) {
|
|
t.Helper()
|
|
|
|
// Check if gpg is installed
|
|
_, err := exec.LookPath("gpg")
|
|
if err != nil {
|
|
t.Skip("gpg not installed, skipping signing test")
|
|
}
|
|
|
|
// Create temporary GPG home directory (0700 by default)
|
|
gpgHome := t.TempDir()
|
|
|
|
// Generate a test key with no passphrase
|
|
keyParams := `%no-protection
|
|
Key-Type: RSA
|
|
Key-Length: 2048
|
|
Name-Real: MFER Test Key
|
|
Name-Email: test@mfer.test
|
|
Expire-Date: 0
|
|
%commit
|
|
`
|
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
|
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
|
defer cancel()
|
|
|
|
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
|
cmd := exec.CommandContext(ctx, "gpg",
|
|
"--batch", "--gen-key", paramsFile)
|
|
|
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
|
}
|
|
|
|
// Get the key fingerprint
|
|
cmd = exec.CommandContext(ctx, "gpg",
|
|
"--list-keys", "--with-colons", "test@mfer.test")
|
|
|
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
|
|
output, err = cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("failed to list test key: %v", err)
|
|
}
|
|
|
|
// Parse fingerprint from output
|
|
var keyID string
|
|
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= gpgFingerprintMinFields &&
|
|
fields[0] == gpgFingerprintField {
|
|
keyID = fields[9]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if keyID == "" {
|
|
t.Fatal("failed to find test key fingerprint")
|
|
}
|
|
|
|
return GPGKeyID(keyID), gpgHome
|
|
}
|
|
|
|
func TestGPGSign(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, sig)
|
|
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
|
assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----")
|
|
}
|
|
|
|
func TestGPGExportPublicKey(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, pubKey)
|
|
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
assert.Contains(t, string(pubKey), "-----END PGP PUBLIC KEY BLOCK-----")
|
|
}
|
|
|
|
func TestGPGGetKeyFingerprint(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, fingerprint)
|
|
// The fingerprint should be 40 hex chars
|
|
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
|
|
}
|
|
|
|
// TestGPGArgsSeparatesPositionals pins that caller-supplied values are
|
|
// placed after an end-of-options marker. Key IDs arrive from --sign-key
|
|
// and MFER_SIGN_KEY as bare positional arguments, so without the marker
|
|
// a value beginning with "-" would be parsed by gpg as one of its own
|
|
// options.
|
|
func TestGPGArgsSeparatesPositionals(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t,
|
|
[]string{"--opt-a", "--opt-b", "--", "--version"},
|
|
gpgArgs([]string{"--opt-a", "--opt-b"}, "--version"))
|
|
|
|
assert.Equal(t,
|
|
[]string{"--opt-c", "--", "sig", "data"},
|
|
gpgArgs([]string{"--opt-c"}, "sig", "data"))
|
|
|
|
assert.Equal(t, []string{"--opt-d", "--"},
|
|
gpgArgs([]string{"--opt-d"}))
|
|
}
|
|
|
|
// TestGPGOptionLikeKeyIDIsNotAnOption drives real gpg with a key ID that
|
|
// looks like an option and asserts it is treated as a (nonexistent) key
|
|
// rather than executed as gpg's own --version.
|
|
func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
|
_, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), GPGKeyID("--version"))
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errGPGKeyNotFound)
|
|
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
|
|
|
|
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
|
|
require.Error(t, err)
|
|
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
|
}
|
|
|
|
func TestGPGSignInvalidKey(t *testing.T) {
|
|
// Set up test environment (we need GNUPGHOME set)
|
|
_, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data")
|
|
_, err := gpgSign(context.Background(), data,
|
|
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestBuilderWithSigning(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a builder with signing options
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are populated
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be populated")
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigner(), "signer should be populated")
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"signing public key should be populated")
|
|
|
|
// Verify signature is a valid PGP signature
|
|
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
|
|
"-----BEGIN PGP SIGNATURE-----")
|
|
|
|
// Verify public key is a valid PGP public key block
|
|
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
|
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
}
|
|
|
|
func TestScannerWithSigning(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create in-memory filesystem with test files
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
|
|
|
// Create scanner with signing options
|
|
opts := &ScannerOptions{
|
|
Fs: fs,
|
|
SigningOptions: &SigningOptions{
|
|
KeyID: keyID,
|
|
},
|
|
}
|
|
s := NewScannerWithOptions(opts)
|
|
|
|
// Enumerate files
|
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
|
assert.Equal(t, FileCount(2), s.FileCount())
|
|
|
|
// Generate signed manifest
|
|
var buf bytes.Buffer
|
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
|
|
// Parse and verify
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
|
|
}
|
|
|
|
func TestGPGVerify(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign and verify")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the signature
|
|
err = gpgVerify(context.Background(), data, sig, pubKey)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Try to verify with different data - should fail
|
|
wrongData := []byte("different data")
|
|
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestGPGVerifyBadPublicKey(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Try to verify with invalid public key - should fail
|
|
badPubKey := []byte("not a valid public key")
|
|
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManifestSignatureVerification(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a builder with signing options
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
// Add a test file
|
|
content := []byte("test file content for verification")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest - signature should be verified during load
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest)
|
|
|
|
// Signature should be present and valid
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
}
|
|
|
|
func TestManifestTamperedSignatureFails(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a signed manifest
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Tamper with the signature by replacing some bytes
|
|
data := buf.Bytes()
|
|
// Find and modify a byte in the signature portion
|
|
for i := range data {
|
|
if i > 100 && data[i] == 'A' {
|
|
data[i] = 'B'
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Try to load the tampered manifest - should fail
|
|
_, err = NewManifestFromReader(bytes.NewReader(data))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestBuilderWithoutSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a builder without signing options
|
|
b := NewBuilder()
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are empty
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.Empty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigner(),
|
|
"signer should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"signing public key should be empty when not signing")
|
|
}
|
|
|
|
// fakeGPGPath writes script as an executable named gpg into a temporary
|
|
// directory and returns a PATH value with that directory first.
|
|
func fakeGPGPath(t *testing.T, script string) string {
|
|
t.Helper()
|
|
|
|
binDir := t.TempDir()
|
|
//nolint:gosec // G306: the fake gpg has to be executable
|
|
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"),
|
|
[]byte(script), 0o700))
|
|
|
|
return binDir + string(os.PathListSeparator) + os.Getenv("PATH")
|
|
}
|
|
|
|
// TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on
|
|
// PATH and checks that a run past its deadline is killed and reported as
|
|
// a timeout of the named operation, instead of hanging.
|
|
func TestGPGTimeoutKillsGPG(t *testing.T) {
|
|
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nexec sleep 10\n"))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
|
}
|
|
|
|
// TestGPGTimeoutWhenChildHoldsOutput uses a fake gpg that runs sleep as a
|
|
// child instead of exec-ing it, the way a wrapper script around the real
|
|
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
|
|
// stderr open; the call must still return shortly after the deadline
|
|
// instead of waiting for sleep to exit. The fake gpg writes the process ID
|
|
// of sleep to a file so that the test can kill it before returning.
|
|
func TestGPGTimeoutWhenChildHoldsOutput(t *testing.T) {
|
|
pidFile := filepath.Join(t.TempDir(), "sleep.pid")
|
|
t.Setenv("PATH", fakeGPGPath(t,
|
|
"#!/bin/sh\nsleep 3 &\necho $! >'"+pidFile+"'\nwait\n"))
|
|
t.Cleanup(func() {
|
|
pid, err := os.ReadFile(pidFile) //nolint:gosec // G304: path inside t.TempDir()
|
|
require.NoError(t, err)
|
|
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
|
|
require.NoError(t, err)
|
|
|
|
sleep, err := os.FindProcess(n)
|
|
require.NoError(t, err)
|
|
require.NoError(t, sleep.Kill())
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
assert.Less(t, time.Since(start), 3*time.Second,
|
|
"the call waited for the child holding gpg's output to exit")
|
|
}
|
|
|
|
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg
|
|
// runs that sign a manifest through the context given to Build.
|
|
func TestBuildPassesContextToSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{KeyID: "any"})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
|
|
}
|