Artifact 74feb35fab424491ca0966aa212542c4a0a17d6229a082ea2813e7420c11448f:
- File
internal/deck/clearsign.go
— part of check-in
[c1cbd26795]
at
2026-07-24 22:17:38
on branch trunk
— deck: strip PGP/SSH clearsign framing before VerifyZ (#156) (#160)
VerifyZ validated a control artifact by hashing everything up to the last 35 bytes and comparing against the Z card it expected to find there. When a manifest arrived PGP-clearsigned, the raw blob carried a trailing "-----BEGIN PGP SIGNATURE-----" ... "-----END PGP SIGNATURE-----" block after the Z card, so the last-35-byte window landed inside the signature framing instead of on the Z card, and verification failed. deck.Parse propagated that failure, so ~95 of Fossil's own 2007-era clearsigned check-ins could not be parsed -- and any child whose primary parent was one of them lost its parent's file tree in loadPrimaryParentFiles, cascading into all-added mlink data (~98 affected children on the real corpus).
Mirror fossil's src/manifest.c remove_pgp_signature: strip the armor header (up to the blank line that ends it) and the trailing signature block, isolating the inner content the Z card is actually computed over, then verify and parse that. The stripping is an identity on non-clearsigned input (the common case) and idempotent.
The artifact's content-addressed hash is taken over the raw blob elsewhere and is untouched by this change: a clearsigned manifest still resolves to the same UUID it always has. Verified against the real Fossil check-in 61829b07 (aku, 2007-11-29), whose raw-blob SHA-1 still equals its UUID and whose inner Z-card MD5 now validates; cross-checked byte-for-byte against fossil 2.28. (user: [email protected], size: 3466) [annotate] [blame] [check-ins using]
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-----"), } 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 }