| // Copyright 2026 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package api |
| |
| import ( |
| "crypto/aes" |
| "crypto/cipher" |
| "crypto/rand" |
| "crypto/sha256" |
| "encoding/binary" |
| "encoding/hex" |
| "errors" |
| "io" |
| "os" |
| "sync" |
| "time" |
| ) |
| |
| // tokenCipher computes a cipher.Block from the K_SERVICE environment variable. |
| var tokenCipher = sync.OnceValues(func() (cipher.Block, error) { |
| // K_SERVICE is the name of the Cloud Run service. It isn't |
| // exactly a secret, but it's also not known to users. If |
| // someone does manage to guess it or break the encryption |
| // (for integer tokens we are using only one block and there |
| // are many easily guessable plaintexts), it's not the end of |
| // the world. And anyone who does that much work to de-obfuscate |
| // a token surely knows they are doing something wrong. |
| service := os.Getenv("K_SERVICE") |
| if service == "" { |
| return nil, errors.New("K_SERVICE is not set") |
| } |
| key := sha256.Sum256([]byte(service)) |
| return aes.NewCipher(key[:]) |
| }) |
| |
| // encodePageToken obfuscates a page token by binary encoding the integer n and |
| // the current timestamp, encrypting it with AES using K_SERVICE as a key, and |
| // hex encoding the result. |
| func encodePageToken(n int) (string, error) { |
| return encodePageToken1(n, time.Now()) |
| } |
| |
| // encodePageToken1 is like encodePageToken but allows passing a specific time for testing. |
| func encodePageToken1(n int, t time.Time) (string, error) { |
| // 1. Binary encode the timestamp and the int. |
| // AES block size is 16 bytes. The high-order 8 bytes contain the Unix timestamp, |
| // and the low-order 8 bytes contain the int n. |
| src := make([]byte, aes.BlockSize) |
| binary.BigEndian.PutUint64(src[:8], uint64(t.Unix())) |
| binary.BigEndian.PutUint64(src[8:], uint64(n)) |
| |
| // 2. Compute AES on it. |
| block, err := tokenCipher() |
| if err != nil { |
| return "", err |
| } |
| |
| dst := make([]byte, aes.BlockSize) |
| block.Encrypt(dst, src) |
| |
| // 3. Hex encode the result. |
| return hex.EncodeToString(dst), nil |
| } |
| |
| const ( |
| tokenExpiry = 48 * time.Hour |
| maxOffset = 1e6 |
| ) |
| |
| // decodePageToken reverses the obfuscation of a page token, returning the original integer n. |
| // It rejects tokens older than tokenExpiry. |
| func decodePageToken(token string) (int, error) { |
| // 1. Hex decode the token. |
| src, err := hex.DecodeString(token) |
| if err != nil { |
| return 0, err |
| } |
| if len(src) != aes.BlockSize { |
| return 0, errors.New("invalid length") |
| } |
| |
| // 2. Compute AES decryption. |
| block, err := tokenCipher() |
| if err != nil { |
| return 0, err |
| } |
| |
| dst := make([]byte, aes.BlockSize) |
| block.Decrypt(dst, src) |
| |
| // 3. Binary decode the result. |
| timestamp := int64(binary.BigEndian.Uint64(dst[:8])) |
| n := binary.BigEndian.Uint64(dst[8:]) |
| |
| // Reject expired tokens. |
| tokenTime := time.Unix(timestamp, 0) |
| if time.Since(tokenTime) > tokenExpiry { |
| return 0, errors.New("expired") |
| } |
| // Reject tokens from the future (by more than 1 minute, allowing for clock skew). |
| if time.Since(tokenTime) < -time.Minute { |
| return 0, errors.New("from the future") |
| } |
| // Reject overly large offsets. |
| if n > maxOffset { |
| return 0, errors.New("offset too large") |
| } |
| in := int(n) |
| if in < 0 { |
| return 0, errors.New("negative offset") |
| } |
| return in, nil |
| } |
| |
| // encodeStringPageToken obfuscates a string page token by prepending the current |
| // timestamp, encrypting it with AES-GCM using K_SERVICE as a key, and hex |
| // encoding the result. |
| func encodeStringPageToken(s string) (string, error) { |
| return encodeStringPageToken1(s, time.Now()) |
| } |
| |
| // encodeStringPageToken1 is like encodeStringPageToken but allows passing a specific time for testing. |
| func encodeStringPageToken1(s string, t time.Time) (string, error) { |
| block, err := tokenCipher() |
| if err != nil { |
| return "", err |
| } |
| aesgcm, err := cipher.NewGCM(block) |
| if err != nil { |
| return "", err |
| } |
| nonce := make([]byte, aesgcm.NonceSize()) |
| if _, err := io.ReadFull(rand.Reader, nonce); err != nil { |
| return "", err |
| } |
| |
| // Pack timestamp (8 bytes) + string |
| plaintext := make([]byte, 8+len(s)) |
| binary.BigEndian.PutUint64(plaintext[:8], uint64(t.Unix())) |
| copy(plaintext[8:], s) |
| |
| ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) |
| |
| // Result is nonce + ciphertext |
| result := append(nonce, ciphertext...) |
| return hex.EncodeToString(result), nil |
| } |
| |
| // decodeStringPageToken reverses the obfuscation of a string page token, returning the original string. |
| // It rejects tokens older than tokenExpiry. |
| func decodeStringPageToken(token string) (string, error) { |
| src, err := hex.DecodeString(token) |
| if err != nil { |
| return "", err |
| } |
| block, err := tokenCipher() |
| if err != nil { |
| return "", err |
| } |
| aesgcm, err := cipher.NewGCM(block) |
| if err != nil { |
| return "", err |
| } |
| nonceSize := aesgcm.NonceSize() |
| if len(src) < nonceSize { |
| return "", errors.New("token too short") |
| } |
| nonce, ciphertext := src[:nonceSize], src[nonceSize:] |
| plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) |
| if err != nil { |
| return "", err |
| } |
| if len(plaintext) < 8 { |
| return "", errors.New("invalid plaintext") |
| } |
| timestamp := int64(binary.BigEndian.Uint64(plaintext[:8])) |
| s := string(plaintext[8:]) |
| |
| // Reject expired tokens. |
| tokenTime := time.Unix(timestamp, 0) |
| if time.Since(tokenTime) > tokenExpiry { |
| return "", errors.New("expired") |
| } |
| // Reject tokens from the future. |
| if time.Since(tokenTime) < -time.Minute { |
| return "", errors.New("from the future") |
| } |
| return s, nil |
| } |