Commit cab50f5004

cab50f5004df82a3ff699f71bb31f049f8deca75

parent: e2a32d5f8d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 07:24 UTC

store: ssh_keys.expires_at; expired keys are not live

Ref #277

Layout: unified · split

internal/store/keyexpiry_test.go added +48
@@ -0,0 +1,48 @@
1package store
2
3import (
4 "testing"
5 "time"
6)
7
8func TestKeyExpiry(t *testing.T) {
9 s, uid, _ := revokeFixture(t)
10 past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
11 for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
12 if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
13 t.Fatal(err)
14 }
15 }
16 now := time.Now()
17 ids := map[string]int64{}
18 for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
19 k, err := s.SSHKeyByFingerprint(fp)
20 if err != nil {
21 t.Fatal(err)
22 }
23 ids[fp] = k.ID
24 byID, err := s.SSHKeyByID(k.ID)
25 if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
26 t.Fatalf("%s by id: %+v %v", fp, byID, err)
27 }
28 if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
29 t.Errorf("%s Expired = %v, want %v", fp, got, want)
30 }
31 }
32 live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
33 if err != nil {
34 t.Fatal(err)
35 }
36 if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
37 t.Fatalf("live = %v", live)
38 }
39 keys, err := s.ListSSHKeys(uid)
40 if err != nil || len(keys) != 3 {
41 t.Fatalf("list: %+v %v", keys, err)
42 }
43 for _, k := range keys {
44 if k.Fingerprint == "SHA256:ever" && k.ExpiresAt != nil {
45 t.Fatalf("list: %s should have nil ExpiresAt: %+v", k.Fingerprint, k)
46 }
47 }
48}
internal/store/migrations/0061_ssh_key_expiry.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE ssh_keys DROP COLUMN expires_at;
internal/store/migrations/0061_ssh_key_expiry.up.sql added +2
@@ -0,0 +1,2 @@
1-- When the key stops authenticating; NULL for never.
2ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
internal/store/revoke.go +8 −6
@@ -3,6 +3,7 @@ package store
33import (
44 "slices"
55 "strings"
6 "time"
67)
78
89// Revoked names SSH keys that stopped being valid: by id, or every key
@@ -32,19 +33,20 @@ func (s *Store) announce(r Revoked) {
3233 }
3334}
3435
35// LiveSSHKeys reports which of ids still name a registered key on an
36// account that is not disabled.
36// LiveSSHKeys reports which of ids still name a registered, unexpired
37// key on an account that is not disabled.
3738func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
3839 live := map[int64]bool{}
3940 if len(ids) == 0 {
4041 return live, nil
4142 }
42 args := make([]any, len(ids))
43 for i, id := range ids {
44 args[i] = id
43 args := []any{fmtTime(time.Now())}
44 for _, id := range ids {
45 args = append(args, id)
4546 }
4647 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
47 WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
48 WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
49 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
4850 if err != nil {
4951 return nil, err
5052 }
internal/store/users.go +34 −13
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "strings"
8 "time"
89)
910
1011type User struct {
@@ -24,8 +25,14 @@ type SSHKey struct {
2425 Scope string
2526 Label string // "" when the key was added with no name
2627 CreatedAt string
27 LastUsedAt string // "" when the key has never authenticated
28 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
28 LastUsedAt string // "" when the key has never authenticated
29 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
30 ExpiresAt *time.Time // nil when the key never expires
31}
32
33// Expired reports whether the key has lapsed at now.
34func (k SSHKey) Expired(now time.Time) bool {
35 return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
2936}
3037
3138// ErrDuplicateKey carries the exact user-facing message from the spec. It
@@ -273,7 +280,8 @@ func (s *Store) UserByID(id int64) (User, error) {
273280
274281// KeyOrigin is how a key came to be.
275282type KeyOrigin struct {
276 CreatedByToken int64 // the API token that added it; 0 for none
283 CreatedByToken int64 // the API token that added it; 0 for none
284 ExpiresAt *time.Time // when it stops authenticating; nil for never
277285}
278286
279287// AddSSHKey registers a key and bumps the key epoch in one transaction.
@@ -288,9 +296,13 @@ func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byt
288296 return err
289297 }
290298 defer tx.Rollback()
299 var exp any
300 if o.ExpiresAt != nil {
301 exp = fmtTime(*o.ExpiresAt)
302 }
291303 if _, err := tx.Exec(
292 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
293 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
304 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
305 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
294306 if isUniqueErr(err) {
295307 return ErrDuplicateKey
296308 }
@@ -343,19 +355,21 @@ func (s *Store) SetSSHKeyLabel(userID int64, fingerprint, label string) error {
343355
344356func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
345357 var k SSHKey
358 var exp sql.NullString
346359 err := s.DB.QueryRow(
347 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE fingerprint = ?",
348 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
360 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
361 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
349362 if errors.Is(err, sql.ErrNoRows) {
350363 return k, ErrNotFound
351364 }
365 k.ExpiresAt = parseTime(exp)
352366 return k, err
353367}
354368
355369func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
356370 rows, err := s.DB.Query(
357371 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
358 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
372 COALESCE(k.last_used_at, ''), COALESCE(t.name, ''), k.expires_at
359373 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
360374 WHERE k.user_id = ? ORDER BY k.id`,
361375 userID)
@@ -366,9 +380,11 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
366380 var keys []SSHKey
367381 for rows.Next() {
368382 var k SSHKey
369 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
383 var exp sql.NullString
384 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy, &exp); err != nil {
370385 return nil, err
371386 }
387 k.ExpiresAt = parseTime(exp)
372388 keys = append(keys, k)
373389 }
374390 return keys, rows.Err()
@@ -523,19 +539,22 @@ func isUniqueErr(err error) bool {
523539
524540func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
525541 var k SSHKey
542 var exp sql.NullString
526543 err := s.DB.QueryRow(
527 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE id = ?",
528 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label)
544 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE id = ?",
545 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
529546 if errors.Is(err, sql.ErrNoRows) {
530547 return k, ErrNotFound
531548 }
549 k.ExpiresAt = parseTime(exp)
532550 return k, err
533551}
534552
535553// ListDeployKeys returns the deploy keys bound to a repository.
536554func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
537555 rows, err := s.DB.Query(
538 "SELECT id, user_id, fingerprint, algo, blob, scope, label FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id",
556 `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
557 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
539558 repoID)
540559 if err != nil {
541560 return nil, err
@@ -544,9 +563,11 @@ func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
544563 var keys []SSHKey
545564 for rows.Next() {
546565 var k SSHKey
547 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label); err != nil {
566 var exp sql.NullString
567 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
548568 return nil, err
549569 }
570 k.ExpiresAt = parseTime(exp)
550571 keys = append(keys, k)
551572 }
552573 return keys, rows.Err()