Bind device age assurance to its origin region, add KWS fallback

Device assurance can't be verified server-side (the OS gives only age
bounds, no signed attestation), so it's persisted client-side only. Bind
each cached grant to the region it was captured in so a TX grant can't
unlock another region.

- Add AgeAssuranceDeviceSignals (signals + originRegion); store the
  region-tagged record in the persisted cache instead of the raw response
- getAssuredAgeFromDeviceSignals now requires the current region to match
  the capture region
- Gate the native age request to native platforms (web returns a
  misleading default); web/new-device/declined falls back to KWS
- TX allows ['device', 'kws'] so the fallback path is real

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-06-22 18:38:51 -05:00
parent e834167f81
commit 85f6031708
6 changed files with 116 additions and 43 deletions
+23 -13
View File
@@ -38,6 +38,7 @@ import {
useAgeAssuranceServerDataContext, useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data' } from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceDeviceSignals} from '#/ageAssurance/types'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import { import {
getAssuredAgeFromDeviceSignals, getAssuredAgeFromDeviceSignals,
@@ -338,25 +339,34 @@ function AccessSection() {
const onPressVerify = useCallback(async () => { const onPressVerify = useCallback(async () => {
/* /*
* In regions that permit on-device verification, try the native age API * 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 * first. We tag the result with the current region (device assurance is
* AA state recompute and lift the gate. Otherwise we fall back to the KWS * region-bound — a TX grant only counts in TX) and, if it's sufficient,
* flow below. `getDeviceSignals` handles its own errors and returns * persist it client-side so the AA state recompute lifts the gate.
* undefined on failure, which also routes us to the fallback. * 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)) { if (region && regionAllowsDeviceVerification(region)) {
const did = currentAccount?.did const did = currentAccount?.did
const signals = await getDeviceSignals() const signals = await getDeviceSignals()
if (did) { if (signals && did) {
setDeviceSignalsForDid({did, signals}) const deviceSignals: AgeAssuranceDeviceSignals = {
} signals,
const assuredAge = getAssuredAgeFromDeviceSignals(region, signals) originRegion: {
if (assuredAge !== undefined) { countryCode: region.countryCode,
// Sufficient device signals: AA state recomputes from the cache regionCode: region.regionCode,
// write above and unlocks access. Nothing else to do here. },
return }
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( logger.debug(
`onPressVerify: device signals insufficient, falling back to KWS`, `onPressVerify: device signals unavailable or insufficient, falling back to KWS`,
) )
} }
+35 -18
View File
@@ -25,12 +25,15 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug' import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceMetadata} from '#/ageAssurance/types' import {
type AgeAssuranceDeviceSignals,
type AgeAssuranceMetadata,
} from '#/ageAssurance/types'
import { import {
getBirthdateStringFromAge, getBirthdateStringFromAge,
isLegacyBirthdateBug, isLegacyBirthdateBug,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {IS_DEV} from '#/env' import {IS_DEV, IS_NATIVE} from '#/env'
import {device} from '#/storage' import {device} from '#/storage'
/** /**
@@ -489,10 +492,18 @@ export function useOtherRequiredDataQuery() {
export function createDeviceSignalsQueryKey({did}: {did: string}) { export function createDeviceSignalsQueryKey({did}: {did: string}) {
return ['device-signals', did] 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< export async function getDeviceSignals(): Promise<
AgeRange.AgeRangeResponse | undefined AgeRange.AgeRangeResponse | undefined
> { > {
if (debug.enabled) return debug.resolve(debug.deviceSignals) if (debug.enabled) return debug.resolve(debug.deviceSignals)
if (!IS_NATIVE) return undefined
try { try {
return await AgeRange.requestAgeRangeAsync({ return await AgeRange.requestAgeRangeAsync({
threshold1: 13, threshold1: 13,
@@ -511,25 +522,29 @@ export function getDeviceSignalsFromCache({
did, did,
}: { }: {
did: string did: string
}): AgeRange.AgeRangeResponse | undefined { }): AgeAssuranceDeviceSignals | undefined {
return qc.getQueryData<AgeRange.AgeRangeResponse>( return qc.getQueryData<AgeAssuranceDeviceSignals>(
createDeviceSignalsQueryKey({did}), createDeviceSignalsQueryKey({did}),
) )
} }
/** /**
* Writes freshly granted device signals into the (persisted) cache. Notifies * Writes freshly granted device signals into the (persisted) cache, tagged with
* the disabled `useDeviceSignalsQuery` observer so the AA state recomputes. * 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({ export function setDeviceSignalsForDid({
did, did,
signals, deviceSignals,
}: { }: {
did: string did: string
signals: AgeRange.AgeRangeResponse | undefined deviceSignals: AgeAssuranceDeviceSignals | undefined
}) { }) {
qc.setQueryData<AgeRange.AgeRangeResponse | undefined>( qc.setQueryData<AgeAssuranceDeviceSignals | undefined>(
createDeviceSignalsQueryKey({did}), createDeviceSignalsQueryKey({did}),
signals, deviceSignals,
) )
} }
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { 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 * 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 * prompt the OS for every logged-in user. We restore from the persisted
* cache (via `initialData`) and otherwise only update reactively when the * cache (via `initialData`) and otherwise only update reactively when the
* user explicitly verifies (see `getDeviceSignals` + `setQueryData` in * user explicitly verifies (see `getDeviceSignals` + `setDeviceSignalsForDid`
* the NoAccessScreen verify flow). * in the NoAccessScreen verify flow).
* *
* A future enhancement could silently refresh here when already cached, * A future enhancement could silently refresh here when already cached,
* since the OS returns the granted result without re-prompting. * since the OS returns the granted result without re-prompting.
@@ -567,9 +582,10 @@ export function useDeviceSignalsQuery() {
enabled: false, enabled: false,
initialData: getDeviceSignalsFromCache({did: did!}), initialData: getDeviceSignalsFromCache({did: did!}),
queryKey: createDeviceSignalsQueryKey({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}),
async queryFn() { queryFn() {
logger.debug(`useDeviceSignalsQuery: fetching device signals`) // Never auto-fetches (see `enabled: false`); the verify flow writes the
return getDeviceSignals() // region-tagged record directly via `setDeviceSignalsForDid`.
return getDeviceSignalsFromCache({did: did!})
}, },
}, },
qc, qc,
@@ -618,10 +634,11 @@ export type AgeAssuranceServerData = {
state: AppBskyAgeassuranceDefs.State | undefined state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined metadata: AgeAssuranceMetadata | undefined
/** /**
* The native on-device age signals, if the user has granted access. Only * The native on-device age signals, if the user has granted access, tagged
* consumed for regions that permit device verification. * 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<AgeAssuranceServerData>({ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
config: undefined, config: undefined,
+3 -1
View File
@@ -71,10 +71,12 @@ export const config: DebugConfig = {
{ {
// On-device verification region (e.g. Texas). Set debug.geolocation to // On-device verification region (e.g. Texas). Set debug.geolocation to
// {countryCode: 'US', regionCode: 'TX'} to exercise the device flow. // {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', countryCode: 'US',
regionCode: 'TX', regionCode: 'TX',
minAccessAge: 18, minAccessAge: 18,
verificationMethods: ['device'], verificationMethods: ['device', 'kws'],
rules: [ rules: [
{ {
age: 18, age: 18,
+2 -2
View File
@@ -1,5 +1,4 @@
import {useEffect, useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess, computeAgeAssuranceRegionAccess,
@@ -17,6 +16,7 @@ import {
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
import { import {
AgeAssuranceAccess, AgeAssuranceAccess,
type AgeAssuranceDeviceSignals,
type AgeAssuranceMetadata, type AgeAssuranceMetadata,
type AgeAssuranceState, type AgeAssuranceState,
AgeAssuranceStatus, AgeAssuranceStatus,
@@ -49,7 +49,7 @@ function computeAgeAssuranceState({
config?: AppBskyAgeassuranceDefs.Config config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse deviceSignals?: AgeAssuranceDeviceSignals
}) { }) {
/** /**
* This is where we control logged-out moderation prefs. It's all * This is where we control logged-out moderation prefs. It's all
+18
View File
@@ -1,3 +1,4 @@
import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
type computeAgeAssuranceRegionAccess, type computeAgeAssuranceRegionAccess,
@@ -27,6 +28,23 @@ export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & {
verificationMethods?: AgeAssuranceVerificationMethod[] 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 { export enum AgeAssuranceAccess {
Unknown = 'unknown', Unknown = 'unknown',
None = 'none', None = 'none',
+35 -9
View File
@@ -1,5 +1,4 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
@@ -13,6 +12,7 @@ import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import { import {
AgeAssuranceAccess, AgeAssuranceAccess,
type AgeAssuranceConfigRegion, type AgeAssuranceConfigRegion,
type AgeAssuranceDeviceSignals,
type AgeAssuranceFlags, type AgeAssuranceFlags,
type AgeAssuranceMetadata, type AgeAssuranceMetadata,
type AgeAssuranceState, type AgeAssuranceState,
@@ -64,20 +64,46 @@ export function regionAllowsDeviceVerification(
} }
/** /**
* Derives an assured age from native device signals, but only for regions that * Whether two regions refer to the same country + region. Used to ensure device
* permit device verification. The OS-provided `lowerBound` is the minimum age * signals are only applied within the region they were captured in.
* the platform will attest to, which maps directly onto the `assuredAge` input */
* of the rule engine (i.e. `IfAssuredOverAge`/`IfAssuredUnderAge` rules). 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 * 1. the current region permits device verification, and
* the OS didn't provide a usable lower bound. * 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( export function getAssuredAgeFromDeviceSignals(
region: AppBskyAgeassuranceDefs.ConfigRegion, region: AppBskyAgeassuranceDefs.ConfigRegion,
deviceSignals: AgeRange.AgeRangeResponse | undefined, deviceSignals: AgeAssuranceDeviceSignals | undefined,
): number | undefined { ): number | undefined {
if (!regionAllowsDeviceVerification(region)) return 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 return typeof lowerBound === 'number' ? lowerBound : undefined
} }