feat(desktop): return nostr binding proof in browser fragment (#1933)

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-15 20:03:01 -07:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 91be66a2d7
commit ccf7f1a2d0
8 changed files with 206 additions and 9 deletions
+29 -2
View File
@@ -155,8 +155,13 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result<NostrBindDeepLinkPayload, Str
// Expired links still reach the consent surface so the user gets an explicit
// failure instead of a silent stderr-only rejection from a launched app.
nostr_bind::validate_expires_at_format(&expires_at)?;
if return_mode != nostr_bind::RETURN_MODE {
return Err("unsupported return mode".into());
match return_mode.as_str() {
nostr_bind::RETURN_MODE_CLIPBOARD => {}
nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 if callback_url.is_some() => {}
nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 => {
return Err("browser_fragment_v1 requires callback_url".into());
}
_ => return Err("unsupported return mode".into()),
}
if let Some(callback_url) = callback_url.as_deref() {
validate_nostr_bind_callback_url(callback_url, &origin)?;
@@ -389,6 +394,28 @@ mod tests {
);
}
#[test]
fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap();
let payload = parse_nostr_bind_deep_link(&url).unwrap();
assert_eq!(payload.return_mode, "browser_fragment_v1");
assert_eq!(
payload.callback_url.as_deref(),
Some("https://example.com/buzz")
);
}
#[test]
fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap();
assert_eq!(
parse_nostr_bind_deep_link(&url).unwrap_err(),
"browser_fragment_v1 requires callback_url"
);
}
#[test]
fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() {
let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap();
+2 -1
View File
@@ -6,7 +6,8 @@ pub(crate) const ACTION: &str = "bind_nostr_identity";
pub(crate) const CONTENT: &str = "";
pub(crate) const KIND: u16 = buzz_core_pkg::kind::KIND_NOSTR_IDENTITY_BINDING as u16;
pub(crate) const PROTOCOL: &str = "buzz-nostr-identity";
pub(crate) const RETURN_MODE: &str = "clipboard";
pub(crate) const RETURN_MODE_CLIPBOARD: &str = "clipboard";
pub(crate) const RETURN_MODE_BROWSER_FRAGMENT_V1: &str = "browser_fragment_v1";
pub(crate) const VERSION: &str = "1";
const NONCE_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildNostrBindCallbackUrl } from "./nostrBindCallback.ts";
function decodePayload(callbackUrl) {
const encoded = new URL(callbackUrl).hash.split("v1.")[1];
const padded = encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
return new TextDecoder().decode(
Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)),
);
}
test("buildNostrBindCallbackUrl returns a UTF-8 base64url payload in the fragment", () => {
const response = JSON.stringify({ content: "Buzz ⚡", sig: "+/=" });
const result = buildNostrBindCallbackUrl(
"https://example.com/buzz?source=bind",
response,
);
const url = new URL(result);
assert.equal(url.origin, "https://example.com");
assert.equal(url.pathname, "/buzz");
assert.equal(url.search, "?source=bind");
assert.match(url.hash, /^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
assert.equal(decodePayload(result), response);
assert.equal(url.searchParams.has("buzz_bind"), false);
});
test("buildNostrBindCallbackUrl replaces an existing fragment", () => {
const result = buildNostrBindCallbackUrl(
"https://example.com/buzz#stale-fragment",
"signed",
);
assert.equal(new URL(result).hash, "#buzz_bind=v1.c2lnbmVk");
});
test("buildNostrBindCallbackUrl rejects callback URLs beyond the opener ceiling", () => {
assert.throws(
() =>
buildNostrBindCallbackUrl("https://example.com/buzz", "x".repeat(4_096)),
/too large/,
);
});
@@ -0,0 +1,28 @@
const MAX_CALLBACK_URL_LENGTH = 4_096;
const CALLBACK_FRAGMENT_KEY = "buzz_bind";
const CALLBACK_PAYLOAD_VERSION = "v1";
function encodeBase64Url(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}
export function buildNostrBindCallbackUrl(
callbackUrl: string,
signedResponse: string,
): string {
const url = new URL(callbackUrl);
url.hash = `${CALLBACK_FRAGMENT_KEY}=${CALLBACK_PAYLOAD_VERSION}.${encodeBase64Url(signedResponse)}`;
const result = url.toString();
if (result.length > MAX_CALLBACK_URL_LENGTH) {
throw new Error("Signed response is too large to return to the browser.");
}
return result;
}
@@ -9,6 +9,7 @@ import type { Identity } from "@/shared/api/types";
import type { NostrBindDeepLinkPayload } from "@/shared/deep-link";
import { listenForNostrBindDeepLinks } from "@/shared/deep-link";
import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition";
import { buildNostrBindCallbackUrl } from "@/features/profile/lib/nostrBindCallback";
import { signNostrIdentityBinding } from "@/features/profile/lib/nostrIdentityBinding";
import { cn } from "@/shared/lib/cn";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
@@ -121,6 +122,19 @@ async function notifySignedResponseReady(callbackUrl: string | undefined) {
}
}
async function returnSignedResponseToBrowser(
callbackUrl: string,
signedResponse: string,
): Promise<string | null> {
try {
await openUrl(buildNostrBindCallbackUrl(callbackUrl, signedResponse));
return null;
} catch (error) {
console.warn("return signed nostr binding response failed:", error);
return "Could not open the browser. Copy the response below to finish manually.";
}
}
export function NostrBindConsentDialog() {
const isPreview = isNostrBindPreviewEnabled();
const [payload, setPayload] = React.useState<NostrBindDeepLinkPayload | null>(
@@ -448,6 +462,11 @@ export function NostrBindConsentDialog() {
expiresAt: payload.expiresAt,
});
setSignedResponse(signed);
if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) {
setError(
await returnSignedResponseToBrowser(payload.callbackUrl, signed),
);
}
} catch (error) {
setError(formatError(error) || "Failed to sign binding response.");
} finally {
@@ -473,14 +492,22 @@ export function NostrBindConsentDialog() {
setCopyFailed(!copied);
if (copied) {
showCopiedState();
await notifySignedResponseReady(payload?.callbackUrl);
if (payload?.returnMode === "clipboard") {
await notifySignedResponseReady(payload.callbackUrl);
}
toast.success(
isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE,
);
} else {
toast.warning(COPY_FAILURE_MESSAGE);
}
}, [isPreview, payload?.callbackUrl, showCopiedState, signedResponse]);
}, [
isPreview,
payload?.callbackUrl,
payload?.returnMode,
showCopiedState,
signedResponse,
]);
return (
<DialogPrimitive.Root
@@ -512,16 +539,25 @@ export function NostrBindConsentDialog() {
transitionKey="nostr-bind-finish"
>
<DialogPrimitive.Title className="mt-6 text-3xl font-semibold tracking-tight">
Finish on the Buzz website
{payload.returnMode === "browser_fragment_v1"
? "Continue in your browser"
: "Finish on the Buzz website"}
</DialogPrimitive.Title>
<DialogPrimitive.Description
className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground"
id="nostr-bind-description"
>
Copy the response below, then paste it into the Buzz website
to finish verification.
{payload.returnMode === "browser_fragment_v1"
? "Buzz opened your browser to finish verification. If it did not open, copy the response below."
: "Copy the response below, then paste it into the Buzz website to finish verification."}
</DialogPrimitive.Description>
{error ? (
<p className="mt-4 w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-left text-sm text-destructive">
{error}
</p>
) : null}
<pre
className="mt-10 max-h-56 w-full overflow-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-left shadow-xs"
data-testid="nostr-bind-signed-response"
+1 -1
View File
@@ -38,7 +38,7 @@ export type NostrBindDeepLinkPayload = {
version: "1";
origin: string;
expiresAt: string;
returnMode: "clipboard";
returnMode: "clipboard" | "browser_fragment_v1";
callbackUrl?: string;
};
+2
View File
@@ -9476,6 +9476,8 @@ export function maybeInstallE2eTauriMocks() {
return sendToMockSocket(
payload as Parameters<typeof sendToMockSocket>[0],
);
case "plugin:opener|open_url":
return null;
case "plugin:window|show":
case "plugin:window|unminimize":
case "plugin:window|set_focus":
+55
View File
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
type NostrBindPayload = {
action: string;
audience: string;
callbackUrl?: string;
challengeId: string;
expiresAt: string;
nonce: string;
@@ -297,6 +298,60 @@ test("signs a valid request, shows the response, and copies it", async ({
.toBe(signedResponse);
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
returnMode: "browser_fragment_v1",
});
await expect
.poll(async () =>
page.evaluate(
() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).filter(({ command }) => command === "plugin:opener|open_url")
.length,
),
)
.toBe(0);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await page.getByTestId("nostr-bind-sign-and-copy").click();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
const callback = await page.evaluate(() => {
const command = (
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { url?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).find(({ command }) => command === "plugin:opener|open_url");
return command?.payload.url;
});
const callbackUrl = new URL(callback ?? "");
expect(callbackUrl.origin).toBe("https://admin.example.com");
expect(callbackUrl.pathname).toBe("/buzz");
expect(callbackUrl.search).toBe("?source=bind");
expect(callbackUrl.searchParams.has("buzz_bind")).toBe(false);
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {