internal/sshd/revoke_test.go
169 lines · 5215 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 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
98 t.Fatal(err)
99 }
100 waitClosed(t, ts.client)
101}
102
103// A revocation made by another process is not announced here; the
104// sweep finds it. A live key survives the sweep.
105func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
106 ts := newTestServer(t)
107 ts.srv.sweepOnce()
108 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
109 t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
110 }
111 if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
112 t.Fatal(err)
113 }
114 ts.srv.sweepOnce()
115 waitClosed(t, ts.client)
116}
117
118// A key that expires while connected: the next exec is refused, and
119// the sweep closes the connection.
120func TestExpiredKeyRefusedAndCut(t *testing.T) {
121 ts := newTestServer(t)
122 past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
123 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
124 t.Fatal(err)
125 }
126 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
127 t.Fatalf("whoami with an expired key: %d %q", code, errOut)
128 }
129 ts.srv.sweepOnce()
130 waitClosed(t, ts.client)
131}
132
133// An expiring key may not mint.
134func TestExpiringKeyCannotMint(t *testing.T) {
135 ts := newTestServer(t)
136 future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
137 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
138 t.Fatal(err)
139 }
140 if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
141 t.Fatalf("token create with an expiring key: %d %q", code, errOut)
142 }
143}
144
145func TestExpiredKeyRefusedAtAuth(t *testing.T) {
146 ts := newTestServer(t)
147 _, priv, err := ed25519.GenerateKey(rand.Reader)
148 if err != nil {
149 t.Fatal(err)
150 }
151 signer, err := ssh.NewSignerFromKey(priv)
152 if err != nil {
153 t.Fatal(err)
154 }
155 pub := signer.PublicKey()
156 past := time.Now().Add(-time.Minute)
157 if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
158 t.Fatal(err)
159 }
160 _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
161 User: "git",
162 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
163 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
164 Timeout: 5 * time.Second,
165 })
166 if err == nil {
167 t.Fatal("an expired key authenticated")
168 }
169}