about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/env/common.ts7
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.tsx88
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx87
-rw-r--r--src/screens/Signup/index.tsx14
-rw-r--r--yarn.lock5
6 files changed, 160 insertions, 42 deletions
diff --git a/package.json b/package.json
index f89c7089c..cc06bcd96 100644
--- a/package.json
+++ b/package.json
@@ -186,6 +186,7 @@
     "react-native": "^0.79.3",
     "react-native-compressor": "^1.11.0",
     "react-native-date-picker": "^5.0.12",
+    "react-native-device-attest": "^0.1.6",
     "react-native-drawer-layout": "^4.1.8",
     "react-native-edge-to-edge": "^1.6.0",
     "react-native-gesture-handler": "2.25.0",
diff --git a/src/env/common.ts b/src/env/common.ts
index 5b902622b..dd2df7b17 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -78,3 +78,10 @@ export const SENTRY_DSN: string | undefined = process.env.EXPO_PUBLIC_SENTRY_DSN
  */
 export const BITDRIFT_API_KEY: string | undefined =
   process.env.EXPO_PUBLIC_BITDRIFT_API_KEY
+
+/**
+ * GCP project ID which is required for device attestation
+ */
+export const GCP_PROJECT_ID: number = Number(
+  process.env.EXPO_PUBLIC_GCP_PROJECT_ID,
+)
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
diff --git a/yarn.lock b/yarn.lock
index bc7e35f67..4b153fb56 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16787,6 +16787,11 @@ react-native-date-picker@^5.0.12:
   resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-5.0.12.tgz#12540b6a58500811ee7e4fc0244e3accc7cca9c1"
   integrity sha512-R/mUnCKhcuxbhKPFwYdBQCxQt9HHLqpM4ruRUqlcBjiUZ3N2wdnwOMyc888Ps8qp8e7v29PrDHtUlG8LPuFn9w==
 
+react-native-device-attest@^0.1.6:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/react-native-device-attest/-/react-native-device-attest-0.1.6.tgz#51796a92d9199b1d231d4aa62d557019753b30c3"
+  integrity sha512-oTgBu6il+czHIMLs2IVWv2+WZ6a/vUtVLQ40q6/Dgns7NuG69mwmR8lS0e2Sl/yOtpD9YZXc8cYzBDKHAyV5MA==
+
 react-native-dotenv@^3.4.11:
   version "3.4.11"
   resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz#2e6c4eabd55d5f1bf109b3dd9141dadf9c55cdd4"