krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/sig/verify.go · raw

  1package sig
  2
  3import (
  4	"bytes"
  5	"slices"
  6	"time"
  7
  8	"github.com/ProtonMail/go-crypto/openpgp"
  9	"github.com/ProtonMail/go-crypto/openpgp/packet"
 10)
 11
 12type State string
 13
 14const (
 15	Verified            State = "verified"
 16	SignedUnknownKey    State = "signed_unknown_key"
 17	SignedEmailMismatch State = "signed_email_mismatch"
 18	SignedKeyExpired    State = "signed_key_expired"
 19	SignedKeyRevoked    State = "signed_key_revoked"
 20	BadSignature        State = "bad_signature"
 21	Unsigned            State = "unsigned"
 22)
 23
 24type Result struct {
 25	State          State
 26	SignerUserID   int64 // 0 when unknown
 27	KeyFingerprint string
 28}
 29
 30// PGPKeyInfo is a registered OpenPGP key as the verifier needs it.
 31type PGPKeyInfo struct {
 32	UserID    int64
 33	Armored   string
 34	ExpiresAt *time.Time
 35	RevokedAt *time.Time
 36}
 37
 38// SSHKeyInfo is a registered SSH key as the verifier needs it.
 39type SSHKeyInfo struct {
 40	UserID      int64
 41	Fingerprint string
 42}
 43
 44// DB is the store surface the verifier depends on. Implemented by
 45// store.SigDB.
 46type DB interface {
 47	// PGPKeyByIssuer finds a registered key whose fingerprint ends with the
 48	// issuer key id (16 hex chars, lowercase).
 49	PGPKeyByIssuer(keyIDHex string) (PGPKeyInfo, string, bool, error) // info, fingerprint, found
 50	SSHSignerByFingerprint(fp string) (SSHKeyInfo, bool, error)
 51	VerifiedEmails(userID int64) ([]string, error)
 52}
 53
 54// VerifyCommit classifies one parsed commit. The commit's author email
 55// drives the identity check, per the plan.
 56func VerifyCommit(db DB, c *Commit) (Result, error) {
 57	switch KindOf(c.Signature) {
 58	case SigNone:
 59		return Result{State: Unsigned}, nil
 60	case SigOpenPGP:
 61		return verifyOpenPGP(db, c)
 62	case SigSSH:
 63		return verifySSH(db, c)
 64	default:
 65		return Result{State: BadSignature}, nil
 66	}
 67}
 68
 69func verifyOpenPGP(db DB, c *Commit) (Result, error) {
 70	issuer, sigTime, ok := openpgpIssuer(c.Signature)
 71	if !ok {
 72		return Result{State: BadSignature}, nil
 73	}
 74	key, fpr, found, err := db.PGPKeyByIssuer(issuer)
 75	if err != nil {
 76		return Result{}, err
 77	}
 78	if !found {
 79		return Result{State: SignedUnknownKey, KeyFingerprint: issuer}, nil
 80	}
 81	res := Result{SignerUserID: key.UserID, KeyFingerprint: fpr}
 82
 83	// Key-state policy comes before cryptography: a revoked or expired key
 84	// invalidates the trust claim no matter what the signature says, and
 85	// hard revocations make the library's own verdict on such keys
 86	// unpredictable.
 87	now := time.Now()
 88	if key.RevokedAt != nil && key.RevokedAt.Before(now) {
 89		res.State = SignedKeyRevoked
 90		return res, nil
 91	}
 92	if key.ExpiresAt != nil && key.ExpiresAt.Before(now) {
 93		res.State = SignedKeyExpired
 94		return res, nil
 95	}
 96
 97	ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(key.Armored)))
 98	if err != nil {
 99		return Result{}, err
100	}
101	// Verify the cryptography at the signature's own creation time: key
102	// expiry is our policy decision (above), not the library's.
103	cfg := &packet.Config{Time: func() time.Time { return sigTime }}
104	signer, err := openpgp.CheckArmoredDetachedSignature(
105		ring, bytes.NewReader(c.Payload), bytes.NewReader(c.Signature), cfg)
106	if err != nil || signer == nil {
107		res.State = BadSignature
108		return res, nil
109	}
110
111	// Author email must appear in a UID on the signing key AND be a
112	// verified address on the owning account.
113	uidMatch := false
114	for _, id := range signer.Identities {
115		if id.UserId != nil && id.UserId.Email == c.AuthorEmail {
116			uidMatch = true
117			break
118		}
119	}
120	verified, err := db.VerifiedEmails(key.UserID)
121	if err != nil {
122		return Result{}, err
123	}
124	if !uidMatch || !slices.Contains(verified, c.AuthorEmail) {
125		res.State = SignedEmailMismatch
126		return res, nil
127	}
128	res.State = Verified
129	return res, nil
130}
131
132// openpgpIssuer extracts the issuer key id (lowercase hex) and creation time
133// from an armored signature without verifying it.
134func openpgpIssuer(armored []byte) (string, time.Time, bool) {
135	block, err := decodeArmor(armored, "PGP SIGNATURE")
136	if err != nil {
137		return "", time.Time{}, false
138	}
139	p, err := packet.Read(bytes.NewReader(block))
140	if err != nil {
141		return "", time.Time{}, false
142	}
143	sig, ok := p.(*packet.Signature)
144	if !ok || sig.IssuerKeyId == nil {
145		return "", time.Time{}, false
146	}
147	return keyIDHex(*sig.IssuerKeyId), sig.CreationTime, true
148}