diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index f5b050d408..9bed93872b 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -38,6 +38,7 @@ import { useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' +import {type AgeAssuranceDeviceSignals} from '#/ageAssurance/types' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import { getAssuredAgeFromDeviceSignals, @@ -338,25 +339,34 @@ function AccessSection() { const onPressVerify = useCallback(async () => { /* * In regions that permit on-device verification, try the native age API - * first. If it returns a sufficient age, the cached signals flow into the - * AA state recompute and lift the gate. Otherwise we fall back to the KWS - * flow below. `getDeviceSignals` handles its own errors and returns - * undefined on failure, which also routes us to the fallback. + * first. We tag the result with the current region (device assurance is + * region-bound — a TX grant only counts in TX) and, if it's sufficient, + * persist it client-side so the AA state recompute lifts the gate. + * Otherwise we fall back to the KWS flow below. `getDeviceSignals` handles + * its own errors and returns undefined (e.g. on web or failure), which also + * routes us to the fallback. */ if (region && regionAllowsDeviceVerification(region)) { const did = currentAccount?.did const signals = await getDeviceSignals() - if (did) { - setDeviceSignalsForDid({did, signals}) - } - const assuredAge = getAssuredAgeFromDeviceSignals(region, signals) - if (assuredAge !== undefined) { - // Sufficient device signals: AA state recomputes from the cache - // write above and unlocks access. Nothing else to do here. - return + if (signals && did) { + const deviceSignals: AgeAssuranceDeviceSignals = { + signals, + originRegion: { + countryCode: region.countryCode, + regionCode: region.regionCode, + }, + } + const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals) + if (assuredAge !== undefined) { + // Sufficient device signals: persist and let the AA state recompute + // from the cache write unlock access. Nothing else to do here. + setDeviceSignalsForDid({did, deviceSignals}) + return + } } logger.debug( - `onPressVerify: device signals insufficient, falling back to KWS`, + `onPressVerify: device signals unavailable or insufficient, falling back to KWS`, ) } diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 5167552fc7..40d44ccece 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -25,12 +25,15 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar import {useAgent, useSession} from '#/state/session' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' -import {type AgeAssuranceMetadata} from '#/ageAssurance/types' +import { + type AgeAssuranceDeviceSignals, + type AgeAssuranceMetadata, +} from '#/ageAssurance/types' import { getBirthdateStringFromAge, isLegacyBirthdateBug, } from '#/ageAssurance/util' -import {IS_DEV} from '#/env' +import {IS_DEV, IS_NATIVE} from '#/env' import {device} from '#/storage' /** @@ -489,10 +492,18 @@ export function useOtherRequiredDataQuery() { export function createDeviceSignalsQueryKey({did}: {did: string}) { return ['device-signals', did] } +/** + * Prompts the native OS age API. Returns the raw response, or undefined if the + * platform can't provide one. + * + * Native-only: on web `expo-age-range` returns a misleading default (e.g. + * `{lowerBound: 18}`), so we never call it there — web users fall back to KWS. + */ export async function getDeviceSignals(): Promise< AgeRange.AgeRangeResponse | undefined > { if (debug.enabled) return debug.resolve(debug.deviceSignals) + if (!IS_NATIVE) return undefined try { return await AgeRange.requestAgeRangeAsync({ threshold1: 13, @@ -511,25 +522,29 @@ export function getDeviceSignalsFromCache({ did, }: { did: string -}): AgeRange.AgeRangeResponse | undefined { - return qc.getQueryData( +}): AgeAssuranceDeviceSignals | undefined { + return qc.getQueryData( createDeviceSignalsQueryKey({did}), ) } /** - * Writes freshly granted device signals into the (persisted) cache. Notifies - * the disabled `useDeviceSignalsQuery` observer so the AA state recomputes. + * Writes freshly granted device signals into the (persisted) cache, tagged with + * the region they were captured in. Notifies the disabled + * `useDeviceSignalsQuery` observer so the AA state recomputes. + * + * Device assurance is client-side only (it can't be verified server-side) and + * region-bound — see {@link AgeAssuranceDeviceSignals}. */ export function setDeviceSignalsForDid({ did, - signals, + deviceSignals, }: { did: string - signals: AgeRange.AgeRangeResponse | undefined + deviceSignals: AgeAssuranceDeviceSignals | undefined }) { - qc.setQueryData( + qc.setQueryData( createDeviceSignalsQueryKey({did}), - signals, + deviceSignals, ) } export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { @@ -558,8 +573,8 @@ export function useDeviceSignalsQuery() { * Disabled so we never auto-call the native age API on load — that would * prompt the OS for every logged-in user. We restore from the persisted * cache (via `initialData`) and otherwise only update reactively when the - * user explicitly verifies (see `getDeviceSignals` + `setQueryData` in - * the NoAccessScreen verify flow). + * user explicitly verifies (see `getDeviceSignals` + `setDeviceSignalsForDid` + * in the NoAccessScreen verify flow). * * A future enhancement could silently refresh here when already cached, * since the OS returns the granted result without re-prompting. @@ -567,9 +582,10 @@ export function useDeviceSignalsQuery() { enabled: false, initialData: getDeviceSignalsFromCache({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}), - async queryFn() { - logger.debug(`useDeviceSignalsQuery: fetching device signals`) - return getDeviceSignals() + queryFn() { + // Never auto-fetches (see `enabled: false`); the verify flow writes the + // region-tagged record directly via `setDeviceSignalsForDid`. + return getDeviceSignalsFromCache({did: did!}) }, }, qc, @@ -618,10 +634,11 @@ export type AgeAssuranceServerData = { state: AppBskyAgeassuranceDefs.State | undefined metadata: AgeAssuranceMetadata | undefined /** - * The native on-device age signals, if the user has granted access. Only - * consumed for regions that permit device verification. + * The native on-device age signals, if the user has granted access, tagged + * with the region they were captured in. Only consumed for regions that + * permit device verification and that match the capture region. */ - deviceSignals: AgeRange.AgeRangeResponse | undefined + deviceSignals: AgeAssuranceDeviceSignals | undefined } const AgeAssuranceServerDataContext = createContext({ config: undefined, diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts index 986d875f0b..2ac9029d4c 100644 --- a/src/ageAssurance/debug.ts +++ b/src/ageAssurance/debug.ts @@ -71,10 +71,12 @@ export const config: DebugConfig = { { // On-device verification region (e.g. Texas). Set debug.geolocation to // {countryCode: 'US', regionCode: 'TX'} to exercise the device flow. + // KWS is included as a fallback for platforms without the native age API + // (e.g. web) or when the device result is insufficient. countryCode: 'US', regionCode: 'TX', minAccessAge: 18, - verificationMethods: ['device'], + verificationMethods: ['device', 'kws'], rules: [ { age: 18, diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index 69276c9737..bb3678f0ca 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,5 +1,4 @@ import {useEffect, useMemo, useState} from 'react' -import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, computeAgeAssuranceRegionAccess, @@ -17,6 +16,7 @@ import { import {logger} from '#/ageAssurance/logger' import { AgeAssuranceAccess, + type AgeAssuranceDeviceSignals, type AgeAssuranceMetadata, type AgeAssuranceState, AgeAssuranceStatus, @@ -49,7 +49,7 @@ function computeAgeAssuranceState({ config?: AppBskyAgeassuranceDefs.Config state?: AppBskyAgeassuranceDefs.State metadata?: AgeAssuranceMetadata - deviceSignals?: AgeRange.AgeRangeResponse + deviceSignals?: AgeAssuranceDeviceSignals }) { /** * This is where we control logged-out moderation prefs. It's all diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index 1215e888f2..92ad5d2d77 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -1,3 +1,4 @@ +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, type computeAgeAssuranceRegionAccess, @@ -27,6 +28,23 @@ export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & { verificationMethods?: AgeAssuranceVerificationMethod[] } +/** + * The on-device age signals plus the region they were captured in. + * + * Device assurance can't be verified server-side (the OS gives us no signed + * attestation, only age bounds), so we persist it client-side only and bind it + * to its origin region. The signals are only honored when the user's current + * region matches `originRegion` — a grant captured in TX must not silently + * unlock another region. See `getAssuredAgeFromDeviceSignals`. + */ +export type AgeAssuranceDeviceSignals = { + signals: AgeRange.AgeRangeResponse + originRegion: { + countryCode: string + regionCode?: string + } +} + export enum AgeAssuranceAccess { Unknown = 'unknown', None = 'none', diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index 6c5b6478f1..85ab0f8706 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -1,5 +1,4 @@ import {useMemo} from 'react' -import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, getAgeAssuranceRegionConfig, @@ -13,6 +12,7 @@ import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import { AgeAssuranceAccess, type AgeAssuranceConfigRegion, + type AgeAssuranceDeviceSignals, type AgeAssuranceFlags, type AgeAssuranceMetadata, type AgeAssuranceState, @@ -64,20 +64,46 @@ export function regionAllowsDeviceVerification( } /** - * Derives an assured age from native device signals, but only for regions that - * permit device verification. The OS-provided `lowerBound` is the minimum age - * the platform will attest to, which maps directly onto the `assuredAge` input - * of the rule engine (i.e. `IfAssuredOverAge`/`IfAssuredUnderAge` rules). + * Whether two regions refer to the same country + region. Used to ensure device + * signals are only applied within the region they were captured in. + */ +function isSameRegion( + a: {countryCode: string; regionCode?: string}, + b: {countryCode: string; regionCode?: string}, +): boolean { + return a.countryCode === b.countryCode && a.regionCode === b.regionCode +} + +/** + * Derives an assured age from native device signals, but only when: * - * Returns undefined when the region doesn't allow device verification or when - * the OS didn't provide a usable lower bound. + * 1. the current region permits device verification, and + * 2. the signals were captured in this same region. + * + * Device assurance is region-bound (see {@link AgeAssuranceDeviceSignals}): a + * grant captured in TX must not unlock another region. The OS-provided + * `lowerBound` is the minimum age the platform will attest to, which maps + * directly onto the `assuredAge` input of the rule engine (i.e. + * `IfAssuredOverAge`/`IfAssuredUnderAge` rules). + * + * Returns undefined when device verification doesn't apply or the OS didn't + * provide a usable lower bound. */ export function getAssuredAgeFromDeviceSignals( region: AppBskyAgeassuranceDefs.ConfigRegion, - deviceSignals: AgeRange.AgeRangeResponse | undefined, + deviceSignals: AgeAssuranceDeviceSignals | undefined, ): number | undefined { if (!regionAllowsDeviceVerification(region)) return undefined - const lowerBound = deviceSignals?.lowerBound + if (!deviceSignals) return undefined + if ( + !isSameRegion(deviceSignals.originRegion, { + countryCode: region.countryCode, + regionCode: region.regionCode, + }) + ) { + return undefined + } + const lowerBound = deviceSignals.signals.lowerBound return typeof lowerBound === 'number' ? lowerBound : undefined }