v0.27.0 0039612

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.
52//
53//hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain
54func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
55	v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
56	defer func() { // a parser bug must not take the server down
57		if recover() != nil {
58			v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
59		}
60	}()
61	if len(signature) > maxSignatureBytes {
62		return v
63	}
64	blob, ok := decodeSSHArmor(signature)
65	if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) {
66		return v
67	}
68	var sb sshSigBlob
69	if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil {
70		return v
71	}
72	pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
73	if err != nil {
74		return v
75	}
76	v.Fingerprint = ssh.FingerprintSHA256(pk)
77	if sb.Version != sshVersion || sb.Namespace != sshNamespace {
78		return v
79	}
80	var sum []byte
81	switch sb.HashAlgorithm {
82	case "sha256":
83		h := sha256.Sum256(payload)
84		sum = h[:]
85	case "sha512":
86		h := sha512.Sum512(payload)
87		sum = h[:]
88	default:
89		return v
90	}
91	var sf struct {
92		Format string
93		Blob   string
94		Rest   []byte `ssh:"rest"`
95	}
96	if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil {
97		return v
98	}
99	if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 {
100		return v // ssh-rsa is SHA-1
101	}
102	msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{
103		Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum),
104	})...)
105	if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil {
106		return v
107	}
108
109	want := pk.Marshal()
110	for _, k := range keys {
111		if k.Format != domain.FormatSSH {
112			continue
113		}
114		tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
115		if err != nil || !bytes.Equal(tk.Marshal(), want) {
116			continue
117		}
118		return trusted(v, k, committerEmail)
119	}
120	v.Status = domain.StatusUnknownKey
121	return v
122}
123
124// decodeSSHArmor returns the binary signature file of an armored SSH
125// signature.
126func decodeSSHArmor(armored []byte) ([]byte, bool) {
127	lines := strings.Split(strings.TrimSpace(string(armored)), "\n")
128	if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail {
129		return nil, false
130	}
131	var b strings.Builder
132	for _, l := range lines[1 : len(lines)-1] {
133		b.WriteString(strings.TrimSpace(l))
134	}
135	raw, err := base64.StdEncoding.DecodeString(b.String())
136	return raw, err == nil
137}

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 4

Tags 45

↑↓ moveenter openesc close