Files
mfer/internal/cli/signing_test.go
T
clawbot 0fbb3da0a5
check / check (push) Waiting to run
Sign and verify manifests in Go with OpenPGP instead of running gpg (closes #181)
mfer ran the gpg binary to sign, export keys and verify, so it failed
wherever gpg is missing. It now uses github.com/ProtonMail/go-crypto/openpgp.
--sign-key and MFER_SIGN_KEY name a file holding one version 4 OpenPGP
secret key; a protected key's passphrase comes from
MFER_SIGN_KEY_PASSPHRASE or a terminal prompt. gen and freshen check that
the key can sign before they read any file. Verification keeps the rules
of the --require-signature fix: one primary key in the embedded block,
counted from its packets, exactly one signature, made by that key or a
subkey, and signer equal to its fingerprint. The embedded block may hold
no DSA key and no secret key, and an armored field must be one
well-formed block.

Model: opus-5-5
2026-10-08 09:42:45 +02:00

210 lines
6.4 KiB
Go

//nolint:testpackage // white-box tests exercise unexported internals
package cli
import (
"bufio"
"io"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/creack/pty"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testFlagSignKey = "--sign-key"
testKeyFile = "/key.asc"
)
// TestGenAndFreshenSignWithKeyFile runs gen, then freshen after a file is
// added, with --sign-key naming a key file: one key with no passphrase and
// one protected by the passphrase in MFER_SIGN_KEY_PASSPHRASE. check
// --require-signature must accept each manifest as signed by that key.
// freshen leaves its manifest out of the listing only on the real
// filesystem, so the test uses that.
func TestGenAndFreshenSignWithKeyFile(t *testing.T) {
for name, passphrase := range map[string][]byte{
"unprotected": nil,
"protected": []byte("passphrase"),
} {
t.Run(name, func(t *testing.T) {
t.Setenv(envSignKeyPassphrase, string(passphrase))
secretKey, fingerprint := testSecretKey(t, passphrase, nil)
fs := afero.NewOsFs()
keyFile := filepath.Join(t.TempDir(), "key.asc")
root := t.TempDir()
manifestPath := filepath.Join(root, defaultManifestName)
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
"-o", manifestPath, root,
}, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
check := []string{
testApp, cmdCheck, "-q",
"--" + flagRequireSignature, fingerprint, manifestPath,
}
opts = testOpts(check, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
opts = testOpts([]string{
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
}, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
opts = testOpts(check, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
assert.Len(t, manifestFiles(t, fs, manifestPath), 2)
})
}
}
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
// fail, naming the variable, and write no manifest.
func TestSignWithProtectedKeyNeedsPassphrase(t *testing.T) {
t.Setenv(envSignKeyPassphrase, "")
secretKey, _ := testSecretKey(t, []byte("secret"), nil)
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
"-o", testMF, testDir,
}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"signing key is protected: set MFER_SIGN_KEY_PASSPHRASE to its passphrase")
exists, err := afero.Exists(fs, testMF)
require.NoError(t, err)
assert.False(t, exists)
}
// TestSignWithKeyThatCannotSignFailsFirst runs gen on a directory and
// freshen on a manifest, neither of which exists, with keys that cannot
// sign: a protected key with a wrong MFER_SIGN_KEY_PASSPHRASE, a key that
// expired in 2020, and a version 6 key. Each run must fail on the key: it
// checks the key before it reads any file, so a missing file goes
// unnoticed.
func TestSignWithKeyThatCannotSignFailsFirst(t *testing.T) {
t.Setenv(envSignKeyPassphrase, "wrong")
wrongPassphrase, _ := testSecretKey(t, []byte("right"), nil)
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
expired, _ := testSecretKey(t, nil, &packet.Config{
Algorithm: packet.PubKeyAlgoEdDSA,
Time: func() time.Time { return made },
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
})
version6, _ := testSecretKey(t, nil, &packet.Config{
Algorithm: packet.PubKeyAlgoEd25519,
V6Keys: true,
})
for want, secretKey := range map[string][]byte{
"unlock signing key": wrongPassphrase,
"signing key cannot sign": expired,
"signing key must be an OpenPGP version 4 key": version6,
} {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
for _, args := range [][]string{
{
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
"-o", testMF, "/missing",
},
{testApp, cmdFreshen, "-q", testFlagSignKey, testKeyFile, "/missing.mf"},
} {
opts := testOpts(args, fs)
assert.Equal(t, 1, runCLI(opts), args[1], want)
assert.Contains(t, testStderr(t, opts), testKeyFile+": "+want, args[1])
}
}
}
// TestGenAsksForPassphraseOnTerminal runs gen with a protected key, no
// MFER_SIGN_KEY_PASSPHRASE, and a terminal as stdin and stderr. gen must
// ask for the passphrase on stderr, and sign with what is typed after the
// prompt.
func TestGenAsksForPassphraseOnTerminal(t *testing.T) {
t.Setenv(envSignKeyPassphrase, "")
secretKey, fingerprint := testSecretKey(t, []byte("passphrase"), nil)
fs := afero.NewOsFs()
keyFile := filepath.Join(t.TempDir(), "key.asc")
root := t.TempDir()
manifestPath := filepath.Join(root, defaultManifestName)
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
terminal, tty, err := pty.Open()
require.NoError(t, err)
t.Cleanup(func() { _ = terminal.Close() })
opts := testOpts([]string{
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
"-o", manifestPath, root,
}, fs)
opts.Stdin = tty
opts.Stderr = tty
exitCode := make(chan int, 1)
go func() {
exitCode <- runCLI(opts)
// Once gen has ended, reading the terminal fails instead of
// waiting for a prompt that will not come.
_ = tty.Close()
}()
prompt := "Passphrase for " + keyFile + ": "
output := bufio.NewReader(terminal)
written := ""
for !strings.HasSuffix(written, prompt) {
b, err := output.ReadByte()
require.NoError(t, err, "gen wrote %q and no prompt", written)
written += string(b)
}
_, err = terminal.WriteString("passphrase\n")
require.NoError(t, err)
code := <-exitCode
rest, _ := io.ReadAll(output)
require.Equal(t, 0, code, "gen wrote %q", rest)
check := testOpts([]string{
testApp, cmdCheck, "-q",
"--" + flagRequireSignature, fingerprint, manifestPath,
}, fs)
require.Equal(t, 0, runCLI(check), testStderr(t, check))
}