From b9d19295c10f68a93e23ed1910c00dbc75d8e91e Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 25 Jun 2026 15:57:28 -0400 Subject: [PATCH] feat(bskyweb): add HMAC download token for invite passes --- bskyweb/cmd/bskyweb/passtoken.go | 61 +++++++++++++++++++++++++++ bskyweb/cmd/bskyweb/passtoken_test.go | 58 +++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 bskyweb/cmd/bskyweb/passtoken.go create mode 100644 bskyweb/cmd/bskyweb/passtoken_test.go diff --git a/bskyweb/cmd/bskyweb/passtoken.go b/bskyweb/cmd/bskyweb/passtoken.go new file mode 100644 index 0000000000..2bce1d4c6f --- /dev/null +++ b/bskyweb/cmd/bskyweb/passtoken.go @@ -0,0 +1,61 @@ +// bskyweb/cmd/bskyweb/passtoken.go +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +var ( + ErrTokenExpired = errors.New("invite pass token expired") + ErrTokenInvalid = errors.New("invite pass token invalid") +) + +type passTokenPayload struct { + Did string `json:"d"` + Theme string `json:"t"` + Exp int64 `json:"e"` +} + +func MintPassToken(secret []byte, did, theme string, now time.Time, ttl time.Duration) (string, error) { + p := passTokenPayload{Did: did, Theme: theme, Exp: now.Add(ttl).Unix()} + body, err := json.Marshal(p) + if err != nil { + return "", err + } + bodyB64 := base64.RawURLEncoding.EncodeToString(body) + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(bodyB64)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return bodyB64 + "." + sig, nil +} + +func VerifyPassToken(secret []byte, token string, now time.Time) (string, string, error) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return "", "", ErrTokenInvalid + } + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(parts[0])) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(parts[1])) { + return "", "", ErrTokenInvalid + } + body, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", "", ErrTokenInvalid + } + var p passTokenPayload + if err := json.Unmarshal(body, &p); err != nil { + return "", "", ErrTokenInvalid + } + if now.Unix() > p.Exp { + return "", "", ErrTokenExpired + } + return p.Did, p.Theme, nil +} diff --git a/bskyweb/cmd/bskyweb/passtoken_test.go b/bskyweb/cmd/bskyweb/passtoken_test.go new file mode 100644 index 0000000000..4b9fc46e4c --- /dev/null +++ b/bskyweb/cmd/bskyweb/passtoken_test.go @@ -0,0 +1,58 @@ +// bskyweb/cmd/bskyweb/passtoken_test.go +package main + +import ( + "errors" + "testing" + "time" +) + +func TestPassToken_RoundTrip(t *testing.T) { + secret := []byte("test-secret-32-bytes-of-random!!") + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + tok, err := MintPassToken(secret, "did:plc:abc", "dusk", now, 60*time.Second) + if err != nil { + t.Fatalf("mint: %v", err) + } + gotDid, gotTheme, err := VerifyPassToken(secret, tok, now.Add(10*time.Second)) + if err != nil { + t.Fatalf("verify: %v", err) + } + if gotDid != "did:plc:abc" || gotTheme != "dusk" { + t.Fatalf("mismatch: %q %q", gotDid, gotTheme) + } +} + +func TestPassToken_Expired(t *testing.T) { + secret := []byte("test-secret-32-bytes-of-random!!") + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second) + _, _, err := VerifyPassToken(secret, tok, now.Add(61*time.Second)) + if !errors.Is(err, ErrTokenExpired) { + t.Fatalf("want ErrTokenExpired, got %v", err) + } +} + +func TestPassToken_BadSignature(t *testing.T) { + secretA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + secretB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + tok, _ := MintPassToken(secretA, "did:plc:abc", "day", now, 60*time.Second) + _, _, err := VerifyPassToken(secretB, tok, now) + if !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("want ErrTokenInvalid, got %v", err) + } +} + +func TestPassToken_Mutated(t *testing.T) { + secret := []byte("test-secret-32-bytes-of-random!!") + now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) + tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second) + // flip a byte in the middle of the payload portion + mutated := []byte(tok) + mutated[10] ^= 0x01 + _, _, err := VerifyPassToken(secret, string(mutated), now) + if !errors.Is(err, ErrTokenInvalid) { + t.Fatalf("want ErrTokenInvalid, got %v", err) + } +}