diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx index 09723701c..f8d4383f0 100644 --- a/js/app/components/settings/branding-admin.tsx +++ b/js/app/components/settings/branding-admin.tsx @@ -1054,6 +1054,27 @@ export function BrandingAdmin() { {t("branding-reset")} + + {t("branding-verify-bluesky-description")} + + + v === "off" + ? deleteBlob("verifyBluesky") + : uploadText("verifyBluesky", v) + } + /> {t("branding-verify-url-description")} diff --git a/js/components/src/components/chat/verified-badge.tsx b/js/components/src/components/chat/verified-badge.tsx index f4fea010c..234ef3ec4 100644 --- a/js/components/src/components/chat/verified-badge.tsx +++ b/js/components/src/components/chat/verified-badge.tsx @@ -29,6 +29,10 @@ export function VerifiedCheck({ const BLUESKY_BLUE = "#1185fe"; // token-ok: Bluesky's verification color +/** The verifier DID the node files Bluesky's blue check under when the + * branding key verifyBluesky is on (pkg/atproto/appview_verification.go). */ +export const BLUESKY_APPVIEW_ISSUER = "did:web:api.bsky.app"; + function decodeDataUrlText(dataUrl: string): string | null { const comma = dataUrl.indexOf(","); if (comma < 0) return null; @@ -86,7 +90,8 @@ function NodeVerifiedIcon({ size }: { size: number }) { * - the node's trusted verifiers (branding key verifierDids), which the * node reports on the message author itself — the branded badge; * - the public app view's verification (e.g. Bluesky's), read from the - * profile cache — the network's blue check. + * profile cache, or mirrored by the node under Bluesky's app view DID + * when verifyBluesky is on — the network's blue check. * A user verified both ways gets the node's badge only. */ export function VerifiedBadge({ @@ -95,13 +100,26 @@ export function VerifiedBadge({ size = 16, style, }: { - author: { verification?: { verifiedStatus?: string } | null }; + author: { + verification?: { + verifiedStatus?: string; + verifications?: { issuer: string }[]; + } | null; + }; profile?: AppBskyActorDefs.ProfileViewDetailed | null; size?: number; style?: any; }) { - const nodeVerified = author.verification?.verifiedStatus === "valid"; - const networkVerified = profile?.verification?.verifiedStatus === "valid"; + const nodeIssuers = author.verification?.verifications ?? []; + const nodeValid = author.verification?.verifiedStatus === "valid"; + // Rows filed under Bluesky's app view are Bluesky's check, not ours. + const nodeVerified = + nodeValid && + (nodeIssuers.length === 0 || + nodeIssuers.some((v) => v.issuer !== BLUESKY_APPVIEW_ISSUER)); + const networkVerified = + profile?.verification?.verifiedStatus === "valid" || + (nodeValid && !nodeVerified); if (!nodeVerified && !networkVerified) return null; return ( , so the app knows where to send people back. Leave empty to append nothing. branding-verify-url-description = Where a signed-in but unverified user goes to get verified: the "Verify now" link under the composer when chat is verified-only. Unset shows no link. branding-verified-only-message-description = What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead. diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json index 1d692d32a..9723c9866 100644 --- a/js/i18n/public/locales/en-US/settings.json +++ b/js/i18n/public/locales/en-US/settings.json @@ -252,6 +252,9 @@ "branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.", "branding-labeler-description": "Or a labeler and the label values that mean verified on your network: the labeler's DID, then the labels, comma-separated, with a trailing * for a prefix (e.g. verified-*). Accounts carrying one of those labels count as verified here, next to any verifiers above.", "branding-verify-appview-description": "Or an app view whose getProfile carries your network's own verification field: its URL, the field name (default wsocialVerified) and the values that count, comma-separated, or * for any value. The node asks it about each chat author on first sight and re-checks every few minutes.", + "branding-verify-bluesky-description": "Also count Bluesky's blue check: accounts Bluesky's public app view reports as verified can chat here too, and wear Bluesky's check rather than your badge.", + "branding-verify-bluesky-off": "Your verifiers only", + "branding-verify-bluesky-on": "Plus Bluesky's blue check", "branding-quick-login-return-description": "Appended to the identity app's sign-in link on phones as ?return=, so the app knows where to send people back. Leave empty to append nothing.", "branding-verify-url-description": "Where a signed-in but unverified user goes to get verified: the \"Verify now\" link under the composer when chat is verified-only. Unset shows no link.", "branding-verified-only-message-description": "What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead.", diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json index 1d692d32a..9723c9866 100644 --- a/js/web/public/locales/en-US/settings.json +++ b/js/web/public/locales/en-US/settings.json @@ -252,6 +252,9 @@ "branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.", "branding-labeler-description": "Or a labeler and the label values that mean verified on your network: the labeler's DID, then the labels, comma-separated, with a trailing * for a prefix (e.g. verified-*). Accounts carrying one of those labels count as verified here, next to any verifiers above.", "branding-verify-appview-description": "Or an app view whose getProfile carries your network's own verification field: its URL, the field name (default wsocialVerified) and the values that count, comma-separated, or * for any value. The node asks it about each chat author on first sight and re-checks every few minutes.", + "branding-verify-bluesky-description": "Also count Bluesky's blue check: accounts Bluesky's public app view reports as verified can chat here too, and wear Bluesky's check rather than your badge.", + "branding-verify-bluesky-off": "Your verifiers only", + "branding-verify-bluesky-on": "Plus Bluesky's blue check", "branding-quick-login-return-description": "Appended to the identity app's sign-in link on phones as ?return=, so the app knows where to send people back. Leave empty to append nothing.", "branding-verify-url-description": "Where a signed-in but unverified user goes to get verified: the \"Verify now\" link under the composer when chat is verified-only. Unset shows no link.", "branding-verified-only-message-description": "What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead.", diff --git a/pkg/atproto/appview_verification.go b/pkg/atproto/appview_verification.go index cc3444a92..7e858d7a4 100644 --- a/pkg/atproto/appview_verification.go +++ b/pkg/atproto/appview_verification.go @@ -17,8 +17,10 @@ import ( // App-view verification: a network whose notion of "verified" is a field on // its own app view's getProfile (branding keys verifyAppViewUrl, -// verifyAppViewField, verifyAppViewValues). There is no feed of changes to -// subscribe to, so the node asks: +// verifyAppViewField, verifyAppViewValues), and, with verifyBluesky on, +// Bluesky's public app view, whose getProfile carries the blue check +// (verification.verifiedStatus). There is no feed of changes to subscribe +// to, so the node asks: // // - on first sight of an account (a chat message, a getStatus for it), a // synchronous lookup with a short timeout, so a verified viewer's very @@ -40,12 +42,36 @@ const ( type appViewConfig struct { URL string Host string - Field string + issuer string // "" = did:web:Host + Fields []string // dotted paths into the profile; any one matching counts Values []string // empty = any non-empty value } // Issuer is the verifier DID the app view's rows are filed under. -func (c appViewConfig) Issuer() string { return "did:web:" + c.Host } +func (c appViewConfig) Issuer() string { + if c.issuer != "" { + return c.issuer + } + return "did:web:" + c.Host +} + +// Bluesky's blue check: the public app view says verifiedStatus (a +// verified account) or trustedVerifierStatus (a verifier, whose check is +// scalloped) is valid. +const ( + blueskyAppViewURL = "https://public.api.bsky.app" + BlueskyAppViewIssuer = "did:web:api.bsky.app" +) + +func blueskyAppViewConfig() appViewConfig { + return appViewConfig{ + URL: blueskyAppViewURL, + Host: "public.api.bsky.app", + issuer: BlueskyAppViewIssuer, + Fields: []string{"verification.verifiedStatus", "verification.trustedVerifierStatus"}, + Values: []string{"valid"}, + } +} func parseAppViewConfig(rawURL, field, values string) appViewConfig { rawURL = strings.TrimSpace(rawURL) @@ -56,10 +82,11 @@ func parseAppViewConfig(rawURL, field, values string) appViewConfig { if err != nil || u.Host == "" { return appViewConfig{} } - c := appViewConfig{URL: strings.TrimRight(rawURL, "/"), Host: u.Host, Field: strings.TrimSpace(field)} - if c.Field == "" { - c.Field = "wsocialVerified" + c := appViewConfig{URL: strings.TrimRight(rawURL, "/"), Host: u.Host} + if field = strings.TrimSpace(field); field == "" { + field = "wsocialVerified" } + c.Fields = []string{field} for _, v := range strings.Split(values, ",") { if v = strings.TrimSpace(v); v != "" && v != "*" { c.Values = append(c.Values, v) @@ -85,6 +112,31 @@ func (c appViewConfig) matches(v any) bool { return false } +// verified reports whether a getProfile view counts as verified: any of +// the configured fields matches. +func (c appViewConfig) verified(profile map[string]any) bool { + for _, f := range c.Fields { + if c.matches(fieldValue(profile, f)) { + return true + } + } + return false +} + +// fieldValue walks a dotted path ("verification.verifiedStatus") into a +// decoded JSON object; nil when any step is missing. +func fieldValue(obj map[string]any, path string) any { + var cur any = obj + for _, key := range strings.Split(path, ".") { + m, ok := cur.(map[string]any) + if !ok { + return nil + } + cur = m[key] + } + return cur +} + // appViewVerificationURI is the synthetic record URI of a mirrored answer. func appViewVerificationURI(host, did string) string { return fmt.Sprintf("appview://%s/%s", host, did) @@ -92,26 +144,38 @@ func appViewVerificationURI(host, did string) string { var ( appViewNegMu sync.Mutex - appViewNeg = map[string]time.Time{} // did -> when the "no" expires + appViewNeg = map[string]time.Time{} // issuer+did -> when the "no" expires appViewGroup singleflight.Group ) -// appViewLookup asks the app view about did if nothing vouches for it yet -// and no recent "no" is remembered. It returns true when the app view says -// verified (and the row has been written). Callers on hot paths share one -// in-flight request per DID. +func appViewNegKey(c appViewConfig, did string) string { return c.Issuer() + " " + did } + +// appViewLookup asks each app view about did, in order, if nothing vouches +// for it yet, stopping at the first yes. It returns true when one says +// verified (and the row has been written). func (atsync *ATProtoSynchronizer) appViewLookup(ctx context.Context, did string) bool { - c := atsync.AppView(ctx) - if c.URL == "" || did == "" { + if did == "" { return false } + for _, c := range atsync.AppViews(ctx) { + if atsync.appViewLookupOne(ctx, c, did) { + return true + } + } + return false +} + +// appViewLookupOne asks one app view about did unless a recent "no" is +// remembered. Callers on hot paths share one in-flight request per DID. +func (atsync *ATProtoSynchronizer) appViewLookupOne(ctx context.Context, c appViewConfig, did string) bool { + key := appViewNegKey(c, did) appViewNegMu.Lock() - until, denied := appViewNeg[did] + until, denied := appViewNeg[key] appViewNegMu.Unlock() if denied && time.Now().Before(until) { return false } - v, _, _ := appViewGroup.Do(did, func() (any, error) { + v, _, _ := appViewGroup.Do(key, func() (any, error) { lctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), appViewLookupTimeout) defer cancel() res, err := atsync.checkAppView(lctx, c, []string{did}) @@ -131,9 +195,10 @@ func (atsync *ATProtoSynchronizer) appViewLookup(ctx context.Context, did string // (and no row) for no. func (atsync *ATProtoSynchronizer) applyAppViewAnswer(ctx context.Context, c appViewConfig, did string, verified bool) { uri := appViewVerificationURI(c.Host, did) + key := appViewNegKey(c, did) if verified { appViewNegMu.Lock() - delete(appViewNeg, did) + delete(appViewNeg, key) appViewNegMu.Unlock() err := atsync.Model.CreateVerification(ctx, &model.Verification{ URI: uri, @@ -147,7 +212,7 @@ func (atsync *ATProtoSynchronizer) applyAppViewAnswer(ctx context.Context, c app return } appViewNegMu.Lock() - appViewNeg[did] = time.Now().Add(appViewNegativeTTL) + appViewNeg[key] = time.Now().Add(appViewNegativeTTL) appViewNegMu.Unlock() if err := atsync.Model.DeleteVerification(ctx, uri); err != nil { log.Warn(ctx, "failed to clear app view verification", "did", did, "err", err) @@ -194,13 +259,13 @@ func (atsync *ATProtoSynchronizer) checkAppView(ctx context.Context, c appViewCo if did == "" { continue } - out[did] = c.matches(p[c.Field]) + out[did] = c.verified(p) } } return out, nil } -// RefreshAppViewVerificationsForever re-asks the app view about every +// RefreshAppViewVerificationsForever re-asks each app view about every // account it has vouched for, in batches, so a verification the network // withdraws stops counting here within appViewRefreshInterval. Accounts // that were never verified are re-asked on sight instead (see @@ -213,36 +278,38 @@ func (atsync *ATProtoSynchronizer) RefreshAppViewVerificationsForever(ctx contex return case <-time.After(appViewRefreshInterval): } - c := atsync.AppView(ctx) - if c.URL == "" { - continue - } - rows, err := atsync.Model.ListVerificationsByIssuer(ctx, c.Issuer()) - if err != nil { - log.Warn(ctx, "failed to list app view verifications", "err", err) - continue - } - dids := make([]string, 0, len(rows)) - for _, r := range rows { - dids = append(dids, r.SubjectDID) + for _, c := range atsync.AppViews(ctx) { + atsync.refreshAppView(ctx, c) } - if len(dids) == 0 { - continue - } - res, err := atsync.checkAppView(ctx, c, dids) - if err != nil { - log.Warn(ctx, "app view verification refresh failed", "err", err) - continue - } - revoked := 0 - for _, did := range dids { - if !res[did] { - atsync.applyAppViewAnswer(ctx, c, did, false) - revoked++ - } - } - if revoked > 0 { - log.Log(ctx, "app view verifications refreshed", "checked", len(dids), "revoked", revoked) + } +} + +func (atsync *ATProtoSynchronizer) refreshAppView(ctx context.Context, c appViewConfig) { + rows, err := atsync.Model.ListVerificationsByIssuer(ctx, c.Issuer()) + if err != nil { + log.Warn(ctx, "failed to list app view verifications", "issuer", c.Issuer(), "err", err) + return + } + dids := make([]string, 0, len(rows)) + for _, r := range rows { + dids = append(dids, r.SubjectDID) + } + if len(dids) == 0 { + return + } + res, err := atsync.checkAppView(ctx, c, dids) + if err != nil { + log.Warn(ctx, "app view verification refresh failed", "issuer", c.Issuer(), "err", err) + return + } + revoked := 0 + for _, did := range dids { + if !res[did] { + atsync.applyAppViewAnswer(ctx, c, did, false) + revoked++ } } + if revoked > 0 { + log.Log(ctx, "app view verifications refreshed", "issuer", c.Issuer(), "checked", len(dids), "revoked", revoked) + } } diff --git a/pkg/atproto/appview_verification_test.go b/pkg/atproto/appview_verification_test.go index 9b4cfe854..a7c5d4698 100644 --- a/pkg/atproto/appview_verification_test.go +++ b/pkg/atproto/appview_verification_test.go @@ -14,7 +14,7 @@ func TestParseAppViewConfig(t *testing.T) { c := parseAppViewConfig("https://api.example.com/", "", "") require.Equal(t, "https://api.example.com", c.URL) require.Equal(t, "did:web:api.example.com", c.Issuer()) - require.Equal(t, "wsocialVerified", c.Field) + require.Equal(t, []string{"wsocialVerified"}, c.Fields) require.True(t, c.matches("wid"), "* means any non-empty value") require.False(t, c.matches("")) require.False(t, c.matches(nil)) @@ -27,6 +27,20 @@ func TestParseAppViewConfig(t *testing.T) { require.Equal(t, appViewConfig{}, parseAppViewConfig("not a url", "", "")) } +func TestBlueskyAppViewConfig(t *testing.T) { + c := blueskyAppViewConfig() + require.Equal(t, BlueskyAppViewIssuer, c.Issuer()) + verified := map[string]any{"verification": map[string]any{"verifiedStatus": "valid", "trustedVerifierStatus": "none"}} + verifier := map[string]any{"verification": map[string]any{"verifiedStatus": "none", "trustedVerifierStatus": "valid"}} + invalid := map[string]any{"verification": map[string]any{"verifiedStatus": "invalid", "trustedVerifierStatus": "none"}} + plain := map[string]any{"did": "did:plc:x"} + require.True(t, c.verified(verified)) + require.True(t, c.verified(verifier), "a trusted verifier wears a check too") + require.False(t, c.verified(invalid)) + require.False(t, c.verified(plain)) + require.Nil(t, fieldValue(map[string]any{"verification": "oops"}, "verification.verifiedStatus")) +} + func TestCheckAppView(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/xrpc/app.bsky.actor.getProfiles", r.URL.Path) diff --git a/pkg/atproto/verification.go b/pkg/atproto/verification.go index ca9e8fca5..455f8a517 100644 --- a/pkg/atproto/verification.go +++ b/pkg/atproto/verification.go @@ -32,7 +32,7 @@ var ( verifiedOnlyC bool labelerCached string labelPatternsC []string - appViewCached appViewConfig + appViewsCached []appViewConfig ) // Verifiers returns the trusted verifier DIDs, cached briefly. @@ -73,26 +73,34 @@ func (atsync *ATProtoSynchronizer) Verifiers(ctx context.Context) []string { labelerCached = "" } // An app view with its own verification field is a verifier too, - // under the did:web of its host (see appview_verification.go). - appViewCached = parseAppViewConfig( + // under the did:web of its host (see appview_verification.go); so + // is Bluesky's public app view, for the blue check, when verifyBluesky + // is on. The network's own view is asked first. + appViewsCached = nil + if c := parseAppViewConfig( branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewUrl"), branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewField"), branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewValues"), - ) - if appViewCached.URL != "" { - verifierCached = append(verifierCached, appViewCached.Issuer()) + ); c.URL != "" { + appViewsCached = append(appViewsCached, c) + } + if branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyBluesky") == "on" { + appViewsCached = append(appViewsCached, blueskyAppViewConfig()) + } + for _, c := range appViewsCached { + verifierCached = append(verifierCached, c.Issuer()) } } verifierAt = time.Now() return verifierCached } -// AppView returns the verifying app view's configuration; URL "" when unset. -func (atsync *ATProtoSynchronizer) AppView(ctx context.Context) appViewConfig { +// AppViews returns the verifying app views, in the order they are asked. +func (atsync *ATProtoSynchronizer) AppViews(ctx context.Context) []appViewConfig { atsync.Verifiers(ctx) verifierMu.Lock() defer verifierMu.Unlock() - return appViewCached + return appViewsCached } // Labeler returns the verifying labeler's DID and the label values (exact, diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go index 9a94301b6..a6d0a6b71 100644 --- a/pkg/branding/vocab.go +++ b/pkg/branding/vocab.go @@ -88,6 +88,7 @@ var Specs = []Spec{ {Key: "verifiedLabels", Kind: KindText, MaxSize: textMax, Doc: "Label values from labelerDid that count as verified, comma-separated; a trailing * matches a prefix: \"wsocial-*\" or \"verified,org\"."}, {Key: "verifyAppViewUrl", Kind: KindText, MaxSize: textMax, Doc: "An app view whose app.bsky.actor.getProfile carries the network's own verification field (see verifyAppViewField): https://api.example.com. The node asks it about each chat author on first sight and re-checks on a schedule; verified accounts count as verified here."}, {Key: "verifyAppViewField", Kind: KindText, MaxSize: textMax, Doc: "The getProfile field that marks a verified account on verifyAppViewUrl. Default \"wsocialVerified\"."}, + {Key: "verifyBluesky", Kind: KindEnum, Enum: []string{"on", "off"}, MaxSize: textMax, Doc: "Bluesky accounts with a blue check (verified on Bluesky's public app view, api.bsky.app) count as verified here too, alongside the node's own verifiers; they wear Bluesky's check rather than the verifiedIcon."}, {Key: "verifyAppViewValues", Kind: KindText, MaxSize: textMax, Doc: "Field values that count as verified, comma-separated; \"*\" (the default) means any non-empty value."}, {Key: "quickLoginReturn", Kind: KindText, MaxSize: textMax, Doc: "Value of the return parameter appended to the identity app's sign-in deep link on phones (…?return=), so the app knows where to send the viewer back; unset appends nothing."}, {Key: "verifyUrl", Kind: KindText, MaxSize: textMax, Doc: "Where a signed-in but unverified user goes to get verified (the composer's \"Verify now\" link); unset shows no link."},