Skip to content

Commit bb7c952

Browse files
committed
feat(botstoken): add compact token codec with HMAC-signed variant for off-platform tokens
New package botstoken implements the (verb, subject, args) token codec described in architecture.md §4.3 (SYS-CB-010). Plain codec: - Encode(verb, subject, args) -> tab-delimited wire format with sorted args - Decode(token) -> Token{Verb, Subject, Args} - Enforces a 64-byte limit (ErrTokenTooLong if exceeded) so tokens fit in Telegram callback_data and WhatsApp interactive reply ID fields - Args are sorted by key, making output deterministic across calls Signed codec (for tokens leaving the platform — web deep links, wa.me URLs): - EncodeSignedToken(verb, subject, args, now, KeyProvider) -> base64url string - DecodeSignedToken(token, maxAge, KeyProvider) -> Token (or error) - HMAC-SHA256 over the payload before the sig field is added, with a hex-encoded signature appended as the "sig" arg - Issued-at timestamp stored as "at" (unix seconds); expiry checked on decode - Key rotation supported via the KeyProvider interface (SigningKey / VerifyingKey) - Internal fields (sig, at, kid) are stripped from the returned Token.Args - Signed tokens carry authentication overhead and are not subject to the 64-byte plain-codec limit; callers control token length via input length Errors: ErrTokenTooLong, ErrInvalidToken, ErrTokenExpired, ErrInvalidSignature 16 table-driven tests covering: simple/args encoding, exact-64-byte boundary, over-64 rejection, round-trip decode, empty/malformed inputs, signed round-trip, determinism, expiry, tamper detection (modified payload), unknown key ID, invalid base64, and confirmation that signed tokens are not length-limited.
1 parent fb0b9f1 commit bb7c952

2 files changed

Lines changed: 457 additions & 0 deletions

File tree

