S36: internal/pagination/keyset — opaque cursor + HMAC-signed variant
- SHA
8dd3cc02ffb261f59f80ea19d73d2985c5b14f71- Parents
-
fd186d2 - Tree
2a46b85
8dd3cc0
8dd3cc02ffb261f59f80ea19d73d2985c5b14f71fd186d2
2a46b85| Status | File | + | - |
|---|---|---|---|
| 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 | +} | |