From eafd8dbdede910127e435756693005349db2272b Mon Sep 17 00:00:00 2001 From: Ulysse Carion Date: Fri, 9 Aug 2024 11:32:16 -0700 Subject: [PATCH] Initial commit --- .github/workflows/test.yml | 11 ++ LICENSE | 14 +++ README.md | 249 +++++++++++++++++++++++++++++++++++++ go.mod | 7 ++ go.sum | 4 + hyrumtoken.go | 68 ++++++++++ hyrumtoken_test.go | 89 +++++++++++++ screenshot.png | Bin 0 -> 8986 bytes 8 files changed, 442 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hyrumtoken.go create mode 100644 hyrumtoken_test.go create mode 100644 screenshot.png diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e584051 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,11 @@ +on: [push] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.21.x' + - run: go get . + - run: go test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a34ba76 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright 2024 SSOReady + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the “Software”), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ed9605 --- /dev/null +++ b/README.md @@ -0,0 +1,249 @@ +# hyrumtoken + +[![Go Reference](https://pkg.go.dev/badge/github.com/ssoready/hyrumtoken.svg)](https://pkg.go.dev/github.com/ssoready/hyrumtoken) + +`hyrumtoken` is a Go package to encrypt pagination tokens, so that your API +clients can't depend on their contents, ordering, or any other characteristics. + +## Installation + +```bash +go get github.com/ssoready/hyrumtoken +``` + +## Usage + +`hyrumtoken.Marshal/Unmarshal` works like the equivalent `json` functions, +except they take a `key *[32]byte`: + +```go +var key [32]byte = ... + +// create an encrypted pagination token +token, err := hyrumtoken.Marshal(&key, "any-json-encodable-data") + +// parse an encrypted pagination token +var parsedToken string +err := hyrumtoken.Unmarshal(&key, token, &parsedToken) +``` + +You can use any data type that works with `json.Marshal` as your pagination +token. + +## Motivation + +[Hyrum's Law](https://www.hyrumslaw.com/) goes: + +> With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable +behaviors of your system will be depended on by somebody. + +Pagination tokens are one of the most common ways this turns up. I'll illustrate +with a story. + +### Getting stuck with LIMIT/OFFSET + +I was implementing an audit logging feature. My job was the backend, some other +folks were doing the frontend. To get them going quickly, I gave them an API +documented like this: + +> To list audit log events, do `GET /v1/events?pageToken=...`. For the first +page, use an empty `pageToken`. +> +> That will return `{"events": [...], "nextPageToken": "...", "totalCount": ...}`. +If `nextPageToken` is empty, you've hit the end of the list. + +To keep things real simple, my unblock-the-frontend MVP used `limit/offset` +pagination. The page tokens were just the `offset` values. This wasn't going to +work once we had filters/sorts/millions of events, but whatever! Just rendering +the audit log events was already a good chunk of work for the frontend folks, +and we wanted to work in parallel. + +A week ensues. The frontend folks came back with a UI that had one of these at +the bottom: + +![](./screenshot.png) + +Weird. The documented API doesn't really promise any affordance of "seeking" to +a random page. "If you're on page 1 and you click on 3, what happens?" The +reply: "We just set the pageToken to 300". + +This happened because folks saw the initial real-world behavior of the API: + +``` +GET /v1/events +{"events": [... 100 events ...], "nextPageToken": "100", "totalCount": "8927"} + +GET /v1/events?pageToken=100 +{"events": [... 100 events ...], "nextPageToken": "200", "totalCount": "8927"} +``` + +And so it didn't matter what you document. People will guess what you meant, and +it really looks like you meant to make `pageToken` be an offset token. + +The fun part about this story is that I in fact have lied to you. We *knew* +keyset-based pagination was coming, and so we needed a way to encode potentially +URL-unsafe data in `pageToken`. So right from the get-go we were base64-encoding +the token. So the actual requests looked like: + +``` +GET /v1/events +{"events": [... 100 events ...], "nextPageToken": "MTAwCg==", "totalCount": "8927"} + +GET /v1/events?pageToken=MTAwCg== +{"events": [... 100 events ...], "nextPageToken": "MjAwCg==", "totalCount": "8927"} +``` + +The effect is the same. If it ends in `==`, you bet your ass the intellectual +curiosity of your coworkers demands they base64-parse it. Parse `MTAwCg==` and +you get back `100\n`. Our company design system had a prebuilt component with a +jump-to-page affordance, and the UX folks put two and two together +instinctively. + +By making an API that looked like it wanted to let you "seek" through the data, +I had invited my colleagues to design and implement a user interface that I had +no plans to support. This problem was on me. + +In a lot of ways, I got lucky here. I can just politely ask my coworkers to +redesign their frontend to only offer a "Load More" button, no "jump to page". +If I had made this API public, paying customers would have read the tea-leaves +of my API, and they'd be broken if I changed anything. We'd probably be stuck +with the limit/offset approach forever. + +### Binary searching through pagination-token-space + +I've been on the opposite end of this. In the past, I've worked at companies +that had to ETL data out of systems faster than the public API would allow. Each +individual request is slow, but parallel requests increased throughput out of +their API. Problem was figuring out how to usefully do parallel requests over a +paginated list. + +We figured out that their pagination tokens were alphabetically increasing, and +so we made a program that "searched" for the last pagination token, divided up +the pagination token space into *N* chunks, and synced those chunks in parallel. + +Probably not what they intended! But in practice we're now one of the biggest +users of their API, and they can't change their behavior. Even the *alphabetical +ordering* of your pagination tokens can get you stuck. + +At that same company, we would sometimes parse pagination tokens to implement +internal logging of where we were in the list. This might seem gratuitous, but +engineers are always tempted to do this. + +If you didn't want me to parse your sorta-opaque token, you should've made it +actually-opaque. + +### Encrypt your pagination tokens + +So that's why I like to encrypt my pagination tokens. It seems extreme, but it +eliminates this entire class of problems. Instead of obscurity-by-base64, I just +enforce opacity-by-Salsa20. + +`hyrumtoken` prevents your users from: + +1. Creating their own pagination tokens to "seek" through your data +2. Parsing your returned pagination tokens to infer where they are in the data +3. Having their software be broken if you change what you put inside your + pagination tokens + +If you intend your pagination tokens to be opaque strings, `hyrumtoken` can +enforce that opacity. Concretely, `hyrumtoken` does this: + +1. JSON-encode the "pagination state" data +2. Encrypt that using NaCL's [secretbox](https://nacl.cr.yp.to/secretbox.html) + with a random nonce. This requires a secret key, hence the need for a `key + *[32]byte`. +3. Concatenate the nonce and the encrypted message +4. Return a base64url-encoded copy + +Secretbox is implemented using Golang's widely-used [`x/crypto/nacl/secretbox` +package](https://pkg.go.dev/golang.org/x/crypto/nacl/secretbox). There are +Secretbox implementations in every language, so it's pretty easy to port or +share tokens between backend languages. + +## Advanced Usage + +### Expiring tokens + +This one isn't particularly tied to `hyrumtoken`. + +Your customers may get into the habit of assuming your pagination tokens never +expire (again in the spirit of Hyrum's Law). You can enforce that by having +tokens keep track of their own expiration: + +```go +type tokenData struct { + ExpireTime time.Time + ID string +} + +// encode +hyrumtoken.Marshal(&key, tokenData{ + ExpireTime: time.Now().Add(time.Hour), + ID: ..., +}) + +// decode +var data tokenData +if err := hyrumtoken.Unmarshal(&key, token, &data); err != nil { + return err +} +if data.ExpireTime.Before(time.Now()) { + return fmt.Errorf("token is expired") +} +``` + +That way, your customer probably sees they're wrong to assume "tokens never +expire" while they're still developing their software, and that assumption is +still easy to undo. + +### Rotating keys + +Any time you have keys, you should think about how you're gonna rotate them. It +might be obvious, but you can just have a "primary" key you encode new tokens +with, and a set of "backup" keys you try to decode with. Something like this: + +```go +var primaryKey [32]byte = ... +var backupKey1 [32]byte = ... +var backupKey2 [32]byte = ... + +// encode +token, err := hyrumtoken.Marshal(&key, data) + +// decode +keys := [][32]byte{primaryKey, backupKey1, backupKey2} +for _, k := range keys { + var data tokenData + if err := hyrumtoken.Unmarshal(&k, token, &data); err == nil { + return &data, nil + } +} +return nil, fmt.Errorf("invalid pagination token") +``` + +You can use expiring tokens to eventually guarantee the backup keys are never +used, and stop accepting them entirely. + +### Changing pagination schemes + +You can change from one type of pagination to another by putting both into the +same struct, and then looking at which fields are populated: + +```go +type tokenData struct { + Offset int + StartID string +} + +var data tokenData +if err := hyrumtoken.Unmarshal(&key, token, &data); err != nil { + return err +} + +if data.Offset != 0 { + // offset-based approach +} +// startid-based approach +``` + +Expiring tokens also help here, so you can get rid of the old codepath quickly. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..32cd66d --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/ssoready/hyrumtoken + +go 1.22.3 + +require golang.org/x/crypto v0.26.0 + +require golang.org/x/sys v0.23.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c88cd4b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/hyrumtoken.go b/hyrumtoken.go new file mode 100644 index 0000000..86c6717 --- /dev/null +++ b/hyrumtoken.go @@ -0,0 +1,68 @@ +// Package hyrumtoken implements opaque pagination tokens. +// +// Token opacity is implemented using NaCl secretbox: +// +// https://pkg.go.dev/golang.org/x/crypto/nacl/secretbox +// +// Marshal and Unmarshal require a key. Tokens are only opaque to those who do +// not have this key. Do not publish this key to your API consumers. +package hyrumtoken + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + + "golang.org/x/crypto/nacl/secretbox" +) + +// Marshal returns an encrypted, URL-safe serialization of v using key. +// +// Marshal panics if v cannot be JSON-encoded. +// +// Marshal uses a random nonce. Providing the same key and v in multiple +// invocations will produce different results every time. +func Marshal(key *[32]byte, v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + + var nonce [24]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + panic(err) + } + + d := secretbox.Seal(nonce[:], b, &nonce, key) + return base64.URLEncoding.EncodeToString(d) +} + +// Unmarshal uses key to decrypt s and store the decoded value in v. +// +// If s is empty, v is not modified and Unmarshal returns nil. +func Unmarshal(key *[32]byte, s string, v any) error { + if s == "" { + return nil + } + + d, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return fmt.Errorf("decode token: %w", err) + } + + var nonce [24]byte + copy(nonce[:], d[:24]) + + b, ok := secretbox.Open(nil, d[24:], &nonce, key) + if !ok { + return fmt.Errorf("decrypt token: %w", err) + } + + if err := json.Unmarshal(b, v); err != nil { + return fmt.Errorf("unmarshal token data: %w", err) + } + + return nil +} diff --git a/hyrumtoken_test.go b/hyrumtoken_test.go new file mode 100644 index 0000000..7e6b8bd --- /dev/null +++ b/hyrumtoken_test.go @@ -0,0 +1,89 @@ +package hyrumtoken_test + +import ( + "crypto/rand" + "reflect" + "testing" + + "github.com/ssoready/hyrumtoken" +) + +// testkey is a randomized key for testing. Do not use it in production. +var testkey = [32]byte{24, 12, 15, 90, 143, 133, 171, 28, 34, 75, 185, 194, 102, 93, 165, 183, 235, 96, 135, 135, 165, 1, 129, 91, 32, 7, 139, 135, 130, 2, 241, 168} + +func TestEncoder(t *testing.T) { + type data struct { + Foo string + Bar string + } + + in := data{ + Foo: "foo", + Bar: "bar", + } + + encoded := hyrumtoken.Marshal(&testkey, in) + + var out data + err := hyrumtoken.Unmarshal(&testkey, encoded, &out) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if !reflect.DeepEqual(in, out) { + t.Fatalf("round-trip failure") + } +} + +func TestEncoder_Unmarshal_empty(t *testing.T) { + data := 123 + if err := hyrumtoken.Unmarshal(&testkey, "", &data); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if data != 123 { + t.Fatalf("data unexpectedly modified: %d", data) + } +} + +func TestEncoder_Marshal(t *testing.T) { + // test known produced values using fixed, zero rand and secret + r := rand.Reader + rand.Reader = zeroReader{} + defer func() { + rand.Reader = r + }() + + token := hyrumtoken.Marshal(&testkey, 123) + + if token != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAULRUMRVA4GIqe5Y8N_z8B4J7hw==" { + t.Fatalf("encoding regression, got: %q", token) + } +} + +func TestEncoder_Unmarshal(t *testing.T) { + // inverse of TestEncoder_Marshal + r := rand.Reader + rand.Reader = zeroReader{} + defer func() { + rand.Reader = r + }() + + var data int + if err := hyrumtoken.Unmarshal(&testkey, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAULRUMRVA4GIqe5Y8N_z8B4J7hw==", &data); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if data != 123 { + t.Fatalf("unmarshal regression, got: %d", data) + } +} + +type zeroReader struct{} + +func (z zeroReader) Read(p []byte) (n int, err error) { + for i := 0; i < len(p); i++ { + p[i] = 0 + } + return len(p), nil +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..82ff135503d1fdc48c799cecf1e3f53d5b3b55a3 GIT binary patch literal 8986 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 VmByo6xVRFasj90|p>!|&{{Wi!99aMW literal 0 HcmV?d00001