Keep device secrets behind biometric or passcode authentication
One.ProtectedStore keeps a separate protected namespace for secrets
that require device authentication to read or change. Unlike
One.SecureStore, every item carries an access control policy fixed at
creation. All calls are async, so a system authentication prompt never
blocks the JavaScript thread.
On iOS items live in the Keychain behind SecAccessControl, unlocking
through LAContext. On Android each item gets its own Keystore key
with encrypted records on API 30 and later, unlocking per operation
through BiometricPrompt. Web and older Android never read or write an
unprotected substitute; they stay unavailable.
import { One } from 'one'
async function saveRecoveryKey(secret: string, nextSecret: string) { await One.ProtectedStore.createItem('recovery-key', secret, 'biometryCurrentSet') const value = await One.ProtectedStore.getItem( 'recovery-key', 'Unlock your recovery key', 'biometryCurrentSet' ) await One.ProtectedStore.updateItem( 'recovery-key', nextSecret, 'Update your recovery key', 'biometryCurrentSet' ) await One.ProtectedStore.deleteItem('recovery-key', 'Remove your recovery key', 'biometryCurrentSet') return value}Every read and mutation takes the reason the system shows and the policy fixed at creation. Creation takes no reason on Android, where it prompts nothing.
Set native.app.ios.faceIdUsageDescription before prebuilding,
including for Touch ID apps. One’s Android library manifest declares
the normal install-time biometric permission, which the manifest merger
adds to the app; an app that strips it rejects every call.
userPresence accepts biometrics or the device passcode.
biometryCurrentSet requires the currently enrolled biometrics and
invalidates the item when that enrollment changes. Both keep the item
on this device and available only while unlocked. Pass the same policy
to later reads and mutations; a mismatch rejects. updateItem changes
the value while keeping the policy. createItem rejects when the key
exists; delete first to recreate under another policy.
getItem returns null for a missing item. An item invalidated by an
enrollment change cannot be read, but deleteItem removes it so the
key can be reused. updateItem rejects for a missing key while
deleteItem succeeds for one. A cancelled prompt rejects as
cancelled; authentication failures reject as auth errors.
On Android every read or update authenticates one fresh private-key operation, and deleting an existing item needs fresh authentication too. Calls made while locked reject without prompting or changing the item. A missing result applies only when both the encrypted record and its Keystore alias are absent. Values and temporary encryption keys are never stored as plaintext.
| Methods | Type | Description |
|---|---|---|
createItem(key: string, value: string, policy: ProtectedStorePolicy) => Promise<void>Store a secret, fixing its policy. Rejects when the key exists. | (key: string, value: string, policy: ProtectedStorePolicy) => Promise<void> | Store a secret, fixing its policy. Rejects when the key exists. |
getItem(key: string, reason: string, policy: ProtectedStorePolicy) => Promise<string | null>Read a secret after authentication. Null when missing. | (key: string, reason: string, policy: ProtectedStorePolicy) => Promise<string | null> | Read a secret after authentication. Null when missing. |
updateItem(key: string, value: string, reason: string, policy: ProtectedStorePolicy) => Promise<void>Replace a secret, keeping its policy. | (key: string, value: string, reason: string, policy: ProtectedStorePolicy) => Promise<void> | Replace a secret, keeping its policy. |
deleteItem(key: string, reason: string, policy: ProtectedStorePolicy) => Promise<void>Remove a secret. Succeeds when missing. | (key: string, reason: string, policy: ProtectedStorePolicy) => Promise<void> | Remove a secret. Succeeds when missing. |
The policy is userPresence or biometryCurrentSet.
| iOS | Android | Web | |
|---|---|---|---|
| Store | Keychain | Keystore | None |
| Unlock | System prompt | Prompt each op | Unavailable |
| Floor | All versions | API 30 plus | None |
| Missing | Null | Null | Null |
biometryCurrentSet
invalidates on re-enrollment. Catch the read failure, delete, and
re-enroll the secret.import { One } from 'one'
const POLICY = 'biometryCurrentSet' as const
async function readRecoveryKey() { const value = await One.ProtectedStore.getItem( 'recovery-key', 'Unlock your recovery key', POLICY ) if (value === null) { await One.ProtectedStore.deleteItem('recovery-key', 'Clear stale key', POLICY) return null } return value}A null read after an enrollment change means an invalidated item; the explicit delete frees the key for a fresh secret on the next setup.
import { One } from 'one'
async function rotateToken(next: string) { try { await One.ProtectedStore.updateItem( 'api-token', next, 'Rotate your API token', 'userPresence' ) } catch (error) { if ((error as { code?: string }).code === 'E_PROTECTED_STORE_NOT_FOUND') { await One.ProtectedStore.createItem('api-token', next, 'userPresence') return } throw error }}Update keeps the policy while swapping the value. The not-found branch covers first-run, creating under the same policy the update would have kept.
Edit this page on GitHub.