krz/gitbay

A CLI-first git forge.

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

main: internal/sig/sshsig.go · raw

  1package sig
  2
  3import (
  4	"bytes"
  5	"crypto/sha256"
  6	"crypto/sha512"
  7	"encoding/base64"
  8	"encoding/binary"
  9	"fmt"
 10	"slices"
 11	"strings"
 12
 13	"golang.org/x/crypto/ssh"
 14)
 15
 16// SSHSIG armored signature format, per openssh PROTOCOL.sshsig.
 17const sshsigMagic = "SSHSIG"
 18
 19type sshsigBlob struct {
 20	Version       uint32
 21	PublicKey     []byte
 22	Namespace     string
 23	Reserved      string
 24	HashAlgorithm string
 25	Signature     []byte
 26}
 27
 28func verifySSH(db DB, c *Commit) (Result, error) {
 29	blob, err := decodeArmor(c.Signature, "SSH SIGNATURE")
 30	if err != nil {
 31		return Result{State: BadSignature}, nil
 32	}
 33	sb, err := parseSSHSig(blob)
 34	if err != nil {
 35		return Result{State: BadSignature}, nil
 36	}
 37	pub, err := ssh.ParsePublicKey(sb.PublicKey)
 38	if err != nil {
 39		return Result{State: BadSignature}, nil
 40	}
 41
 42	fp := ssh.FingerprintSHA256(pub)
 43	key, found, err := db.SSHSignerByFingerprint(fp)
 44	if err != nil {
 45		return Result{}, err
 46	}
 47	if !found {
 48		return Result{State: SignedUnknownKey, KeyFingerprint: fp}, nil
 49	}
 50	res := Result{SignerUserID: key.UserID, KeyFingerprint: fp}
 51
 52	// Reconstruct the signed blob: MAGIC || namespace || reserved ||
 53	// hash_algorithm || H(payload).
 54	var h []byte
 55	switch sb.HashAlgorithm {
 56	case "sha512":
 57		d := sha512.Sum512(c.Payload)
 58		h = d[:]
 59	case "sha256":
 60		d := sha256.Sum256(c.Payload)
 61		h = d[:]
 62	default:
 63		res.State = BadSignature
 64		return res, nil
 65	}
 66	if sb.Namespace != "git" {
 67		res.State = BadSignature
 68		return res, nil
 69	}
 70	signed := buildSSHSignedData(sb.Namespace, sb.Reserved, sb.HashAlgorithm, h)
 71
 72	var sshSig ssh.Signature
 73	if err := ssh.Unmarshal(sb.Signature, &sshSig); err != nil {
 74		res.State = BadSignature
 75		return res, nil
 76	}
 77	if err := pub.Verify(signed, &sshSig); err != nil {
 78		res.State = BadSignature
 79		return res, nil
 80	}
 81
 82	// SSH keys carry no identities: the principal set is the owning
 83	// account's verified emails.
 84	verified, err := db.VerifiedEmails(key.UserID)
 85	if err != nil {
 86		return Result{}, err
 87	}
 88	if !slices.Contains(verified, c.AuthorEmail) {
 89		res.State = SignedEmailMismatch
 90		return res, nil
 91	}
 92	res.State = Verified
 93	return res, nil
 94}
 95
 96func parseSSHSig(blob []byte) (*sshsigBlob, error) {
 97	if !bytes.HasPrefix(blob, []byte(sshsigMagic)) {
 98		return nil, fmt.Errorf("missing SSHSIG magic")
 99	}
100	var sb sshsigBlob
101	if err := ssh.Unmarshal(blob[len(sshsigMagic):], &sb); err != nil {
102		return nil, err
103	}
104	if sb.Version != 1 {
105		return nil, fmt.Errorf("unsupported sshsig version %d", sb.Version)
106	}
107	return &sb, nil
108}
109
110func buildSSHSignedData(namespace, reserved, hashAlg string, hash []byte) []byte {
111	var b bytes.Buffer
112	b.WriteString(sshsigMagic)
113	writeSSHString(&b, []byte(namespace))
114	writeSSHString(&b, []byte(reserved))
115	writeSSHString(&b, []byte(hashAlg))
116	writeSSHString(&b, hash)
117	return b.Bytes()
118}
119
120func writeSSHString(b *bytes.Buffer, s []byte) {
121	var l [4]byte
122	binary.BigEndian.PutUint32(l[:], uint32(len(s)))
123	b.Write(l[:])
124	b.Write(s)
125}
126
127// MarshalSSHSig builds an armored SSHSIG over payload with the given signer.
128// Used by fixture generation and (later) forge-side tooling.
129func MarshalSSHSig(signer ssh.Signer, payload []byte) ([]byte, error) {
130	d := sha512.Sum512(payload)
131	signed := buildSSHSignedData("git", "", "sha512", d[:])
132	sshSig, err := signer.Sign(nil, signed)
133	if err != nil {
134		return nil, err
135	}
136	var body bytes.Buffer
137	body.WriteString(sshsigMagic)
138	blob := sshsigBlob{
139		Version:       1,
140		PublicKey:     signer.PublicKey().Marshal(),
141		Namespace:     "git",
142		Reserved:      "",
143		HashAlgorithm: "sha512",
144		Signature:     ssh.Marshal(sshSig),
145	}
146	body.Write(ssh.Marshal(blob))
147
148	b64 := base64.StdEncoding.EncodeToString(body.Bytes())
149	var out strings.Builder
150	out.WriteString("-----BEGIN SSH SIGNATURE-----\n")
151	for len(b64) > 70 {
152		out.WriteString(b64[:70] + "\n")
153		b64 = b64[70:]
154	}
155	out.WriteString(b64 + "\n-----END SSH SIGNATURE-----\n")
156	return []byte(out.String()), nil
157}
158
159// decodeArmor extracts the base64 body between BEGIN/END markers for the
160// given label. Works for both SSHSIG and OpenPGP armor (checksum lines and
161// armor headers are skipped).
162func decodeArmor(armored []byte, label string) ([]byte, error) {
163	begin := "-----BEGIN " + label + "-----"
164	end := "-----END " + label + "-----"
165	s := string(armored)
166	i := strings.Index(s, begin)
167	j := strings.Index(s, end)
168	if i < 0 || j < i {
169		return nil, fmt.Errorf("no %s armor", label)
170	}
171	var b64 strings.Builder
172	for _, line := range strings.Split(s[i+len(begin):j], "\n") {
173		line = strings.TrimSpace(line)
174		if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") {
175			continue // armor checksum or header line
176		}
177		b64.WriteString(line)
178	}
179	return base64.StdEncoding.DecodeString(b64.String())
180}