Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 28m ago 32290114
history blame raw 11.7 KiB
1package session
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "golang.org/x/crypto/ssh"
14 "rodent.software/vole/forge/identity"
15)
16
17type fixture struct {
18 ident *identity.Store
19 store *Store
20 now time.Time
21}
22
23func newFixture(t *testing.T) *fixture {
24 t.Helper()
25 path := filepath.Join(t.TempDir(), "users.db")
26 if err := identity.Init(path); err != nil {
27 t.Fatal(err)
28 }
29 ident, err := identity.Open(path)
30 if err != nil {
31 t.Fatal(err)
32 }
33 t.Cleanup(func() { ident.Close() })
34 store, err := Open(path)
35 if err != nil {
36 ident.Close()
37 t.Fatal(err)
38 }
39 t.Cleanup(func() { store.Close() })
40 f := &fixture{ident: ident, store: store, now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}
41 store.Now = func() time.Time { return f.now }
42 store.AuditPath = filepath.Join(filepath.Dir(path), "audit.log")
43 ident.AuditPath = store.AuditPath
44 return f
45}
46
47func (f *fixture) account(t *testing.T, name string) *identity.Account {
48 t.Helper()
49 pub, _, err := ed25519.GenerateKey(rand.Reader)
50 if err != nil {
51 t.Fatal(err)
52 }
53 key, err := ssh.NewPublicKey(pub)
54 if err != nil {
55 t.Fatal(err)
56 }
57 a, err := f.ident.Create(name, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), "test")
58 if err != nil {
59 t.Fatal(err)
60 }
61 return a
62}
63
64func (f *fixture) credential(t *testing.T, a *identity.Account) identity.Credential {
65 t.Helper()
66 id := make([]byte, 20)
67 pub := make([]byte, 32)
68 if _, err := rand.Read(id); err != nil {
69 t.Fatal(err)
70 }
71 if _, err := rand.Read(pub); err != nil {
72 t.Fatal(err)
73 }
74 cred := identity.Credential{ID: id, PublicKey: pub, Label: "laptop"}
75 if err := f.ident.AddCredential(a.Name, cred, "test"); err != nil {
76 t.Fatal(err)
77 }
78 return cred
79}
80
81func TestCreateLookupAndRevoke(t *testing.T) {
82 t.Parallel()
83 f := newFixture(t)
84 a := f.account(t, "alice")
85 sess, err := f.store.Create(a.Name, nil, "test-agent")
86 if err != nil {
87 t.Fatal(err)
88 }
89 if sess.Token == "" || !sess.Expires.Equal(f.now.Add(DefaultAbsolute)) {
90 t.Fatalf("created session = %+v", sess)
91 }
92 // The plaintext token must not be in the database.
93 var plain int
94 if err := f.store.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash=?`, sess.Token).Scan(&plain); err != nil {
95 t.Fatal(err)
96 }
97 if plain != 0 {
98 t.Fatal("the plaintext session token was stored")
99 }
100 got, err := f.store.Lookup(sess.Token)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if got.Name != a.Name || got.UserAgent != "test-agent" || got.Token != "" {
105 t.Fatalf("lookup = %+v", got)
106 }
107 if _, err := f.store.Lookup("not-a-token"); !errors.Is(err, ErrNoSession) {
108 t.Fatalf("unknown token = %v, want ErrNoSession", err)
109 }
110 if err := f.store.Revoke(sess.Token); err != nil {
111 t.Fatal(err)
112 }
113 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
114 t.Fatalf("revoked token still works: %v", err)
115 }
116 if err := f.store.Revoke(sess.Token); err != nil {
117 t.Fatalf("revoking twice = %v", err)
118 }
119}
120
121func TestIdleExpiryAndSlidingUse(t *testing.T) {
122 t.Parallel()
123 f := newFixture(t)
124 f.store.Idle = time.Hour
125 f.store.Absolute = 24 * time.Hour
126 a := f.account(t, "alice")
127 sess, err := f.store.Create(a.Name, nil, "agent")
128 if err != nil {
129 t.Fatal(err)
130 }
131 f.now = f.now.Add(50 * time.Minute)
132 if _, err := f.store.Lookup(sess.Token); err != nil {
133 t.Fatalf("session expired before the idle limit: %v", err)
134 }
135 // The lookup refreshed the idle clock; another 50 minutes is still inside it.
136 f.now = f.now.Add(50 * time.Minute)
137 if _, err := f.store.Lookup(sess.Token); err != nil {
138 t.Fatalf("session expired despite recent use: %v", err)
139 }
140 f.now = f.now.Add(61 * time.Minute)
141 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
142 t.Fatalf("idle session survived: %v", err)
143 }
144}
145
146func TestAbsoluteExpiry(t *testing.T) {
147 t.Parallel()
148 f := newFixture(t)
149 f.store.Idle = 100 * time.Hour
150 f.store.Absolute = 2 * time.Hour
151 a := f.account(t, "alice")
152 sess, err := f.store.Create(a.Name, nil, "agent")
153 if err != nil {
154 t.Fatal(err)
155 }
156 f.now = f.now.Add(2*time.Hour + time.Second)
157 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
158 t.Fatalf("expired session survived: %v", err)
159 }
160}
161
162func TestLastSeenIsWrittenCoarsely(t *testing.T) {
163 t.Parallel()
164 f := newFixture(t)
165 a := f.account(t, "alice")
166 sess, err := f.store.Create(a.Name, nil, "agent")
167 if err != nil {
168 t.Fatal(err)
169 }
170 first := f.now.Unix()
171 f.now = f.now.Add(LastSeenEvery + time.Minute)
172 if _, err := f.store.Lookup(sess.Token); err != nil {
173 t.Fatal(err)
174 }
175 var seen int64
176 if err := f.store.db.QueryRow(`SELECT last_seen FROM sessions WHERE user_name=?`, a.Name).Scan(&seen); err != nil {
177 t.Fatal(err)
178 }
179 if seen != f.now.Unix() || seen == first {
180 t.Fatalf("last_seen = %d, want coarse update to %d", seen, f.now.Unix())
181 }
182 // A request inside the interval must not write again.
183 f.now = f.now.Add(time.Minute)
184 if _, err := f.store.Lookup(sess.Token); err != nil {
185 t.Fatal(err)
186 }
187 var again int64
188 if err := f.store.db.QueryRow(`SELECT last_seen FROM sessions WHERE user_name=?`, a.Name).Scan(&again); err != nil {
189 t.Fatal(err)
190 }
191 if again != seen {
192 t.Fatalf("last_seen was rewritten inside the coarse interval: %d -> %d", seen, again)
193 }
194}
195
196func TestRevokeAccountAndCredential(t *testing.T) {
197 t.Parallel()
198 f := newFixture(t)
199 a := f.account(t, "alice")
200 cred := f.credential(t, a)
201 byCredential, err := f.store.Create(a.Name, cred.ID, "laptop")
202 if err != nil {
203 t.Fatal(err)
204 }
205 byCookie, err := f.store.Create(a.Name, nil, "other")
206 if err != nil {
207 t.Fatal(err)
208 }
209 n, err := f.store.RevokeCredential(cred.ID)
210 if err != nil {
211 t.Fatal(err)
212 }
213 if n != 1 {
214 t.Fatalf("RevokeCredential deleted %d, want 1", n)
215 }
216 if _, err := f.store.Lookup(byCredential.Token); !errors.Is(err, ErrNoSession) {
217 t.Fatal("passkey session survived credential revoke")
218 }
219 if _, err := f.store.Lookup(byCookie.Token); err != nil {
220 t.Fatalf("unrelated session was revoked: %v", err)
221 }
222 n, err = f.store.RevokeAccount(a.Name)
223 if err != nil {
224 t.Fatal(err)
225 }
226 if n != 1 {
227 t.Fatalf("RevokeAccount deleted %d, want 1", n)
228 }
229 if _, err := f.store.Lookup(byCookie.Token); !errors.Is(err, ErrNoSession) {
230 t.Fatal("account session survived account revoke")
231 }
232}
233
234func TestRetireAndCredentialRemovalEndSessions(t *testing.T) {
235 t.Parallel()
236 f := newFixture(t)
237 a := f.account(t, "alice")
238 cred := f.credential(t, a)
239 sess, err := f.store.Create(a.Name, cred.ID, "laptop")
240 if err != nil {
241 t.Fatal(err)
242 }
243 if err := f.ident.RemoveCredential(a.Name, cred.ID, "test"); err != nil {
244 t.Fatal(err)
245 }
246 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
247 t.Fatal("removing the passkey left its session alive")
248 }
249 sess, err = f.store.Create(a.Name, nil, "cookie")
250 if err != nil {
251 t.Fatal(err)
252 }
253 if _, err := f.ident.Retire(a.Name, "test"); err != nil {
254 t.Fatal(err)
255 }
256 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
257 t.Fatal("retiring the account left its session alive")
258 }
259 if _, err := f.store.Create(a.Name, nil, "retired"); !errors.Is(err, ErrNoAccount) {
260 t.Fatalf("created a session for a retired account: %v", err)
261 }
262}
263
264func TestListShowsLiveSessionsWithoutTokens(t *testing.T) {
265 t.Parallel()
266 f := newFixture(t)
267 a := f.account(t, "alice")
268 if _, err := f.store.Create(a.Name, nil, "one"); err != nil {
269 t.Fatal(err)
270 }
271 f.now = f.now.Add(time.Minute)
272 if _, err := f.store.Create(a.Name, nil, "two"); err != nil {
273 t.Fatal(err)
274 }
275 list, err := f.store.List(a.Name)
276 if err != nil {
277 t.Fatal(err)
278 }
279 if len(list) != 2 {
280 t.Fatalf("List = %d sessions, want 2", len(list))
281 }
282 if list[0].UserAgent != "two" || list[1].UserAgent != "one" {
283 t.Fatalf("List order = %q, %q; want newest first", list[0].UserAgent, list[1].UserAgent)
284 }
285 if list[0].Token != "" || list[1].Token != "" {
286 t.Fatal("List exposed a token")
287 }
288}
289
290func TestRevokeAll(t *testing.T) {
291 t.Parallel()
292 f := newFixture(t)
293 alice := f.account(t, "alice")
294 bob := f.account(t, "bob")
295 a, err := f.store.Create(alice.Name, nil, "one")
296 if err != nil {
297 t.Fatal(err)
298 }
299 b, err := f.store.Create(bob.Name, nil, "two")
300 if err != nil {
301 t.Fatal(err)
302 }
303 n, err := f.store.RevokeAll()
304 if err != nil {
305 t.Fatal(err)
306 }
307 if n != 2 {
308 t.Fatalf("RevokeAll deleted %d, want 2", n)
309 }
310 for _, token := range []string{a.Token, b.Token} {
311 if _, err := f.store.Lookup(token); !errors.Is(err, ErrNoSession) {
312 t.Fatalf("session survived RevokeAll: %v", err)
313 }
314 }
315}
316
317func TestCreateRejectsAnotherAccountsPasskey(t *testing.T) {
318 t.Parallel()
319 f := newFixture(t)
320 alice := f.account(t, "alice")
321 bob := f.account(t, "bob")
322 cred := f.credential(t, alice)
323 if _, err := f.store.Create(bob.Name, cred.ID, "wrong"); !errors.Is(err, ErrCredentialMismatch) {
324 t.Fatalf("created a session for somebody else's passkey: %v", err)
325 }
326}
327
328// A session is a credential. Issuing one that cannot be recorded would create a
329// login nobody can account for, so the login fails instead.
330func TestCreateFailsWhenTheLogCannotBeWritten(t *testing.T) {
331 t.Parallel()
332 f := newFixture(t)
333 alice := f.account(t, "alice")
334 dir := t.TempDir()
335 blocker := filepath.Join(dir, "blocked")
336 if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
337 t.Fatal(err)
338 }
339 f.store.AuditPath = filepath.Join(blocker, "audit.log")
340 if _, err := f.store.Create(alice.Name, nil, "agent"); err == nil {
341 t.Fatal("Create succeeded with an unwritable audit log")
342 }
343 list, err := f.store.List(alice.Name)
344 if err != nil {
345 t.Fatal(err)
346 }
347 if len(list) != 0 {
348 t.Fatalf("a session was issued anyway: %d row(s)", len(list))
349 }
350}
351
352// Revoking a compromised session is exactly the action an attacker would want to
353// keep out of the log, so it does not happen unless it is written.
354func TestRevokeFailsWhenTheLogCannotBeWritten(t *testing.T) {
355 t.Parallel()
356 f := newFixture(t)
357 alice := f.account(t, "alice")
358 sess, err := f.store.Create(alice.Name, nil, "agent")
359 if err != nil {
360 t.Fatal(err)
361 }
362 dir := t.TempDir()
363 blocker := filepath.Join(dir, "blocked")
364 if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
365 t.Fatal(err)
366 }
367 f.store.AuditPath = filepath.Join(blocker, "audit.log")
368 if err := f.store.Revoke(sess.Token); err == nil {
369 t.Fatal("Revoke succeeded with an unwritable audit log")
370 }
371 if _, err := f.store.Lookup(sess.Token); err != nil {
372 t.Fatalf("the session was revoked anyway: %v", err)
373 }
374}
375
376// A cookie is client-supplied. A value that is not the shape this store issues is
377// refused before it is hashed or queried, so an oversized or malformed cookie
378// cannot make the server work for it.
379func TestLookupRefusesOversizedAndMalformedTokens(t *testing.T) {
380 t.Parallel()
381 f := newFixture(t)
382 alice := f.account(t, "alice")
383 sess, err := f.store.Create(alice.Name, nil, "agent")
384 if err != nil {
385 t.Fatal(err)
386 }
387 for _, bad := range []string{
388 strings.Repeat("a", maxTokenBytes+1),
389 strings.Repeat("a", 4096),
390 "has spaces in it",
391 "has/slash",
392 "has\ttab",
393 } {
394 if _, err := f.store.Lookup(bad); !errors.Is(err, ErrNoSession) {
395 t.Fatalf("Lookup(%d bytes) = %v, want ErrNoSession", len(bad), err)
396 }
397 }
398 // The real token still works, so the check is a shape test and not a guess.
399 if _, err := f.store.Lookup(sess.Token); err != nil {
400 t.Fatalf("a valid token was refused: %v", err)
401 }
402}
403
404// The User-Agent is stored and shown to an operator. It is client-supplied, so it
405// is cut to a display length rather than kept as whatever the client sent.
406func TestUserAgentIsTruncated(t *testing.T) {
407 t.Parallel()
408 f := newFixture(t)
409 alice := f.account(t, "alice")
410 if _, err := f.store.Create(alice.Name, nil, strings.Repeat("A", 5000)); err != nil {
411 t.Fatal(err)
412 }
413 list, err := f.store.List(alice.Name)
414 if err != nil {
415 t.Fatal(err)
416 }
417 if len(list) != 1 {
418 t.Fatalf("sessions = %d", len(list))
419 }
420 if got := len(list[0].UserAgent); got != maxUserAgentBytes {
421 t.Fatalf("stored User-Agent is %d bytes, want %d", got, maxUserAgentBytes)
422 }
423}
424