Sign and verify manifests in Go with OpenPGP instead of running gpg (closes #181)
check / check (push) Waiting to run
check / check (push) Waiting to run
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
This commit was merged in pull request #183.
This commit is contained in:
+66
-40
@@ -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,48 @@ 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. config sets how
|
||||
// the key is made; without one it is an Ed25519 key, which is quick to
|
||||
// make.
|
||||
func testSecretKey(
|
||||
t *testing.T, passphrase []byte, config *packet.Config,
|
||||
) ([]byte, string) {
|
||||
t.Helper()
|
||||
|
||||
if config == nil {
|
||||
config = &packet.Config{Algorithm: packet.PubKeyAlgoEdDSA}
|
||||
}
|
||||
|
||||
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test", config)
|
||||
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, 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 +198,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 +215,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)
|
||||
|
||||
Reference in New Issue
Block a user