tenseleyflow/shithub / 8dd3cc0

Browse files

S36: internal/pagination/keyset — opaque cursor + HMAC-signed variant

Authored by espadonne
SHA
8dd3cc02ffb261f59f80ea19d73d2985c5b14f71
Parents
fd186d2
Tree
2a46b85

2 changed files

StatusFile+-
A internal/pagination/keyset/keyset.go 115 0
A internal/pagination/keyset/keyset_test.go 77 0
internal/pagination/keyset/keyset.goadded
@@ -0,0 +1,115 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package keyset implements opaque keyset-pagination cursors. The
4
+// cursor wraps the last row's ordering value + id (deterministic
5
+// tiebreaker for rows that share the value) so the next page is a
6
+// simple `WHERE (value, id) < ($cursor_value, $cursor_id) ORDER BY
7
+// value DESC, id DESC LIMIT n` — constant-time per page regardless
8
+// of depth.
9
+//
10
+// Two encoding modes:
11
+//
12
+//   - Encode  — base64url(JSON) of the cursor. Use for pagination
13
+//     surfaces where the leak of the value/id pair is OK
14
+//     (commits list, public issues).
15
+//
16
+//   - Sign    — base64url(JSON || HMAC-SHA256). Use for visibility-
17
+//     sensitive surfaces (audit log, notifications) where
18
+//     a leaked cursor must not let a different user
19
+//     resume someone else's enumeration.
20
+//
21
+// Decoders are strict: a malformed or tampered cursor returns
22
+// ErrInvalidCursor, never a "best-effort" partial decode. Callers
23
+// fall back to the first page on error.
24
+package keyset
25
+
26
+import (
27
+	"crypto/hmac"
28
+	"crypto/sha256"
29
+	"encoding/base64"
30
+	"encoding/json"
31
+	"errors"
32
+)
33
+
34
+// ErrInvalidCursor is returned by Decode / Verify when the cursor
35
+// can't be parsed, has an HMAC mismatch, or has unsupported fields.
36
+var ErrInvalidCursor = errors.New("keyset: invalid cursor")
37
+
38
+// Cursor is the canonical payload. Value is the ordering column's
39
+// last-seen value (timestamp Unix nanos, integer id, etc.); ID is
40
+// the tiebreaker (typically the row's primary key). Both are
41
+// int64s — surface flexibility comes from the caller's choice of
42
+// what to put there.
43
+type Cursor struct {
44
+	Value int64 `json:"v"`
45
+	ID    int64 `json:"i"`
46
+}
47
+
48
+// Encode wraps c into an opaque base64url string. Suitable for the
49
+// `?cursor=` URL parameter; URL-safe alphabet, no padding.
50
+func Encode(c Cursor) string {
51
+	raw, _ := json.Marshal(c)
52
+	return base64.RawURLEncoding.EncodeToString(raw)
53
+}
54
+
55
+// Decode reverses Encode. Returns ErrInvalidCursor on malformed
56
+// input.
57
+func Decode(s string) (Cursor, error) {
58
+	if s == "" {
59
+		return Cursor{}, ErrInvalidCursor
60
+	}
61
+	raw, err := base64.RawURLEncoding.DecodeString(s)
62
+	if err != nil {
63
+		return Cursor{}, ErrInvalidCursor
64
+	}
65
+	var c Cursor
66
+	if err := json.Unmarshal(raw, &c); err != nil {
67
+		return Cursor{}, ErrInvalidCursor
68
+	}
69
+	return c, nil
70
+}
71
+
72
+// Sign returns a tamper-resistant cursor. The output is the JSON-
73
+// encoded cursor concatenated with a 32-byte HMAC-SHA256, both
74
+// base64url'd. Use the same key as your other per-user-binding
75
+// HMAC consumers (e.g. notif unsubscribe key) or mint a dedicated
76
+// keyset signing key — whichever your operator-secrets layout
77
+// prefers.
78
+func Sign(c Cursor, key []byte) string {
79
+	if len(key) == 0 {
80
+		// Refusing to sign with an empty key is the operator's bug
81
+		// surfacing as a panic at boot rather than a silently-
82
+		// unsigned cursor making it to production. Callers should
83
+		// pre-check; this is the belt-and-braces guard.
84
+		panic("keyset: empty signing key")
85
+	}
86
+	raw, _ := json.Marshal(c)
87
+	mac := hmac.New(sha256.New, key)
88
+	mac.Write(raw)
89
+	tag := mac.Sum(nil)
90
+	return base64.RawURLEncoding.EncodeToString(append(raw, tag...))
91
+}
92
+
93
+// Verify reverses Sign. Constant-time HMAC compare.
94
+func Verify(s string, key []byte) (Cursor, error) {
95
+	if s == "" || len(key) == 0 {
96
+		return Cursor{}, ErrInvalidCursor
97
+	}
98
+	raw, err := base64.RawURLEncoding.DecodeString(s)
99
+	if err != nil || len(raw) < sha256.Size+1 {
100
+		return Cursor{}, ErrInvalidCursor
101
+	}
102
+	body := raw[:len(raw)-sha256.Size]
103
+	tag := raw[len(raw)-sha256.Size:]
104
+	mac := hmac.New(sha256.New, key)
105
+	mac.Write(body)
106
+	want := mac.Sum(nil)
107
+	if !hmac.Equal(want, tag) {
108
+		return Cursor{}, ErrInvalidCursor
109
+	}
110
+	var c Cursor
111
+	if err := json.Unmarshal(body, &c); err != nil {
112
+		return Cursor{}, ErrInvalidCursor
113
+	}
114
+	return c, nil
115
+}
internal/pagination/keyset/keyset_test.goadded
@@ -0,0 +1,77 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package keyset
4
+
5
+import (
6
+	"errors"
7
+	"strings"
8
+	"testing"
9
+)
10
+
11
+func TestEncodeDecodeRoundtrip(t *testing.T) {
12
+	t.Parallel()
13
+	c := Cursor{Value: 1700000000, ID: 42}
14
+	s := Encode(c)
15
+	got, err := Decode(s)
16
+	if err != nil {
17
+		t.Fatalf("Decode: %v", err)
18
+	}
19
+	if got != c {
20
+		t.Errorf("roundtrip = %+v; want %+v", got, c)
21
+	}
22
+}
23
+
24
+func TestDecodeRejectsMalformed(t *testing.T) {
25
+	t.Parallel()
26
+	cases := []string{"", "not-base64!", "abc"}
27
+	for _, in := range cases {
28
+		if _, err := Decode(in); !errors.Is(err, ErrInvalidCursor) {
29
+			t.Errorf("Decode(%q) err = %v; want ErrInvalidCursor", in, err)
30
+		}
31
+	}
32
+}
33
+
34
+func TestSignVerifyRoundtrip(t *testing.T) {
35
+	t.Parallel()
36
+	key := []byte("super-secret-32-bytes-or-longer-")
37
+	c := Cursor{Value: 99, ID: 1}
38
+	s := Sign(c, key)
39
+	got, err := Verify(s, key)
40
+	if err != nil {
41
+		t.Fatalf("Verify: %v", err)
42
+	}
43
+	if got != c {
44
+		t.Errorf("verify roundtrip = %+v; want %+v", got, c)
45
+	}
46
+}
47
+
48
+func TestVerifyRejectsTamper(t *testing.T) {
49
+	t.Parallel()
50
+	key := []byte("k")
51
+	c := Cursor{Value: 1, ID: 2}
52
+	s := Sign(c, key)
53
+	// Flip a body byte at the start.
54
+	bad := strings.Replace(s, s[:4], "AAAA", 1)
55
+	if _, err := Verify(bad, key); !errors.Is(err, ErrInvalidCursor) {
56
+		t.Errorf("tampered cursor accepted; want ErrInvalidCursor")
57
+	}
58
+}
59
+
60
+func TestVerifyRejectsWrongKey(t *testing.T) {
61
+	t.Parallel()
62
+	c := Cursor{Value: 1, ID: 2}
63
+	s := Sign(c, []byte("alice"))
64
+	if _, err := Verify(s, []byte("bob")); !errors.Is(err, ErrInvalidCursor) {
65
+		t.Errorf("wrong key accepted; want ErrInvalidCursor")
66
+	}
67
+}
68
+
69
+func TestSignPanicsOnEmptyKey(t *testing.T) {
70
+	t.Parallel()
71
+	defer func() {
72
+		if r := recover(); r == nil {
73
+			t.Errorf("Sign with empty key did not panic")
74
+		}
75
+	}()
76
+	Sign(Cursor{}, nil)
77
+}