Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215de1f857 |
+1
-1
@@ -25,7 +25,7 @@ RUN go test -timeout 90s -race -cover ./... || \
|
||||
# No stage depends on it, so the image build does not run it.
|
||||
# golang:1.27.1, 2026-10-06
|
||||
FROM golang@sha256:1e93e00a31255c07e9a34c4207f3006e1501730c5323697cee7dfb827fdae44c AS vulncheck
|
||||
# govulncheck v1.8.0, 2026-10-06
|
||||
# govulncheck v1.8.0, pinned to the commit its release tag names.
|
||||
RUN go install golang.org/x/vuln/cmd/govulncheck@709015412431dd2b5b28a53c06c70bc02d49074c
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
+5
-20
@@ -37,7 +37,7 @@ The outer message contains:
|
||||
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
|
||||
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
||||
| `signature` | 201 | bytes (optional) | GPG signature (ASCII-armored or binary) |
|
||||
| `signer` | 202 | bytes (optional) | Fingerprint of the signing key |
|
||||
| `signer` | 202 | bytes (optional) | Full GPG key ID of the signer |
|
||||
| `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
|
||||
|
||||
### SHA-256 Hash
|
||||
@@ -50,14 +50,11 @@ allows verifying data integrity before decompression.
|
||||
The `innerMessage` field is compressed with
|
||||
[Zstandard (zstd)](https://facebook.github.io/zstd/). Implementations must
|
||||
enforce a decompression size limit to prevent decompression bombs. The reference
|
||||
implementation limits decompressed size to 256 MiB. It writes zstd frames with a
|
||||
implementation limits decompressed size to 256 MB. It writes zstd frames with a
|
||||
window of at most 8 MiB, the largest window the zstd format recommends decoders
|
||||
support, and refuses frames that ask for a larger one. It also refuses an inner
|
||||
message whose file entries, hashes, timestamps and MIME types, counted at 176,
|
||||
112, 64 and 16 bytes each, add up to more than 8 times its size. It refuses a
|
||||
manifest file larger than 258 MiB without reading the rest of it: zstd's worst
|
||||
case grows a 256 MiB inner message by 1/256 to 257 MiB, and the last MiB is room
|
||||
for the signature, the signing key and the other outer fields.
|
||||
112, 64 and 16 bytes each, add up to more than 8 times its size.
|
||||
|
||||
## Inner Message (`MFFile`)
|
||||
|
||||
@@ -109,12 +106,9 @@ All `path` values must satisfy these invariants:
|
||||
- **No parent traversal**: no `..` path segments
|
||||
- **No empty segments**: no `//` sequences
|
||||
- **No trailing slash**: paths refer to files, not directories
|
||||
- **Listed once**: each path appears at most once in a manifest, compared byte
|
||||
for byte, so `A.txt` and `a.txt` are two paths
|
||||
|
||||
Implementations must validate these invariants when reading and writing
|
||||
manifests. Paths that violate these rules must be rejected, and a reader must
|
||||
reject a manifest that lists a path more than once.
|
||||
manifests. Paths that violate these rules must be rejected.
|
||||
|
||||
## Hash Format (`MFFileChecksum`)
|
||||
|
||||
@@ -143,16 +137,7 @@ Where:
|
||||
compressed data)
|
||||
|
||||
Components are separated by hyphens. The signature is produced by GPG over this
|
||||
canonical string and stored in the `signature` field of the outer message. The
|
||||
signing key's public key goes in `signingPubKey` and its fingerprint, in hex, in
|
||||
`signer`.
|
||||
|
||||
A verifier accepts a signed manifest only if `signingPubKey` holds exactly one
|
||||
primary key, `signature` is one good signature over the canonical string made by
|
||||
that key (or one of its subkeys), and `signer` is that key's fingerprint. The
|
||||
reference implementation refuses to load a manifest that fails these checks;
|
||||
`check` and `fetch` given `--require-signature` then compare the required
|
||||
fingerprint with `signer`.
|
||||
canonical string and stored in the `signature` field of the outer message.
|
||||
|
||||
## Deterministic Serialization
|
||||
|
||||
|
||||
+20
-20
@@ -112,17 +112,10 @@ func (mfa *CLIApp) fetchManifestToTemp(
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Copying stops one byte past mfa.maxManifestSize, which is enough to
|
||||
// tell that the manifest is too large.
|
||||
written, cpErr := io.Copy(tmpFile, io.LimitReader(rc, mfa.maxManifestSize+1))
|
||||
_, cpErr := io.Copy(tmpFile, rc)
|
||||
_ = rc.Close()
|
||||
_ = tmpFile.Close()
|
||||
|
||||
if cpErr == nil && written > mfa.maxManifestSize {
|
||||
cpErr = fmt.Errorf("%w of %d bytes", errManifestTooLarge, mfa.maxManifestSize)
|
||||
}
|
||||
|
||||
if cpErr != nil {
|
||||
_ = mfa.Fs.Remove(tmpPath)
|
||||
|
||||
@@ -133,8 +126,10 @@ func (mfa *CLIApp) fetchManifestToTemp(
|
||||
}
|
||||
|
||||
// verifyRequiredSigner enforces the --require-signature fingerprint
|
||||
// against the key that made the manifest's signature.
|
||||
func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
// against the manifest's embedded signing key.
|
||||
func verifyRequiredSigner(
|
||||
ctx context.Context, chk *mfer.Checker, requiredSigner string,
|
||||
) error {
|
||||
// Validate fingerprint format: must be exactly 40 hex characters
|
||||
if len(requiredSigner) != fingerprintHexLen {
|
||||
return fmt.Errorf("%w, got %d", errInvalidFingerprint, len(requiredSigner))
|
||||
@@ -150,17 +145,22 @@ func verifyRequiredSigner(chk *mfer.Checker, requiredSigner string) error {
|
||||
errManifestNotSigned, requiredSigner)
|
||||
}
|
||||
|
||||
// Loading the manifest checked that the signer is the fingerprint of
|
||||
// the key that made the signature.
|
||||
signer := string(chk.Signer())
|
||||
|
||||
// Compare fingerprints - must be exact match (case-insensitive)
|
||||
if !strings.EqualFold(signer, requiredSigner) {
|
||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
signer, errSignerMismatch, requiredSigner)
|
||||
// Extract fingerprint from the embedded public key (not from the
|
||||
// signer field). This validates the key is importable and gets its
|
||||
// actual fingerprint.
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to extract fingerprint from embedded signing key: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("manifest signature verified (signer: %s)", signer)
|
||||
// Compare fingerprints - must be exact match (case-insensitive)
|
||||
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
||||
return fmt.Errorf("embedded signing key fingerprint %s %w %s",
|
||||
embeddedFP, errSignerMismatch, requiredSigner)
|
||||
}
|
||||
|
||||
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -330,7 +330,7 @@ func (mfa *CLIApp) checkManifestOperation(
|
||||
// Check signature requirement
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
err = verifyRequiredSigner(chk, requiredSigner)
|
||||
err = verifyRequiredSigner(ctx, chk, requiredSigner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+8
-10
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
// NoColor disables colored output when set. Automatically true if the
|
||||
@@ -57,15 +56,14 @@ func Run(appname, version, gitrev string) int {
|
||||
// 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,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
Fs: opts.Fs,
|
||||
appname: opts.Appname,
|
||||
version: opts.Version,
|
||||
gitrev: opts.Gitrev,
|
||||
exitCode: 0,
|
||||
Stdin: opts.Stdin,
|
||||
Stdout: opts.Stdout,
|
||||
Stderr: opts.Stderr,
|
||||
Fs: opts.Fs,
|
||||
}
|
||||
|
||||
m.run(opts.Args)
|
||||
|
||||
@@ -354,44 +354,6 @@ func TestGenerateCommand(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
// TestGenerateRefusesTwoFilesAtOnePath runs gen on arguments whose files
|
||||
// would share a path in the manifest: two directories that each hold a.txt,
|
||||
// and one directory given twice. gen must fail while it lists the files,
|
||||
// before it hashes any, naming the path, and write no manifest.
|
||||
func TestGenerateRefusesTwoFilesAtOnePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
first, second string
|
||||
}{
|
||||
{"two directories", testDir, "/other"},
|
||||
{"one directory twice", testDir, testDir},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
require.NoError(t, fs.MkdirAll("/other", 0o755))
|
||||
writeTestFile(t, fs, "/testdir/a.txt", "first")
|
||||
writeTestFile(t, fs, "/other/a.txt", "second")
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdGenerate, "-q", "-o", testOutput, tc.first, tc.second,
|
||||
}, fs)
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts),
|
||||
`generate: failed to enumerate paths: duplicate path "a.txt": `+
|
||||
tc.first+"/a.txt and "+tc.second+"/a.txt")
|
||||
|
||||
exists, err := afero.Exists(fs, testOutput)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSeededManifestBytes pins the exact bytes `gen --seed` writes
|
||||
// for a fixed tree, so that a Go or dependency update that changes what
|
||||
// mfer writes fails here. testdata/seeded.mf was written by an mfer built
|
||||
@@ -651,33 +613,6 @@ func runCheckAfterRewrite(t *testing.T, rewritten, msg string) {
|
||||
assert.Equal(t, 1, exitCode, msg)
|
||||
}
|
||||
|
||||
// TestCheckRequireSignatureRefusesOtherSigningKey runs check
|
||||
// --require-signature on a manifest signed by another key whose embedded
|
||||
// public key block also holds the required key. check must refuse it. It
|
||||
// needs gpg and is skipped without it, as the other signing tests are.
|
||||
//
|
||||
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
||||
func TestCheckRequireSignatureRefusesOtherSigningKey(t *testing.T) {
|
||||
content := []byte("signed file")
|
||||
manifest, required := manifestSignedByAnotherKey(t,
|
||||
map[string][]byte{testFileTxt: content})
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(testDir, testFileTxt), content, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644))
|
||||
|
||||
opts := testOpts([]string{
|
||||
testApp, cmdCheck, "-q", testFlagBase, testDir,
|
||||
"--" + flagRequireSignature, required, testManifest,
|
||||
}, fs)
|
||||
assert.Equal(t, 1, runCLI(opts))
|
||||
assert.Contains(t, testStderr(t, opts),
|
||||
"failed to load manifest: signature verification failed: "+
|
||||
"embedded public key block must hold exactly one key, found 2")
|
||||
}
|
||||
|
||||
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+13
-39
@@ -9,14 +9,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
urfcli "github.com/urfave/cli/v3"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
|
||||
@@ -88,7 +86,8 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("invalid fingerprint length", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner(unsignedChecker(t), "12345678")
|
||||
err := verifyRequiredSigner(context.Background(),
|
||||
unsignedChecker(t), "12345678")
|
||||
require.ErrorIs(t, err, errInvalidFingerprint)
|
||||
assert.EqualError(t, err,
|
||||
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
||||
@@ -97,7 +96,8 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
t.Run("manifest not signed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := verifyRequiredSigner(unsignedChecker(t), msgFpA)
|
||||
err := verifyRequiredSigner(context.Background(),
|
||||
unsignedChecker(t), msgFpA)
|
||||
require.ErrorIs(t, err, errManifestNotSigned)
|
||||
assert.EqualError(t, err,
|
||||
"manifest is not signed, but signature from "+msgFpA+" is required")
|
||||
@@ -105,21 +105,23 @@ func TestVerifyRequiredSignerMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
||||
// 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.
|
||||
// manifest. The embedded 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
|
||||
func TestSignerMismatchMessage(t *testing.T) {
|
||||
chk := signedChecker(t,
|
||||
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
||||
|
||||
err := verifyRequiredSigner(chk, msgFpB)
|
||||
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = verifyRequiredSigner(context.Background(), chk, msgFpB)
|
||||
require.ErrorIs(t, err, errSignerMismatch)
|
||||
assert.EqualError(t, err,
|
||||
"embedded signing key fingerprint "+string(chk.Signer())+
|
||||
"embedded signing key fingerprint "+embeddedFP+
|
||||
" does not match required "+msgFpB)
|
||||
}
|
||||
|
||||
@@ -189,34 +191,6 @@ func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
||||
return chk
|
||||
}
|
||||
|
||||
// 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
|
||||
// block holds the required key followed by the second key, and its signer
|
||||
// field names the required key.
|
||||
func manifestSignedByAnotherKey(
|
||||
t *testing.T, files map[string][]byte,
|
||||
) ([]byte, string) {
|
||||
t.Helper()
|
||||
|
||||
required := new(mfer.MFFileOuter)
|
||||
require.NoError(t, proto.Unmarshal(
|
||||
signedManifest(t, files)[len(mfer.MAGIC):], required))
|
||||
|
||||
outer := new(mfer.MFFileOuter)
|
||||
require.NoError(t, proto.Unmarshal(
|
||||
signedManifest(t, files)[len(mfer.MAGIC):], outer))
|
||||
|
||||
outer.SigningPubKey = slices.Concat(
|
||||
required.GetSigningPubKey(), outer.GetSigningPubKey())
|
||||
outer.Signer = required.GetSigner()
|
||||
|
||||
data, err := proto.Marshal(outer)
|
||||
require.NoError(t, err)
|
||||
|
||||
return append([]byte(mfer.MAGIC), data...), string(required.GetSigner())
|
||||
}
|
||||
|
||||
func TestPathDoesNotExistMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+12
-19
@@ -361,7 +361,7 @@ func (mfa *CLIApp) fetchManifestOperation(
|
||||
firstDelay: firstRetryDelay,
|
||||
}
|
||||
|
||||
manifestData, files, err := mfa.fetchManifest(ctx, cmd, client, manifestURL)
|
||||
manifestData, files, err := fetchManifest(ctx, cmd, client, manifestURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -426,22 +426,20 @@ func (mfa *CLIApp) fetchManifestOperation(
|
||||
// that lists a file where fetch writes another or a mode outside 0777. It
|
||||
// returns the manifest as downloaded, to be saved once the files are in
|
||||
// place, and the files it lists.
|
||||
func (mfa *CLIApp) fetchManifest(
|
||||
func fetchManifest(
|
||||
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
|
||||
) ([]byte, []*mfer.MFFilePath, error) {
|
||||
log.Infof("fetching manifest from %s", manifestURL)
|
||||
|
||||
// Read the whole manifest before parsing it, so that a connection
|
||||
// lost partway through is retried rather than reported as a bad
|
||||
// manifest. Reading stops one byte past mfa.maxManifestSize, which is
|
||||
// enough to tell that the manifest is too large.
|
||||
// manifest.
|
||||
var manifestData []byte
|
||||
|
||||
err := client.get(ctx, manifestURL, func(resp *http.Response) error {
|
||||
var readErr error
|
||||
|
||||
manifestData, readErr = io.ReadAll(
|
||||
io.LimitReader(resp.Body, mfa.maxManifestSize+1))
|
||||
manifestData, readErr = io.ReadAll(resp.Body)
|
||||
|
||||
return readErr
|
||||
})
|
||||
@@ -449,11 +447,6 @@ func (mfa *CLIApp) fetchManifest(
|
||||
return nil, nil, fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(manifestData)) > mfa.maxManifestSize {
|
||||
return nil, nil, fmt.Errorf("failed to fetch manifest: %w of %d bytes",
|
||||
errManifestTooLarge, mfa.maxManifestSize)
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||
@@ -463,8 +456,7 @@ func (mfa *CLIApp) fetchManifest(
|
||||
|
||||
requiredSigner := cmd.String(flagRequireSignature)
|
||||
if requiredSigner != "" {
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
err = verifyFetchedSigner(manifestData, requiredSigner)
|
||||
err = verifyFetchedSigner(ctx, manifestData, requiredSigner)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -546,7 +538,9 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
|
||||
// exactly as check does. verifyRequiredSigner takes a Checker, which loads
|
||||
// its manifest from a file, so the manifest is handed to it as a file in
|
||||
// memory.
|
||||
func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||
func verifyFetchedSigner(
|
||||
ctx context.Context, manifestData []byte, requiredSigner string,
|
||||
) error {
|
||||
memFs := afero.NewMemMapFs()
|
||||
manifestPath := "/" + defaultManifestName
|
||||
|
||||
@@ -555,6 +549,7 @@ func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:contextcheck // mfer loads a manifest without a context
|
||||
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
||||
ManifestPath: manifestPath,
|
||||
BasePath: "/",
|
||||
@@ -564,7 +559,7 @@ func verifyFetchedSigner(manifestData []byte, requiredSigner string) error {
|
||||
return fmt.Errorf("failed to load manifest: %w", err)
|
||||
}
|
||||
|
||||
return verifyRequiredSigner(chk, requiredSigner)
|
||||
return verifyRequiredSigner(ctx, chk, requiredSigner)
|
||||
}
|
||||
|
||||
// saveManifest writes the fetched manifest into dest under the default
|
||||
@@ -915,10 +910,8 @@ func saveResponse(
|
||||
progress: progress,
|
||||
}
|
||||
|
||||
// Copy content while hashing and reporting progress. One byte past
|
||||
// the listed size is enough for finishDownload to report a size
|
||||
// mismatch.
|
||||
written, copyErr := io.Copy(pw, io.LimitReader(resp.Body, expectedSize+1))
|
||||
// Copy content while hashing and reporting progress
|
||||
written, copyErr := io.Copy(pw, resp.Body)
|
||||
|
||||
// Close file before checking errors (to flush writes)
|
||||
closeErr := out.Close()
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
urfcli "github.com/urfave/cli/v3"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sneak.berlin/go/mfer/mfer"
|
||||
)
|
||||
@@ -442,75 +441,6 @@ func TestFetchSizeMismatch(t *testing.T) {
|
||||
"temp file should be cleaned up on size mismatch")
|
||||
}
|
||||
|
||||
// zeros is an io.Reader of zero bytes without end.
|
||||
type zeros struct{}
|
||||
|
||||
func (zeros) Read(p []byte) (int, error) {
|
||||
clear(p)
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestFetchStopsReadingFilePastListedSize serves a body that never ends
|
||||
// for a file listed at 16 bytes. fetch must stop reading one byte past
|
||||
// the listed size, report the size mismatch and remove its temp file.
|
||||
//
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchStopsReadingFilePastListedSize(t *testing.T) {
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.Copy(w, zeros{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
chdirTemp(t)
|
||||
|
||||
err := downloadFile(context.Background(), testClient(),
|
||||
server.URL+"/"+testFileTxt, ".", testFileTxt,
|
||||
&mfer.MFFilePath{Path: testFileTxt, Size: 16}, nil)
|
||||
require.ErrorIs(t, err, errSizeMismatch)
|
||||
require.EqualError(t, err, "size mismatch: expected 16 bytes, got 17")
|
||||
assert.NoFileExists(t, tempPathFor(testFileTxt))
|
||||
}
|
||||
|
||||
// TestManifestDownloadStopsAtLimit serves a manifest that never ends to
|
||||
// fetch and to check, with the most they download of a manifest lowered
|
||||
// to 64 KiB. Each must stop reading at that limit, fail with an error
|
||||
// naming it and leave no temp file.
|
||||
func TestManifestDownloadStopsAtLimit(t *testing.T) {
|
||||
server := httptest.NewServer(
|
||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.Copy(w, zeros{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmpDir)
|
||||
|
||||
mfa := &CLIApp{Fs: afero.NewOsFs(), maxManifestSize: 64 << 10}
|
||||
|
||||
fetch := mfa.fetchCommand()
|
||||
fetch.Action = mfa.fetchManifestOperation
|
||||
|
||||
check := mfa.checkCommand()
|
||||
check.Action = mfa.checkManifestOperation
|
||||
|
||||
for _, cmd := range []*urfcli.Command{fetch, check} {
|
||||
// Both operations log to the process-global logger.
|
||||
err := runLocked(func() error {
|
||||
return cmd.Run(context.Background(),
|
||||
[]string{cmd.Name, server.URL + "/index.mf"})
|
||||
})
|
||||
require.ErrorIs(t, err, errManifestTooLarge, cmd.Name)
|
||||
require.ErrorContains(t, err,
|
||||
"maximum allowed size of 65536 bytes", cmd.Name)
|
||||
}
|
||||
|
||||
leftover, err := os.ReadDir(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftover)
|
||||
}
|
||||
|
||||
//nolint:paralleltest // changes the process-global working directory
|
||||
func TestFetchProgress(t *testing.T) {
|
||||
// Create source filesystem with a larger test file
|
||||
@@ -1187,10 +1117,8 @@ func TestFetchIntoDest(t *testing.T) {
|
||||
// TestFetchRequireSignature runs fetch with --require-signature. A
|
||||
// manifest that is unsigned, or signed by another key, must stop fetch
|
||||
// with check's message before it downloads or writes anything; the
|
||||
// required key lets it through. A manifest signed by another key whose
|
||||
// embedded public key block also holds the required key must stop fetch
|
||||
// too. The signed cases need gpg and are skipped without it, as the other
|
||||
// signing tests are.
|
||||
// required key lets it through. The signed cases need gpg and are skipped
|
||||
// without it, as the other signing tests are.
|
||||
//
|
||||
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
||||
func TestFetchRequireSignature(t *testing.T) {
|
||||
@@ -1205,7 +1133,9 @@ func TestFetchRequireSignature(t *testing.T) {
|
||||
t.Run("signed", func(t *testing.T) {
|
||||
manifest := signedManifest(t, files)
|
||||
|
||||
signer := string(signedChecker(t, manifest).Signer())
|
||||
signer, err := signedChecker(t, manifest).
|
||||
ExtractEmbeddedSigningKeyFP(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assertFetchRefused(t, manifest, files,
|
||||
"embedded signing key fingerprint "+signer+" does not match required "+msgFpB,
|
||||
@@ -1223,15 +1153,6 @@ func TestFetchRequireSignature(t *testing.T) {
|
||||
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
||||
assert.Equal(t, files[testFileTxt], filesUnder(t, dest)[testFileTxt])
|
||||
})
|
||||
|
||||
t.Run("signed by another key embedded after the required one", func(t *testing.T) {
|
||||
manifest, required := manifestSignedByAnotherKey(t, files)
|
||||
|
||||
assertFetchRefused(t, manifest, files,
|
||||
"failed to parse manifest: signature verification failed: "+
|
||||
"embedded public key block must hold exactly one key, found 2",
|
||||
"--"+flagRequireSignature, required)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetchRefusesListedManifestName fetches manifests that list, at the
|
||||
@@ -1530,7 +1451,7 @@ func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) []
|
||||
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := uuid.NewV4()
|
||||
id := uuid.New()
|
||||
|
||||
inner, err := proto.Marshal(&mfer.MFFile{
|
||||
Version: mfer.MFFile_VERSION_ONE,
|
||||
|
||||
@@ -23,10 +23,6 @@ const manifestFetchTimeout = 30 * time.Second
|
||||
// its message.
|
||||
var errHTTPStatus = errors.New("HTTP")
|
||||
|
||||
// errManifestTooLarge indicates a manifest download that passed
|
||||
// CLIApp.maxManifestSize.
|
||||
var errManifestTooLarge = errors.New("manifest exceeds maximum allowed size")
|
||||
|
||||
// isHTTPURL returns true if the string starts with http:// or https://.
|
||||
func isHTTPURL(s string) bool {
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
|
||||
@@ -59,10 +59,6 @@ type CLIApp struct {
|
||||
exitCode int
|
||||
app *cli.Command
|
||||
|
||||
// maxManifestSize is the most of a manifest that fetch, and check
|
||||
// given a URL, download: mfer.MaxManifestSize, which tests lower.
|
||||
maxManifestSize int64
|
||||
|
||||
Stdin io.Reader // Standard input stream
|
||||
Stdout io.Writer // Standard output stream for normal output
|
||||
Stderr io.Writer // Standard error stream for diagnostics
|
||||
|
||||
+12
-25
@@ -38,7 +38,6 @@ var (
|
||||
errNegativeSize = errors.New("size cannot be negative")
|
||||
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
||||
errHashTooShort = errors.New("hash digest is too short")
|
||||
errDuplicatePath = errors.New("duplicate path")
|
||||
)
|
||||
|
||||
// ValidatePath checks that a file path conforms to manifest path invariants:
|
||||
@@ -116,7 +115,6 @@ type FileHashProgress struct {
|
||||
type Builder struct {
|
||||
mu sync.Mutex
|
||||
files []*MFFilePath
|
||||
paths map[string]bool // the path of each entry in files
|
||||
createdAt time.Time
|
||||
includeTimestamps bool
|
||||
signingOptions *SigningOptions
|
||||
@@ -127,7 +125,6 @@ type Builder struct {
|
||||
func NewBuilder() *Builder {
|
||||
return &Builder{
|
||||
files: make([]*MFFilePath, 0),
|
||||
paths: make(map[string]bool),
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -141,7 +138,6 @@ func (b *Builder) SetSeed(seed string) {
|
||||
}
|
||||
|
||||
// AddFile reads file content from reader, computes hashes, and adds to manifest.
|
||||
// A path already added is refused once the file is read.
|
||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
||||
// Progress updates are sent to the progress channel (if non-nil) without blocking.
|
||||
// Returns the number of bytes read.
|
||||
@@ -208,7 +204,11 @@ func (b *Builder) AddFile(
|
||||
Mode: uint32(mode.Perm()),
|
||||
}
|
||||
|
||||
return totalRead, b.addEntry(entry)
|
||||
b.mu.Lock()
|
||||
b.files = append(b.files, entry)
|
||||
b.mu.Unlock()
|
||||
|
||||
return totalRead, nil
|
||||
}
|
||||
|
||||
// sendFileHashProgress sends a progress update without blocking.
|
||||
@@ -234,9 +234,8 @@ func (b *Builder) FileCount() int {
|
||||
// AddFileWithHash adds a file entry with a pre-computed hash.
|
||||
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
|
||||
// Returns an error if path is invalid or already added, size is negative,
|
||||
// or hash is not a multihash with a digest of at least 32 bytes, as long
|
||||
// as SHA-256's.
|
||||
// Returns an error if path is invalid, size is negative, or hash is not a
|
||||
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
|
||||
func (b *Builder) AddFileWithHash(
|
||||
path RelFilePath,
|
||||
size FileSize,
|
||||
@@ -278,7 +277,11 @@ func (b *Builder) AddFileWithHash(
|
||||
Mode: uint32(mode.Perm()),
|
||||
}
|
||||
|
||||
return b.addEntry(entry)
|
||||
b.mu.Lock()
|
||||
b.files = append(b.files, entry)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp.
|
||||
@@ -346,19 +349,3 @@ func (b *Builder) Build(ctx context.Context, w io.Writer) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addEntry adds entry to the manifest unless an entry with its path is
|
||||
// already there.
|
||||
func (b *Builder) addEntry(entry *MFFilePath) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.paths[entry.GetPath()] {
|
||||
return fmt.Errorf("%w %q", errDuplicatePath, entry.GetPath())
|
||||
}
|
||||
|
||||
b.paths[entry.GetPath()] = true
|
||||
b.files = append(b.files, entry)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -125,32 +125,6 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilderRefusesPathAlreadyAdded adds a path, then adds it again with
|
||||
// AddFile and with AddFileWithHash. Each must refuse it, naming it, and
|
||||
// keep the one entry already added.
|
||||
func TestBuilderRefusesPathAlreadyAdded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
b := NewBuilder()
|
||||
require.NoError(t, b.AddFileWithHash("dir/a.txt", 4, ModTime{}, 0, hash))
|
||||
|
||||
content := []byte("data")
|
||||
_, err = b.AddFile(
|
||||
"dir/a.txt", FileSize(len(content)), ModTime{}, 0, bytes.NewReader(content), nil,
|
||||
)
|
||||
require.ErrorIs(t, err, errDuplicatePath)
|
||||
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
|
||||
|
||||
err = b.AddFileWithHash("dir/a.txt", 4, ModTime{}, 0, hash)
|
||||
require.ErrorIs(t, err, errDuplicatePath)
|
||||
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
|
||||
|
||||
assert.Equal(t, 1, b.FileCount())
|
||||
}
|
||||
|
||||
func TestBuilderBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+13
-7
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errNoSigningPubKey = errors.New("manifest has no signing public key")
|
||||
errManifestPathEmpty = errors.New("manifest path cannot be empty")
|
||||
errBasePathEmpty = errors.New("base path cannot be empty")
|
||||
)
|
||||
@@ -172,14 +173,8 @@ func (c *Checker) IsSigned() bool {
|
||||
return len(c.signature) > 0
|
||||
}
|
||||
|
||||
// Signer returns the fingerprint of the key that made the manifest's
|
||||
// signature, which loading the manifest checked, or nil if the manifest is
|
||||
// not signed.
|
||||
// Signer returns the signer fingerprint if the manifest is signed, nil otherwise.
|
||||
func (c *Checker) Signer() []byte {
|
||||
if !c.IsSigned() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.signer
|
||||
}
|
||||
|
||||
@@ -189,6 +184,17 @@ func (c *Checker) SigningPubKey() []byte {
|
||||
return c.signingPubKey
|
||||
}
|
||||
|
||||
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
||||
// temporary keyring and extracts its fingerprint. This validates the key and
|
||||
// returns its actual fingerprint from the key material itself.
|
||||
func (c *Checker) ExtractEmbeddedSigningKeyFP(ctx context.Context) (string, error) {
|
||||
if len(c.signingPubKey) == 0 {
|
||||
return "", errNoSigningPubKey
|
||||
}
|
||||
|
||||
return gpgExtractPubKeyFingerprint(ctx, c.signingPubKey)
|
||||
}
|
||||
|
||||
// Check verifies all files against the manifest.
|
||||
// Results are sent to the results channel as files are checked.
|
||||
// Progress updates are sent to the progress channel approximately once per second.
|
||||
|
||||
+1
-8
@@ -8,17 +8,10 @@ const (
|
||||
ReleaseDate = "2025-12-17"
|
||||
|
||||
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
||||
// data (256 MiB). This prevents decompression bombs from consuming excessive
|
||||
// data (256 MB). This prevents decompression bombs from consuming excessive
|
||||
// memory.
|
||||
MaxDecompressedSize int64 = 256 * 1024 * 1024
|
||||
|
||||
// MaxManifestSize is the largest manifest file mfer reads (258 MiB).
|
||||
// zstd's worst case grows data it cannot compress by 1/256, so an inner
|
||||
// message of MaxDecompressedSize compresses to at most 257 MiB; the
|
||||
// last MiB is room for the signature, the signing key and the other
|
||||
// outer fields.
|
||||
MaxManifestSize = MaxDecompressedSize + MaxDecompressedSize/256 + 1<<20
|
||||
|
||||
// zstdWindowSize is the zstd window zstd.SpeedBestCompression gives mfer's writer.
|
||||
zstdWindowSize = 8 << 20
|
||||
|
||||
|
||||
+6
-42
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/spf13/afero"
|
||||
@@ -24,14 +23,11 @@ var (
|
||||
errCompressedHashWrong = errors.New("compressed data hash mismatch")
|
||||
errSignatureNoPubKey = errors.New("signature present but no public key")
|
||||
errDecompressedTooLarge = errors.New("decompressed data exceeds maximum allowed size")
|
||||
errManifestTooLarge = errors.New("manifest exceeds maximum allowed size")
|
||||
errUUIDMismatch = errors.New("outer and inner UUID mismatch")
|
||||
errInvalidFileFormat = errors.New("invalid file format")
|
||||
errInvalidManifestPath = errors.New("manifest contains invalid path")
|
||||
errDecodedTooLarge = errors.New(
|
||||
"manifest would take too much memory to decode")
|
||||
errSignerNotSigningKey = errors.New(
|
||||
"signer is not the fingerprint of the key that made the signature")
|
||||
)
|
||||
|
||||
// validateUUID checks that the byte slice is the 16 bytes of a binary UUID.
|
||||
@@ -64,10 +60,8 @@ func (m *manifest) validateOuterHeader() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyOuterIntegrity checks the hash of the compressed payload and, if a
|
||||
// signature is present, verifies it against the embedded public key, which
|
||||
// must be one key, and checks that the signer field is that key's
|
||||
// fingerprint.
|
||||
// verifyOuterIntegrity checks the hash of the compressed payload and,
|
||||
// if a signature is present, verifies it against the embedded public key.
|
||||
func (m *manifest) verifyOuterIntegrity() error {
|
||||
h := sha256.New()
|
||||
|
||||
@@ -97,7 +91,7 @@ func (m *manifest) verifyOuterIntegrity() error {
|
||||
}
|
||||
|
||||
// Loading a manifest takes no context; gpgTimeout still bounds gpg.
|
||||
signingKey, err := gpgVerify(
|
||||
err = gpgVerify(
|
||||
context.Background(),
|
||||
[]byte(sigString),
|
||||
m.pbOuter.GetSignature(),
|
||||
@@ -107,11 +101,6 @@ func (m *manifest) verifyOuterIntegrity() error {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
if !strings.EqualFold(string(m.pbOuter.GetSigner()), signingKey) {
|
||||
return fmt.Errorf("%w: signer %q, signing key %s",
|
||||
errSignerNotSigningKey, m.pbOuter.GetSigner(), signingKey)
|
||||
}
|
||||
|
||||
log.Infof("signature verified successfully")
|
||||
|
||||
return nil
|
||||
@@ -294,21 +283,12 @@ func (m *manifest) deserializeInner() error {
|
||||
// extract path tomorrow — acts on a traversal or absolute path from an
|
||||
// untrusted .mf. Reject loudly on the first offender rather than
|
||||
// dropping entries, which would let a hostile manifest hide files from a
|
||||
// check. A path listed twice is refused too: check would check the one
|
||||
// file against both entries.
|
||||
seen := make(map[string]bool, len(m.pbInner.GetFiles()))
|
||||
|
||||
// check.
|
||||
for _, f := range m.pbInner.GetFiles() {
|
||||
err = ValidatePath(f.GetPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errInvalidManifestPath, err)
|
||||
}
|
||||
|
||||
if seen[f.GetPath()] {
|
||||
return fmt.Errorf("%w %q", errDuplicatePath, f.GetPath())
|
||||
}
|
||||
|
||||
seen[f.GetPath()] = true
|
||||
}
|
||||
|
||||
log.Infof("loaded manifest with %d files", len(m.pbInner.GetFiles()))
|
||||
@@ -328,14 +308,13 @@ func validateMagic(dat []byte) bool {
|
||||
return bytes.Equal(got, expected)
|
||||
}
|
||||
|
||||
// NewManifestFromReader reads a manifest from an io.Reader. It refuses a
|
||||
// manifest larger than MaxManifestSize, reading at most one byte past it.
|
||||
// NewManifestFromReader reads a manifest from an io.Reader.
|
||||
//
|
||||
//nolint:revive // unexported-return: exporting manifest is owner question 13
|
||||
func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||
m := &manifest{}
|
||||
|
||||
dat, err := readAtMost(input, MaxManifestSize)
|
||||
dat, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -367,21 +346,6 @@ func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// readAtMost reads all of input, or refuses it with errManifestTooLarge
|
||||
// once it passes maxSize bytes, after reading one byte past maxSize.
|
||||
func readAtMost(input io.Reader, maxSize int64) ([]byte, error) {
|
||||
dat, err := io.ReadAll(io.LimitReader(input, maxSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(len(dat)) > maxSize {
|
||||
return nil, fmt.Errorf("%w of %d bytes", errManifestTooLarge, maxSize)
|
||||
}
|
||||
|
||||
return dat, nil
|
||||
}
|
||||
|
||||
// ManifestFromFileOptions configures NewManifestFromFile.
|
||||
type ManifestFromFileOptions struct {
|
||||
// Path is the manifest file to read (required).
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"uuid"
|
||||
@@ -91,7 +92,7 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := uuid.NewV4()
|
||||
id := uuid.New()
|
||||
data := wrapInner(t, id, craftInnerBytes(id, tt.path))
|
||||
|
||||
_, err := NewManifestFromReader(bytes.NewReader(data))
|
||||
@@ -117,61 +118,12 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest that lists a path twice is refused as it is loaded, naming the
|
||||
// path. Paths are compared byte for byte: two that differ only in letter case
|
||||
// load, and fetch refuses those itself. Each entry has a hash, as entries mfer
|
||||
// writes do; entries of a path alone would take too much memory to decode.
|
||||
func TestDeserializeRefusesPathListedTwice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
refused bool
|
||||
}{
|
||||
{"same path twice", []string{"dir/a.txt", "other.txt", "dir/a.txt"}, true},
|
||||
{"paths differing in letter case", []string{"dir/b.txt", "dir/B.txt"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := uuid.NewV4()
|
||||
inner := &MFFile{Version: MFFile_VERSION_ONE, Uuid: id[:]}
|
||||
|
||||
for _, p := range tt.paths {
|
||||
inner.Files = append(inner.Files, &MFFilePath{
|
||||
Path: p,
|
||||
Hashes: []*MFFileChecksum{{MultiHash: hash}},
|
||||
})
|
||||
}
|
||||
|
||||
innerData, err := proto.Marshal(inner)
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, innerData)))
|
||||
if tt.refused {
|
||||
require.ErrorIs(t, err, errDuplicatePath)
|
||||
require.EqualError(t, err, `duplicate path "dir/a.txt"`)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, m.Files(), len(tt.paths))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Entries of a path, an empty hash, an empty MIME type and empty modification
|
||||
// and change times are counted at 432 bytes each (176 + 112 + 16 + 64 + 64)
|
||||
// and take 16 bytes plus the path to encode. A 37-character path makes that
|
||||
// 53 bytes, about 8.2 times: refused, and leaving any one of the five
|
||||
// uncounted, even the MIME type, brings it under 8. A 39-character path makes
|
||||
// it 55 bytes, about 7.9 times: loaded. Each entry's path is its number,
|
||||
// padded with zeros to that length, since a manifest lists a path only once.
|
||||
// it 55 bytes, about 7.9 times: loaded.
|
||||
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -187,22 +139,22 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
|
||||
t.Run(strconv.Itoa(tt.pathLen), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id := uuid.NewV4()
|
||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||
entry = protowire.AppendString(entry, strings.Repeat("a", tt.pathLen))
|
||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
|
||||
id := uuid.New()
|
||||
inner := protowire.AppendTag(nil, 102, protowire.BytesType) // MFFile.uuid
|
||||
inner = protowire.AppendBytes(inner, id[:])
|
||||
|
||||
for i := range 1000 {
|
||||
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
|
||||
entry = protowire.AppendString(entry, fmt.Sprintf("%0*d", tt.pathLen, i))
|
||||
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
|
||||
entry = protowire.AppendBytes(entry, nil)
|
||||
|
||||
for range 1000 {
|
||||
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
|
||||
inner = protowire.AppendBytes(inner, entry)
|
||||
}
|
||||
@@ -229,7 +181,7 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
|
||||
entry = protowire.AppendString(entry, "a")
|
||||
entry = append(entry, unknown...)
|
||||
|
||||
id := uuid.NewV4()
|
||||
id := uuid.New()
|
||||
inner := protowire.AppendTag(nil, 101, protowire.BytesType) // MFFile.files
|
||||
inner = protowire.AppendBytes(inner, entry)
|
||||
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
//nolint:testpackage // white-box tests exercise unexported internals
|
||||
package mfer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReadAtMost gives readAtMost exactly its maximum, which it must
|
||||
// return whole, and twice its maximum, which it must refuse after reading
|
||||
// one byte past the maximum, and no more. NewManifestFromReader reads
|
||||
// through it with MaxManifestSize; the test uses 64 KiB, since reading
|
||||
// MaxManifestSize under the race detector takes gigabytes of memory.
|
||||
func TestReadAtMost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxSize = 64 << 10
|
||||
|
||||
dat, err := readAtMost(bytes.NewReader(make([]byte, maxSize)), maxSize)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, dat, maxSize)
|
||||
|
||||
input := bytes.NewReader(make([]byte, 2*maxSize))
|
||||
|
||||
_, err = readAtMost(input, maxSize)
|
||||
require.ErrorIs(t, err, errManifestTooLarge)
|
||||
require.EqualError(t, err,
|
||||
"manifest exceeds maximum allowed size of 65536 bytes")
|
||||
assert.Equal(t, maxSize-1, input.Len(), "bytes left unread")
|
||||
}
|
||||
+83
-122
@@ -41,26 +41,16 @@ const (
|
||||
// fields in a gpg fingerprint record (the fingerprint is field 10).
|
||||
gpgFingerprintMinFields = 10
|
||||
|
||||
// gpgStatusPrefix starts each status line gpg writes to the file
|
||||
// descriptor named by --status-fd.
|
||||
gpgStatusPrefix = "[GNUPG:]"
|
||||
|
||||
// gpg option names used from more than one call site.
|
||||
gpgOptArmor = "--armor"
|
||||
gpgOptHomedir = "--homedir"
|
||||
gpgOptStatusFD = "--status-fd"
|
||||
gpgOptVerify = "--verify"
|
||||
gpgOptArmor = "--armor"
|
||||
gpgOptHomedir = "--homedir"
|
||||
gpgOptVerify = "--verify"
|
||||
)
|
||||
|
||||
var (
|
||||
errGPGKeyNotFound = errors.New("gpg key not found")
|
||||
errFingerprintNotFound = errors.New("fingerprint not found for key")
|
||||
errSigningKeyCount = errors.New(
|
||||
"embedded public key block must hold exactly one key")
|
||||
errNotOneGoodSignature = errors.New(
|
||||
"gpg did not report exactly one good signature")
|
||||
errSigningKeyNotReported = errors.New(
|
||||
"gpg did not report the key that made the signature")
|
||||
errImportedFPRNotFound = errors.New("fingerprint not found in imported key")
|
||||
)
|
||||
|
||||
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
|
||||
@@ -146,67 +136,19 @@ func parseFingerprint(colonOutput string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseStatusLine returns the arguments of the status line for keyword in
|
||||
// gpg --status-fd output, or ok=false unless there is exactly one such line
|
||||
// and it has arguments.
|
||||
func parseStatusLine(statusOutput, keyword string) ([]string, bool) {
|
||||
var found [][]string
|
||||
|
||||
for line := range strings.SplitSeq(statusOutput, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 2 && fields[0] == gpgStatusPrefix && fields[1] == keyword {
|
||||
found = append(found, fields[2:])
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return found[0], true
|
||||
}
|
||||
|
||||
// gpgSign creates an armored detached signature of data with the key gpg
|
||||
// picks for keyID, and returns it with the fingerprint of the key that made
|
||||
// it, which is a subkey's when gpg signed with a subkey.
|
||||
func gpgSign(
|
||||
ctx context.Context, data []byte, keyID GPGKeyID,
|
||||
) ([]byte, string, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-sign-*")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
sigFile := filepath.Join(tmpDir, "signature.asc")
|
||||
|
||||
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
|
||||
// lines to stdout; its messages go to stderr.
|
||||
// gpgSign creates a detached signature of the data using the specified key.
|
||||
// Returns the armored detached signature.
|
||||
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
||||
"--detach-sign",
|
||||
gpgOptArmor,
|
||||
"--output", sigFile,
|
||||
gpgOptStatusFD, "1",
|
||||
"--local-user", string(keyID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
||||
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// The last argument of SIG_CREATED is the fingerprint of the key that
|
||||
// made the signature.
|
||||
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("%w: %s", errSigningKeyNotReported, stderr.String())
|
||||
}
|
||||
|
||||
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read signature: %w", err)
|
||||
}
|
||||
|
||||
return sig, created[len(created)-1], nil
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||
@@ -245,44 +187,12 @@ func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
||||
return []byte(fpr), nil
|
||||
}
|
||||
|
||||
// gpgImportOneKey imports the public key block in pubKeyFile into the
|
||||
// keyring in gpgHome. The block must hold exactly one primary key.
|
||||
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
|
||||
// --status-fd 1 sends gpg's status lines to stdout, which importing
|
||||
// otherwise leaves empty; its messages go to stderr.
|
||||
importStdout, importStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
|
||||
pubKeyFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// The first argument of IMPORT_RES counts the primary keys gpg read
|
||||
// from the block, those it then skipped (one with no user ID, for
|
||||
// example) included.
|
||||
result, ok := parseStatusLine(importStdout.String(), "IMPORT_RES")
|
||||
if !ok {
|
||||
return fmt.Errorf("%w, gpg reported no count", errSigningKeyCount)
|
||||
}
|
||||
|
||||
if result[0] != "1" {
|
||||
return fmt.Errorf("%w, found %s", errSigningKeyCount, result[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gpgVerify verifies a detached signature against data using the provided
|
||||
// public key, imported into a temporary keyring, and returns the
|
||||
// fingerprint of the primary key that made the signature. The public key
|
||||
// must hold exactly one primary key, so that a good signature can come
|
||||
// from no other key.
|
||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, error) {
|
||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||
// and extracts its fingerprint. This verifies the key is valid and returns
|
||||
// the actual fingerprint from the key material.
|
||||
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
||||
// Create temporary directory for GPG operations
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
@@ -303,12 +213,67 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, err
|
||||
return "", fmt.Errorf("failed to write public key: %w", err)
|
||||
}
|
||||
|
||||
// Import the public key into the temporary keyring
|
||||
_, importStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// List keys to get fingerprint
|
||||
listStdout, listStderr, err := runGPG(ctx, nil,
|
||||
"--homedir", tmpDir,
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to list keys: %w: %s", err, listStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
fpr, ok := parseFingerprint(listStdout.String())
|
||||
if !ok {
|
||||
return "", errImportedFPRNotFound
|
||||
}
|
||||
|
||||
return fpr, nil
|
||||
}
|
||||
|
||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||
// It creates a temporary keyring to import the public key for verification.
|
||||
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
|
||||
// Create temporary directory for GPG operations
|
||||
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Set restrictive permissions
|
||||
err = os.Chmod(tmpDir, privateDirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set temp dir permissions: %w", err)
|
||||
}
|
||||
|
||||
// Write public key to temp file
|
||||
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
||||
|
||||
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write public key: %w", err)
|
||||
}
|
||||
|
||||
// Write signature to temp file
|
||||
sigFile := filepath.Join(tmpDir, "signature.asc")
|
||||
|
||||
err = os.WriteFile(sigFile, signature, privateFilePerms)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write signature: %w", err)
|
||||
return fmt.Errorf("failed to write signature: %w", err)
|
||||
}
|
||||
|
||||
// Write data to temp file
|
||||
@@ -316,33 +281,29 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, err
|
||||
|
||||
err = os.WriteFile(dataFile, data, privateFilePerms)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write data: %w", err)
|
||||
return fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
err = gpgImportOneKey(ctx, tmpDir, pubKeyFile)
|
||||
// Import the public key into the temporary keyring
|
||||
_, importStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return fmt.Errorf(
|
||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
|
||||
// detached signature otherwise leaves empty; its messages go to stderr.
|
||||
verifyStdout, verifyStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
|
||||
// Verify the signature
|
||||
_, verifyStderr, err := runGPG(ctx, nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
||||
sigFile, dataFile)...,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
return fmt.Errorf(
|
||||
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// gpg writes a VALIDSIG line for each good signature. Its first
|
||||
// argument is the fingerprint of the key that made the signature,
|
||||
// which may be a subkey; its last is that of the primary key.
|
||||
valid, ok := parseStatusLine(verifyStdout.String(), "VALIDSIG")
|
||||
if !ok {
|
||||
return "", errNotOneGoodSignature
|
||||
}
|
||||
|
||||
return valid[len(valid)-1], nil
|
||||
return nil
|
||||
}
|
||||
|
||||
+36
-225
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
||||
@@ -37,18 +35,39 @@ func testGPGEnv(t *testing.T) (GPGKeyID, string) {
|
||||
// Create temporary GPG home directory (0700 by default)
|
||||
gpgHome := t.TempDir()
|
||||
|
||||
genTestKey(t, gpgHome, testKeyParams)
|
||||
// Generate a test key with no passphrase
|
||||
keyParams := `%no-protection
|
||||
Key-Type: RSA
|
||||
Key-Length: 2048
|
||||
Name-Real: MFER Test Key
|
||||
Name-Email: test@mfer.test
|
||||
Expire-Date: 0
|
||||
%commit
|
||||
`
|
||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Get the key fingerprint
|
||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
||||
cmd := exec.CommandContext(ctx, "gpg",
|
||||
"--batch", "--gen-key", paramsFile)
|
||||
|
||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
||||
}
|
||||
|
||||
// Get the key fingerprint
|
||||
cmd = exec.CommandContext(ctx, "gpg",
|
||||
"--list-keys", "--with-colons", "test@mfer.test")
|
||||
|
||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||
|
||||
output, err := cmd.Output()
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list test key: %v", err)
|
||||
}
|
||||
@@ -73,79 +92,13 @@ func testGPGEnv(t *testing.T) (GPGKeyID, string) {
|
||||
return GPGKeyID(keyID), gpgHome
|
||||
}
|
||||
|
||||
// testKeyParams are the gpg key generation parameters of an RSA key that
|
||||
// signs and does not expire.
|
||||
const testKeyParams = "Key-Type: RSA\nKey-Length: 2048\nExpire-Date: 0\n"
|
||||
|
||||
// genTestKey generates a key with no passphrase for
|
||||
// "MFER Test Key <test@mfer.test>" from the gpg key generation parameters
|
||||
// keyParams in gpgHome, which may already hold one.
|
||||
func genTestKey(t *testing.T, gpgHome, keyParams string) {
|
||||
t.Helper()
|
||||
|
||||
params := "%no-protection\n" + keyParams +
|
||||
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n%commit\n"
|
||||
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
||||
cmd := exec.CommandContext(ctx, "gpg",
|
||||
"--batch", "--gen-key", paramsFile)
|
||||
|
||||
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
// signedTestManifest returns a manifest of one file signed with keyID.
|
||||
func signedTestManifest(t *testing.T, keyID GPGKeyID) []byte {
|
||||
t.Helper()
|
||||
|
||||
b := NewBuilder()
|
||||
b.SetSigningOptions(&SigningOptions{KeyID: keyID})
|
||||
|
||||
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 TestGPGSign(t *testing.T) {
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign")
|
||||
sig, signingKey, err := gpgSign(context.Background(), data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(keyID), signingKey)
|
||||
assert.NotEmpty(t, sig)
|
||||
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
||||
assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----")
|
||||
@@ -210,18 +163,15 @@ func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
||||
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
||||
}
|
||||
|
||||
// TestGPGSignInvalidKey signs with a key that has no secret key in the
|
||||
// keyring. The error must hold gpg's messages and none of its status lines.
|
||||
func TestGPGSignInvalidKey(t *testing.T) {
|
||||
// Set up test environment (we need GNUPGHOME set)
|
||||
_, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data")
|
||||
_, _, err := gpgSign(context.Background(), data,
|
||||
_, err := gpgSign(context.Background(), data,
|
||||
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), gpgStatusPrefix)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuilderWithSigning(t *testing.T) {
|
||||
@@ -309,16 +259,15 @@ func TestGPGVerify(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign and verify")
|
||||
sig, _, err := gpgSign(context.Background(), data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the signature; it names the key that made it
|
||||
signingKey, err := gpgVerify(context.Background(), data, sig, pubKey)
|
||||
// Verify the signature
|
||||
err = gpgVerify(context.Background(), data, sig, pubKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(keyID), signingKey)
|
||||
}
|
||||
|
||||
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||
@@ -326,7 +275,7 @@ func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data to sign")
|
||||
sig, _, err := gpgSign(context.Background(), data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
||||
@@ -334,7 +283,7 @@ func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||
|
||||
// Try to verify with different data - should fail
|
||||
wrongData := []byte("different data")
|
||||
_, err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
||||
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -343,12 +292,12 @@ func TestGPGVerifyBadPublicKey(t *testing.T) {
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
data := []byte("test data")
|
||||
sig, _, err := gpgSign(context.Background(), data, keyID)
|
||||
sig, err := gpgSign(context.Background(), data, keyID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to verify with invalid public key - should fail
|
||||
badPubKey := []byte("not a valid public key")
|
||||
_, err = gpgVerify(context.Background(), data, sig, badPubKey)
|
||||
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -419,144 +368,6 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// TestManifestRefusesSecondEmbeddedKey loads a manifest whose embedded
|
||||
// public key block holds another key before the key that signed it.
|
||||
// Loading must refuse it, although the signature is good and the signer
|
||||
// field names the key that made it.
|
||||
func TestManifestRefusesSecondEmbeddedKey(t *testing.T) {
|
||||
otherKey, otherHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", otherHome)
|
||||
|
||||
otherPubKey, err := gpgExportPublicKey(context.Background(), otherKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
|
||||
func(outer *MFFileOuter) {
|
||||
outer.SigningPubKey = slices.Concat(otherPubKey, outer.GetSigningPubKey())
|
||||
})
|
||||
|
||||
_, err = NewManifestFromReader(bytes.NewReader(manifest))
|
||||
require.ErrorIs(t, err, errSigningKeyCount)
|
||||
}
|
||||
|
||||
// TestManifestRefusesSecondEmbeddedKeyWithoutUserID loads a manifest whose
|
||||
// embedded public key block holds, before the key that signed it, another
|
||||
// key with its user ID removed, which gpg skips on import. Loading must
|
||||
// refuse it: the block holds two keys.
|
||||
func TestManifestRefusesSecondEmbeddedKeyWithoutUserID(t *testing.T) {
|
||||
otherKey, otherHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", otherHome)
|
||||
|
||||
// Keeping only the user IDs that match "nobody" exports none.
|
||||
otherPubKey, _, err := runGPG(context.Background(), nil,
|
||||
gpgArgs([]string{
|
||||
"--export", gpgOptArmor, "--export-filter", "keep-uid=uid = nobody",
|
||||
}, string(otherKey))...)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
|
||||
func(outer *MFFileOuter) {
|
||||
outer.SigningPubKey = slices.Concat(
|
||||
otherPubKey.Bytes(), outer.GetSigningPubKey())
|
||||
})
|
||||
|
||||
_, err = NewManifestFromReader(bytes.NewReader(manifest))
|
||||
require.ErrorIs(t, err, errSigningKeyCount)
|
||||
}
|
||||
|
||||
// TestManifestRefusesTwoSignatures loads a manifest whose signature field
|
||||
// holds its good signature twice. Loading must refuse it.
|
||||
func TestManifestRefusesTwoSignatures(t *testing.T) {
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
|
||||
func(outer *MFFileOuter) {
|
||||
outer.Signature = slices.Concat(
|
||||
outer.GetSignature(), outer.GetSignature())
|
||||
})
|
||||
|
||||
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
||||
require.ErrorIs(t, err, errNotOneGoodSignature)
|
||||
}
|
||||
|
||||
// TestManifestSignedWithSubkey signs with a key whose primary key can only
|
||||
// certify, so gpg signs with its signing subkey. The manifest must load,
|
||||
// with the primary key's fingerprint as signer.
|
||||
func TestManifestSignedWithSubkey(t *testing.T) {
|
||||
gpgHome := t.TempDir()
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
genTestKey(t, gpgHome, "Key-Type: RSA\nKey-Length: 2048\nKey-Usage: cert\n"+
|
||||
"Subkey-Type: RSA\nSubkey-Length: 2048\nSubkey-Usage: sign\n"+
|
||||
"Expire-Date: 0\n")
|
||||
|
||||
primary, err := gpgGetKeyFingerprint(context.Background(), "test@mfer.test")
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(
|
||||
signedTestManifest(t, GPGKeyID("test@mfer.test"))))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, primary, m.pbOuter.GetSigner())
|
||||
}
|
||||
|
||||
// TestManifestRefusesSignerOtherThanSigningKey loads a manifest whose
|
||||
// signer field names a key other than the one that made the signature.
|
||||
func TestManifestRefusesSignerOtherThanSigningKey(t *testing.T) {
|
||||
keyID, gpgHome := testGPGEnv(t)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
manifest := rewriteOuter(t, signedTestManifest(t, keyID),
|
||||
func(outer *MFFileOuter) {
|
||||
outer.Signer = []byte(strings.Repeat("A", len(keyID)))
|
||||
})
|
||||
|
||||
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
||||
require.ErrorIs(t, err, errSignerNotSigningKey)
|
||||
}
|
||||
|
||||
// TestBuilderSigningKeyIDMatchingTwoKeys signs with a key ID that two keys
|
||||
// in the keyring match. The manifest must embed and name only the key that
|
||||
// signed it, or loading refuses it.
|
||||
func TestBuilderSigningKeyIDMatchingTwoKeys(t *testing.T) {
|
||||
_, gpgHome := testGPGEnv(t)
|
||||
genTestKey(t, gpgHome, testKeyParams)
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
manifest := signedTestManifest(t, GPGKeyID("test@mfer.test"))
|
||||
|
||||
_, err := NewManifestFromReader(bytes.NewReader(manifest))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestBuilderSigningUserIDWithExpiredFirstKey signs with a user ID whose
|
||||
// first key in the keyring has expired. gpg signs with the other key for
|
||||
// that user ID, and the manifest must name and embed that key.
|
||||
func TestBuilderSigningUserIDWithExpiredFirstKey(t *testing.T) {
|
||||
gpgHome := t.TempDir()
|
||||
t.Setenv("GNUPGHOME", gpgHome)
|
||||
|
||||
// Made in 2020 and valid for one day.
|
||||
genTestKey(t, gpgHome, "Key-Type: RSA\nKey-Length: 2048\n"+
|
||||
"Creation-Date: 20200101T000000\nExpire-Date: 1d\n")
|
||||
|
||||
expired, err := gpgGetKeyFingerprint(context.Background(), "test@mfer.test")
|
||||
require.NoError(t, err)
|
||||
|
||||
genTestKey(t, gpgHome, testKeyParams)
|
||||
|
||||
m, err := NewManifestFromReader(bytes.NewReader(
|
||||
signedTestManifest(t, GPGKeyID("test@mfer.test"))))
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, expired, m.pbOuter.GetSigner())
|
||||
}
|
||||
|
||||
func TestBuilderWithoutSigning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -610,7 +421,7 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, _, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
||||
}
|
||||
@@ -635,7 +446,7 @@ func TestGPGCancelWhenChildHoldsOutput(t *testing.T) {
|
||||
signErr := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
_, _, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
||||
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
||||
signErr <- err
|
||||
}()
|
||||
|
||||
|
||||
+1
-16
@@ -2,7 +2,6 @@ package mfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -80,8 +79,7 @@ type FileEntry struct {
|
||||
type Scanner struct {
|
||||
mu sync.RWMutex
|
||||
files []*FileEntry
|
||||
paths map[RelFilePath]AbsFilePath // the file at each path in files
|
||||
totalBytes FileSize // cached sum of all file sizes
|
||||
totalBytes FileSize // cached sum of all file sizes
|
||||
options *ScannerOptions
|
||||
fs afero.Fs
|
||||
excluded []fs.FileInfo // the files named in ExcludePaths that exist
|
||||
@@ -105,7 +103,6 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
|
||||
|
||||
s := &Scanner{
|
||||
files: make([]*FileEntry, 0),
|
||||
paths: make(map[RelFilePath]AbsFilePath),
|
||||
options: opts,
|
||||
fs: fs,
|
||||
}
|
||||
@@ -481,18 +478,6 @@ func (s *Scanner) enumerateFileWithInfo(
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// Each path is relative to the input path it was found under, so files
|
||||
// under two input paths can share one.
|
||||
first, ok := s.paths[entry.Path]
|
||||
if ok {
|
||||
s.mu.Unlock()
|
||||
|
||||
return fmt.Errorf("%w %q: %s and %s",
|
||||
errDuplicatePath, entry.Path, first, entry.AbsPath)
|
||||
}
|
||||
|
||||
s.paths[entry.Path] = entry.AbsPath
|
||||
s.files = append(s.files, entry)
|
||||
s.totalBytes += entry.Size
|
||||
filesFound := FileCount(len(s.files))
|
||||
|
||||
+5
-9
@@ -88,7 +88,7 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
||||
if len(m.fixedUUID) == uuidLength {
|
||||
copy(manifestUUID[:], m.fixedUUID)
|
||||
} else {
|
||||
manifestUUID = uuid.NewV4()
|
||||
manifestUUID = uuid.New()
|
||||
}
|
||||
|
||||
m.pbInner.Uuid = manifestUUID[:]
|
||||
@@ -143,32 +143,28 @@ func (m *manifest) generateOuter(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// signOuter signs the outer message with the configured GPG key and
|
||||
// embeds the signature, signer fingerprint, and public key. The signer
|
||||
// and public key are those of the key gpg reports it signed with, so that
|
||||
// a key ID matching more than one key cannot name or embed another key.
|
||||
// embeds the signature, signer fingerprint, and public key.
|
||||
func (m *manifest) signOuter(ctx context.Context) error {
|
||||
sigString, err := m.signatureString()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate signature string: %w", err)
|
||||
}
|
||||
|
||||
sig, signingKey, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
||||
sig, err := gpgSign(ctx, []byte(sigString), m.signingOptions.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign manifest: %w", err)
|
||||
}
|
||||
|
||||
m.pbOuter.Signature = sig
|
||||
|
||||
// Listing the signing key, a subkey's included, puts its primary key's
|
||||
// fingerprint first.
|
||||
fingerprint, err := gpgGetKeyFingerprint(ctx, GPGKeyID(signingKey))
|
||||
fingerprint, err := gpgGetKeyFingerprint(ctx, m.signingOptions.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
m.pbOuter.Signer = fingerprint
|
||||
|
||||
pubKey, err := gpgExportPublicKey(ctx, GPGKeyID(fingerprint))
|
||||
pubKey, err := gpgExportPublicKey(ctx, m.signingOptions.KeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to export public key: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user