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 | +} | ||