internal/sshd/revoke_test.go
183 lines · 5827 bytes
1package sshd
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "crypto/rand"
7 "errors"
8 "strings"
9 "testing"
10 "time"
11
12 "golang.org/x/crypto/ssh"
13
14 "gitbay.org/gitbay/internal/store"
15)
16
17// execStatus runs cmd on a new session and returns its exit status and
18// stderr; -1 when the session could not run.
19func execStatus(client *ssh.Client, cmd string) (int, string) {
20 sess, err := client.NewSession()
21 if err != nil {
22 return -1, err.Error()
23 }
24 defer sess.Close()
25 var stderr bytes.Buffer
26 sess.Stderr = &stderr
27 err = sess.Run(cmd)
28 var exit *ssh.ExitError
29 switch {
30 case err == nil:
31 return 0, stderr.String()
32 case errors.As(err, &exit):
33 return exit.ExitStatus(), stderr.String()
34 }
35 return -1, err.Error()
36}
37
38// waitClosed fails unless the server closes the client's connection
39// within five seconds.
40func waitClosed(t *testing.T, client *ssh.Client) {
41 t.Helper()
42 done := make(chan struct{})
43 go func() { client.Wait(); close(done) }()
44 select {
45 case <-done:
46 case <-time.After(5 * time.Second):
47 t.Fatal("the connection stayed open")
48 }
49}
50
51// Each exec reads the key again. The rows change behind the store's
52// back here, so no revocation is announced and the connection stays up:
53// what refuses the command is the per-exec check alone.
54func TestExecRevalidatesKey(t *testing.T) {
55 ts := newTestServer(t)
56 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
57 t.Fatalf("whoami: %d %s", code, errOut)
58 }
59 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil {
60 t.Fatal(err)
61 }
62 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") {
63 t.Fatalf("whoami after re-scope: %d %q", code, errOut)
64 }
65 if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil {
66 t.Fatal(err)
67 }
68 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") {
69 t.Fatalf("whoami after delete: %d %q", code, errOut)
70 }
71}
72
73// Removing the key cuts the connection, ending a command running on it.
74func TestRemoveKeyCutsConnection(t *testing.T) {
75 ts := newTestServer(t)
76 withBuild(t, ts)
77 var stderr bytes.Buffer
78 sess := startFollow(t, ts.client, &stderr)
79 if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil {
80 t.Fatal(err)
81 }
82 waited := make(chan error, 1)
83 go func() { waited <- sess.Wait() }()
84 select {
85 case err := <-waited:
86 if err == nil {
87 t.Fatal("the follow exited cleanly after its key was removed")
88 }
89 case <-time.After(5 * time.Second):
90 t.Fatal("the follow outlived its key")
91 }
92 waitClosed(t, ts.client)
93}
94
95func TestDisableCutsConnection(t *testing.T) {
96 ts := newTestServer(t)
97 // The client's handshake can finish before the server has recorded
98 // the connection's account; a command answered proves it has.
99 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
100 t.Fatalf("whoami: %d %s", code, errOut)
101 }
102 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
103 t.Fatal(err)
104 }
105 waitClosed(t, ts.client)
106}
107
108// A revocation made by another process is not announced here; the
109// sweep finds it. A live key survives the sweep.
110func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
111 ts := newTestServer(t)
112 ts.srv.sweepOnce()
113 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
114 t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
115 }
116 if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
117 t.Fatal(err)
118 }
119 ts.srv.sweepOnce()
120 waitClosed(t, ts.client)
121}
122
123// A key that expires while connected: the next exec is refused, and
124// the sweep closes the connection.
125func TestExpiredKeyRefusedAndCut(t *testing.T) {
126 ts := newTestServer(t)
127 past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
128 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
129 t.Fatal(err)
130 }
131 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
132 t.Fatalf("whoami with an expired key: %d %q", code, errOut)
133 }
134 ts.srv.sweepOnce()
135 waitClosed(t, ts.client)
136}
137
138// An expiring key may not mint.
139func TestExpiringKeyCannotMint(t *testing.T) {
140 ts := newTestServer(t)
141 future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
142 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
143 t.Fatal(err)
144 }
145 if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
146 t.Fatalf("token create with an expiring key: %d %q", code, errOut)
147 }
148}
149
150func TestExpiredKeyRefusedAtAuth(t *testing.T) {
151 ts := newTestServer(t)
152 _, priv, err := ed25519.GenerateKey(rand.Reader)
153 if err != nil {
154 t.Fatal(err)
155 }
156 signer, err := ssh.NewSignerFromKey(priv)
157 if err != nil {
158 t.Fatal(err)
159 }
160 pub := signer.PublicKey()
161 past := time.Now().Add(-time.Minute)
162 if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
163 t.Fatal(err)
164 }
165 _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
166 User: "git",
167 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
168 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
169 Timeout: 5 * time.Second,
170 })
171 if err == nil {
172 t.Fatal("an expired key authenticated")
173 }
174 // Anyone holding only the public key can offer it; each offer
175 // counts against the address like an unknown key.
176 ip := remoteIP(ts.client.LocalAddr())
177 ts.srv.authLimiter.mu.Lock()
178 w := ts.srv.authLimiter.seen[ip]
179 ts.srv.authLimiter.mu.Unlock()
180 if w == nil || w.count < 1 {
181 t.Fatalf("an expired key's attempt from %s did not count against the limiter", ip)
182 }
183}