| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | // Package sigverify orchestrates OpenPGP signature verification for |
| 4 | // commits and annotated tags. The shape of the Result type mirrors |
| 5 | // GitHub's documented `verification` object on the REST commits |
| 6 | // response so the API layer can render it directly. |
| 7 | // |
| 8 | // The package is read-only relative to the git repository — it shells |
| 9 | // out to `git cat-file` for object bodies but never modifies the |
| 10 | // repo. Cache writes go through the commit_verification_cache table |
| 11 | // via the sqlc helpers in WriteResult. |
| 12 | package sigverify |
| 13 | |
| 14 | import "time" |
| 15 | |
| 16 | // Reason mirrors GitHub's documented verification.reason enum on the |
| 17 | // REST commits response. The full set is: |
| 18 | // |
| 19 | // - valid — signature checks out + signer email |
| 20 | // matches one of the user's verified emails. |
| 21 | // - unsigned — the commit/tag carries no gpgsig header. |
| 22 | // - unknown_key — gpgsig present but the signing subkey |
| 23 | // fingerprint isn't registered with shithub. |
| 24 | // - bad_email — signature checks cryptographically but |
| 25 | // the signer email doesn't match any of the |
| 26 | // registered user's emails at all. |
| 27 | // - unverified_email — signature checks cryptographically; the |
| 28 | // email matches a user-emails row but the |
| 29 | // row's verified flag is false. |
| 30 | // - malformed_signature — gpgsig header present but the armored |
| 31 | // block doesn't parse. |
| 32 | // - invalid — signature parses, key matches, but the |
| 33 | // cryptographic check fails. |
| 34 | // - expired_key — signature is by a subkey that had already |
| 35 | // expired by the commit's signature time. |
| 36 | // - not_signing_key — signature is by a subkey that exists in |
| 37 | // the registry but doesn't carry the sign |
| 38 | // capability flag. |
| 39 | // |
| 40 | // Add new strings here only when GitHub adds them; the value is the |
| 41 | // JSON wire format and is load-bearing for shithub-cli compatibility. |
| 42 | type Reason string |
| 43 | |
| 44 | const ( |
| 45 | ReasonValid Reason = "valid" |
| 46 | ReasonUnsigned Reason = "unsigned" |
| 47 | ReasonUnknownKey Reason = "unknown_key" |
| 48 | ReasonBadEmail Reason = "bad_email" |
| 49 | ReasonUnverifiedEmail Reason = "unverified_email" |
| 50 | ReasonMalformedSignature Reason = "malformed_signature" |
| 51 | ReasonInvalid Reason = "invalid" |
| 52 | ReasonExpiredKey Reason = "expired_key" |
| 53 | ReasonNotSigningKey Reason = "not_signing_key" |
| 54 | ) |
| 55 | |
| 56 | // Kind discriminates commit-object verifications from annotated-tag |
| 57 | // verifications in the cache table. |
| 58 | type Kind string |
| 59 | |
| 60 | const ( |
| 61 | KindCommit Kind = "commit" |
| 62 | KindTag Kind = "tag" |
| 63 | ) |
| 64 | |
| 65 | // Result is the orchestrator's per-object verification verdict. All |
| 66 | // fields except Verified+Reason+VerifiedAt are populated only when |
| 67 | // the corresponding information was available — see field comments. |
| 68 | // |
| 69 | // The shape matches GitHub's verification object on the REST commits |
| 70 | // response (api.go marshalls these directly when assembling the |
| 71 | // `verification` field). Fields that gh exposes as nullable strings |
| 72 | // (`signature`, `payload`) map to empty-string here; the JSON |
| 73 | // encoder layer translates "" back to null at the surface. |
| 74 | type Result struct { |
| 75 | // Verified is true if and only if Reason == ReasonValid. Stored |
| 76 | // denormalized so consumers don't have to compare against the |
| 77 | // string constant. |
| 78 | Verified bool |
| 79 | |
| 80 | // Reason carries the gh-documented enum value. Always populated. |
| 81 | Reason Reason |
| 82 | |
| 83 | // Signature is the armored signature block extracted from the |
| 84 | // gpgsig header. Empty for ReasonUnsigned. Populated for every |
| 85 | // other reason where a signature was actually present in the |
| 86 | // object (even ReasonMalformedSignature — we surface the raw |
| 87 | // armor so the REST consumer can inspect the bad payload). |
| 88 | Signature string |
| 89 | |
| 90 | // Payload is the canonical commit-object bytes that the signature |
| 91 | // was computed over (the commit object body with the gpgsig |
| 92 | // header lines removed). Empty for ReasonUnsigned and when the |
| 93 | // signature couldn't be parsed. |
| 94 | Payload []byte |
| 95 | |
| 96 | // SignerUserID is the user-id of the registered GPG key that the |
| 97 | // signature resolved to. Set whenever Reason ∈ {valid, bad_email, |
| 98 | // unverified_email, expired_key, not_signing_key, invalid} — any |
| 99 | // case where the subkey lookup succeeded. Zero when |
| 100 | // Reason ∈ {unsigned, unknown_key, malformed_signature}. |
| 101 | SignerUserID int64 |
| 102 | |
| 103 | // SignerSubkeyID is the user_gpg_subkeys row id of the signing |
| 104 | // subkey. Set under the same conditions as SignerUserID. |
| 105 | SignerSubkeyID int64 |
| 106 | |
| 107 | // SignerEmail is the email extracted from the signature's UID |
| 108 | // embedding (or from the GPG key's primary UID if the signature |
| 109 | // didn't carry one). Used by the popover UI to render |
| 110 | // "Signer: <email>". |
| 111 | SignerEmail string |
| 112 | |
| 113 | // VerifiedAt is the wall-clock time the verification ran. |
| 114 | VerifiedAt time.Time |
| 115 | } |
| 116 | |
| 117 | // unsignedResult is the canonical Result returned when an object has |
| 118 | // no gpgsig header. Constructed once per call so callers always see |
| 119 | // a fresh VerifiedAt; we don't share a package-level value. |
| 120 | func unsignedResult() Result { |
| 121 | return Result{ |
| 122 | Verified: false, |
| 123 | Reason: ReasonUnsigned, |
| 124 | VerifiedAt: time.Now(), |
| 125 | } |
| 126 | } |
| 127 |