| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package sigverify |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // splitSignedObject takes the raw bytes of a git commit or annotated |
| 11 | // tag object (as returned by `git cat-file -p <oid>`) and splits it |
| 12 | // into the signature-payload (the bytes the signature was computed |
| 13 | // over) and the armored signature block. |
| 14 | // |
| 15 | // Git stores the signature as a header line whose value is a multi- |
| 16 | // line PGP armor block; the lines after the first are continuation |
| 17 | // lines starting with a single space. For example: |
| 18 | // |
| 19 | // tree abc123... |
| 20 | // parent def456... |
| 21 | // author Alice <alice@example.com> 1700000000 +0000 |
| 22 | // committer Alice <alice@example.com> 1700000000 +0000 |
| 23 | // gpgsig -----BEGIN PGP SIGNATURE----- |
| 24 | // <body> |
| 25 | // -----END PGP SIGNATURE----- |
| 26 | // |
| 27 | // commit message body |
| 28 | // |
| 29 | // The canonical payload is the same object with the gpgsig header |
| 30 | // (and its continuation lines) removed entirely. The signature is |
| 31 | // the concatenation of the gpgsig-value first line + the un-indented |
| 32 | // continuation lines. |
| 33 | // |
| 34 | // Returns (payload, armoredSig, true) when a gpgsig header was found, |
| 35 | // or (rawBody, "", false) when the object carries no signature. |
| 36 | // |
| 37 | // Tag objects can store the signature either inline at the end of the |
| 38 | // body (the classic format, with `-----BEGIN PGP SIGNATURE-----` |
| 39 | // appearing in the message body) OR via a header (the newer SSH-sig |
| 40 | // convention). For OpenPGP tags we look at both forms — the legacy |
| 41 | // trailing-block form is the dominant one in the wild. |
| 42 | func splitSignedObject(body []byte) (payload []byte, armoredSig string, signed bool) { |
| 43 | // Find the end of the header block (the first blank line). |
| 44 | headerEnd := bytes.Index(body, []byte("\n\n")) |
| 45 | if headerEnd < 0 { |
| 46 | return body, "", false |
| 47 | } |
| 48 | header := body[:headerEnd] |
| 49 | rest := body[headerEnd:] |
| 50 | |
| 51 | // Walk the header lines looking for a gpgsig header. There's |
| 52 | // exactly zero or one per object in practice. |
| 53 | lines := bytes.Split(header, []byte("\n")) |
| 54 | var ( |
| 55 | sigBuilder strings.Builder |
| 56 | newHeader bytes.Buffer |
| 57 | inSig bool |
| 58 | foundSig bool |
| 59 | ) |
| 60 | for i, line := range lines { |
| 61 | if inSig { |
| 62 | if bytes.HasPrefix(line, []byte(" ")) { |
| 63 | // Continuation line — strip one leading space. |
| 64 | sigBuilder.Write(line[1:]) |
| 65 | sigBuilder.WriteByte('\n') |
| 66 | continue |
| 67 | } |
| 68 | // End of signature continuation — fall through to |
| 69 | // header handling for this line. |
| 70 | inSig = false |
| 71 | } |
| 72 | if bytes.HasPrefix(line, []byte("gpgsig ")) { |
| 73 | foundSig = true |
| 74 | inSig = true |
| 75 | sigBuilder.Write(line[len("gpgsig "):]) |
| 76 | sigBuilder.WriteByte('\n') |
| 77 | continue |
| 78 | } |
| 79 | // Tag-object signature header is named differently in some |
| 80 | // gitformats but the modern convention also uses 'gpgsig'. |
| 81 | // Treat any other line as a regular header to preserve. |
| 82 | if i > 0 { |
| 83 | newHeader.WriteByte('\n') |
| 84 | } |
| 85 | newHeader.Write(line) |
| 86 | } |
| 87 | |
| 88 | if !foundSig { |
| 89 | // Check tag-object inline trailing-signature form: the body |
| 90 | // (rest, after the blank line) may end with a PGP signature |
| 91 | // block. This form has no `gpgsig` header. |
| 92 | return splitTagInlineSignature(body) |
| 93 | } |
| 94 | |
| 95 | payload = append(newHeader.Bytes(), rest...) |
| 96 | return payload, sigBuilder.String(), true |
| 97 | } |
| 98 | |
| 99 | // splitTagInlineSignature handles annotated tags that embed the |
| 100 | // signature at the end of the message body (the legacy git-tag |
| 101 | // signing convention) rather than as a header. The signature block |
| 102 | // runs from `-----BEGIN PGP SIGNATURE-----` to `-----END PGP |
| 103 | // SIGNATURE-----` inclusive at the tail of the body. |
| 104 | // |
| 105 | // Returns (payload, armoredSig, true) when an inline block is |
| 106 | // detected, or (body, "", false) otherwise. |
| 107 | func splitTagInlineSignature(body []byte) (payload []byte, armoredSig string, signed bool) { |
| 108 | const begin = "-----BEGIN PGP SIGNATURE-----" |
| 109 | const end = "-----END PGP SIGNATURE-----" |
| 110 | |
| 111 | beginIdx := bytes.Index(body, []byte(begin)) |
| 112 | if beginIdx < 0 { |
| 113 | return body, "", false |
| 114 | } |
| 115 | endIdx := bytes.Index(body[beginIdx:], []byte(end)) |
| 116 | if endIdx < 0 { |
| 117 | return body, "", false |
| 118 | } |
| 119 | endIdx += beginIdx + len(end) |
| 120 | // Include trailing newline if present. |
| 121 | if endIdx < len(body) && body[endIdx] == '\n' { |
| 122 | endIdx++ |
| 123 | } |
| 124 | armoredSig = string(body[beginIdx:endIdx]) |
| 125 | // Payload is everything before the signature block. git tag -s |
| 126 | // appends the signature directly to the tag body — the signed |
| 127 | // payload IS the body up to (and including the trailing newline |
| 128 | // before) the BEGIN marker. No further trimming. |
| 129 | payload = body[:beginIdx] |
| 130 | return payload, armoredSig, true |
| 131 | } |
| 132 |