mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
91be66a2d7
commit
ccf7f1a2d0
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,7 +38,7 @@ export type NostrBindDeepLinkPayload = {
|
||||
version: "1";
|
||||
origin: string;
|
||||
expiresAt: string;
|
||||
returnMode: "clipboard";
|
||||
returnMode: "clipboard" | "browser_fragment_v1";
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user