diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c658fbb769..bb146c9688 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -99,6 +99,7 @@ export default defineConfig({ "**/onboarding-avatar-skip.spec.ts", "**/onboarding-backup.spec.ts", "**/onboarding-agent-defaults.spec.ts", + "**/nostr-bind.spec.ts", "**/profile-nsec-reveal.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index fc0065ee12..f90d2f390a 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -94,6 +94,7 @@ struct NostrBindDeepLinkPayload { origin: String, expires_at: String, return_mode: String, + callback_url: Option, } fn non_empty_param(url: &Url, name: &str) -> Result { @@ -104,6 +105,35 @@ fn non_empty_param(url: &Url, name: &str) -> Result { .ok_or_else(|| format!("missing {name}")) } +fn optional_non_empty_param(url: &Url, name: &str) -> Option { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) +} + +fn validate_nostr_bind_callback_url(callback_url: &str, origin: &str) -> Result<(), String> { + let callback = + Url::parse(callback_url).map_err(|error| format!("invalid callback_url: {error}"))?; + let origin = Url::parse(origin).map_err(|error| format!("invalid origin: {error}"))?; + if callback.scheme() != "https" { + return Err("callback_url must use https".into()); + } + if callback.host_str().is_none() { + return Err("callback_url missing host".into()); + } + if !callback.username().is_empty() || callback.password().is_some() { + return Err("callback_url must not include credentials".into()); + } + if callback.scheme() != origin.scheme() + || callback.host_str() != origin.host_str() + || callback.port_or_known_default() != origin.port_or_known_default() + { + return Err("callback_url must match origin".into()); + } + Ok(()) +} + fn parse_nostr_bind_deep_link(url: &Url) -> Result { let challenge_id = non_empty_param(url, "challenge_id")?; let nonce = non_empty_param(url, "nonce")?; @@ -115,6 +145,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result Result Result ""); +} + +function normalizeVerificationCode(value: string): string[] { + return value + .replace(/\D/g, "") + .slice(0, VERIFICATION_CODE_LENGTH) + .padEnd(VERIFICATION_CODE_LENGTH, " ") + .split("") + .map((character) => character.trim()); } function formatError(error: unknown): string { @@ -48,23 +104,97 @@ async function copyToClipboard(text: string): Promise { } } +function appendCallbackStatus(callbackUrl: string): string { + const url = new URL(callbackUrl); + url.searchParams.set("buzz_bind", "signed"); + return url.toString(); +} + +async function notifySignedResponseReady(callbackUrl: string | undefined) { + if (!callbackUrl) { + return; + } + try { + await openUrl(appendCallbackStatus(callbackUrl)); + } catch (error) { + console.warn("open nostr bind callback failed:", error); + } +} + export function NostrBindConsentDialog() { + const isPreview = isNostrBindPreviewEnabled(); const [payload, setPayload] = React.useState( - null, + isPreview ? NOSTR_BIND_PREVIEW_PAYLOAD : null, + ); + const [identity, setIdentity] = React.useState( + isPreview ? NOSTR_BIND_PREVIEW_IDENTITY : null, ); - const [identity, setIdentity] = React.useState(null); const [isSigning, setIsSigning] = React.useState(false); const [signedResponse, setSignedResponse] = React.useState( null, ); + const [isCopied, setIsCopied] = React.useState(false); + const [verificationCode, setVerificationCode] = React.useState( + createEmptyVerificationCode, + ); + const [hasCodeMismatch, setHasCodeMismatch] = React.useState(false); const [copyFailed, setCopyFailed] = React.useState(false); const [error, setError] = React.useState(null); + const codeInputRefs = React.useRef>([]); + const codeShakeRef = React.useRef(null); + const codeShakeAnimationRef = React.useRef(null); + const copiedTimerRef = React.useRef | null>( + null, + ); + const systemColorScheme = useSystemColorScheme(); + const shouldReduceMotion = useReducedMotion(); + const enteredVerificationCode = verificationCode.join(""); + const isVerificationCodeComplete = + enteredVerificationCode.length === VERIFICATION_CODE_LENGTH; + const isVerificationCodeValid = + payload !== null && enteredVerificationCode === payload.verificationCode; + const copyButtonLabel = isSigning ? "Signing…" : "Continue"; + const finishCopyButtonLabel = isCopied ? "Copied" : "Copy response"; + + const clearCopiedState = React.useCallback(() => { + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } + setIsCopied(false); + }, []); + + const showCopiedState = React.useCallback(() => { + clearCopiedState(); + setIsCopied(true); + copiedTimerRef.current = setTimeout(() => { + setIsCopied(false); + copiedTimerRef.current = null; + }, 2_000); + }, [clearCopiedState]); + + React.useEffect( + () => () => { + codeShakeAnimationRef.current?.cancel(); + if (copiedTimerRef.current) { + clearTimeout(copiedTimerRef.current); + } + }, + [], + ); React.useEffect(() => { + if (isPreview) { + return; + } + const unlistenPromise = listenForNostrBindDeepLinks((nextPayload) => { + clearCopiedState(); setPayload(nextPayload); setIdentity(null); setSignedResponse(null); + setVerificationCode(createEmptyVerificationCode()); + setHasCodeMismatch(false); setCopyFailed(false); setError(null); getIdentity() @@ -79,7 +209,7 @@ export function NostrBindConsentDialog() { return () => { void unlistenPromise.then((unlisten) => unlisten()); }; - }, []); + }, [clearCopiedState, isPreview]); const isExpired = React.useMemo(() => { if (!payload) { @@ -90,21 +220,198 @@ export function NostrBindConsentDialog() { }, [payload]); const resetDialog = React.useCallback(() => { + clearCopiedState(); setPayload(null); setSignedResponse(null); + setVerificationCode(createEmptyVerificationCode()); + setHasCodeMismatch(false); setCopyFailed(false); setError(null); setIdentity(null); setIsSigning(false); - }, []); + }, [clearCopiedState]); const handleOpenChange = React.useCallback( (open: boolean) => { - if (!open) { + if (!open && !isPreview) { resetDialog(); } }, - [resetDialog], + [isPreview, resetDialog], + ); + + const shakeVerificationCode = React.useCallback(() => { + if (shouldReduceMotion || !codeShakeRef.current) { + return; + } + + codeShakeAnimationRef.current?.cancel(); + codeShakeAnimationRef.current = codeShakeRef.current.animate( + [ + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0, + transform: "translateX(0px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.2857, + transform: "translateX(6px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.5714, + transform: "translateX(-6px)", + }, + { + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + offset: 0.7857, + transform: "translateX(4px)", + }, + { offset: 1, transform: "translateX(0px)" }, + ], + { duration: 280, easing: "linear" }, + ); + }, [shouldReduceMotion]); + + const showVerificationCodeMismatch = React.useCallback(() => { + setHasCodeMismatch(true); + shakeVerificationCode(); + }, [shakeVerificationCode]); + + const handleVerificationCodeChange = React.useCallback( + (index: number, value: string) => { + const nextDigits = value.replace(/\D/g, ""); + const next = [...verificationCode]; + + if (!nextDigits) { + next[index] = ""; + setVerificationCode(next); + setHasCodeMismatch(false); + return; + } + + if (index === VERIFICATION_CODE_LENGTH - 1 && verificationCode[index]) { + shakeVerificationCode(); + return; + } + + for ( + let offset = 0; + offset < nextDigits.length && index + offset < next.length; + offset += 1 + ) { + next[index + offset] = nextDigits[offset] ?? ""; + } + setVerificationCode(next); + + const completedCode = next.join(""); + if ( + completedCode.length === VERIFICATION_CODE_LENGTH && + completedCode !== payload?.verificationCode + ) { + showVerificationCodeMismatch(); + } else { + setHasCodeMismatch(false); + } + + const nextIndex = Math.min( + index + nextDigits.length, + VERIFICATION_CODE_LENGTH - 1, + ); + codeInputRefs.current[nextIndex]?.focus(); + codeInputRefs.current[nextIndex]?.select(); + }, + [ + payload?.verificationCode, + shakeVerificationCode, + showVerificationCodeMismatch, + verificationCode, + ], + ); + + const handleVerificationCodePaste = React.useCallback( + (index: number, event: React.ClipboardEvent) => { + if (index === VERIFICATION_CODE_LENGTH - 1 && verificationCode[index]) { + event.preventDefault(); + if (/\d/.test(event.clipboardData.getData("text"))) { + shakeVerificationCode(); + } + return; + } + + const pastedCode = event.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, VERIFICATION_CODE_LENGTH); + if (!pastedCode) { + return; + } + + event.preventDefault(); + const next = normalizeVerificationCode(pastedCode); + setVerificationCode(next); + if ( + pastedCode.length === VERIFICATION_CODE_LENGTH && + pastedCode !== payload?.verificationCode + ) { + showVerificationCodeMismatch(); + } else { + setHasCodeMismatch(false); + } + const nextIndex = Math.min( + pastedCode.length, + VERIFICATION_CODE_LENGTH - 1, + ); + codeInputRefs.current[nextIndex]?.focus(); + codeInputRefs.current[nextIndex]?.select(); + }, + [ + payload?.verificationCode, + shakeVerificationCode, + showVerificationCodeMismatch, + verificationCode, + ], + ); + + const handleVerificationCodeKeyDown = React.useCallback( + (index: number, event: React.KeyboardEvent) => { + if ( + index === VERIFICATION_CODE_LENGTH - 1 && + verificationCode[index] && + /^\d$/.test(event.key) + ) { + event.preventDefault(); + shakeVerificationCode(); + return; + } + + if (event.key === "Backspace") { + event.preventDefault(); + const targetIndex = verificationCode[index] + ? index + : Math.max(index - 1, 0); + setVerificationCode((current) => { + const next = [...current]; + next[targetIndex] = ""; + return next; + }); + setHasCodeMismatch(false); + codeInputRefs.current[targetIndex]?.focus(); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + codeInputRefs.current[Math.max(index - 1, 0)]?.focus(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + codeInputRefs.current[ + Math.min(index + 1, VERIFICATION_CODE_LENGTH - 1) + ]?.focus(); + } + }, + [shakeVerificationCode, verificationCode], ); const handleSign = React.useCallback(async () => { @@ -115,32 +422,48 @@ export function NostrBindConsentDialog() { setError(EXPIRED_LINK_MESSAGE); return; } + if (!isVerificationCodeValid) { + if (isVerificationCodeComplete) { + showVerificationCodeMismatch(); + } + const firstEmptyIndex = verificationCode.findIndex((digit) => !digit); + codeInputRefs.current[ + firstEmptyIndex === -1 ? 0 : firstEmptyIndex + ]?.focus(); + return; + } setIsSigning(true); + clearCopiedState(); setError(null); setCopyFailed(false); try { - const signed = await signNostrIdentityBinding({ - challengeId: payload.challengeId, - nonce: payload.nonce, - verificationCode: payload.verificationCode, - origin: payload.origin, - expiresAt: payload.expiresAt, - }); + const signed = isPreview + ? NOSTR_BIND_PREVIEW_SIGNED_RESPONSE + : await signNostrIdentityBinding({ + challengeId: payload.challengeId, + nonce: payload.nonce, + verificationCode: enteredVerificationCode, + origin: payload.origin, + expiresAt: payload.expiresAt, + }); setSignedResponse(signed); - const copied = await copyToClipboard(signed); - setCopyFailed(!copied); - if (copied) { - toast.success(COPY_SUCCESS_MESSAGE); - } else { - toast.warning("Signed response ready. Copy it manually below."); - } } catch (error) { setError(formatError(error) || "Failed to sign binding response."); } finally { setIsSigning(false); } - }, [isExpired, payload]); + }, [ + clearCopiedState, + enteredVerificationCode, + isExpired, + isPreview, + isVerificationCodeComplete, + isVerificationCodeValid, + payload, + showVerificationCodeMismatch, + verificationCode, + ]); const handleCopyAgain = React.useCallback(async () => { if (!signedResponse) { @@ -149,114 +472,314 @@ export function NostrBindConsentDialog() { const copied = await copyToClipboard(signedResponse); setCopyFailed(!copied); if (copied) { - toast.success(COPY_SUCCESS_MESSAGE); + showCopiedState(); + await notifySignedResponseReady(payload?.callbackUrl); + toast.success( + isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE, + ); + } else { + toast.warning(COPY_FAILURE_MESSAGE); } - }, [signedResponse]); + }, [isPreview, payload?.callbackUrl, showCopiedState, signedResponse]); return ( - - - - Bind Buzz identity? - - Buzz will sign a one-time proof. Your private key is not shared. - - - + + {payload ? ( -
-
-

- Verification code -

-

- {payload.verificationCode} -

-

- Only sign if this code matches the code shown by the requesting - website. -

-
+ + +
+ Buzz -
-
-
Requesting origin
-
- {payload.origin} -
-
-
-
Buzz identity
-
- {identity - ? `${identity.displayName} (${truncatePubkey(identity.pubkey)})` - : "Loading…"} -
-
-
-
Expires
-
- {formatExpiry(payload.expiresAt)} -
-
-
- - {isExpired ? ( -

- {EXPIRED_LINK_MESSAGE} -

- ) : null} - - {error ? ( -

- {error} -

- ) : null} - - {signedResponse ? ( -
-

- Signed response {copyFailed ? "ready" : "copied"}. Paste it - back into the requesting app. -

-