Artifact d08851bd4dae1136cf8da79a88919d4a7b00a2cd9642da2757279982436b63a1:
- File
internal/deck/zcard.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: 3157) [annotate] [blame] [check-ins using] [more...]
package deck import ( "crypto/md5" "encoding/hex" "fmt" ) // VerifyZ validates a control artifact's trailing Z-card checksum. The blob // may arrive PGP/SSH clearsigned (historical artifacts, e.g. Fossil's own // 2007-era check-ins); StripClearsign isolates the inner content the Z card // is computed over so the last-35-byte check lands on the Z card and not on a // trailing signature block. The artifact's content-addressed hash is computed // over the raw blob elsewhere and is unaffected by this stripping. func VerifyZ(data []byte) error { return verifyZ(StripClearsign(data)) } // HasValidZ reports whether data, clearsign-stripped, ends in a Z card whose // checksum verifies. Every artifact type requires a Z card (§5.2), so data // without one can never parse as a deck; this costs one MD5 pass and no card // allocation. func HasValidZ(data []byte) bool { body := StripClearsign(data) n := len(body) if n < 35 || body[n-1] != '\n' || body[n-35] != 'Z' || body[n-34] != ' ' { return false } return verifyZ(body) == nil } // verifyZ checks the Z card of already-clearsign-stripped artifact content. // It mirrors fossil's verify_z_card (src/manifest.c): the Z card is located // as the last 35 bytes beginning "Z ", the checksum covers every byte before // it, and the stated digest is the 32 bytes at n-33 -- byte n-1, the final // LF, is never read. §6.2: a body too short to hold a Z card, or one whose // last 35 bytes are not a Z card, has the Z card absent, which this reports // as success and leaves the required-card rule (§5.2) to judge; only a // present-but-wrong checksum is an error here. Framing (§4.1) -- the missing // final LF, residue after the Z card -- is the caller's to check, so this // never rejects an input the rule it breaks should. func verifyZ(body []byte) error { n := len(body) if n < 35 { return nil // no room for a Z card: absent } if body[n-35] != 'Z' || body[n-34] != ' ' { return nil // last 35 bytes are not a Z card: absent } // §6.2 compares byte-exact against the lowercase computeZ, so an // uppercase digest fails verification even though §6.1's grammar admits // it. body[n-1] is deliberately not read. stated := body[n-33 : n-1] computed := computeZ(body[:n-35]) if computed != string(stated) { return fmt.Errorf("deck.VerifyZ: checksum mismatch: stated=%s computed=%s", stated, computed) } return nil } // isHexDigit reports whether c is a base-16 digit in either case. §6.1 // defines the Z payload's grammar as md5-token = 32hexdig-ci, a // parse-accept grammar that is explicitly case-insensitive, so rejecting // uppercase would refuse artifacts canonical fossil accepts. The // case-insensitivity is structural only: §6.2 still requires an uppercase // token to fail verification, which VerifyZ enforces by comparing // byte-exact against the lowercase computeZ. func isHexDigit(c byte) bool { switch { case c >= '0' && c <= '9': return true case c >= 'a' && c <= 'f': return true case c >= 'A' && c <= 'F': return true default: return false } } func computeZ(data []byte) string { h := md5.Sum(data) return hex.EncodeToString(h[:]) }