Sign and verify manifests in Go with OpenPGP instead of running gpg #181

Open
opened 2026-10-07 23:19:40 +02:00 by clawbot · 1 comment
Collaborator

From sneak's ruling on the signature scheme decisions (#82 (comment)): pure Go crypto, never a gpg subprocess. Between the two pure-Go options he named neither, so the recommendation stands: OpenPGP, which keeps existing PGP keys and the README's key-fingerprint URL idea.

Problem

mfer/gpg.go signs, exports keys and verifies by running the gpg binary. The library therefore does not work where gpg is not installed, including this repo's own FROM scratch image.

Decided here (standing rulings and the code's design; not for sneak)

  • Library: github.com/ProtonMail/go-crypto/openpgp. golang.org/x/crypto/openpgp is frozen and deprecated.
  • No pure-Go library reads gpg-agent's secret key store, so --sign-key (and MFER_SIGN_KEY) names a file holding an OpenPGP secret key, armored or binary, as gpg --export-secret-keys writes it. A file holding more than one primary key is refused. A key held only on a smartcard cannot sign; the README says so.
  • A protected key's passphrase comes from MFER_SIGN_KEY_PASSPHRASE. Otherwise mfer prompts on the terminal (golang.org/x/term) when there is one, and fails naming that variable when there is not.
  • The signature stays an armored detached OpenPGP signature over the same canonical string. signingPubKey holds the armored public key. signer holds the primary key's fingerprint in upper-case hex, as today.
  • SigningOptions carries the secret key's bytes and the passphrase; GPGKeyID goes. The public API exposes no type from the OpenPGP library.

Definition of done

  • No code runs gpg; mfer/gpg.go's subprocess code, its timeouts and its status-line parsing are gone.
  • Verification runs in process and keeps every rule from the --require-signature fix (#167): one primary key in the embedded block, exactly one good signature, made by that key or one of its subkeys, and signer equal to its fingerprint. Its tests are kept and pass without gpg.
  • gen and freshen sign with a key file, protected or not. A manifest signed by the old gpg code (the signed seed in mfer/testdata/fuzz/FuzzNewManifestFromReader) still loads and verifies.
  • Tests make their keys in process and need no gpg binary.
  • The --sign-key help text, the README and every line of docs/FORMAT.md that names GPG as the tool say OpenPGP and the key file. The rest of the Signature Scheme section is corrected under the specification work (#84).
  • make check passes.

Model: opus-5-5

From sneak's ruling on the signature scheme decisions (https://git.eeqj.de/sneak/mfer/issues/82#issuecomment-132656): pure Go crypto, never a `gpg` subprocess. Between the two pure-Go options he named neither, so the recommendation stands: OpenPGP, which keeps existing PGP keys and the README's key-fingerprint URL idea. ## Problem `mfer/gpg.go` signs, exports keys and verifies by running the `gpg` binary. The library therefore does not work where `gpg` is not installed, including this repo's own `FROM scratch` image. ## Decided here (standing rulings and the code's design; not for sneak) - Library: `github.com/ProtonMail/go-crypto/openpgp`. `golang.org/x/crypto/openpgp` is frozen and deprecated. - No pure-Go library reads gpg-agent's secret key store, so `--sign-key` (and `MFER_SIGN_KEY`) names a file holding an OpenPGP secret key, armored or binary, as `gpg --export-secret-keys` writes it. A file holding more than one primary key is refused. A key held only on a smartcard cannot sign; the README says so. - A protected key's passphrase comes from `MFER_SIGN_KEY_PASSPHRASE`. Otherwise mfer prompts on the terminal (`golang.org/x/term`) when there is one, and fails naming that variable when there is not. - The signature stays an armored detached OpenPGP signature over the same canonical string. `signingPubKey` holds the armored public key. `signer` holds the primary key's fingerprint in upper-case hex, as today. - `SigningOptions` carries the secret key's bytes and the passphrase; `GPGKeyID` goes. The public API exposes no type from the OpenPGP library. ## Definition of done - No code runs `gpg`; `mfer/gpg.go`'s subprocess code, its timeouts and its status-line parsing are gone. - Verification runs in process and keeps every rule from the `--require-signature` fix (https://git.eeqj.de/sneak/mfer/issues/167): one primary key in the embedded block, exactly one good signature, made by that key or one of its subkeys, and `signer` equal to its fingerprint. Its tests are kept and pass without `gpg`. - `gen` and `freshen` sign with a key file, protected or not. A manifest signed by the old `gpg` code (the signed seed in `mfer/testdata/fuzz/FuzzNewManifestFromReader`) still loads and verifies. - Tests make their keys in process and need no `gpg` binary. - The `--sign-key` help text, the README and every line of `docs/FORMAT.md` that names GPG as the tool say OpenPGP and the key file. The rest of the Signature Scheme section is corrected under the specification work (https://git.eeqj.de/sneak/mfer/issues/84). - `make check` passes. Model: opus-5-5
Author
Collaborator

Implemented in #183. mfer now signs and verifies manifests itself with OpenPGP, without running gpg. --sign-key names a secret key file, and a protected key's passphrase comes from MFER_SIGN_KEY_PASSPHRASE or the terminal. Loading keeps the rules from #167; the embedded block's key count now also counts secret key packets. Judgement calls are in the PR body.

Model: opus-5-5

Implemented in https://git.eeqj.de/sneak/mfer/pulls/183. mfer now signs and verifies manifests itself with OpenPGP, without running `gpg`. `--sign-key` names a secret key file, and a protected key's passphrase comes from `MFER_SIGN_KEY_PASSPHRASE` or the terminal. Loading keeps the rules from https://git.eeqj.de/sneak/mfer/issues/167; the embedded block's key count now also counts secret key packets. Judgement calls are in the PR body. Model: opus-5-5
Sign in to join this conversation.