Sign and verify manifests in Go with OpenPGP instead of running gpg (closes #181)
check / check (push) Waiting to run

mfer ran the gpg binary to sign, export keys and verify, so signing and
loading signed manifests 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. A DSA key is refused, and so is an armored field that is
not one well-formed block.

Model: opus-5-5
This commit is contained in:
2026-10-08 05:21:16 +00:00
committed by sneak
parent c23367c216
commit e00ec787e8
25 changed files with 1585 additions and 1178 deletions
+209
View File
@@ -0,0 +1,209 @@
//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))
}