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 OpenPGP secret key; a protected key's passphrase
comes from MFER_SIGN_KEY_PASSPHRASE or a prompt on the terminal, and is
checked before any file is read. Verification keeps the rules of the
--require-signature fix: one primary key in the embedded block, counted
from its packets so that keys the library skips count too, exactly one
signature, made by that key or one of its subkeys, and signer equal to
its fingerprint. An armored key or signature must be one block and
nothing else.

Model: opus-5-5
This commit is contained in:
2026-10-08 03:17:34 +00:00
committed by sneak
parent 6229c4eca0
commit b1860d7931
25 changed files with 1318 additions and 1176 deletions
+60 -40
View File
@@ -4,14 +4,18 @@ package cli
import (
"bytes"
"context"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -108,11 +112,10 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
// manifest. The signing key's fingerprint is whatever the generated key
// produced, so it is read back from the checker and substituted into the
// expected string; the required signer is a fixed value that cannot match
// it. Requires gpg and is skipped where it is absent, as the other signing
// tests are.
//
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
// it.
func TestSignerMismatchMessage(t *testing.T) {
t.Parallel()
chk := signedChecker(t,
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
@@ -123,43 +126,42 @@ func TestSignerMismatchMessage(t *testing.T) {
" does not match required "+msgFpB)
}
// signedManifest returns a manifest of files signed by a throwaway GPG key
// generated in a temporary GNUPGHOME, which it leaves set for the rest of
// the test.
// testSecretKey returns a new OpenPGP key with its secret key, armored, as
// gpg --export-secret-keys --armor writes it, and the key's fingerprint.
// The key is protected by passphrase unless that is nil. It is an Ed25519
// key, which is quick to make.
func testSecretKey(t *testing.T, passphrase []byte) ([]byte, string) {
t.Helper()
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test",
&packet.Config{Algorithm: packet.PubKeyAlgoEdDSA})
require.NoError(t, err)
if passphrase != nil {
require.NoError(t, key.EncryptPrivateKeys(passphrase, nil))
}
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
require.NoError(t, err)
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
require.NoError(t, w.Close())
return buf.Bytes(), strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
}
// signedManifest returns a manifest of files signed by a new OpenPGP key.
func signedManifest(t *testing.T, files map[string][]byte) []byte {
t.Helper()
_, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed, skipping signing test")
}
gpgHome := t.TempDir()
params := "%no-protection\n" +
"Key-Type: RSA\nKey-Length: 2048\n" +
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
"Expire-Date: 0\n%commit\n"
paramsFile := filepath.Join(gpgHome, "key-params")
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
cmd := exec.CommandContext(context.Background(), "gpg",
"--batch", "--gen-key", paramsFile)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
out, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
}
t.Setenv("GNUPGHOME", gpgHome)
secretKey, _ := testSecretKey(t, nil)
b := mfer.NewBuilder()
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
b.SetSigningOptions(&mfer.SigningOptions{SecretKey: secretKey})
for path, content := range files {
_, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
_, err := b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
mfer.ModTime{}, 0, bytes.NewReader(content), nil)
require.NoError(t, err)
}
@@ -190,8 +192,8 @@ func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
}
// manifestSignedByAnotherKey returns a manifest of files and the
// fingerprint of a throwaway key, the required key, that did not sign it.
// The manifest is signed by a second throwaway key; its embedded public key
// fingerprint of a new key, the required key, that did not sign it.
// The manifest is signed by a second new key; its embedded public key
// block holds the required key followed by the second key, and its signer
// field names the required key.
func manifestSignedByAnotherKey(
@@ -207,8 +209,26 @@ func manifestSignedByAnotherKey(
require.NoError(t, proto.Unmarshal(
signedManifest(t, files)[len(mfer.MAGIC):], outer))
outer.SigningPubKey = slices.Concat(
required.GetSigningPubKey(), outer.GetSigningPubKey())
// One armored block holding both keys, as gpg --export --armor writes
// two keys.
var block bytes.Buffer
w, err := armor.Encode(&block, openpgp.PublicKeyType, nil)
require.NoError(t, err)
for _, key := range [][]byte{
required.GetSigningPubKey(), outer.GetSigningPubKey(),
} {
decoded, err := armor.Decode(bytes.NewReader(key))
require.NoError(t, err)
_, err = io.Copy(w, decoded.Body)
require.NoError(t, err)
}
require.NoError(t, w.Close())
outer.SigningPubKey = block.Bytes()
outer.Signer = required.GetSigner()
data, err := proto.Marshal(outer)