check / check (push) Canceled after 0s
mfer ran the gpg binary to sign, export keys and verify, so it failed wherever gpg is missing. It now uses github.com/ProtonMail/go-crypto/openpgp. --sign-key and MFER_SIGN_KEY name a file holding one version 4 OpenPGP secret key; a protected key's passphrase comes from MFER_SIGN_KEY_PASSPHRASE or a terminal prompt. gen and freshen check that the key can sign before they read any file. Verification keeps the rules of the --require-signature fix: one primary key in the embedded block, counted from its packets, exactly one signature, made by that key or a subkey, and signer equal to its fingerprint. The embedded block may hold no DSA key and no secret key, and an armored field must be one well-formed block. Model: opus-5-5
184 lines
4.2 KiB
Go
184 lines
4.2 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
"uuid"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp"
|
|
"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("inner message not set")
|
|
// errInternal is returned by generateOuter for the same condition.
|
|
errInternal = errors.New("inner message not set")
|
|
)
|
|
|
|
// 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("marshal outer message: %w", err)
|
|
}
|
|
|
|
m.output = bytes.NewBufferString(MAGIC)
|
|
|
|
_, err = m.output.Write(dat)
|
|
if err != nil {
|
|
return fmt.Errorf("write outer message: %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("marshal inner message: %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("create compressor: %w", err)
|
|
}
|
|
|
|
_, err = zw.Write(innerData)
|
|
if err != nil {
|
|
return fmt.Errorf("compress inner message: %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("hash inner message: %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 {
|
|
return m.signOuter(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// signOuter signs the outer message with the secret key in the signing
|
|
// options and embeds the signature, the key's fingerprint and its public
|
|
// key.
|
|
func (m *manifest) signOuter(ctx context.Context) error {
|
|
// Unlocking a protected key can take a while; do not start once ctx
|
|
// has ended.
|
|
err := ctx.Err()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sigString, err := m.signatureString()
|
|
if err != nil {
|
|
return fmt.Errorf("build signature string: %w", err)
|
|
}
|
|
|
|
key, err := readSigningKey(m.signingOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var sig bytes.Buffer
|
|
|
|
err = openpgp.ArmoredDetachSign(&sig, key, strings.NewReader(sigString), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("sign manifest: %w", err)
|
|
}
|
|
|
|
pubKey, err := armoredPublicKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.pbOuter.Signature = sig.Bytes()
|
|
m.pbOuter.Signer = []byte(fingerprint(key))
|
|
m.pbOuter.SigningPubKey = pubKey
|
|
|
|
return nil
|
|
}
|