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:
@@ -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,
|
||||||
|
originRegion: {
|
||||||
|
countryCode: region.countryCode,
|
||||||
|
regionCode: region.regionCode,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const assuredAge = getAssuredAgeFromDeviceSignals(region, signals)
|
const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals)
|
||||||
if (assuredAge !== undefined) {
|
if (assuredAge !== undefined) {
|
||||||
// Sufficient device signals: AA state recomputes from the cache
|
// Sufficient device signals: persist and let the AA state recompute
|
||||||
// write above and unlocks access. Nothing else to do here.
|
// from the cache write unlock access. Nothing else to do here.
|
||||||
|
setDeviceSignalsForDid({did, deviceSignals})
|
||||||
return
|
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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user