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}