check / check (push) Waiting to run
check and fetch --require-signature compared the required fingerprint with the first key in the manifest's embedded public key block, while gpg accepted a good signature by any key in that block. Loading a signed manifest now refuses one whose embedded block holds more than one primary key, or whose signer field is not the fingerprint gpg reports for the signing key on its VALIDSIG status line. --require-signature compares with the signer field, which loading has checked. Signing now signs with and exports the key by its fingerprint, so a key ID matching two keys still writes a manifest that loads. docs/FORMAT.md states what a verifier checks. Model: opus-5-5
178 lines
4.4 KiB
Go
178 lines
4.4 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
"uuid"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// MAGIC is the file format magic bytes prefix (rot13 of "MANIFEST").
|
|
const MAGIC string = "ZNAVSRFG"
|
|
|
|
var (
|
|
// errInnerNotSet is returned by generate when the inner manifest is
|
|
// missing.
|
|
errInnerNotSet = errors.New("internal error: pbInner not set")
|
|
// errInternal is returned by generateOuter for the same condition.
|
|
// The two messages differ, and both are load-bearing for callers that
|
|
// match on text, so they are kept distinct.
|
|
errInternal = errors.New("internal error")
|
|
)
|
|
|
|
// nanosecondsInt32 converts t's nanosecond component to int32.
|
|
// time.Time.Nanosecond is documented to return a value in [0, 999999999],
|
|
// so the conversion cannot overflow. This sits directly in the manifest
|
|
// content path: silently substituting a default would zero every entry's
|
|
// mtime nanos and change the serialized bytes and their hash, so an
|
|
// out-of-contract value is a programming error and panics rather than
|
|
// being papered over.
|
|
func nanosecondsInt32(t time.Time) int32 {
|
|
n := t.Nanosecond()
|
|
if n < 0 || n > math.MaxInt32 {
|
|
panic(fmt.Sprintf(
|
|
"mfer: time.Time.Nanosecond out of contract: %d", n))
|
|
}
|
|
|
|
return int32(n)
|
|
}
|
|
|
|
func newTimestampFromTime(t time.Time) *Timestamp {
|
|
return &Timestamp{
|
|
Seconds: t.Unix(),
|
|
Nanos: nanosecondsInt32(t),
|
|
}
|
|
}
|
|
|
|
func (m *manifest) generate(ctx context.Context) error {
|
|
if m.pbInner == nil {
|
|
return errInnerNotSet
|
|
}
|
|
|
|
if m.pbOuter == nil {
|
|
e := m.generateOuter(ctx)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
|
|
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: marshal outer: %w", err)
|
|
}
|
|
|
|
m.output = bytes.NewBufferString(MAGIC)
|
|
|
|
_, err = m.output.Write(dat)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: write output: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *manifest) generateOuter(ctx context.Context) error {
|
|
if m.pbInner == nil {
|
|
return errInternal
|
|
}
|
|
|
|
// Use fixed UUID if provided, otherwise generate a new one
|
|
var manifestUUID uuid.UUID
|
|
if len(m.fixedUUID) == uuidLength {
|
|
copy(manifestUUID[:], m.fixedUUID)
|
|
} else {
|
|
manifestUUID = uuid.NewV4()
|
|
}
|
|
|
|
m.pbInner.Uuid = manifestUUID[:]
|
|
|
|
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: marshal inner: %w", err)
|
|
}
|
|
|
|
// Compress the inner data
|
|
idc := new(bytes.Buffer)
|
|
|
|
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: create compressor: %w", err)
|
|
}
|
|
|
|
_, err = zw.Write(innerData)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: compress: %w", err)
|
|
}
|
|
|
|
_ = zw.Close()
|
|
|
|
compressedData := idc.Bytes()
|
|
|
|
// Hash the compressed data for integrity verification before decompression
|
|
h := sha256.New()
|
|
|
|
_, err = h.Write(compressedData)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize: hash write: %w", err)
|
|
}
|
|
|
|
sha256Hash := h.Sum(nil)
|
|
|
|
m.pbOuter = &MFFileOuter{
|
|
InnerMessage: compressedData,
|
|
Size: int64(len(innerData)),
|
|
Sha256: sha256Hash,
|
|
Uuid: manifestUUID[:],
|
|
Version: MFFileOuter_VERSION_ONE,
|
|
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
|
|
}
|
|
|
|
// Sign the manifest if signing options are provided
|
|
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
|
return m.signOuter(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// signOuter signs the outer message with the configured GPG key and
|
|
// embeds the signature, signer fingerprint, and public key. It signs with
|
|
// and exports the key by its fingerprint, so that a key ID matching more
|
|
// than one key cannot embed a key other than the one that signed.
|
|
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)
|
|
}
|
|
|
|
fingerprint, err := gpgGetKeyFingerprint(ctx, m.signingOptions.KeyID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
|
}
|
|
|
|
m.pbOuter.Signer = fingerprint
|
|
|
|
sig, err := gpgSign(ctx, []byte(sigString), GPGKeyID(fingerprint))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sign manifest: %w", err)
|
|
}
|
|
|
|
m.pbOuter.Signature = sig
|
|
|
|
pubKey, err := gpgExportPublicKey(ctx, GPGKeyID(fingerprint))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to export public key: %w", err)
|
|
}
|
|
|
|
m.pbOuter.SigningPubKey = pubKey
|
|
|
|
return nil
|
|
}
|