check / check (push) Canceled after 0s
freshen skipped the write whenever no file was added, changed or removed, so --sign-key and --include-timestamps on an unchanged tree did nothing. It now skips only when the manifest would come out the same: nothing in the tree changed, the manifest is signed by the --sign-key key (unsigned without one), and it has a createdAt timestamp exactly when --include-timestamps asks for one. A file whose recorded mode is not what --include-permissions asks for already counts as changed. Deviation: the mfer package gains SecretKeyFingerprint and a loaded manifest's CreatedAt, which the comparison needs. Model: opus-5-5
973 lines
30 KiB
Go
973 lines
30 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/dsa" //nolint:staticcheck // SA1019: tests need a DSA key to refuse
|
|
"io"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// newTestKey returns a new Ed25519 key, which is quick to make, for
|
|
// "MFER Test Key <test@mfer.test>". config may set when it is made and how
|
|
// long it lasts.
|
|
func newTestKey(t *testing.T, config *packet.Config) *openpgp.Entity {
|
|
t.Helper()
|
|
|
|
if config == nil {
|
|
config = &packet.Config{}
|
|
}
|
|
|
|
config.Algorithm = packet.PubKeyAlgoEdDSA
|
|
|
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test", config)
|
|
require.NoError(t, err)
|
|
|
|
return key
|
|
}
|
|
|
|
// armoredSecretKeys returns keys with their secret keys in one armored
|
|
// block, as gpg --export-secret-keys --armor writes them.
|
|
func armoredSecretKeys(t *testing.T, keys ...*openpgp.Entity) []byte {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range keys {
|
|
require.NoError(t, key.SerializePrivateWithoutSigning(w, nil))
|
|
}
|
|
|
|
require.NoError(t, w.Close())
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// armoredPublicKeys returns the public parts of keys in one armored block,
|
|
// as gpg --export --armor writes them.
|
|
func armoredPublicKeys(t *testing.T, keys ...*openpgp.Entity) []byte {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range keys {
|
|
require.NoError(t, key.Serialize(w))
|
|
}
|
|
|
|
require.NoError(t, w.Close())
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// testSigningOptions returns signing options for a new key with no
|
|
// passphrase.
|
|
func testSigningOptions(t *testing.T) *SigningOptions {
|
|
t.Helper()
|
|
|
|
return &SigningOptions{SecretKey: armoredSecretKeys(t, newTestKey(t, nil))}
|
|
}
|
|
|
|
// joinArmored returns the armored block first followed by the armored
|
|
// block second on the next line. armor.Encode ends a block without a
|
|
// newline, unlike gpg, and a block only starts at the start of a line.
|
|
func joinArmored(first, second []byte) []byte {
|
|
return slices.Concat(first, []byte("\n"), second)
|
|
}
|
|
|
|
// signedTestManifest returns a manifest of one file signed with opts.
|
|
func signedTestManifest(t *testing.T, opts *SigningOptions) []byte {
|
|
t.Helper()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(opts)
|
|
|
|
content := []byte("signed file content")
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0,
|
|
bytes.NewReader(content), nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, b.Build(context.Background(), &buf))
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// rewriteOuter returns manifest with its outer message changed by edit.
|
|
// A signature stays good as long as edit leaves the UUID and hash alone.
|
|
func rewriteOuter(t *testing.T, manifest []byte, edit func(*MFFileOuter)) []byte {
|
|
t.Helper()
|
|
|
|
outer := new(MFFileOuter)
|
|
require.NoError(t, proto.Unmarshal(manifest[len(MAGIC):], outer))
|
|
|
|
edit(outer)
|
|
|
|
data, err := proto.Marshal(outer)
|
|
require.NoError(t, err)
|
|
|
|
return append([]byte(MAGIC), data...)
|
|
}
|
|
|
|
func TestBuilderWithSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := newTestKey(t, nil)
|
|
|
|
// Create a builder with signing options
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{SecretKey: armoredSecretKeys(t, key)})
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are populated
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be populated")
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"signing public key should be populated")
|
|
|
|
// The signer is the key's fingerprint in 40 upper-case hex characters.
|
|
assert.Equal(t, fingerprint(key), string(manifest.pbOuter.GetSigner()))
|
|
assert.Regexp(t, "^[0-9A-F]{40}$", string(manifest.pbOuter.GetSigner()))
|
|
|
|
// Verify signature is a valid PGP signature
|
|
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
|
|
"-----BEGIN PGP SIGNATURE-----")
|
|
|
|
// Verify public key is a valid PGP public key block
|
|
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
|
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
}
|
|
|
|
// TestManifestSigner checks that Signer gives the signing key's
|
|
// fingerprint for a signed manifest, and nil once the signature is taken
|
|
// out, although the signer field is still there.
|
|
func TestManifestSigner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := newTestKey(t, nil)
|
|
data := signedTestManifest(t, &SigningOptions{SecretKey: armoredSecretKeys(t, key)})
|
|
|
|
signed, err := NewManifestFromReader(bytes.NewReader(data))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fingerprint(key), string(signed.Signer()))
|
|
|
|
unsigned, err := NewManifestFromReader(bytes.NewReader(
|
|
rewriteOuter(t, data, func(outer *MFFileOuter) { outer.Signature = nil })))
|
|
require.NoError(t, err)
|
|
assert.Nil(t, unsigned.Signer())
|
|
}
|
|
|
|
func TestScannerWithSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create in-memory filesystem with test files
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
|
|
|
// Create scanner with signing options
|
|
opts := &ScannerOptions{
|
|
Fs: fs,
|
|
SigningOptions: testSigningOptions(t),
|
|
}
|
|
s := NewScannerWithOptions(opts)
|
|
|
|
// Enumerate files
|
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
|
assert.Equal(t, FileCount(2), s.FileCount())
|
|
|
|
// Generate signed manifest
|
|
var buf bytes.Buffer
|
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
|
|
// Parse and verify
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
|
|
}
|
|
|
|
// TestSigningWithBinarySecretKey signs with a secret key that is not
|
|
// armored, as gpg --export-secret-keys writes it without --armor.
|
|
func TestSigningWithBinarySecretKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var secretKey bytes.Buffer
|
|
|
|
require.NoError(t, newTestKey(t, nil).SerializePrivateWithoutSigning(&secretKey, nil))
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(
|
|
signedTestManifest(t, &SigningOptions{SecretKey: secretKey.Bytes()})))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestSigningWithProtectedKey signs with a key protected by a passphrase:
|
|
// with the passphrase, without one, and with a wrong one.
|
|
func TestSigningWithProtectedKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := newTestKey(t, nil)
|
|
require.NoError(t, key.EncryptPrivateKeys([]byte("right"), nil))
|
|
secretKey := armoredSecretKeys(t, key)
|
|
|
|
protected, err := SecretKeyIsProtected(secretKey)
|
|
require.NoError(t, err)
|
|
assert.True(t, protected)
|
|
|
|
_, err = NewManifestFromReader(bytes.NewReader(signedTestManifest(t,
|
|
&SigningOptions{SecretKey: secretKey, Passphrase: []byte("right")})))
|
|
require.NoError(t, err)
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{SecretKey: secretKey})
|
|
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNoPassphrase)
|
|
|
|
b.SetSigningOptions(&SigningOptions{
|
|
SecretKey: secretKey, Passphrase: []byte("wrong"),
|
|
})
|
|
assert.ErrorContains(t, b.Build(context.Background(), io.Discard),
|
|
"unlock signing key")
|
|
}
|
|
|
|
func TestSecretKeyIsProtectedWithoutPassphrase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
protected, err := SecretKeyIsProtected(testSigningOptions(t).SecretKey)
|
|
require.NoError(t, err)
|
|
assert.False(t, protected)
|
|
}
|
|
|
|
// TestSecretKeyFingerprint checks that SecretKeyFingerprint gives the
|
|
// signer that a manifest signed with the key records.
|
|
func TestSecretKeyFingerprint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := testSigningOptions(t)
|
|
|
|
got, err := SecretKeyFingerprint(opts.SecretKey)
|
|
require.NoError(t, err)
|
|
|
|
m, err := NewManifestFromReader(bytes.NewReader(signedTestManifest(t, opts)))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(m.Signer()), got)
|
|
}
|
|
|
|
// TestSigningKeyFileWithTwoKeys signs with a key file that holds two keys,
|
|
// as gpg writes it for a user ID that two keys have. It names no one key
|
|
// to sign with, so signing must fail.
|
|
func TestSigningKeyFileWithTwoKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{SecretKey: armoredSecretKeys(t,
|
|
newTestKey(t, nil), newTestKey(t, nil))})
|
|
|
|
err := b.Build(context.Background(), io.Discard)
|
|
require.ErrorIs(t, err, errKeyCount)
|
|
assert.EqualError(t, err, "signing key file must hold exactly one key, found 2")
|
|
}
|
|
|
|
// TestSigningKeyFileWithoutSecretKey signs with a file that holds only a
|
|
// public key.
|
|
func TestSigningKeyFileWithoutSecretKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
SecretKey: armoredPublicKeys(t, newTestKey(t, nil)),
|
|
})
|
|
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNoSecretKey)
|
|
}
|
|
|
|
// TestCheckSigningKeyRefusesKeyThatCannotSign checks a key that can sign,
|
|
// and keys that read and need no passphrase but cannot sign now: one that
|
|
// expired in 2020 and one that has been revoked. CheckSigningKey must
|
|
// refuse each of the two, as signing a manifest with it would fail.
|
|
func TestCheckSigningKeyRefusesKeyThatCannotSign(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.NoError(t, CheckSigningKey(testSigningOptions(t)))
|
|
|
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
expired := newTestKey(t, &packet.Config{
|
|
Time: func() time.Time { return made },
|
|
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
|
|
})
|
|
|
|
revoked := newTestKey(t, nil)
|
|
require.NoError(t, revoked.RevokeKey(packet.KeyRetired, "", nil))
|
|
|
|
for name, key := range map[string]*openpgp.Entity{
|
|
"expired": expired,
|
|
"revoked": revoked,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.ErrorContains(t, CheckSigningKey(
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, key)}),
|
|
"signing key cannot sign")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSigningRefusesVersion6Key signs with an OpenPGP version 6 key, whose
|
|
// fingerprint is 64 hex characters. mfer signs only with version 4 keys.
|
|
func TestSigningRefusesVersion6Key(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key, err := openpgp.NewEntity("MFER Test Key", "", "test@mfer.test",
|
|
&packet.Config{V6Keys: true, Algorithm: packet.PubKeyAlgoEd25519})
|
|
require.NoError(t, err)
|
|
|
|
secretKey := armoredSecretKeys(t, key)
|
|
|
|
_, err = SecretKeyIsProtected(secretKey)
|
|
require.ErrorIs(t, err, errNotV4Key)
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{SecretKey: secretKey})
|
|
require.ErrorIs(t, b.Build(context.Background(), io.Discard), errNotV4Key)
|
|
}
|
|
|
|
// TestMalformedArmorIsNamed reads a signing key file, a signature and an
|
|
// embedded public key block whose armor is malformed: a header line with
|
|
// no colon, or no blank line after the BEGIN line. Each must fail saying
|
|
// the armor is malformed.
|
|
func TestMalformedArmorIsNamed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
opts := testSigningOptions(t)
|
|
manifest := signedTestManifest(t, opts)
|
|
|
|
for name, change := range map[string]func([]byte) []byte{
|
|
"header line with no colon": func(block []byte) []byte {
|
|
return bytes.Replace(block,
|
|
[]byte("-----\n"), []byte("-----\nno colon\n"), 1)
|
|
},
|
|
"no blank line after the BEGIN line": func(block []byte) []byte {
|
|
return bytes.Replace(block, []byte("-----\n\n"), []byte("-----\n"), 1)
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := SecretKeyIsProtected(change(opts.SecretKey))
|
|
require.ErrorIs(t, err, errMalformedArmor)
|
|
|
|
for _, changed := range [][]byte{
|
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.Signature = change(outer.GetSignature())
|
|
}),
|
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = change(outer.GetSigningPubKey())
|
|
}),
|
|
} {
|
|
_, err = NewManifestFromReader(bytes.NewReader(changed))
|
|
require.ErrorIs(t, err, errMalformedArmor)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifySignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := newTestKey(t, nil)
|
|
data := []byte("test data to sign and verify")
|
|
|
|
var armored, binary bytes.Buffer
|
|
|
|
require.NoError(t, openpgp.ArmoredDetachSign(&armored, key,
|
|
bytes.NewReader(data), nil))
|
|
require.NoError(t, openpgp.DetachSign(&binary, key, bytes.NewReader(data), nil))
|
|
|
|
pubKey, err := armoredPublicKey(key)
|
|
require.NoError(t, err)
|
|
|
|
var binaryPubKey bytes.Buffer
|
|
|
|
require.NoError(t, key.Serialize(&binaryPubKey))
|
|
|
|
// Verifying names the key that made the signature, whether the
|
|
// signature and key are armored or not.
|
|
signer, err := verifySignature(data, armored.Bytes(), pubKey)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fingerprint(key), signer)
|
|
|
|
signer, err = verifySignature(data, binary.Bytes(), binaryPubKey.Bytes())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fingerprint(key), signer)
|
|
|
|
// A signature over other data is bad.
|
|
_, err = verifySignature([]byte("different data"), armored.Bytes(), pubKey)
|
|
require.Error(t, err)
|
|
|
|
// A public key that is not one cannot verify anything.
|
|
_, err = verifySignature(data, armored.Bytes(), []byte("not a public key"))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// 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, as is one made then by a signing subkey that
|
|
// expired a day after it was made. One that expired an hour after it was
|
|
// made is refused, although the key has expired too.
|
|
func TestVerifySignatureKeyExpiredSince(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
config := &packet.Config{
|
|
Time: func() time.Time { return made },
|
|
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
|
|
}
|
|
key := newTestKey(t, config)
|
|
data := []byte("signed in 2020")
|
|
|
|
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)
|
|
|
|
signer, err := verifySignature(data, sig.Bytes(), pubKey)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fingerprint(key), signer)
|
|
|
|
withSubkey := newTestKey(t, &packet.Config{Time: config.Time})
|
|
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
|
Algorithm: packet.PubKeyAlgoEdDSA, Time: config.Time,
|
|
KeyLifetimeSecs: config.KeyLifetimeSecs,
|
|
}))
|
|
|
|
var subkeySig bytes.Buffer
|
|
|
|
require.NoError(t, openpgp.ArmoredDetachSign(&subkeySig, withSubkey,
|
|
bytes.NewReader(data), config))
|
|
|
|
subkeyPubKey, err := armoredPublicKey(withSubkey)
|
|
require.NoError(t, err)
|
|
|
|
_, err = verifySignature(data, subkeySig.Bytes(), subkeyPubKey)
|
|
require.NoError(t, err)
|
|
|
|
_, err = verifySignature(data, expiringSig.Bytes(), pubKey)
|
|
require.ErrorIs(t, err, pgperrors.ErrSignatureExpired)
|
|
}
|
|
|
|
// signDated returns a detached signature over data by signer, dated when.
|
|
// openpgp.DetachSign signs only with a key that is valid at the time its
|
|
// config gives, so this makes the signature packet without that check.
|
|
func signDated(t *testing.T, signer *packet.PrivateKey, data []byte,
|
|
when time.Time,
|
|
) []byte {
|
|
t.Helper()
|
|
|
|
sig := &packet.Signature{
|
|
SigType: packet.SigTypeBinary,
|
|
PubKeyAlgo: signer.PubKeyAlgo,
|
|
Hash: crypto.SHA256,
|
|
CreationTime: when,
|
|
IssuerKeyId: &signer.KeyId,
|
|
}
|
|
|
|
h, err := sig.PrepareSign(nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = h.Write(data)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, sig.Sign(h, signer, nil))
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, sig.Serialize(&buf))
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestVerifySignatureRefusesKeyNotValidWhenSigned verifies signatures
|
|
// dated when the key or subkey that made them was not valid: after the key
|
|
// expired; after the key's self-signature, or the signing subkey's binding
|
|
// signature, expired, the key or subkey having expired since as well; and
|
|
// before the key was made, also by a key made in 2100. Each must be
|
|
// refused.
|
|
func TestVerifySignatureRefusesKeyNotValidWhenSigned(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
twoDaysLater := made.Add(48 * time.Hour)
|
|
day := uint32((24 * time.Hour).Seconds())
|
|
clock := func(when time.Time) func() time.Time {
|
|
return func() time.Time { return when }
|
|
}
|
|
|
|
expiredAfterADay := newTestKey(t, &packet.Config{
|
|
Time: clock(made), KeyLifetimeSecs: day,
|
|
})
|
|
selfSignatureExpiredAfterADay := newTestKey(t, &packet.Config{
|
|
Time: clock(made), KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
|
})
|
|
|
|
withSubkey := newTestKey(t, &packet.Config{Time: clock(made)})
|
|
require.NoError(t, withSubkey.AddSigningSubkey(&packet.Config{
|
|
Algorithm: packet.PubKeyAlgoEdDSA, Time: clock(made),
|
|
KeyLifetimeSecs: 30 * day, SigLifetimeSecs: day,
|
|
}))
|
|
subkey := withSubkey.Subkeys[len(withSubkey.Subkeys)-1].PrivateKey
|
|
|
|
madeADayLater := newTestKey(t, &packet.Config{
|
|
Time: clock(made.Add(24 * time.Hour)),
|
|
})
|
|
madeIn2100 := newTestKey(t, &packet.Config{
|
|
Time: clock(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)),
|
|
})
|
|
|
|
for name, test := range map[string]struct {
|
|
key *openpgp.Entity
|
|
signer *packet.PrivateKey
|
|
when time.Time
|
|
}{
|
|
"after the key expired": {
|
|
expiredAfterADay, expiredAfterADay.PrivateKey, twoDaysLater,
|
|
},
|
|
"after the self-signature expired": {
|
|
selfSignatureExpiredAfterADay,
|
|
selfSignatureExpiredAfterADay.PrivateKey, twoDaysLater,
|
|
},
|
|
"after the binding signature expired": {
|
|
withSubkey, subkey, twoDaysLater,
|
|
},
|
|
"before the key was made": {
|
|
madeADayLater, madeADayLater.PrivateKey, made,
|
|
},
|
|
"before a key made in 2100": {
|
|
madeIn2100, madeIn2100.PrivateKey, made,
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
data := []byte("signed in 2020")
|
|
|
|
pubKey, err := armoredPublicKey(test.key)
|
|
require.NoError(t, err)
|
|
|
|
_, err = verifySignature(data,
|
|
signDated(t, test.signer, data, test.when), pubKey)
|
|
require.ErrorIs(t, err, errKeyNotValidWhenSigned)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManifestSignatureVerification(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Parse the manifest - signature should be verified during load
|
|
manifest, err := NewManifestFromReader(bytes.NewReader(
|
|
signedTestManifest(t, testSigningOptions(t))))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest)
|
|
|
|
// Signature should be present and valid
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
}
|
|
|
|
func TestManifestTamperedSignatureFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Change one character of the signature's base64 body, which starts
|
|
// after the blank line that ends the armor headers.
|
|
data := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
|
|
func(outer *MFFileOuter) {
|
|
sig := outer.GetSignature()
|
|
i := bytes.Index(sig, []byte("\n\n")) + len("\n\n") + 20
|
|
|
|
sig[i]++
|
|
})
|
|
|
|
// Try to load the tampered manifest - should fail
|
|
_, err := NewManifestFromReader(bytes.NewReader(data))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// TestManifestSignedByGPGLoads loads the signed seed of
|
|
// FuzzNewManifestFromReader, a manifest signed with gpg before mfer signed
|
|
// and verified manifests itself.
|
|
func TestManifestSignedByGPGLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seed, err := os.ReadFile(filepath.Join(
|
|
"testdata", "fuzz", "FuzzNewManifestFromReader", "signed"))
|
|
require.NoError(t, err)
|
|
|
|
// After its header line the seed holds the manifest as []byte("...").
|
|
_, quoted, found := strings.Cut(string(seed), "[]byte(")
|
|
require.True(t, found)
|
|
|
|
manifest, err := strconv.Unquote(
|
|
strings.TrimSuffix(strings.TrimSpace(quoted), ")"))
|
|
require.NoError(t, err)
|
|
|
|
m, err := NewManifestFromReader(strings.NewReader(manifest))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "4F562BFB863FDC6B51B4EE88872A51176CEF23AE",
|
|
string(m.pbOuter.GetSigner()))
|
|
}
|
|
|
|
// TestManifestRefusesSecondEmbeddedKey loads manifests whose embedded
|
|
// public key block holds another key besides the key that signed it: as a
|
|
// public key, as a secret key with no user ID, which openpgp.ReadKeyRing
|
|
// skips, and written as a subkey packet at the start of the block, which
|
|
// openpgp.ReadKeyRing reads as a primary key. Loading must refuse each,
|
|
// although the signature is good and the signer field names the key that
|
|
// made it.
|
|
func TestManifestRefusesSecondEmbeddedKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
other := newTestKey(t, nil)
|
|
signer := newTestKey(t, nil)
|
|
manifest := signedTestManifest(t,
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
|
|
|
|
otherWithoutUserID := newTestKey(t, nil)
|
|
otherWithoutUserID.Identities = map[string]*openpgp.Identity{}
|
|
|
|
otherAsSubkey := newTestKey(t, nil)
|
|
otherAsSubkey.PrimaryKey.IsSubkey = true
|
|
|
|
for name, block := range map[string][]byte{
|
|
"public key": armoredPublicKeys(t, other, signer),
|
|
"secret key without user ID": armoredSecretKeys(t, signer, otherWithoutUserID),
|
|
"subkey packet first": armoredPublicKeys(t, otherAsSubkey, signer),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
embedded := rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = block
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(embedded))
|
|
require.ErrorIs(t, err, errKeyCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestManifestRefusesSecondEmbeddedKeyWithoutUserID loads a manifest whose
|
|
// embedded public key block holds, before the key that signed it, another
|
|
// key with no user ID, which openpgp.ReadKeyRing skips. Loading must
|
|
// refuse it: the block holds two keys.
|
|
func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
other := newTestKey(t, nil)
|
|
other.Identities = map[string]*openpgp.Identity{}
|
|
|
|
signer := newTestKey(t, nil)
|
|
|
|
manifest := rewriteOuter(t, signedTestManifest(t,
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)}),
|
|
func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = armoredPublicKeys(t, other, signer)
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
|
require.ErrorIs(t, err, errKeyCount)
|
|
}
|
|
|
|
// dsaKeyPacket returns a public key packet holding a DSA key, or a public
|
|
// subkey packet when isSubkey. Its numbers are not a working key: loading
|
|
// must refuse the packet before it uses them.
|
|
func dsaKeyPacket(t *testing.T, isSubkey bool) []byte {
|
|
t.Helper()
|
|
|
|
key := packet.NewDSAPublicKey(time.Now(), &dsa.PublicKey{
|
|
P: big.NewInt(23), Q: big.NewInt(11), G: big.NewInt(4), Y: big.NewInt(8),
|
|
})
|
|
key.IsSubkey = isSubkey
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, key.Serialize(&buf))
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestManifestRefusesDSAKey loads manifests whose embedded public key
|
|
// block holds a DSA key: alone, or as a subkey after the key that signed
|
|
// the manifest. openpgp.ReadKeyRing checks a key's self-signatures, which
|
|
// for a DSA key with very large numbers takes minutes each. Loading must
|
|
// refuse each block before that.
|
|
func TestManifestRefusesDSAKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := newTestKey(t, nil)
|
|
manifest := signedTestManifest(t,
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
|
|
|
|
var signerKey bytes.Buffer
|
|
|
|
require.NoError(t, signer.Serialize(&signerKey))
|
|
|
|
for name, block := range map[string][]byte{
|
|
"DSA key": dsaKeyPacket(t, false),
|
|
"DSA subkey": slices.Concat(signerKey.Bytes(), dsaKeyPacket(t, true)),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
embedded := rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = block
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(embedded))
|
|
require.ErrorIs(t, err, errDSAKey)
|
|
})
|
|
}
|
|
}
|
|
|
|
// elGamalSecretSubkeyPacket returns a secret subkey packet holding an
|
|
// ElGamal key. Its numbers are not a working key: loading must refuse the
|
|
// packet before it uses them.
|
|
func elGamalSecretSubkeyPacket(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
key := packet.NewElGamalPrivateKey(time.Now(), &elgamal.PrivateKey{
|
|
P: big.NewInt(23), G: big.NewInt(4), Y: big.NewInt(8), X: big.NewInt(3),
|
|
})
|
|
key.IsSubkey = true
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, key.Serialize(&buf))
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestManifestRefusesSecretKey loads manifests whose embedded public key
|
|
// block holds a secret key: the key that signed the manifest with its
|
|
// secret key, or its public key followed by an ElGamal secret subkey.
|
|
// openpgp.ReadKeyRing checks the numbers of every secret key it reads,
|
|
// which for an ElGamal key with a very large prime takes minutes. Loading
|
|
// must refuse each block before that.
|
|
func TestManifestRefusesSecretKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
signer := newTestKey(t, nil)
|
|
manifest := signedTestManifest(t,
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, signer)})
|
|
|
|
var signerKey bytes.Buffer
|
|
|
|
require.NoError(t, signer.Serialize(&signerKey))
|
|
|
|
for _, block := range [][]byte{
|
|
armoredSecretKeys(t, signer),
|
|
slices.Concat(signerKey.Bytes(), elGamalSecretSubkeyPacket(t)),
|
|
} {
|
|
embedded := rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = block
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(embedded))
|
|
require.ErrorIs(t, err, errSecretKey)
|
|
}
|
|
}
|
|
|
|
// TestManifestRefusesTwoSignatures loads a manifest whose signature field
|
|
// holds its good signature twice, not armored. Loading must refuse it.
|
|
func TestManifestRefusesTwoSignatures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
|
|
func(outer *MFFileOuter) {
|
|
sig, err := dearmor(outer.GetSignature())
|
|
require.NoError(t, err)
|
|
|
|
outer.Signature = slices.Concat(sig, sig)
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
|
require.ErrorIs(t, err, errNotOneSignature)
|
|
}
|
|
|
|
// TestManifestRefusesFieldNotOneArmoredBlock loads manifests whose
|
|
// signature or embedded public key block holds its good armored block with
|
|
// something else: a second armored block, text after the END line, or many
|
|
// END lines before the block. Decoding the block once for each END line
|
|
// before it would take time and memory that grow with the square of the
|
|
// field's size. Loading must refuse each.
|
|
func TestManifestRefusesFieldNotOneArmoredBlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manifest := signedTestManifest(t, testSigningOptions(t))
|
|
|
|
for name, change := range map[string]func([]byte) []byte{
|
|
"second armored block": func(block []byte) []byte {
|
|
return joinArmored(block, block)
|
|
},
|
|
"text after the END line": func(block []byte) []byte {
|
|
return slices.Concat(block, []byte("\nmore text\n"))
|
|
},
|
|
"END lines before the block": func(block []byte) []byte {
|
|
return slices.Concat(
|
|
[]byte(strings.Repeat(armorEnd+"\n", 1000)), block)
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, changed := range [][]byte{
|
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.Signature = change(outer.GetSignature())
|
|
}),
|
|
rewriteOuter(t, manifest, func(outer *MFFileOuter) {
|
|
outer.SigningPubKey = change(outer.GetSigningPubKey())
|
|
}),
|
|
} {
|
|
_, err := NewManifestFromReader(bytes.NewReader(changed))
|
|
require.ErrorIs(t, err, errNotOneArmoredBlock)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestManifestSignedWithSubkey signs with a key that has a signing subkey,
|
|
// which signs in place of the primary key. The manifest must load, with
|
|
// the primary key's fingerprint as signer.
|
|
func TestManifestSignedWithSubkey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := newTestKey(t, nil)
|
|
require.NoError(t, key.AddSigningSubkey(
|
|
&packet.Config{Algorithm: packet.PubKeyAlgoEdDSA}))
|
|
|
|
m, err := NewManifestFromReader(bytes.NewReader(signedTestManifest(t,
|
|
&SigningOptions{SecretKey: armoredSecretKeys(t, key)})))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fingerprint(key), string(m.pbOuter.GetSigner()))
|
|
|
|
block, err := armor.Decode(bytes.NewReader(m.pbOuter.GetSignature()))
|
|
require.NoError(t, err)
|
|
|
|
p, err := packet.Read(block.Body)
|
|
require.NoError(t, err)
|
|
|
|
sig, ok := p.(*packet.Signature)
|
|
require.True(t, ok)
|
|
|
|
subkey := key.Subkeys[len(key.Subkeys)-1].PublicKey
|
|
assert.Equal(t, subkey.KeyId, *sig.IssuerKeyId,
|
|
"the signing subkey made the signature")
|
|
}
|
|
|
|
// TestManifestRefusesSignerOtherThanSigningKey loads a manifest whose
|
|
// signer field names a key other than the one that made the signature.
|
|
func TestManifestRefusesSignerOtherThanSigningKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manifest := rewriteOuter(t, signedTestManifest(t, testSigningOptions(t)),
|
|
func(outer *MFFileOuter) {
|
|
outer.Signer = []byte(strings.Repeat("A", len(outer.GetSigner())))
|
|
})
|
|
|
|
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
|
require.ErrorIs(t, err, errSignerNotSigningKey)
|
|
}
|
|
|
|
func TestBuilderWithoutSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a builder without signing options
|
|
b := NewBuilder()
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are empty
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.Empty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigner(),
|
|
"signer should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"signing public key should be empty when not signing")
|
|
}
|
|
|
|
// TestBuildPassesContextToSigning checks that Build does not sign once the
|
|
// context given to it has ended.
|
|
func TestBuildPassesContextToSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{SecretKey: []byte("any")})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
|
|
}
|