diff options
author | hailey <me@haileyok.com> | 2025-08-07 12:33:38 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-07 12:33:38 -0700 |
commit | c0593e49792af987b0c7accd6301f235d132030f (patch) | |
tree | afc1faccd7686646d09567165b1f0ebe7849f7b7 /src/screens/Signup | |
parent | 39e775a3768007df05ab91fe3ead39e36355b19a (diff) | |
download | voidsky-c0593e49792af987b0c7accd6301f235d132030f.tar.zst |
Add device attestation to signup flow (#8757)
Diffstat (limited to 'src/screens/Signup')
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.tsx | 88 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 87 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 14 |
3 files changed, 147 insertions, 42 deletions
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index caa0aa28a..27951d4cc 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -1,20 +1,22 @@ -import React from 'react' -import {StyleSheet} from 'react-native' -import {WebView, WebViewNavigation} from 'react-native-webview' -import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' +import {useEffect, useMemo, useRef} from 'react' +import {WebView, type WebViewNavigation} from 'react-native-webview' +import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' -import {SignupState} from '#/screens/Signup/state' +import {type SignupState} from '#/screens/Signup/state' const ALLOWED_HOSTS = [ 'bsky.social', 'bsky.app', 'staging.bsky.app', 'staging.bsky.dev', + 'app.staging.bsky.dev', 'js.hcaptcha.com', 'newassets.hcaptcha.com', 'api2.hcaptcha.com', ] +const MIN_DELAY = 3_500 + export function CaptchaWebView({ url, stateParam, @@ -28,49 +30,67 @@ export function CaptchaWebView({ onSuccess: (code: string) => void onError: (error: unknown) => void }) { - const redirectHost = React.useMemo(() => { + const startedAt = useRef(Date.now()) + const successTo = useRef<NodeJS.Timeout>() + + useEffect(() => { + return () => { + if (successTo.current) { + clearTimeout(successTo.current) + } + } + }, []) + + const redirectHost = useMemo(() => { if (!state?.serviceUrl) return 'bsky.app' return state?.serviceUrl && new URL(state?.serviceUrl).host === 'staging.bsky.dev' - ? 'staging.bsky.app' + ? 'app.staging.bsky.dev' : 'bsky.app' }, [state?.serviceUrl]) - const wasSuccessful = React.useRef(false) + const wasSuccessful = useRef(false) - const onShouldStartLoadWithRequest = React.useCallback( - (event: ShouldStartLoadRequest) => { - const urlp = new URL(event.url) - return ALLOWED_HOSTS.includes(urlp.host) - }, - [], - ) + const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => { + const urlp = new URL(event.url) + return ALLOWED_HOSTS.includes(urlp.host) + } - const onNavigationStateChange = React.useCallback( - (e: WebViewNavigation) => { - if (wasSuccessful.current) return + const onNavigationStateChange = (e: WebViewNavigation) => { + if (wasSuccessful.current) return - const urlp = new URL(e.url) - if (urlp.host !== redirectHost) return + const urlp = new URL(e.url) + if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return - const code = urlp.searchParams.get('code') - if (urlp.searchParams.get('state') !== stateParam || !code) { - onError({error: 'Invalid state or code'}) - return - } + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError({error: 'Invalid state or code'}) + return + } - wasSuccessful.current = true + // We want to delay the completion of this screen ever so slightly so that it doesn't appear to be a glitch if it completes too fast + wasSuccessful.current = true + const now = Date.now() + const timeTaken = now - startedAt.current + if (timeTaken < MIN_DELAY) { + successTo.current = setTimeout(() => { + onSuccess(code) + }, MIN_DELAY - timeTaken) + } else { onSuccess(code) - }, - [redirectHost, stateParam, onSuccess, onError], - ) + } + } return ( <WebView source={{uri: url}} javaScriptEnabled - style={styles.webview} + style={{ + flex: 1, + backgroundColor: 'transparent', + borderRadius: 10, + }} onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} onNavigationStateChange={onNavigationStateChange} scrollEnabled={false} @@ -83,11 +103,3 @@ export function CaptchaWebView({ /> ) } - -const styles = StyleSheet.create({ - webview: { - flex: 1, - backgroundColor: 'transparent', - borderRadius: 10, - }, -}) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx index 388deecaf..e2f249a13 100644 --- a/src/screens/Signup/StepCaptcha/index.tsx +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -1,21 +1,74 @@ -import React from 'react' -import {ActivityIndicator, View} from 'react-native' +import React, {useEffect, useState} from 'react' +import {ActivityIndicator, Platform, View} from 'react-native' +import ReactNativeDeviceAttest from 'react-native-device-attest' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {nanoid} from 'nanoid/non-secure' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' +import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {useSignupContext} from '#/screens/Signup/state' import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' import {atoms as a, useTheme} from '#/alf' import {FormError} from '#/components/forms/FormError' +import {GCP_PROJECT_ID} from '#/env' import {BackNextButtons} from '../BackNextButtons' -const CAPTCHA_PATH = '/gate/signup' +const CAPTCHA_PATH = + isWeb || GCP_PROJECT_ID === 0 ? '/gate/signup' : '/gate/signup/attempt-attest' export function StepCaptcha() { + if (isWeb) { + return <StepCaptchaInner /> + } else { + return <StepCaptchaNative /> + } +} + +export function StepCaptchaNative() { + const [token, setToken] = useState<string>() + const [payload, setPayload] = useState<string>() + const [ready, setReady] = useState(false) + + useEffect(() => { + ;(async () => { + logger.debug('trying to generate attestation token...') + try { + if (isIOS) { + logger.debug('starting to generate devicecheck token...') + const token = await ReactNativeDeviceAttest.getDeviceCheckToken() + setToken(token) + logger.debug(`generated devicecheck token: ${token}`) + } else { + const {token, payload} = + await ReactNativeDeviceAttest.getIntegrityToken('signup') + setToken(token) + setPayload(base64UrlEncode(payload)) + } + } catch (e: any) { + logger.error(e) + } finally { + setReady(true) + } + })() + }, []) + + if (!ready) { + return <View /> + } + + return <StepCaptchaInner token={token} payload={payload} /> +} + +function StepCaptchaInner({ + token, + payload, +}: { + token?: string + payload?: string +}) { const {_} = useLingui() const theme = useTheme() const {state, dispatch} = useSignupContext() @@ -33,8 +86,24 @@ export function StepCaptcha() { newUrl.searchParams.set('state', stateParam) newUrl.searchParams.set('colorScheme', theme.name) + if (isNative && token) { + newUrl.searchParams.set('platform', Platform.OS) + newUrl.searchParams.set('token', token) + if (isAndroid && payload) { + newUrl.searchParams.set('payload', payload) + } + } + return newUrl.href - }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + }, [ + state.serviceUrl, + state.handle, + state.userDomain, + stateParam, + theme.name, + token, + payload, + ]) const onSuccess = React.useCallback( (code: string) => { @@ -105,3 +174,13 @@ export function StepCaptcha() { </ScreenTransition> ) } + +function base64UrlEncode(data: string): string { + const encoder = new TextEncoder() + const bytes = encoder.encode(data) + + const binaryString = String.fromCharCode(...bytes) + const base64 = btoa(binaryString) + + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, '') +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 03f4e2cdd..50cc5aa26 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,11 +1,14 @@ import {useEffect, useReducer, useState} from 'react' import {AppState, type AppStateStatus, View} from 'react-native' +import ReactNativeDeviceAttest from 'react-native-device-attest' import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {FEEDBACK_FORM_URL} from '#/lib/constants' +import {logger} from '#/logger' +import {isAndroid} from '#/platform/detection' import {useServiceQuery} from '#/state/queries/service' import {useStarterPackQuery} from '#/state/queries/starter-packs' import {useActiveStarterPack} from '#/state/shell/starter-pack' @@ -26,6 +29,7 @@ import {Divider} from '#/components/Divider' import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +import {GCP_PROJECT_ID} from '#/env' import * as bsky from '#/types/bsky' export function Signup({onPressBack}: {onPressBack: () => void}) { @@ -101,6 +105,16 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { return () => subscription.remove() }, []) + // On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen. + useEffect(() => { + if (!isAndroid) { + return + } + ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err => + logger.error(err), + ) + }, []) + return ( <SignupContext.Provider value={{state, dispatch}}> <LoggedOutLayout |