Artifact d33bcfc4d177922884e314689781c77b5f927f7a4a89ced78afba5108e03ab65:
- File
internal/deck/clearsign.go
— part of check-in
[612f2e7acc]
at
2026-10-23 20:05:29
on branch main
— feat(signing): sign check-ins and horizon seeds with gpg or ssh
go-libfossil signed nothing and verified nothing, so a structural-horizon clone could not tell a seed its origin minted from one substituted on disk (draft-fossil-horizon-00 ยง5.2.4, HZ-5.2-5 and HZ-5.2-6), and a repository with fossil's clearsign setting on stored its check-ins unsigned.
Signing follows fossil's contract, the same one zit follows. With clearsign on (and the legacy omitsign off), every check-in a commit or branch creation stores is clear-signed by pgp-command: unset, gpg --clearsign -o, run as COMMAND OUTFILE INFILE; a command named ssh* runs as COMMAND INFILE and its detached signature is wrapped in fossil's SSH envelope. A signer that fails, or returns text that does not strip back to the manifest, abandons the commit. NoSign and --nosign store one check-in unsigned.
An origin with clearsign on signs each seed it mints. It signs only after the request is authorized, outside the request's transaction, keeps one signature per seed and command so the seed's name is stable, and re-signs a kept signature its own horizon-seed-key no longer verifies. Keeping is best effort, so a read-only mirror still serves.
A clone given its origin's key (CloneOpts.SeedKey, repo clone --seed-key, kept as horizon-seed-key) verifies every seed on clone, pull and rebuild, and falls back to the content horizon from one unsigned or signed by another key. An SSH key is verified in process: namespace fossilscm, the configured key, no SHA-1 ssh-rsa, and exactly fossil's envelope. An OpenPGP fingerprint is verified by gpg, which must report one good signature by that key over text byte-equal to what the parser reads, with nothing after the signature. A verifier that cannot run keeps the held seed and fails the sync instead of discarding it.
fossil 2.28 and zit read the signed check-ins; a check-in zit signs with the same SSH key verifies here; zit clones a signing origin and holds its seed. (user: [email protected], size: 3828) [annotate] [blame] [check-ins using] [more...]
package deck import "bytes" // A control artifact blob may be PGP- or SSH-clearsigned: the manifest text // is wrapped in an armored header, a blank line, then the cards, and finally a // detached signature block. Fossil's own src/manifest.c (remove_pgp_signature) // strips this framing before verifying the Z-card checksum and parsing cards, // so the checksum is computed over the inner content only. These are the exact // marker strings it matches. var ( clearsignHeaders = [][]byte{ []byte("-----BEGIN PGP SIGNED MESSAGE-----"), []byte("-----BEGIN SSH SIGNED MESSAGE-----"), } // fossil's remove_pgp_signature matches the signature trailer with // strncmp(..., 29): the LF plus the first 28 bytes of the armor line, one // short of its final dash. So a trailing dash mangled to another byte // ("-----BEGIN PGP SIGNATURE----x") is still recognized and stripped. // These markers are those 29 bytes, so bytes.HasPrefix matches the same. clearsignSignatures = [][]byte{ []byte("\n-----BEGIN PGP SIGNATURE----"), []byte("\n-----BEGIN SSH SIGNATURE----"), } ) // clearsignHeaderLen is the fixed byte length of every clearsign header marker // above; the armor-header scan begins just past it. All markers share this // length, which the assertion in StripClearsign pins. const clearsignHeaderLen = 34 // StripClearsign returns the inner control-artifact content of a clearsigned // blob -- the bytes between the armor header's blank-line separator and the // trailing signature block -- which is what the Z card is computed over and // what the card parser consumes. It mirrors fossil's remove_pgp_signature // (src/manifest.c): a byte-for-byte identity on non-clearsigned input (the // overwhelmingly common case) and idempotent, since stripped content no longer // begins with a header marker. The artifact's content-addressed hash is taken // over the raw blob elsewhere, so this stripping never changes which UUID an // artifact resolves to. func StripClearsign(data []byte) []byte { header := matchPrefix(data, clearsignHeaders) if header == nil { return data } if len(header) != clearsignHeaderLen { panic("deck.StripClearsign: clearsign header marker length mismatch") } // Skip the armor headers (e.g. "Hash: SHA1") up to and including the blank // line that separates them from the message body. i lands on the first // byte of the inner content. n := len(data) i := clearsignHeaderLen for i < n && !afterBlankLine(data, i) { i++ } if i >= n { return data // No message body found; verifyZ will reject it. } body := data[i:] // Trim the trailing signature block. Scan backward for the '\n' that // begins "\n-----BEGIN PGP SIGNATURE-----", keeping that '\n' so the inner // content still ends in a newline (matching fossil's n = i+1). for j := len(body) - 1; j >= 0; j-- { if body[j] != '\n' { continue } if matchPrefix(body[j:], clearsignSignatures) != nil { return body[:j+1] } } return body // Header but no signature trailer; leave the body as-is. } // afterBlankLine reports whether index i in z sits immediately after a blank // line, i.e. z[i-1] is a '\n' preceded by either another '\n' or a "\r\n". // Mirrors fossil's after_blank_line. i is always >= clearsignHeaderLen at the // call site, so the lookbehinds are in bounds; the guards assert that. func afterBlankLine(z []byte, i int) bool { if i < 1 || z[i-1] != '\n' { return false } if i >= 2 && z[i-2] == '\n' { return true } if i >= 3 && z[i-2] == '\r' && z[i-3] == '\n' { return true } return false } // matchPrefix returns the first marker in markers that is a prefix of data, or // nil if none match. func matchPrefix(data []byte, markers [][]byte) []byte { for _, marker := range markers { if bytes.HasPrefix(data, marker) { return marker } } return nil }