Author SHA1 Message Date
clawbot a4f5d375b5 Read a path or URL named help or h as an argument (closes #198)
check / check (push) Waiting to run
On gen, check, freshen, fetch, list and export, a first argument of
`help` or `h` ran urfave/cli's built-in help command, so
`check help --require-signature FPR` printed help and passed. Those
commands now set urfave/cli's HideHelpCommand, which leaves out their
help command; the root keeps its own, so `mfer help` and
`mfer help check` still work. `check DIR --help` failed with
`No help topic for 'DIR'`; their CommandNotFound now prints the
command's own help, as `check --help` does.

Model: opus-5-5
2026-10-13 17:29:38 +00:00
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
5 changed files with 159 additions and 36 deletions
+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:
+79 -1
View File
@@ -36,6 +36,8 @@ const (
testFlagNoExtra = "--no-extra-files"
testFlagVersion = "--version"
testFlagVerbose = "--verbose"
testFlagHelp = "--help"
testCmdHelp = "help"
)
var errSimulatedWrite = errors.New("simulated write failure")
@@ -321,7 +323,7 @@ func TestHelpCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
opts := testOpts([]string{testApp, "--help"}, fs)
opts := testOpts([]string{testApp, testFlagHelp}, fs)
exitCode := runCLI(opts)
@@ -1807,6 +1809,82 @@ func TestGenerateReadsPathAfterDoubleDash(t *testing.T) {
assert.Equal(t, testFileTxt, files[0].GetPath())
}
// TestPathNamedHelp runs gen, then check with --require-signature, on a
// directory named help and on one named h, the two names of urfave/cli's
// help command. gen must write the manifest into the directory, and check
// must refuse it as unsigned.
//
//nolint:paralleltest // changes the process-global working directory
func TestPathNamedHelp(t *testing.T) {
for _, name := range []string{testCmdHelp, "h"} {
t.Run(name, func(t *testing.T) {
chdirTemp(t)
fs := afero.NewOsFs()
require.NoError(t, fs.Mkdir(name, 0o755))
writeTestFile(t, fs, filepath.Join(name, testFileTxt), "content")
opts := testOpts([]string{testApp, cmdGenerate, "-q", name}, fs)
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
files := manifestFiles(t, fs, filepath.Join(name, defaultManifestName))
require.Len(t, files, 1)
opts = testOpts([]string{
testApp, cmdCheck, "-q", name, "--" + flagRequireSignature, msgFpA,
}, fs)
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), "manifest is not signed")
})
}
}
// TestCommandsReadHelpAsArgument runs each command that takes a path or URL
// on help and on h where nothing by that name exists. Each must fail on the
// missing path or bad URL instead of printing its help.
func TestCommandsReadHelpAsArgument(t *testing.T) {
t.Parallel()
for _, command := range []string{
cmdGenerate, cmdCheck, cmdFreshen, cmdList, cmdExport, cmdFetch,
} {
for _, name := range []string{testCmdHelp, "h"} {
t.Run(command+" "+name, func(t *testing.T) {
t.Parallel()
opts := testOpts([]string{testApp, command, name}, afero.NewMemMapFs())
assert.Equal(t, 1, runCLI(opts))
assert.NotContains(t, testStdout(t, opts), "USAGE:")
})
}
}
}
// TestCheckHelpBesideArgument runs check with --help or -h before and after
// a directory, and runs help check. Each must print what check --help prints.
func TestCheckHelpBesideArgument(t *testing.T) {
t.Parallel()
want := testOpts([]string{testApp, cmdCheck, testFlagHelp}, afero.NewMemMapFs())
require.Equal(t, 0, runCLI(want))
require.Contains(t, testStdout(t, want), "Validate files using manifest file")
for _, args := range [][]string{
{cmdCheck, testDir, testFlagHelp},
{cmdCheck, testFlagHelp, testDir},
{cmdCheck, testDir, "-h"},
{testCmdHelp, cmdCheck},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Parallel()
opts := testOpts(slices.Concat([]string{testApp}, args), afero.NewMemMapFs())
assert.Equal(t, 0, runCLI(opts), testStderr(t, opts))
assert.Equal(t, testStdout(t, want), testStdout(t, opts))
})
}
}
func TestCheckDetectsManifestCorruption(t *testing.T) {
t.Parallel()
+49 -26
View File
@@ -168,6 +168,17 @@ func refuseExtraArgs(_ context.Context, cmd *cli.Command) error {
return nil
}
// showCommandHelp is the CommandNotFound of the commands that take a path or
// URL. Those commands set HideHelpCommand, so that a path or URL named help or
// h is an argument rather than urfave/cli's help command; each sets it itself,
// because on the root it would also remove `mfer help`. With --help,
// urfave/cli looks the command's first argument up as a help topic, so
// `check DIR --help` asks for a topic DIR and finds none; this prints the
// command's own help instead, as `check --help` does.
func showCommandHelp(ctx context.Context, cmd *cli.Command, _ string) {
_ = cli.ShowCommandHelp(ctx, cmd.Root(), cmd.Name)
}
// requireSignatureFlag returns the --require-signature flag taken by the
// check and fetch subcommands.
func requireSignatureFlag() *cli.StringFlag {
@@ -191,10 +202,12 @@ func includePermissionsFlag() *cli.BoolFlag {
func (mfa *CLIApp) generateCommand() *cli.Command {
return &cli.Command{
Name: cmdGenerate,
Aliases: []string{"gen"},
Usage: "Generate manifest file",
ArgsUsage: "[path ...]",
Name: cmdGenerate,
Aliases: []string{"gen"},
Usage: "Generate manifest file",
ArgsUsage: "[path ...]",
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: func(ctx context.Context, cmd *cli.Command) error {
mfa.setVerbosity(cmd)
mfa.printBanner()
@@ -253,10 +266,12 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
func (mfa *CLIApp) checkCommand() *cli.Command {
return &cli.Command{
Name: cmdCheck,
Usage: "Validate files using manifest file",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
Name: cmdCheck,
Usage: "Validate files using manifest file",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: func(ctx context.Context, cmd *cli.Command) error {
mfa.setVerbosity(cmd)
mfa.printBanner()
@@ -287,10 +302,12 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
func (mfa *CLIApp) freshenCommand() *cli.Command {
return &cli.Command{
Name: cmdFreshen,
Usage: "Update manifest with changed, new, and removed files",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
Name: cmdFreshen,
Usage: "Update manifest with changed, new, and removed files",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: func(ctx context.Context, cmd *cli.Command) error {
mfa.setVerbosity(cmd)
mfa.printBanner()
@@ -338,10 +355,12 @@ func (mfa *CLIApp) freshenCommand() *cli.Command {
func (mfa *CLIApp) exportCommand() *cli.Command {
return &cli.Command{
Name: cmdExport,
Usage: "Export manifest contents as JSON",
ArgsUsage: "[manifest file or URL]",
ArgValidator: refuseExtraArgs,
Name: cmdExport,
Usage: "Export manifest contents as JSON",
ArgsUsage: "[manifest file or URL]",
ArgValidator: refuseExtraArgs,
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: func(ctx context.Context, cmd *cli.Command) error {
mfa.setVerbosity(cmd)
@@ -364,12 +383,14 @@ func (mfa *CLIApp) versionCommand() *cli.Command {
func (mfa *CLIApp) listCommand() *cli.Command {
return &cli.Command{
Name: cmdList,
Aliases: []string{"ls"},
Usage: "List files in manifest",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
Action: mfa.listManifestOperation,
Name: cmdList,
Aliases: []string{"ls"},
Usage: "List files in manifest",
ArgsUsage: manifestArgsUsage,
ArgValidator: refuseExtraArgs,
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: mfa.listManifestOperation,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "long",
@@ -386,10 +407,12 @@ func (mfa *CLIApp) listCommand() *cli.Command {
func (mfa *CLIApp) fetchCommand() *cli.Command {
return &cli.Command{
Name: cmdFetch,
Usage: "fetch manifest and referenced files",
ArgsUsage: "URL",
ArgValidator: refuseExtraArgs,
Name: cmdFetch,
Usage: "fetch manifest and referenced files",
ArgsUsage: "URL",
ArgValidator: refuseExtraArgs,
HideHelpCommand: true,
CommandNotFound: showCommandHelp,
Action: func(ctx context.Context, cmd *cli.Command) error {
mfa.setVerbosity(cmd)
mfa.printBanner()
+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) {