package controlapi import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "os" "strings" "time" ) var ErrInvalidCursor = errors.New("invalid cursor") type CursorPosition struct { CreatedAt time.Time DeviceID string } type cursorPayload struct { Version int `json:"v"` TenantID string `json:"t"` SiteID string `json:"s"` FilterHash string `json:"f"` CreatedAt string `json:"c"` DeviceID string `json:"d"` } type CursorCodec struct { key []byte } func LoadCursorCodec(path string) (*CursorCodec, error) { contents, err := os.ReadFile(path) if err != nil { return nil, errors.New("read Control API cursor key") } key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents))) if err != nil || len(key) < 32 { return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes") } return NewCursorCodec(key), nil } func NewCursorCodec(key []byte) *CursorCodec { copyOfKey := append([]byte(nil), key...) return &CursorCodec{key: copyOfKey} } func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) { payload, err := json.Marshal(cursorPayload{ Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash, CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID, }) if err != nil { return "", errors.New("encode cursor payload") } encoded := base64.RawURLEncoding.EncodeToString(payload) mac := hmac.New(sha256.New, c.key) _, _ = mac.Write([]byte(encoded)) return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil } func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) { var position CursorPosition parts := strings.Split(value, ".") if len(parts) != 2 || len(value) > 512 { return position, ErrInvalidCursor } provided, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || len(provided) != sha256.Size { return position, ErrInvalidCursor } mac := hmac.New(sha256.New, c.key) _, _ = mac.Write([]byte(parts[0])) if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 { return position, ErrInvalidCursor } payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return position, ErrInvalidCursor } var payload cursorPayload decoder := json.NewDecoder(strings.NewReader(string(payloadBytes))) decoder.DisallowUnknownFields() if err := decoder.Decode(&payload); err != nil || payload.Version != 1 || payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash { return position, ErrInvalidCursor } createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt) if err != nil || payload.DeviceID == "" { return position, ErrInvalidCursor } position.CreatedAt = createdAt.UTC() position.DeviceID = payload.DeviceID return position, nil } func filterFingerprint(values ...string) string { hash := sha256.New() for _, value := range values { _, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))}) _, _ = hash.Write([]byte(value)) } return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) }