detached 0e6522c

ssh_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify_test
4
5import (
6	"bytes"
7	"crypto/ed25519"
8	"strings"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13	"golang.org/x/crypto/ssh"
14
15	"bnema.dev/ermite/internal/adapters/sigverify"
16	"bnema.dev/ermite/internal/adapters/sshsig"
17	"bnema.dev/ermite/internal/domain"
18)
19
20func fingerprint(t *testing.T, authorizedKey string) string {
21	t.Helper()
22	pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKey))
23	require.NoError(t, err)
24	return ssh.FingerprintSHA256(pk)
25}
26
27func TestSSHVerify(t *testing.T) {
28	const email = "dev@example.org"
29	keys := map[string]struct{ private, public string }{}
30	for name, args := range map[string][]string{
31		"ed25519": {"ed25519"},
32		"rsa":     {"rsa", "-b", "2048"},
33		"ecdsa":   {"ecdsa", "-b", "256"},
34	} {
35		priv, pub := sshKey(t, args[0], args[1:]...)
36		keys[name] = struct{ private, public string }{priv, pub}
37	}
38
39	for name, key := range keys {
40		t.Run(name, func(t *testing.T) {
41			c := sshCommit(t, key.private, email)
42			fp := fingerprint(t, key.public)
43			other := keys["ed25519"]
44			if name == "ed25519" {
45				other = keys["rsa"]
46			}
47			tamperedPayload := append(bytes.Clone(c.payload), '!')
48			tamperedSig := bytes.Replace(c.signature, []byte("\n"), []byte("\nAAAA"), 2)
49
50			tests := []struct {
51				name      string
52				payload   []byte
53				signature []byte
54				keys      []domain.TrustedKey
55				email     string
56				want      domain.SignatureStatus
57				wantKey   bool
58			}{
59				{"verified", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusVerified, true},
60				{"verified with the canonical key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(strings.Join(strings.Fields(key.public)[:2], " "), email)}, email, domain.StatusVerified, true},
61				{"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, "DEV@Example.ORG", domain.StatusVerified, true},
62				{"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email), trustedGPG("not a key", email), trustedSSH(key.public, email)}, email, domain.StatusVerified, true},
63				{"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, "other@example.org")}, email, domain.StatusEmailMismatch, true},
64				{"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email)}, email, domain.StatusUnknownKey, false},
65				{"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, false},
66				{"gpg keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatGPG, PublicKey: key.public, Emails: []string{email}}}, email, domain.StatusUnknownKey, false},
67				{"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
68				{"tampered signature", c.payload, tamperedSig, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
69				{"empty signature", c.payload, nil, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
70			}
71			for _, tt := range tests {
72				t.Run(tt.name, func(t *testing.T) {
73					v := sigverify.SSH{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys)
74					assert.Equal(t, domain.FormatSSH, v.Format)
75					assert.Equal(t, tt.want, v.Status)
76					if tt.wantKey {
77						assert.Equal(t, "ssh-id", v.KeyID)
78						assert.Equal(t, "ssh key", v.KeyTitle)
79					} else {
80						assert.Empty(t, v.KeyID)
81						assert.Empty(t, v.KeyTitle)
82					}
83					if tt.want == domain.StatusVerified || tt.want == domain.StatusEmailMismatch || tt.want == domain.StatusUnknownKey {
84						assert.Equal(t, fp, v.Fingerprint)
85					}
86				})
87			}
88		})
89	}
90}
91
92func TestSSHVerifyWrongNamespace(t *testing.T) {
93	const email = "dev@example.org"
94	priv, pub := sshKey(t, "ed25519")
95	payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b> 1 +0000\n\nmsg\n")
96	// Reading stdin, ssh-keygen -Y sign writes the armored signature to stdout.
97	sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "file", "-f", priv)
98	require.Contains(t, sig, "BEGIN SSH SIGNATURE")
99
100	v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
101	assert.Equal(t, domain.StatusBadSignature, v.Status)
102	assert.Equal(t, fingerprint(t, pub), v.Fingerprint, "the key is still named")
103
104	// The same signing in namespace git verifies: the namespace is the only change.
105	sig = run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv)
106	v = sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
107	assert.Equal(t, domain.StatusVerified, v.Status)
108}
109
110func TestSSHVerifyHostileInput(t *testing.T) {
111	const email = "dev@example.org"
112	_, pub := sshKey(t, "ed25519")
113	keys := []domain.TrustedKey{trustedSSH(pub, email)}
114	valid := func() []byte {
115		s, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
116		require.NoError(t, err)
117		sig, err := s.Sign([]byte("payload"))
118		require.NoError(t, err)
119		return []byte(sig)
120	}()
121
122	huge := []byte("-----BEGIN SSH SIGNATURE-----\n" + strings.Repeat("A", 70<<10) + "\n-----END SSH SIGNATURE-----\n")
123	tests := []struct {
124		name string
125		sig  []byte
126	}{
127		{"not armored", []byte("hello")},
128		{"no end line", bytes.TrimSuffix(valid, []byte("-----END SSH SIGNATURE-----\n"))},
129		{"bad base64", []byte("-----BEGIN SSH SIGNATURE-----\n!!!!\n-----END SSH SIGNATURE-----\n")},
130		{"wrong magic", []byte("-----BEGIN SSH SIGNATURE-----\nQUJDREVGR0g=\n-----END SSH SIGNATURE-----\n")},
131		{"magic only", []byte("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n")},
132		{"larger than 64 KiB", huge},
133	}
134	for _, tt := range tests {
135		t.Run(tt.name, func(t *testing.T) {
136			var v domain.CommitVerification
137			require.NotPanics(t, func() { v = sigverify.SSH{}.Verify([]byte("payload"), tt.sig, email, 0, keys) })
138			assert.Equal(t, domain.StatusBadSignature, v.Status)
139			assert.Equal(t, domain.FormatSSH, v.Format)
140		})
141	}
142
143	t.Run("every truncation of a valid signature", func(t *testing.T) {
144		for i := range valid {
145			require.NotPanics(t, func() { sigverify.SSH{}.Verify([]byte("payload"), valid[:i], email, 0, keys) })
146		}
147	})
148}
149
150// TestSSHVerifyInstanceKey checks the signature of the commits ermite itself
151// creates (ADR 0022) against the public key it publishes.
152func TestSSHVerifyInstanceKey(t *testing.T) {
153	const email = "ermite@example.org"
154	signer, err := sshsig.New(bytes.Repeat([]byte{5}, ed25519.SeedSize))
155	require.NoError(t, err)
156	payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor E <ermite@example.org> 1 +0000\ncommitter E <ermite@example.org> 1 +0000\n\nmsg\n")
157	sig, err := signer.Sign(payload)
158	require.NoError(t, err)
159
160	key := domain.TrustedKey{ID: signer.PublicKey().Fingerprint, Format: domain.FormatSSH, Title: "instance", PublicKey: signer.PublicKey().AuthorizedKey, Emails: []string{email}, Builtin: true}
161	v := sigverify.Verifier{}.Verify(payload, []byte(sig), email, 1, []domain.TrustedKey{key})
162	assert.Equal(t, domain.CommitVerification{
163		Format: domain.FormatSSH, Status: domain.StatusVerified,
164		Fingerprint: signer.PublicKey().Fingerprint, KeyID: key.ID, KeyTitle: "instance",
165	}, v)
166
167	v = sigverify.Verifier{}.Verify(append(payload, 'x'), []byte(sig), email, 1, []domain.TrustedKey{key})
168	assert.Equal(t, domain.StatusBadSignature, v.Status)
169}
170
171// TestSSHVerifyRSASignatureFormat checks that the RSA signature ssh-keygen
172// makes (rsa-sha2-512) verifies; the legacy SHA-1 format is refused by the
173// verifier.
174func TestSSHVerifyRSASignatureFormat(t *testing.T) {
175	const email = "dev@example.org"
176	priv, pub := sshKey(t, "rsa", "-b", "2048")
177	payload := []byte("payload\n")
178	sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv)
179	v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
180	assert.Equal(t, domain.StatusVerified, v.Status)
181}

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