Author SHA1 Message Date
clawbot 24496880c5 Refuse an expired signature whose key has expired too (closes #193)
check / check (push) Waiting to run
verifySignature accepts a signature whose key has expired, since a
manifest outlives its key. The OpenPGP library stops at the expired key
without checking the signature's own expiry time, so a signature made
with one was refused while the key was live and accepted once the key
had expired as well. verifySignature now gets the signature packet from
openpgp.VerifyDetachedSignature and refuses it when it has expired.
docs/FORMAT.md Signature Scheme states the rule.

Model: opus-5-5
2026-10-13 19:21:53 +02:00
10 changed files with 67 additions and 65 deletions
-9
View File
@@ -17,15 +17,6 @@ The current version is pre-1.0 and while the repo was published in 2022, there
has not yet been any versioned release. [SemVer](https://semver.org) will be
used for releases.
A version is a git tag, and no file in the tree records a version number.
`mfer version` prints the revision the build stamped into the binary, which for
`make build` and the Docker image is the output of `git describe` on the
checkout they build. A binary built without that stamp, as
`go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module
version Go records in it instead. A program that imports the `mfer` package
finds the version it was built against in its own build information
(`runtime/debug.ReadBuildInfo`).
This project was started by [@sneak](https://sneak.berlin) to scratch an itch in
2022 and is currently a one-person effort, though the goal is for this to emerge
as a de-facto standard and be incorporated into other software. A compatible
+6 -4
View File
@@ -10,12 +10,14 @@ import (
// Appname is the name of this program.
const Appname = "mfer"
// Gitrev is the revision `mfer version` prints, injected at build time via
// -ldflags.
// Version and Gitrev are injected at build time via -ldflags.
//
//nolint:gochecknoglobals // set via ldflags at build time
var Gitrev string
var (
Version string
Gitrev string
)
func main() {
os.Exit(cli.Run(Appname, Gitrev))
os.Exit(cli.Run(Appname, Version, Gitrev))
}
+4 -3
View File
@@ -15,10 +15,11 @@ func TestAppname(t *testing.T) {
assert.Equal(t, "mfer", Appname)
}
// TestGitrevDefault documents that Gitrev is empty unless injected at build
// time via -ldflags.
func TestGitrevDefault(t *testing.T) {
// TestVersionDefaults documents that Version and Gitrev are empty unless
// injected at build time via -ldflags.
func TestVersionDefaults(t *testing.T) {
t.Parallel()
assert.Empty(t, Version)
assert.Empty(t, Gitrev)
}
+4
View File
@@ -174,6 +174,10 @@ also refuses a manifest whose `signingPubKey` holds a DSA key or subkey, or any
secret key or subkey, since checking the self-signatures of a DSA key or the
numbers of a secret key can take hours when those numbers are very large.
A signing key that has expired does not make the signature bad, because a
manifest outlives its key. A signature past its own expiry time is not good,
whether or not the key has expired too.
## Deterministic Serialization
By default, manifests are generated deterministically:
+7 -4
View File
@@ -26,7 +26,8 @@ func noColorEnvSet() bool {
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
type RunOptions struct {
Appname string // Application name displayed in help and version output
Gitrev string // Revision stamped in at build time, or empty
Version string // Version string (typically set at build time)
Gitrev string // Git revision hash (typically set at build time)
Args []string // Command-line arguments (typically os.Args)
Stdin io.Reader // Standard input stream
Stdout io.Writer // Standard output stream
@@ -35,9 +36,10 @@ type RunOptions struct {
}
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
func DefaultRunOptions(appname, gitrev string) *RunOptions {
func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
return &RunOptions{
Appname: appname,
Version: version,
Gitrev: gitrev,
Args: os.Args,
Stdin: os.Stdin,
@@ -48,14 +50,15 @@ func DefaultRunOptions(appname, gitrev string) *RunOptions {
}
// Run creates and runs the CLI application with default options.
func Run(appname, gitrev string) int {
return RunWithOptions(DefaultRunOptions(appname, gitrev))
func Run(appname, version, gitrev string) int {
return RunWithOptions(DefaultRunOptions(appname, version, gitrev))
}
// RunWithOptions creates and runs the CLI application with the given options.
func RunWithOptions(opts *RunOptions) int {
m := &CLIApp{
appname: opts.Appname,
version: opts.Version,
gitrev: opts.Gitrev,
exitCode: 0,
maxManifestSize: mfer.MaxManifestSize,
+6 -24
View File
@@ -11,7 +11,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime/debug"
"slices"
"strings"
"sync"
@@ -28,7 +27,6 @@ import (
const (
testApp = "mfer"
testGitrev = "abc123"
testDir = "/testdir"
testFile1 = "/testdir/file1.txt"
testMF = "/testdir/test.mf"
@@ -95,7 +93,8 @@ func TestBuild(t *testing.T) {
func testOpts(args []string, fs afero.Fs) *RunOptions {
return &RunOptions{
Appname: testApp,
Gitrev: testGitrev,
Version: "1.0.0",
Gitrev: "abc123",
Args: args,
Stdin: &bytes.Buffer{},
Stdout: &bytes.Buffer{},
@@ -137,24 +136,10 @@ func TestVersionCommand(t *testing.T) {
exitCode := runCLI(opts)
assert.Equal(t, 0, exitCode)
assert.Equal(t, "mfer version "+testGitrev+"\n", testStdout(t, opts))
}
// TestVersionFromBuildInfo covers a binary whose build stamped no Gitrev,
// as `go install` builds it: mfer version prints the main module's version
// from the build information instead.
func TestVersionFromBuildInfo(t *testing.T) {
t.Parallel()
info, ok := debug.ReadBuildInfo()
require.True(t, ok)
require.NotEmpty(t, info.Main.Version)
opts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs())
opts.Gitrev = ""
require.Equal(t, 0, runCLI(opts))
assert.Equal(t, "mfer version "+info.Main.Version+"\n", testStdout(t, opts))
stdout := testStdout(t, opts)
assert.Contains(t, stdout, mfer.Version)
assert.Contains(t, stdout, "abc123")
}
// TestVFlagCollision covers the -v/--verbose vs --version flag interaction
@@ -180,7 +165,7 @@ func TestVFlagCollision(t *testing.T) {
exitCode := runCLI(opts)
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStdout(t, opts), testGitrev)
assert.Contains(t, testStdout(t, opts), mfer.Version)
assert.NotContains(t, testStderr(t, opts), "two forms of the same flag")
})
}
@@ -754,9 +739,6 @@ func TestBannerOutput(t *testing.T) {
stdout := testStdout(t, opts)
assert.Contains(t, stdout, "___")
assert.Contains(t, stdout, "\\")
// The banner shows the version mfer version prints, and nothing after it.
assert.Contains(t, stdout, "mfer by @sneak: "+testGitrev+"\n")
}
func TestUnknownCommand(t *testing.T) {
+7 -12
View File
@@ -6,12 +6,12 @@ import (
"fmt"
"io"
"os"
"runtime/debug"
"time"
"github.com/spf13/afero"
"github.com/urfave/cli/v3"
"sneak.berlin/go/mfer/internal/log"
"sneak.berlin/go/mfer/mfer"
)
// Command and flag names shared across command definitions and tests.
@@ -58,6 +58,7 @@ var errUnexpectedArgument = errors.New("unexpected argument")
//nolint:revive // established name used throughout the codebase and tests
type CLIApp struct {
appname string
version string
gitrev string
startupTime time.Time
exitCode int
@@ -86,20 +87,13 @@ const banner = `
\ \:\ \ \:\ \ \::/ \ \:\
\__\/ \__\/ \__\/ \__\/`
// VersionString returns the version mfer reports: the revision the build
// stamped into main.Gitrev or, when it stamped none, as with `go install`,
// the main module's version from the binary's build information.
// VersionString returns the version and git revision formatted for display.
func (mfa *CLIApp) VersionString() string {
if mfa.gitrev != "" {
return mfa.gitrev
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
}
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
return info.Main.Version
return mfer.Version
}
// printVersion writes the version line shared by the --version flag and the
@@ -112,7 +106,8 @@ func (mfa *CLIApp) printBanner() {
if log.GetLevel() <= log.InfoLevel {
_, _ = fmt.Fprintln(mfa.Stdout, banner)
_, _ = fmt.Fprintf(mfa.Stdout,
" mfer by @sneak: %s\n", mfa.VersionString())
" mfer by @sneak: v%s released %s\n",
mfer.Version, mfer.ReleaseDate)
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
}
}
+6
View File
@@ -1,6 +1,12 @@
package mfer
const (
// Version is the current mfer release version.
Version = "0.1.0"
// ReleaseDate is the date on which Version was released.
ReleaseDate = "2025-12-17"
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
// data (256 MiB). This prevents decompression bombs from consuming excessive
// memory.
+13 -6
View File
@@ -8,6 +8,7 @@ import (
"io"
"slices"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
@@ -242,10 +243,10 @@ func fingerprint(key *openpgp.Entity) string {
return strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
}
// verifySignature checks that signature is one good OpenPGP signature
// over data, made by the one primary key in pubKey or one of its subkeys,
// and returns that primary key's fingerprint. signature and pubKey may each
// be armored or binary.
// verifySignature checks that signature is one good, unexpired OpenPGP
// signature over data, made by the one primary key in pubKey or one of its
// subkeys, and returns that primary key's fingerprint. signature and pubKey
// may each be armored or binary.
func verifySignature(data, signature, pubKey []byte) (string, error) {
key, err := readOneKey(pubKey, "embedded public key block", true)
if err != nil {
@@ -266,10 +267,16 @@ func verifySignature(data, signature, pubKey []byte) (string, error) {
return "", fmt.Errorf("%w, found %d", errNotOneSignature, sigs)
}
_, err = openpgp.CheckDetachedSignature(openpgp.EntityList{key},
sig, _, err := openpgp.VerifyDetachedSignature(openpgp.EntityList{key},
bytes.NewReader(data), bytes.NewReader(sigData), nil)
// A manifest outlives its signing key, so a signature by a key that
// has expired since is still good.
// has expired since is still good, unless the signature has expired
// itself. The library does not check that once it finds the key
// expired.
if errors.Is(err, pgperrors.ErrKeyExpired) && sig.SigExpired(time.Now()) {
err = pgperrors.ErrSignatureExpired
}
if err != nil && !errors.Is(err, pgperrors.ErrKeyExpired) {
return "", fmt.Errorf("verify signature: %w", err)
}
+14 -3
View File
@@ -18,6 +18,7 @@ import (
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/elgamal"
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
@@ -416,8 +417,10 @@ func TestVerifySignature(t *testing.T) {
assert.Error(t, err)
}
// TestVerifySignatureKeyExpiredSince verifies a signature made in 2020 by
// a key that expired a day after it was made. The signature is still good.
// TestVerifySignatureKeyExpiredSince verifies two signatures made in 2020
// by a key that expired a day after it was made. A signature with no
// expiry time is still good. One that expired an hour after it was made is
// refused, although the key has expired too.
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
t.Parallel()
@@ -429,9 +432,14 @@ func TestVerifySignatureKeyExpiredSince(t *testing.T) {
key := newTestKey(t, config)
data := []byte("signed in 2020")
var sig bytes.Buffer
var sig, expiringSig bytes.Buffer
require.NoError(t, openpgp.ArmoredDetachSign(&sig, key, bytes.NewReader(data), config))
require.NoError(t, openpgp.ArmoredDetachSign(&expiringSig, key,
bytes.NewReader(data), &packet.Config{
Time: config.Time,
SigLifetimeSecs: uint32(time.Hour.Seconds()),
}))
pubKey, err := armoredPublicKey(key)
require.NoError(t, err)
@@ -439,6 +447,9 @@ func TestVerifySignatureKeyExpiredSince(t *testing.T) {
signer, err := verifySignature(data, sig.Bytes(), pubKey)
require.NoError(t, err)
assert.Equal(t, fingerprint(key), signer)
_, err = verifySignature(data, expiringSig.Bytes(), pubKey)
require.ErrorIs(t, err, pgperrors.ErrSignatureExpired)
}
func TestManifestSignatureVerification(t *testing.T) {