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
+35 -9
View File
@@ -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
}