Go · 3770 bytes Raw Blame History
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 }
116