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"])
}
}