botstoken/token.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Package botstoken implements a compact, URL-safe token codec for
2+
// (verb string, subject string, args map[string]string) triples.
3+
//
4+
// Two token variants are provided:
5+
//
6+
// 1. Plain tokens — encode verb/subject/args into a short string, optionally
7+
// base64url-encoded. Suitable for in-platform use (Telegram callback_data,
8+
// WhatsApp interactive reply id) where the platform protects integrity.
9+
// Guaranteed ≤ 64 bytes for typical inputs; Encode returns an error if the
10+
// result would exceed 64 bytes.
11+
//
12+
// 2. Signed tokens — HMAC-SHA256 signed with an issued-at timestamp.
13+
// Suitable for tokens that leave the platform (wa.me URLs, web deep links).
14+
// Expiry is checked on Decode. The key is provided via a pluggable KeyProvider.
15+
//
16+
// Token wire format (plain, before base64url):
17+
//
18+
// <verb>\t<subject>[\t<k1>=<v1>\t<k2>=<v2>...]
19+
//
20+
// Signed token wire format (base64url, no padding):
21+
//
22+
// b64url(<verb>\t<subject>\t<k>=<v>...\tat=<unix-seconds>\tsig=<hex-hmac-sha256>)
23+
//
24+
// Args are sorted by key for deterministic encoding.
25+
package botstoken
26+
27+
import (
28+
"crypto/hmac"
29+
"crypto/sha256"
30+
"encoding/base64"
31+
"encoding/hex"
32+
"errors"
33+
"fmt"
34+
"sort"
35+
"strconv"
36+
"strings"
37+
"time"
38+
)
39+
40+
const (
41+
// MaxTokenBytes is the maximum allowed byte length of the encoded token.
42+
MaxTokenBytes = 64
43+
44+
fieldSep = '\t'
45+
kvSep = '='
46+
)
47+
48+
// ErrTokenTooLong is returned when an encoded token would exceed MaxTokenBytes.
49+
var ErrTokenTooLong = errors.New("botstoken: encoded token exceeds 64 bytes")
50+
51+
// ErrInvalidToken is returned when a token cannot be parsed.
52+
var ErrInvalidToken = errors.New("botstoken: invalid token format")
53+
54+
// ErrTokenExpired is returned when the issued-at timestamp is too old.
55+
var ErrTokenExpired = errors.New("botstoken: token has expired")
56+
57+
// ErrInvalidSignature is returned when the HMAC signature does not match.
58+
var ErrInvalidSignature = errors.New("botstoken: invalid signature")
59+
60+
// Token holds the decoded fields of a token.
61+
type Token struct {
62+
Verb string
63+
Subject string
64+
Args map[string]string
65+
}
66+
67+
// -------------------------------------------------------------------
68+
// Plain token codec
69+
// -------------------------------------------------------------------
70+
71+
// Encode encodes (verb, subject, args) into a compact string token.
72+
// Args are sorted by key for deterministic output.
73+
// Returns ErrTokenTooLong if the result would exceed 64 bytes.
74+
func Encode(verb, subject string, args map[string]string) (string, error) {
75+
raw := buildRaw(verb, subject, args)
76+
if len(raw) > MaxTokenBytes {
77+
return "", fmt.Errorf("%w: got %d bytes (verb=%q subject=%q)", ErrTokenTooLong, len(raw), verb, subject)
78+
}
79+
return raw, nil
80+
}
81+
82+
// Decode parses a plain token produced by Encode.
83+
func Decode(token string) (Token, error) {
84+
return parseRaw(token)
85+
}
86+
87+
// -------------------------------------------------------------------
88+
// Signed token codec
89+
// -------------------------------------------------------------------
90+
91+
// KeyProvider returns the HMAC signing key.
92+
// Implementations may rotate keys; they receive the key ID stored in the token.
93+
// For the current (signing) call, keyID is empty.
94+
type KeyProvider interface {
95+
// SigningKey returns the current signing key and its ID.
96+
SigningKey() (key []byte, keyID string)
97+
// VerifyingKey returns the key for the given key ID.
98+
// Return nil to indicate that the key ID is unknown.
99+
VerifyingKey(keyID string) []byte
100+
}
101+
102+
// EncodeSignedToken encodes and HMAC-signs a token with an issued-at timestamp.
103+
// The result is base64url-encoded (no padding). Signed tokens carry authentication
104+
// overhead (HMAC-SHA256 signature + issued-at timestamp) that makes them inherently
105+
// larger than plain tokens; they are intended for off-platform use (web, wa.me deep
106+
// links) where URL length is not as constrained as in-platform callback fields.
107+
// There is no explicit length limit on signed tokens — the caller is responsible
108+
// for keeping verb, subject and args short enough for the target channel.
109+
func EncodeSignedToken(verb, subject string, args map[string]string, now time.Time, kp KeyProvider) (string, error) {
110+
key, keyID := kp.SigningKey()
111+
argsWithMeta := copyArgs(args)
112+
argsWithMeta["at"] = strconv.FormatInt(now.Unix(), 10)
113+
if keyID != "" {
114+
argsWithMeta["kid"] = keyID
115+
}
116+
117+
payload := buildRaw(verb, subject, argsWithMeta)
118+
sig := computeHMAC(key, payload)
119+
argsWithMeta["sig"] = hex.EncodeToString(sig)
120+
121+
raw := buildRaw(verb, subject, argsWithMeta)
122+
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
123+
return encoded, nil
124+
}
125+
126+
// DecodeSignedToken decodes and verifies a signed token produced by EncodeSignedToken.
127+
// Returns ErrTokenExpired if the token is older than maxAge.
128+
// Returns ErrInvalidSignature if the HMAC is wrong.
129+
// Returns ErrInvalidToken if the format is unrecognised.
130+
func DecodeSignedToken(token string, maxAge time.Duration, kp KeyProvider) (Token, error) {
131+
b, err := base64.RawURLEncoding.DecodeString(token)
132+
if err != nil {
133+
return Token{}, fmt.Errorf("%w: base64 decode failed: %v", ErrInvalidToken, err)
134+
}
135+
t, err := parseRaw(string(b))
136+
if err != nil {
137+
return Token{}, err
138+
}
139+
140+
// Extract and remove "sig" and "at" (and optionally "kid") before verification.
141+
sigHex := t.Args["sig"]
142+
atStr := t.Args["at"]
143+
keyID := t.Args["kid"]
144+
145+
if sigHex == "" {
146+
return Token{}, fmt.Errorf("%w: missing sig field", ErrInvalidToken)
147+
}
148+
if atStr == "" {
149+
return Token{}, fmt.Errorf("%w: missing at field", ErrInvalidToken)
150+
}
151+
152+
atUnix, convErr := strconv.ParseInt(atStr, 10, 64)
153+
if convErr != nil {
154+
return Token{}, fmt.Errorf("%w: invalid at field: %v", ErrInvalidToken, convErr)
155+
}
156+
157+
// Verify expiry.
158+
issuedAt := time.Unix(atUnix, 0)
159+
if time.Since(issuedAt) > maxAge {
160+
return Token{}, ErrTokenExpired
161+
}
162+
163+
// Reconstruct payload as it was before sig was added.
164+
argsForVerify := copyArgs(t.Args)
165+
delete(argsForVerify, "sig")
166+
payload := buildRaw(t.Verb, t.Subject, argsForVerify)
167+
168+
// Verify signature.
169+
key := kp.VerifyingKey(keyID)
170+
if key == nil {
171+
return Token{}, fmt.Errorf("%w: unknown key id %q", ErrInvalidSignature, keyID)
172+
}
173+
expected := computeHMAC(key, payload)
174+
gotSig, hexErr := hex.DecodeString(sigHex)
175+
if hexErr != nil {
176+
return Token{}, fmt.Errorf("%w: malformed sig hex: %v", ErrInvalidToken, hexErr)
177+
}
178+
if !hmac.Equal(expected, gotSig) {
179+
return Token{}, ErrInvalidSignature
180+
}
181+
182+
// Return clean token without internal fields.
183+
clean := copyArgs(t.Args)
184+
delete(clean, "sig")
185+
delete(clean, "at")
186+
delete(clean, "kid")
187+
if len(clean) == 0 {
188+
clean = nil
189+
}
190+
return Token{Verb: t.Verb, Subject: t.Subject, Args: clean}, nil
191+
}
192+
193+
// -------------------------------------------------------------------
194+
// Internal helpers
195+
// -------------------------------------------------------------------
196+
197+
// buildRaw assembles the raw (wire) token string from its components.
198+
// All fields are sorted for deterministic output.
199+
func buildRaw(verb, subject string, args map[string]string) string {
200+
var sb strings.Builder
201+
sb.WriteString(verb)
202+
sb.WriteByte(fieldSep)
203+
sb.WriteString(subject)
204+
205+
if len(args) > 0 {
206+
keys := sortedKeys(args)
207+
for _, k := range keys {
208+
sb.WriteByte(fieldSep)
209+
sb.WriteString(k)
210+
sb.WriteByte(kvSep)
211+
sb.WriteString(args[k])
212+
}
213+
}
214+
return sb.String()
215+
}
216+
217+
// parseRaw parses a raw token string.
218+
func parseRaw(raw string) (Token, error) {
219+
if raw == "" {
220+
return Token{}, ErrInvalidToken
221+
}
222+
parts := strings.Split(raw, string(fieldSep))
223+
if len(parts) < 2 {
224+
return Token{}, fmt.Errorf("%w: expected at least verb and subject", ErrInvalidToken)
225+
}
226+
t := Token{
227+
Verb: parts[0],
228+
Subject: parts[1],
229+
}
230+
if len(parts) > 2 {
231+
t.Args = make(map[string]string, len(parts)-2)
232+
for _, kv := range parts[2:] {
233+
idx := strings.IndexByte(kv, kvSep)
234+
if idx < 0 {
235+
return Token{}, fmt.Errorf("%w: malformed arg %q (missing '=')", ErrInvalidToken, kv)
236+
}
237+
t.Args[kv[:idx]] = kv[idx+1:]
238+
}
239+
}
240+
return t, nil
241+
}
242+
243+
func computeHMAC(key []byte, payload string) []byte {
244+
mac := hmac.New(sha256.New, key)
245+
mac.Write([]byte(payload))
246+
return mac.Sum(nil)
247+
}
248+
249+
func sortedKeys(m map[string]string) []string {
250+
keys := make([]string, 0, len(m))
251+
for k := range m {
252+
keys = append(keys, k)
253+
}
254+
sort.Strings(keys)
255+
return keys
256+
}
257+
258+
func copyArgs(src map[string]string) map[string]string {
259+
dst := make(map[string]string, len(src))
260+
for k, v := range src {
261+
dst[k] = v
262+
}
263+
return dst
264+
}

0 commit comments

Comments
 (0)