v0.28.1 cc7ba64

ssh.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify
4
5import (
6	"bytes"
7	"crypto/sha256"
8	"crypto/sha512"
9	"encoding/base64"
10	"strings"
11
12	"golang.org/x/crypto/ssh"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16)
17
18// SSHSIG constants (PROTOCOL.sshsig); the namespace is the one Git signs with.
19const (
20	sshMagic     = "SSHSIG"
21	sshVersion   = 1
22	sshNamespace = "git"
23	sshArmorHead = "-----BEGIN SSH SIGNATURE-----"
24	sshArmorTail = "-----END SSH SIGNATURE-----"
25)
26
27// SSH verifies SSH signatures (gpg.format=ssh). It ignores the trusted keys
28// that are not SSH keys.
29type SSH struct{}
30
31var _ ports.SignatureVerifier = SSH{}
32
33// sshSignedData is what the signature covers after the magic.
34type sshSignedData struct {
35	Namespace     string
36	Reserved      string
37	HashAlgorithm string
38	Hash          string
39}
40
41// sshSigBlob is the signature file after the magic.
42type sshSigBlob struct {
43	Version       uint32
44	PublicKey     string
45	Namespace     string
46	Reserved      string
47	HashAlgorithm string
48	Signature     string
49}
50
51// Verify implements ports.SignatureVerifier.
52func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
53	v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
54	defer func() { // a parser bug must not take the server down
55		if recover() != nil {
56			v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
57		}
58	}()
59	if len(signature) > maxSignatureBytes {
60		return v
61	}
62	blob, ok := decodeSSHArmor(signature)
63	if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) {
64		return v
65	}
66	var sb sshSigBlob
67	if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil {
68		return v
69	}
70	pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
71	if err != nil {
72		return v
73	}
74	v.Fingerprint = ssh.FingerprintSHA256(pk)
75	if sb.Version != sshVersion || sb.Namespace != sshNamespace {
76		return v
77	}
78	var sum []byte
79	switch sb.HashAlgorithm {
80	case "sha256":
81		h := sha256.Sum256(payload)
82		sum = h[:]
83	case "sha512":
84		h := sha512.Sum512(payload)
85		sum = h[:]
86	default:
87		return v
88	}
89	var sf struct {
90		Format string
91		Blob   string
92		Rest   []byte `ssh:"rest"`
93	}
94	if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil {
95		return v
96	}
97	if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 {
98		return v // ssh-rsa is SHA-1
99	}
100	msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{
101		Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum),
102	})...)
103	if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil {
104		return v
105	}
106
107	want := pk.Marshal()
108	for _, k := range keys {
109		if k.Format != domain.FormatSSH {
110			continue
111		}
112		tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
113		if err != nil || !bytes.Equal(tk.Marshal(), want) {
114			continue
115		}
116		return trusted(v, k, committerEmail)
117	}
118	v.Status = domain.StatusUnknownKey
119	return v
120}
121
122// decodeSSHArmor returns the binary signature file of an armored SSH
123// signature.
124func decodeSSHArmor(armored []byte) ([]byte, bool) {
125	lines := strings.Split(strings.TrimSpace(string(armored)), "\n")
126	if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail {
127		return nil, false
128	}
129	var b strings.Builder
130	for _, l := range lines[1 : len(lines)-1] {
131		b.WriteString(strings.TrimSpace(l))
132	}
133	raw, err := base64.StdEncoding.DecodeString(b.String())
134	return raw, err == nil
135}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close