From d08867ae566b9ed9f66308a572a42e88e8447836 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 12 Oct 2026 09:03:05 +0000 Subject: [PATCH] Describe the signature scheme in docs/FORMAT.md as the code builds it (closes #187) The Signature Scheme section now states what signatureString builds: the magic, the outer uuid as lower-case hex with no hyphens, and the outer sha256 as the lower-case hex of its multihash, which starts 1220, with an example string taken from a run of the code. It says signer is the upper-case hex fingerprint of the signing primary key and that a signature is stored only in the manifest. The mf.proto comments on fields 201 to 203 say the same, and mf.pb.go is regenerated. A new test pins the example string. Nothing about what is signed or how changes. Model: opus-5-5 --- docs/FORMAT.md | 57 ++++++++++++++++++++++++++++--------------- mfer/manifest_test.go | 30 +++++++++++++++++++++++ mfer/mf.pb.go | 9 ++++--- mfer/mf.proto | 14 ++++++----- mfer/mf.proto.sha256 | 2 +- 5 files changed, 82 insertions(+), 30 deletions(-) create mode 100644 mfer/manifest_test.go diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 648c250..d792527 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -36,8 +36,8 @@ The outer message contains: | `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) | | `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID | | `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message | -| `signature` | 201 | bytes (optional) | OpenPGP detached signature (ASCII-armored or binary) | -| `signer` | 202 | bytes (optional) | Fingerprint of the signing key | +| `signature` | 201 | bytes (optional) | One OpenPGP signature over the canonical string, armored or binary | +| `signer` | 202 | bytes (optional) | Fingerprint of the signing primary key, in upper-case hex | | `signingPubKey` | 203 | bytes (optional) | Full OpenPGP public key of the signing key (ASCII-armored or binary) | ### SHA-256 Hash @@ -128,34 +128,51 @@ Implementations must be able to verify SHA-256 multihashes at minimum. ## Signature Scheme -Signing is optional. When present, the signature covers a canonical string -constructed as: +Signing is optional. A signature is stored only in the outer message's +`signature` field, never in a separate file. It is one OpenPGP signature over +the canonical string below, armored or binary, and does not contain the string, +which a verifier builds again from the outer message. + +The canonical string is three parts separated by hyphens (`-`): ``` ZNAVSRFG-- ``` -Where: +- `ZNAVSRFG` is the magic, in ASCII. +- `` is the 16 bytes of the outer message's `uuid` as 32 lower-case hex + digits, with no hyphens. +- `` is the lower-case hex of the outer message's `sha256` encoded as a + SHA-256 multihash (see Hash Format). That multihash is the algorithm code + `0x12`, the digest length `0x20` and the 32 bytes of the hash, so this part is + 68 hex digits starting `1220`. -- `ZNAVSRFG` is the magic bytes string (literal ASCII) -- `` is the hex-encoded UUID from the outer message -- `` is the hex-encoded SHA-256 hash from the outer message (covering - compressed data) +The signature covers exactly the 110 ASCII characters of the string, with no +line ending. For example, the UUID `b0fb6918-6298-443b-ada2-1b162f4dc0b3` and a +`sha256` of `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` +in hex give this canonical string: -Components are separated by hyphens. The signature is an OpenPGP detached -signature over this canonical string, 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`. +``` +ZNAVSRFG-b0fb69186298443bada21b162f4dc0b3-1220e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +The signature covers `innerMessage` only through `sha256`, so a verifier checks +`sha256` against `innerMessage` (see SHA-256 Hash) as well as the signature. + +The signing key's public key goes in `signingPubKey` and the fingerprint of its +primary key, in upper-case hex, in `signer`. The reference implementation signs +only with version 4 keys, whose fingerprints are 40 hex digits, and writes both +the signature and the public key armored. 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`. It also refuses a manifest whose `signingPubKey` -holds a DSA key or subkey, or any secret key or subkey, since checking the -self-signatures of a DSA key or the numbers of a secret key can take hours when -those numbers are very large. +that key (or one of its subkeys), and `signer` is that primary key's +fingerprint, compared without regard to case. 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`. It +also refuses a manifest whose `signingPubKey` holds a DSA key or subkey, or any +secret key or subkey, since checking the self-signatures of a DSA key or the +numbers of a secret key can take hours when those numbers are very large. ## Deterministic Serialization diff --git a/mfer/manifest_test.go b/mfer/manifest_test.go new file mode 100644 index 0000000..2b58897 --- /dev/null +++ b/mfer/manifest_test.go @@ -0,0 +1,30 @@ +//nolint:testpackage // white-box tests exercise unexported internals +package mfer + +import ( + "encoding/hex" + "testing" + "uuid" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSignatureString checks the canonical string for the UUID and sha256 +// of the example in the Signature Scheme section of docs/FORMAT.md, which +// shows the same string. +func TestSignatureString(t *testing.T) { + t.Parallel() + + manifestUUID := uuid.MustParse("b0fb6918-6298-443b-ada2-1b162f4dc0b3") + sha256Hash, err := hex.DecodeString( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + require.NoError(t, err) + + m := &manifest{pbOuter: &MFFileOuter{Uuid: manifestUUID[:], Sha256: sha256Hash}} + + got, err := m.signatureString() + require.NoError(t, err) + assert.Equal(t, "ZNAVSRFG-b0fb69186298443bada21b162f4dc0b3-"+ + "1220e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", got) +} diff --git a/mfer/mf.pb.go b/mfer/mf.pb.go index f4d2a4d..72d2a4e 100644 --- a/mfer/mf.pb.go +++ b/mfer/mf.pb.go @@ -223,11 +223,14 @@ type MFFileOuter struct { // uuid must match the uuid in the inner message Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"` InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"` - //detached signature, ascii or binary + // one OpenPGP signature, armored or binary, over the canonical string + // ZNAVSRFG--, with the uuid and the + // multihash in lower-case hex (see docs/FORMAT.md). A signature is + // never stored anywhere else. Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"` - //full GPG key id + // fingerprint of the signing primary key, in upper-case hex Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"` - //full GPG signing public key, ascii or binary + // OpenPGP public key of the signer, one primary key, armored or binary SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/mfer/mf.proto b/mfer/mf.proto index 0d904ca..607bf88 100644 --- a/mfer/mf.proto +++ b/mfer/mf.proto @@ -32,15 +32,17 @@ message MFFileOuter { bytes uuid = 105; bytes innerMessage = 199; - // 2xx for optional manifest root attributes - // think we might use gosignify instead of gpg: - // github.com/frankbraun/gosignify - //detached signature, ascii or binary + // 2xx for optional manifest root attributes + + // one OpenPGP signature, armored or binary, over the canonical string + // ZNAVSRFG--, with the uuid and the + // multihash in lower-case hex (see docs/FORMAT.md). A signature is + // never stored anywhere else. optional bytes signature = 201; - //full GPG key id + // fingerprint of the signing primary key, in upper-case hex optional bytes signer = 202; - //full GPG signing public key, ascii or binary + // OpenPGP public key of the signer, one primary key, armored or binary optional bytes signingPubKey = 203; } diff --git a/mfer/mf.proto.sha256 b/mfer/mf.proto.sha256 index bd396c7..6ece320 100644 --- a/mfer/mf.proto.sha256 +++ b/mfer/mf.proto.sha256 @@ -1 +1 @@ -3d4dcb0b2f4640dd3ae6bb48b833e9f26ae9771e2d3e88bd646e7f1724dda654 mf.proto +1304a5d944dc272e07b01b2b74c7fdfd3a478ff75efe9f5978cb2d513a5063bc mf.proto -- 2.54.0