diff --git a/bskyweb/cmd/bskyweb/walletjwt.go b/bskyweb/cmd/bskyweb/walletjwt.go new file mode 100644 index 0000000000..3f1a157f29 --- /dev/null +++ b/bskyweb/cmd/bskyweb/walletjwt.go @@ -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}} +} diff --git a/bskyweb/cmd/bskyweb/walletjwt_test.go b/bskyweb/cmd/bskyweb/walletjwt_test.go new file mode 100644 index 0000000000..740b31fad4 --- /dev/null +++ b/bskyweb/cmd/bskyweb/walletjwt_test.go @@ -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"]) + } +} diff --git a/bskyweb/go.mod b/bskyweb/go.mod index f32aa0a86e..9e99566d0b 100644 --- a/bskyweb/go.mod +++ b/bskyweb/go.mod @@ -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 diff --git a/bskyweb/go.sum b/bskyweb/go.sum index 781d39bd4c..7e7a65644c 100644 --- a/bskyweb/go.sum +++ b/bskyweb/go.sum @@ -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=