Raw
  1package replica
  2
  3import (
  4	"bytes"
  5	"crypto/sha512"
  6	"encoding/base64"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"slices"
 11	"strings"
 12
 13	"github.com/go-git/go-git/v5/plumbing"
 14	"github.com/go-git/go-git/v5/plumbing/object"
 15	"github.com/go-git/go-git/v5/plumbing/storer"
 16	"golang.org/x/crypto/ssh"
 17)
 18
 19var (
 20	ErrUnsigned        = errors.New("replica: commit is not signed")
 21	ErrNotSSHSignature = errors.New("replica: signature is not an SSH signature")
 22	ErrMalformed       = errors.New("replica: malformed SSH signature")
 23	ErrNamespace       = errors.New("replica: signature namespace is not git")
 24	ErrWrongKey        = errors.New("replica: commit signed by another key")
 25	ErrBadSignature    = errors.New("replica: signature does not match the commit")
 26)
 27
 28const (
 29	sigHeader       = "gpgsig "
 30	sigHeaderSHA256 = "gpgsig-sha256 "
 31)
 32
 33func VerifyCommit(objects storer.EncodedObjectStorer, id plumbing.Hash, key ssh.PublicKey) (*object.Commit, error) {
 34	if key == nil || key.Type() != ssh.KeyAlgoED25519 {
 35		return nil, ErrUnsupportedKey
 36	}
 37	return VerifySSHCommit(objects, id, key, ssh.KeyAlgoED25519)
 38}
 39
 40func VerifySSHCommit(objects storer.EncodedObjectStorer, id plumbing.Hash, key ssh.PublicKey, formats ...string) (*object.Commit, error) {
 41	if key == nil || len(formats) == 0 {
 42		return nil, ErrUnsupportedKey
 43	}
 44	obj, err := objects.EncodedObject(plumbing.CommitObject, id)
 45	if err != nil {
 46		return nil, err
 47	}
 48	rd, err := obj.Reader()
 49	if err != nil {
 50		return nil, err
 51	}
 52	raw, err := io.ReadAll(rd)
 53	rd.Close()
 54	if err != nil {
 55		return nil, err
 56	}
 57	payload, armored, err := splitSignature(raw)
 58	if err != nil {
 59		return nil, fmt.Errorf("%w: %s", err, id)
 60	}
 61	blob, err := dearmor(armored)
 62	if err != nil {
 63		return nil, fmt.Errorf("%w: %s", err, id)
 64	}
 65	if err := verifySSHBlob(blob, payload, key, formats); err != nil {
 66		return nil, fmt.Errorf("%w: %s", err, id)
 67	}
 68	return object.DecodeCommit(objects, obj)
 69}
 70
 71func splitSignature(raw []byte) ([]byte, []byte, error) {
 72	end := bytes.Index(raw, []byte("\n\n"))
 73	if end < 0 {
 74		return nil, nil, ErrUnsigned
 75	}
 76	headers, rest := raw[:end+1], raw[end+1:]
 77	var payload, sig bytes.Buffer
 78	found, inSig := 0, false
 79	for len(headers) > 0 {
 80		i := bytes.IndexByte(headers, '\n')
 81		line := headers[:i+1]
 82		headers = headers[i+1:]
 83		if inSig && line[0] == ' ' {
 84			sig.Write(line[1:])
 85			continue
 86		}
 87		inSig = false
 88		switch {
 89		case bytes.HasPrefix(line, []byte(sigHeader)):
 90			found++
 91			inSig = true
 92			sig.Write(line[len(sigHeader):])
 93		case bytes.HasPrefix(line, []byte(sigHeaderSHA256)):
 94			return nil, nil, ErrMalformed
 95		default:
 96			payload.Write(line)
 97		}
 98	}
 99	switch {
100	case found == 0:
101		return nil, nil, ErrUnsigned
102	case found > 1:
103		return nil, nil, ErrMalformed
104	}
105	payload.Write(rest)
106	return payload.Bytes(), sig.Bytes(), nil
107}
108
109func dearmor(armored []byte) ([]byte, error) {
110	text := string(armored)
111	if !strings.HasPrefix(text, armorBegin+"\n") {
112		return nil, ErrNotSSHSignature
113	}
114	body := strings.TrimSuffix(strings.TrimPrefix(text, armorBegin+"\n"), "\n")
115	if !strings.HasSuffix(body, "\n"+armorEnd) {
116		return nil, ErrMalformed
117	}
118	body = strings.TrimSuffix(body, "\n"+armorEnd)
119	blob, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(body, "\n", ""))
120	if err != nil {
121		return nil, ErrMalformed
122	}
123	return blob, nil
124}
125
126func verifyBlob(blob, payload []byte, key ssh.PublicKey) error {
127	return verifySSHBlob(blob, payload, key, []string{ssh.KeyAlgoED25519})
128}
129
130func verifySSHBlob(blob, payload []byte, key ssh.PublicKey, formats []string) error {
131	if !bytes.HasPrefix(blob, []byte(sigMagic)) {
132		return ErrMalformed
133	}
134	var w sigBlob
135	if err := ssh.Unmarshal(blob[len(sigMagic):], &w); err != nil {
136		return ErrMalformed
137	}
138	if w.Version != sigVersion || w.Reserved != "" || w.HashAlg != sigHashAlg {
139		return ErrMalformed
140	}
141	if w.Namespace != Namespace {
142		return ErrNamespace
143	}
144	signer, err := ssh.ParsePublicKey(w.PublicKey)
145	if err != nil {
146		return ErrMalformed
147	}
148	if !bytes.Equal(signer.Marshal(), key.Marshal()) {
149		return ErrWrongKey
150	}
151	var sig ssh.Signature
152	if err := ssh.Unmarshal(w.Signature, &sig); err != nil || len(sig.Rest) != 0 || !slices.Contains(formats, sig.Format) {
153		return ErrMalformed
154	}
155	sum := sha512.Sum512(payload)
156	signed := append([]byte(sigMagic), ssh.Marshal(signedData{
157		Namespace: w.Namespace,
158		HashAlg:   w.HashAlg,
159		Hash:      sum[:],
160	})...)
161	if err := key.Verify(signed, &sig); err != nil {
162		return ErrBadSignature
163	}
164	return nil
165}