feat(bskyweb): build Google Wallet save-to-wallet JWT

This commit is contained in:
vineyardbovines
2026-06-25 16:14:50 -04:00
parent 730369e4c1
commit 07a3b987ed
4 changed files with 177 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"net/url"
"github.com/golang-jwt/jwt/v5"
)
type WalletConfig struct {
IssuerEmail string
IssuerID string
PrivateKey *rsa.PrivateKey
HeroBaseURL string
LogoURL string
}
var hexBgByTheme = map[string]string{
"dawn": "#ff6dbe",
"day": "#75afff",
"dusk": "#b15aa2",
"night": "#001533",
}
func LoadWalletConfig(serviceAccountJSON []byte, issuerID, heroBaseURL, logoURL string) (*WalletConfig, error) {
var sa struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
}
if err := json.Unmarshal(serviceAccountJSON, &sa); err != nil {
return nil, err
}
block, _ := pem.Decode([]byte(sa.PrivateKey))
if block == nil {
return nil, errors.New("service account private_key: no PEM block")
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("service account key is not RSA")
}
return &WalletConfig{
IssuerEmail: sa.ClientEmail,
IssuerID: issuerID,
PrivateKey: rsaKey,
HeroBaseURL: heroBaseURL,
LogoURL: logoURL,
}, nil
}
func BuildSaveJWT(cfg *WalletConfig, did, handle, theme string) (string, error) {
theme = CoerceTheme(theme)
profileURL := "https://bsky.app/profile/" + handle
hexBg := hexBgByTheme[theme]
heroQuery := url.Values{"did": {did}, "theme": {theme}}
heroURL := cfg.HeroBaseURL + "?" + heroQuery.Encode()
obj := map[string]any{
"id": cfg.IssuerID + ".bsky-" + did + "-" + theme,
"classId": cfg.IssuerID + ".bsky_invite_v1",
"logo": map[string]any{
"sourceUri": map[string]any{"uri": cfg.LogoURL},
},
"cardTitle": langValue("Bluesky"),
"header": langValue("@" + handle),
"subheader": langValue("bsky.app/profile/" + handle),
"hexBackgroundColor": hexBg,
"heroImage": map[string]any{"sourceUri": map[string]any{"uri": heroURL}},
"barcode": map[string]any{
"type": "QR_CODE",
"value": profileURL,
"alternateText": "@" + handle,
},
"linksModuleData": map[string]any{
"uris": []any{map[string]any{"uri": profileURL, "description": "Open profile"}},
},
}
claims := jwt.MapClaims{
"iss": cfg.IssuerEmail,
"aud": "google",
"typ": "savetowallet",
"payload": map[string]any{
"genericObjects": []any{obj},
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return tok.SignedString(cfg.PrivateKey)
}
func langValue(s string) map[string]any {
return map[string]any{"defaultValue": map[string]any{"language": "en", "value": s}}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestBuildSaveJWT_RoundTrip(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
cfg := &WalletConfig{
IssuerEmail: "issuer@example.com",
IssuerID: "3388000000000000000",
PrivateKey: key,
HeroBaseURL: "https://bsky.app/invite/wallet/hero",
LogoURL: "https://web-cdn.bsky.app/passes/logo.png",
}
tok, err := BuildSaveJWT(cfg, "did:plc:abc", "alice.bsky.social", "dusk")
if err != nil {
t.Fatalf("build: %v", err)
}
parsed, err := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
return &key.PublicKey, nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
claims := parsed.Claims.(jwt.MapClaims)
if claims["iss"] != "issuer@example.com" {
t.Errorf("iss = %v", claims["iss"])
}
if claims["aud"] != "google" {
t.Errorf("aud = %v", claims["aud"])
}
if claims["typ"] != "savetowallet" {
t.Errorf("typ = %v", claims["typ"])
}
payload := claims["payload"].(map[string]any)
objs := payload["genericObjects"].([]any)
if len(objs) != 1 {
t.Fatalf("want 1 object, got %d", len(objs))
}
obj := objs[0].(map[string]any)
if !strings.HasSuffix(obj["id"].(string), ".bsky-did:plc:abc-dusk") {
t.Errorf("unexpected id: %v", obj["id"])
}
if obj["hexBackgroundColor"] != "#b15aa2" {
t.Errorf("hexBackgroundColor = %v", obj["hexBackgroundColor"])
}
barcode := obj["barcode"].(map[string]any)
if barcode["value"] != "https://bsky.app/profile/alice.bsky.social" {
t.Errorf("barcode.value = %v", barcode["value"])
}
}
func TestBuildSaveJWT_ThemeCoerced(t *testing.T) {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
cfg := &WalletConfig{IssuerEmail: "x", IssuerID: "Y", PrivateKey: key}
tok, err := BuildSaveJWT(cfg, "did:plc:abc", "alice.bsky.social", "GARBAGE")
if err != nil {
t.Fatalf("build: %v", err)
}
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) { return &key.PublicKey, nil })
obj := parsed.Claims.(jwt.MapClaims)["payload"].(map[string]any)["genericObjects"].([]any)[0].(map[string]any)
if !strings.HasSuffix(obj["id"].(string), "-day") {
t.Errorf("expected coercion to day, got id %v", obj["id"])
}
}
+1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/flosch/pongo2/v6 v6.0.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.18.0
+2
View File
@@ -31,6 +31,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=