internal/sshd/revoke_test.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/sshd/revoke_test.go history · blame · raw

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}