tenseleyflow/shithub / 05bd39a

Browse files

repos/sigverify: commit + tag signature verification orchestrator

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
05bd39a044f62f9903d5693936f842011535dd2c
Parents
5cbaaa0
Tree
633b893

5 changed files

StatusFile+-
A internal/repos/sigverify/cache.go 64 0
A internal/repos/sigverify/commit_object.go 131 0
A internal/repos/sigverify/lookups.go 78 0
A internal/repos/sigverify/result.go 126 0
A internal/repos/sigverify/verify.go 336 0
internal/repos/sigverify/cache.goadded
@@ -0,0 +1,64 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sigverify
4
+
5
+import (
6
+	"context"
7
+
8
+	"github.com/jackc/pgx/v5/pgtype"
9
+
10
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
11
+)
12
+
13
+// CacheWriter is the minimal surface WriteResult needs from the
14
+// reposdb queries layer. Sub-PR 3 (backfill) and Sub-PR 4 (settings
15
+// add handler) both consume WriteResult; this interface lets them
16
+// pass any concrete DBTX-bound queries set without taking the full
17
+// reposdb.Queries struct as a dependency.
18
+type CacheWriter interface {
19
+	UpsertCommitVerification(ctx context.Context, db reposdb.DBTX, arg reposdb.UpsertCommitVerificationParams) error
20
+}
21
+
22
+// WriteResult persists a verification Result into the cache table.
23
+// Wraps the sqlc-generated UpsertCommitVerification so the Result
24
+// → params translation lives in one place.
25
+//
26
+// Concurrency: UpsertCommitVerification's ON CONFLICT clause makes
27
+// this safe to call from the orchestrator and the backfill worker
28
+// against the same (repo_id, commit_oid) without losing data — the
29
+// most-recent invocation wins.
30
+func WriteResult(
31
+	ctx context.Context,
32
+	q CacheWriter,
33
+	db reposdb.DBTX,
34
+	repoID int64,
35
+	commitOID string,
36
+	kind Kind,
37
+	r Result,
38
+) error {
39
+	return q.UpsertCommitVerification(ctx, db, reposdb.UpsertCommitVerificationParams{
40
+		RepoID:           repoID,
41
+		CommitOid:        commitOID,
42
+		Reason:           string(r.Reason),
43
+		Verified:         r.Verified,
44
+		SignerUserID:     nullableInt64(r.SignerUserID),
45
+		SignerSubkeyID:   nullableInt64(r.SignerSubkeyID),
46
+		Kind:             string(kind),
47
+		SignatureArmored: nullableText(r.Signature),
48
+		Payload:          r.Payload,
49
+	})
50
+}
51
+
52
+func nullableInt64(v int64) pgtype.Int8 {
53
+	if v == 0 {
54
+		return pgtype.Int8{}
55
+	}
56
+	return pgtype.Int8{Int64: v, Valid: true}
57
+}
58
+
59
+func nullableText(s string) pgtype.Text {
60
+	if s == "" {
61
+		return pgtype.Text{}
62
+	}
63
+	return pgtype.Text{String: s, Valid: true}
64
+}
internal/repos/sigverify/commit_object.goadded
@@ -0,0 +1,131 @@
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
+}
internal/repos/sigverify/lookups.goadded
@@ -0,0 +1,78 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sigverify
4
+
5
+import (
6
+	"context"
7
+	"time"
8
+)
9
+
10
+// Lookups is the interface the orchestrator uses to resolve
11
+// signature artifacts (subkeys, parent gpg keys, user emails) back
12
+// to user records. The interface exists so tests can pass in a
13
+// fake without touching sqlc; production wires through to
14
+// usersdb.Queries.
15
+type Lookups interface {
16
+	// SubkeyByFingerprint looks up a user_gpg_subkeys row by its
17
+	// 40-hex canonical fingerprint. Returns (subkey, true, nil) on
18
+	// hit, (_, false, nil) on miss, and (_, _, err) only on DB
19
+	// errors (miss is NOT an error — the orchestrator translates
20
+	// it into ReasonUnknownKey).
21
+	SubkeyByFingerprint(ctx context.Context, fingerprint string) (Subkey, bool, error)
22
+
23
+	// GPGKeyByID resolves a primary gpg-key row by id. Called once
24
+	// per Verify invocation after SubkeyByFingerprint returns a hit
25
+	// so the orchestrator has access to the primary's armored block
26
+	// (needed to construct the openpgp.Entity for cryptographic
27
+	// verification).
28
+	GPGKeyByID(ctx context.Context, id int64) (GPGKey, bool, error)
29
+
30
+	// UserEmailsByUserID returns every email row for the given user
31
+	// (verified or not). The orchestrator uses this for the
32
+	// `bad_email` vs `unverified_email` discrimination — gh's
33
+	// verification check fails open as bad_email if the signature's
34
+	// email isn't claimed by the user at all, and as
35
+	// unverified_email if the email is claimed but unverified.
36
+	UserEmailsByUserID(ctx context.Context, userID int64) ([]UserEmail, error)
37
+}
38
+
39
+// Subkey is the orchestrator-facing shape of a user_gpg_subkeys row.
40
+// Decoupled from sqlc.UserGpgSubkey so tests don't have to construct
41
+// pgtype.Timestamptz values.
42
+type Subkey struct {
43
+	ID                int64
44
+	GPGKeyID          int64
45
+	Fingerprint       string
46
+	KeyID             string
47
+	CanSign           bool
48
+	CanEncryptComms   bool
49
+	CanEncryptStorage bool
50
+	CanCertify        bool
51
+	// ExpiresAt is the subkey's expiration timestamp; the zero Time
52
+	// value means "never expires".
53
+	ExpiresAt time.Time
54
+	// RevokedAt is set if this subkey has been soft-deleted. The
55
+	// orchestrator treats revoked subkeys as if they don't exist
56
+	// (returns ReasonUnknownKey), so production lookups should
57
+	// already filter `revoked_at is null` and this field is mostly
58
+	// here for diagnostic completeness.
59
+	RevokedAt time.Time
60
+}
61
+
62
+// GPGKey is the orchestrator-facing shape of a user_gpg_keys row.
63
+type GPGKey struct {
64
+	ID          int64
65
+	UserID      int64
66
+	Fingerprint string
67
+	KeyID       string
68
+	// Armored is the full ASCII-armored block. The orchestrator
69
+	// re-parses this via ProtonMail/go-crypto to access the actual
70
+	// public-key material for cryptographic verification.
71
+	Armored string
72
+}
73
+
74
+// UserEmail is the orchestrator-facing shape of a user_emails row.
75
+type UserEmail struct {
76
+	Email    string
77
+	Verified bool
78
+}
internal/repos/sigverify/result.goadded
@@ -0,0 +1,126 @@
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
+}
internal/repos/sigverify/verify.goadded
@@ -0,0 +1,336 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sigverify
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/hex"
9
+	"fmt"
10
+	"os/exec"
11
+	"strings"
12
+	"time"
13
+
14
+	"github.com/ProtonMail/go-crypto/openpgp"
15
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
16
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
17
+)
18
+
19
+// Verify resolves the verification state of a single commit object.
20
+// It reads the commit body via `git cat-file -p`, splits out the
21
+// gpgsig header, looks up the signing subkey via lookups, performs
22
+// the cryptographic check, and finally cross-checks the signer's
23
+// email against the user's verified emails (when applicable).
24
+//
25
+// Never returns ReasonMalformedSignature or ReasonInvalid as errors —
26
+// those are part of the Result. Returns an error only when the
27
+// underlying git or DB call fails (i.e. the verification couldn't be
28
+// attempted at all). Callers should record the verification result
29
+// to the cache table even on error states; the error path is for
30
+// "we don't know yet, retry later" situations.
31
+func Verify(ctx context.Context, gitDir, commitOID string, lookups Lookups) (Result, error) {
32
+	body, err := catFile(ctx, gitDir, commitOID)
33
+	if err != nil {
34
+		return Result{}, fmt.Errorf("sigverify: cat-file %s: %w", commitOID, err)
35
+	}
36
+	return verifyObject(ctx, body, lookups, KindCommit)
37
+}
38
+
39
+// VerifyTag is the annotated-tag variant. The commit-object splitter
40
+// already handles both header-form and inline-form signatures, so
41
+// the orchestration is the same — only the cache `kind` discriminator
42
+// differs.
43
+func VerifyTag(ctx context.Context, gitDir, tagOID string, lookups Lookups) (Result, error) {
44
+	body, err := catFile(ctx, gitDir, tagOID)
45
+	if err != nil {
46
+		return Result{}, fmt.Errorf("sigverify: cat-file %s: %w", tagOID, err)
47
+	}
48
+	return verifyObject(ctx, body, lookups, KindTag)
49
+}
50
+
51
+// verifyObject is the shared implementation. _ Kind is currently
52
+// unused (the Result doesn't carry it; the caller stamps Kind into
53
+// the cache row themselves), kept on the signature in case future
54
+// branching needs to inspect it.
55
+func verifyObject(ctx context.Context, body []byte, lookups Lookups, _ Kind) (Result, error) {
56
+	payload, armored, signed := splitSignedObject(body)
57
+	if !signed {
58
+		return unsignedResult(), nil
59
+	}
60
+
61
+	// Parse the signature packet to learn which subkey signed.
62
+	sigPkt, err := readSignaturePacket(armored)
63
+	if err != nil {
64
+		return Result{
65
+			Verified:   false,
66
+			Reason:     ReasonMalformedSignature,
67
+			Signature:  armored,
68
+			VerifiedAt: time.Now(),
69
+		}, nil
70
+	}
71
+
72
+	// Resolve the signing subkey. RFC 4880 supports both an
73
+	// IssuerKeyId subpacket (lower 64 bits of the fingerprint) and
74
+	// an IssuerFingerprint subpacket (the full 40-hex). Modern git/
75
+	// gpg emits both; we prefer the fingerprint when present because
76
+	// 64-bit key ids can collide.
77
+	var fingerprintHex string
78
+	if len(sigPkt.IssuerFingerprint) > 0 {
79
+		fingerprintHex = hex.EncodeToString(sigPkt.IssuerFingerprint)
80
+	}
81
+	if fingerprintHex == "" {
82
+		// IssuerKeyId fallback: we can't do a precise lookup without
83
+		// the full fingerprint. Mark unknown so the user can re-sign
84
+		// with a modern gpg client. (gh produces the same outcome
85
+		// in this rare case.)
86
+		return Result{
87
+			Verified:   false,
88
+			Reason:     ReasonUnknownKey,
89
+			Signature:  armored,
90
+			Payload:    payload,
91
+			VerifiedAt: time.Now(),
92
+		}, nil
93
+	}
94
+
95
+	subkey, found, err := lookups.SubkeyByFingerprint(ctx, fingerprintHex)
96
+	if err != nil {
97
+		return Result{}, fmt.Errorf("sigverify: lookup subkey: %w", err)
98
+	}
99
+	if !found {
100
+		return Result{
101
+			Verified:   false,
102
+			Reason:     ReasonUnknownKey,
103
+			Signature:  armored,
104
+			Payload:    payload,
105
+			VerifiedAt: time.Now(),
106
+		}, nil
107
+	}
108
+
109
+	// Load the parent gpg-key so we can construct the openpgp.Entity
110
+	// from its armored block. The cryptographic check needs the
111
+	// actual public-key material, not just the fingerprint.
112
+	gpgKey, found, err := lookups.GPGKeyByID(ctx, subkey.GPGKeyID)
113
+	if err != nil {
114
+		return Result{}, fmt.Errorf("sigverify: lookup parent gpg key: %w", err)
115
+	}
116
+	if !found {
117
+		// Parent gone (revoked between subkey lookup and parent
118
+		// lookup). Surface as unknown_key from the user's
119
+		// perspective.
120
+		return Result{
121
+			Verified:   false,
122
+			Reason:     ReasonUnknownKey,
123
+			Signature:  armored,
124
+			Payload:    payload,
125
+			VerifiedAt: time.Now(),
126
+		}, nil
127
+	}
128
+
129
+	entity, err := openpgp.ReadArmoredKeyRing(strings.NewReader(gpgKey.Armored))
130
+	if err != nil || len(entity) == 0 {
131
+		// Corrupted at-rest — surface as malformed so the cache row
132
+		// makes the issue visible; rendering UI treats this as
133
+		// unverified.
134
+		return Result{
135
+			Verified:       false,
136
+			Reason:         ReasonMalformedSignature,
137
+			Signature:      armored,
138
+			Payload:        payload,
139
+			SignerUserID:   gpgKey.UserID,
140
+			SignerSubkeyID: subkey.ID,
141
+			VerifiedAt:     time.Now(),
142
+		}, nil
143
+	}
144
+
145
+	// Capability + expiry checks happen BEFORE the cryptographic
146
+	// check. Reason: openpgp.CheckArmoredDetachedSignature does its
147
+	// own expiry check using time.Now() and folds the result into a
148
+	// generic error; running our checks first lets us return the
149
+	// precise gh enum reason (expired_key, not_signing_key).
150
+	if !subkey.CanSign {
151
+		return Result{
152
+			Verified:       false,
153
+			Reason:         ReasonNotSigningKey,
154
+			Signature:      armored,
155
+			Payload:        payload,
156
+			SignerUserID:   gpgKey.UserID,
157
+			SignerSubkeyID: subkey.ID,
158
+			VerifiedAt:     time.Now(),
159
+		}, nil
160
+	}
161
+	if !subkey.ExpiresAt.IsZero() && sigPkt.CreationTime.After(subkey.ExpiresAt) {
162
+		// Signature was made AFTER the key expired — not valid.
163
+		// Sigs made before expiry remain valid even when the key
164
+		// later expires (gh's behavior).
165
+		return Result{
166
+			Verified:       false,
167
+			Reason:         ReasonExpiredKey,
168
+			Signature:      armored,
169
+			Payload:        payload,
170
+			SignerUserID:   gpgKey.UserID,
171
+			SignerSubkeyID: subkey.ID,
172
+			VerifiedAt:     time.Now(),
173
+		}, nil
174
+	}
175
+
176
+	// Cryptographic check. Pass Config.Time = sig creation time so
177
+	// the openpgp library treats the key as live-at-sig-time (we've
178
+	// already run the explicit expiry check above; the library's
179
+	// re-check would just cause false negatives).
180
+	cfg := &packet.Config{Time: func() time.Time { return sigPkt.CreationTime }}
181
+	signer, err := openpgp.CheckArmoredDetachedSignature(
182
+		entity,
183
+		bytes.NewReader(payload),
184
+		strings.NewReader(armored),
185
+		cfg,
186
+	)
187
+	if err != nil || signer == nil {
188
+		return Result{
189
+			Verified:       false,
190
+			Reason:         ReasonInvalid,
191
+			Signature:      armored,
192
+			Payload:        payload,
193
+			SignerUserID:   gpgKey.UserID,
194
+			SignerSubkeyID: subkey.ID,
195
+			VerifiedAt:     time.Now(),
196
+		}, nil
197
+	}
198
+
199
+	// Email cross-check. Pull the signer email from the signature
200
+	// packet's UID embedding when present, otherwise from the
201
+	// primary identity of the parent gpg key.
202
+	signerEmail := extractSignerEmail(sigPkt, entity[0])
203
+
204
+	if signerEmail == "" {
205
+		// No email to cross-check. gh treats this as valid since
206
+		// the cryptography succeeded — we follow suit.
207
+		return Result{
208
+			Verified:       true,
209
+			Reason:         ReasonValid,
210
+			Signature:      armored,
211
+			Payload:        payload,
212
+			SignerUserID:   gpgKey.UserID,
213
+			SignerSubkeyID: subkey.ID,
214
+			VerifiedAt:     time.Now(),
215
+		}, nil
216
+	}
217
+
218
+	emails, err := lookups.UserEmailsByUserID(ctx, gpgKey.UserID)
219
+	if err != nil {
220
+		return Result{}, fmt.Errorf("sigverify: lookup user emails: %w", err)
221
+	}
222
+	emailVerifiedState, claimed := claimEmailLookup(emails, signerEmail)
223
+	switch {
224
+	case !claimed:
225
+		return Result{
226
+			Verified:       false,
227
+			Reason:         ReasonBadEmail,
228
+			Signature:      armored,
229
+			Payload:        payload,
230
+			SignerUserID:   gpgKey.UserID,
231
+			SignerSubkeyID: subkey.ID,
232
+			SignerEmail:    signerEmail,
233
+			VerifiedAt:     time.Now(),
234
+		}, nil
235
+	case !emailVerifiedState:
236
+		return Result{
237
+			Verified:       false,
238
+			Reason:         ReasonUnverifiedEmail,
239
+			Signature:      armored,
240
+			Payload:        payload,
241
+			SignerUserID:   gpgKey.UserID,
242
+			SignerSubkeyID: subkey.ID,
243
+			SignerEmail:    signerEmail,
244
+			VerifiedAt:     time.Now(),
245
+		}, nil
246
+	}
247
+
248
+	return Result{
249
+		Verified:       true,
250
+		Reason:         ReasonValid,
251
+		Signature:      armored,
252
+		Payload:        payload,
253
+		SignerUserID:   gpgKey.UserID,
254
+		SignerSubkeyID: subkey.ID,
255
+		SignerEmail:    signerEmail,
256
+		VerifiedAt:     time.Now(),
257
+	}, nil
258
+}
259
+
260
+// catFile shells out to `git cat-file -p <oid>` and returns the
261
+// object body. We trim the trailing newline that git always emits.
262
+func catFile(ctx context.Context, gitDir, oid string) ([]byte, error) {
263
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir, "cat-file", "-p", oid)
264
+	out, err := cmd.Output()
265
+	if err != nil {
266
+		return nil, err
267
+	}
268
+	return out, nil
269
+}
270
+
271
+// readSignaturePacket parses the first signature packet out of an
272
+// armored block. Used to extract the issuer fingerprint + creation
273
+// time + UID embedding without re-doing the full cryptographic check.
274
+func readSignaturePacket(armored string) (*packet.Signature, error) {
275
+	block, err := armor.Decode(strings.NewReader(armored))
276
+	if err != nil {
277
+		return nil, err
278
+	}
279
+	if block.Type != "PGP SIGNATURE" {
280
+		return nil, fmt.Errorf("sigverify: expected PGP SIGNATURE block, got %q", block.Type)
281
+	}
282
+	pkt, err := packet.Read(block.Body)
283
+	if err != nil {
284
+		return nil, err
285
+	}
286
+	sig, ok := pkt.(*packet.Signature)
287
+	if !ok {
288
+		return nil, fmt.Errorf("sigverify: first packet is not a Signature")
289
+	}
290
+	return sig, nil
291
+}
292
+
293
+// extractSignerEmail returns the email used to sign — preferring the
294
+// signature packet's UID embedding (RFC 4880 §5.2.3.28) when present,
295
+// otherwise the primary UID of the signing entity.
296
+func extractSignerEmail(sig *packet.Signature, e *openpgp.Entity) string {
297
+	if sig.SignerUserId != nil && *sig.SignerUserId != "" {
298
+		// SignerUserId is the full UID string ("Alice <alice@x>");
299
+		// crack out the email part.
300
+		return parseEmailFromUID(*sig.SignerUserId)
301
+	}
302
+	if e != nil {
303
+		if id := e.PrimaryIdentity(); id != nil && id.UserId != nil {
304
+			return id.UserId.Email
305
+		}
306
+	}
307
+	return ""
308
+}
309
+
310
+// parseEmailFromUID pulls the email from a UID string of the form
311
+// "Name (Comment) <email@host>" or just "email@host". Falls back to
312
+// the raw string when no angle brackets are present.
313
+func parseEmailFromUID(uid string) string {
314
+	if i := strings.LastIndex(uid, "<"); i >= 0 {
315
+		if j := strings.LastIndex(uid, ">"); j > i {
316
+			return uid[i+1 : j]
317
+		}
318
+	}
319
+	if strings.Contains(uid, "@") {
320
+		return strings.TrimSpace(uid)
321
+	}
322
+	return ""
323
+}
324
+
325
+// claimEmailLookup walks the user's emails, returning (verified, true)
326
+// when the email is claimed (case-insensitive match) and (false, false)
327
+// when it isn't claimed at all.
328
+func claimEmailLookup(emails []UserEmail, signerEmail string) (verified, claimed bool) {
329
+	se := strings.ToLower(signerEmail)
330
+	for _, e := range emails {
331
+		if strings.ToLower(e.Email) == se {
332
+			return e.Verified, true
333
+		}
334
+	}
335
+	return false, false
336
+}