Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions world-app/backup/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ A handful of named errors are surfaced in client code paths and worth knowing by
- `manifest_hash_mismatch` — client tried to sync from a stale state (see [BF-5](/world-app/backup/flows#bf-5-remote-update-detection)).
- `unauthorized_factor` — factor identifier is in `FactorLookup` but no longer in the backup metadata; the device is no longer authorized (see [Unauthorized Device](/world-app/backup/advanced#unauthorized-device)).
- `backup_does_not_exist` — the metadata is gone (the user deleted their backup); local state should be cleared.
- `backup_account_id_already_exists` — `/v1/create` collision; some prior creation attempt registered this backup ID. The client retries with a fresh sync factor authorization on the existing record.
- `backup_account_id_already_exists` — `/v1/create` collision; some prior creation attempt registered this backup ID. For a pending iOS Secure Enclave attempt, the client retains the same key and confirms its exact membership in authenticated metadata. An accepted creation still returns the duplicate-backup result; retrieving the contents requires a Main Factor (see [BF-1](/world-app/backup/flows#bf-1-backup-creation)).
- `factor_already_exists` — attempted to add a factor whose public identifier is already enrolled.
- `invalid_challenge_context` — challenge token didn't match the operation it was submitted for.

Expand Down Expand Up @@ -112,6 +112,18 @@ Each World App user is a Turnkey *sub-organization*. Throughout the docs and the

> `USER` lets a Sync Factor delete itself on logout and clean up stale sibling Sync Factor users. It cannot delete `auth_user_main` because Root Quorum validation rejects that.

### iOS Sync Factor registration retries

Sync Factor API keys are registered with `API_KEY_CURVE_P256`; the separate Backup Account Key used by `break_glass_user` remains `secp256k1`.

Creating a Sync Factor user and creating its deletion-only policy are **separate activities**. Either may succeed remotely even when its response is lost. When reconciling a pending iOS enrollment in an existing sub-organization, the client:

1. Queries users for the pending key's exact public key. A reusable match must be unique, have the `sync_factor_user_` role name, and hold that key under `sync_keypair`. Duplicate or wrong-role matches fail rather than being adopted.
2. Creates a user only when no matching user exists. After an uncertain create response, it queries again to recover the existing user ID.
3. Queries policies for the exact expected `EFFECT_ALLOW`, deletion-only condition, and consensus bound to that user ID. An existing matching policy is reused; only a missing policy is created. After an uncertain policy-create response, it queries again before confirming success.

The key remains pending if either registration cannot be confirmed. These queries and any missing creation require a Main Factor-authorized Turnkey session; the Sync Factor gains no new enrollment authority. Fresh sub-organization provisioning retains its existing rollback behavior rather than becoming a general resumable account-creation flow.

In addition, Turnkey has the concept of a [Root Quorum](https://docs.turnkey.com/concepts/users/root-quorum). When the Root Quorum is met, any action bypasses the Policy Engine. We set the Root Quorum to 1-of-1 with `auth_user_main` as its sole member.

## Turnkey Migrations
Expand All @@ -129,7 +141,7 @@ The migration list, the per-environment audience tables, and the safety guards t
| OIDC factor secrets (inside enclave) | <SealedBackup /> blob (encrypted) |
| OIDC provider configurations | <BackupMetadata /> (encrypted keypair copies, factor list) |
| Sub-organization membership (auth/sync users) | `FactorLookup` reverse index |
| P-256 API keypairs (per-device sync factor) | Challenge state (Redis) |
| P-256 API public keys (per-device sync factor; private keys remain on the device) | Challenge state (Redis) |

Neither holds the <BackupEncryptionKeypair />'s secret key — that is destroyed at backup creation.

Expand Down
18 changes: 16 additions & 2 deletions world-app/backup/factors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,27 @@ iCloud Keychain is intentionally **not** synced (`kSecAttrSynchronizable: false`

The Sync Factor is a P-256 ECDSA keypair generated client-side at backup creation (and again on every recovery onto a new device). The public key is registered with the Backup Service; the private key is stored locally in platform-secure storage:

- **iOS** — Keychain (non-synced).
- **iOS**: Eligible enrollment uses a device-only Secure Enclave P-256 key. The private key never leaves the enclave; the client exports only the public key and requests signatures through a key reference. The key is permanent, non-synchronized, and accessible only while the device is unlocked (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`). Simulator tests use a Keychain-backed key instead. Existing devices retain the legacy non-synchronized Keychain factor until migration is admitted and its replacement is confirmed.
- **Android** — Android Keystore (hardware-backed when available); falls back to `EncryptedSharedPreferences` if Keystore is unavailable. The fallback is a lower security tier and accepted as a known limitation today.

When the client signs a Backup Service challenge with the Sync Factor, the server verifies the P-256 ECDSA signature (DER-encoded) against the public key in the <BackupMetadata />. Each Sync Factor authorizes exactly one device; recovery onto a new device produces a new Sync Factor and registers it via `/v1/add-sync-factor`.

For OIDC users, the Sync Factor is *also* registered with Turnkey as a sub-organization user (`sync_factor_user_<suffix>`, one per device) with a policy permitting deletion-only actions. This is what makes some background housekeeping operations possible without an additional login (e.g. deleting a backup).

## iOS enrollment and replacement

<Note>
Secure Enclave enrollment is controlled by a disabled-by-default rollout gate. The behavior below applies to eligible iOS enrollment; legacy Keychain factors remain supported.
</Note>

A new Secure Enclave key stays **pending** until its Backup Service registration and, for accounts with OIDC factors, its Turnkey user and deletion-only policy are confirmed. The registration record binds the attempt to the backup ID, exact public key, and Turnkey sub-organization. An uncertain response retains that key for reconciliation; it does not authorize a different key or backup. See [BF-1](/world-app/backup/flows#bf-1-backup-creation) and [BF-2](/world-app/backup/flows#bf-2-backup-retrieval).

An active Secure Enclave factor authorized for the current backup is preferred over the legacy Keychain factor. During legacy replacement, the client confirms and activates the new factor before removing the legacy Turnkey user, legacy Backup Service registration, and finally the local legacy credential. Failed cleanup remains retryable and retains the local legacy credential until remote cleanup completes.

The `backup_enable_secure_enclave_sync_factor` gate admits new enrollment. It does not trigger eager migration at startup. Pending attempts can finish after the gate is disabled, and active enclave keys authorized for the current backup remain preferred. Disabling admission does not convert those keys back to exportable Keychain keys; a binary rollback must retain support for already migrated keys. An eligible flow needs the authorization to complete both registrations; a restore without the required Turnkey Main Factor session keeps the legacy path rather than starting a migration it cannot finish. When restoring a different backup, an enclave key that is not authorized for that backup does not prevent registration through the legacy path if new enclave enrollment is disabled or the required Turnkey session is unavailable.

The storage change does not change factor permissions or the backup format. A Sync Factor still cannot decrypt a backup or enroll Main Factors. Decryption still requires a Main Factor. Money and World ID retain their existing shared Sync Factor authorization on iOS; separate keys and authorization are follow-up work.

# Backup Account ID

Independent of the factor system, every backup has a <BackupAccountKey />, a **secp256k1** keypair derived deterministically from the user's <RootKey /> via a KDF (subkey ID `0x101`, context `OXIDEKEY`), distinct from the P-256 keypairs used elsewhere in the system. Its public key is encoded as the <BackupAccountId />, a string of the form:
Expand All @@ -84,6 +98,6 @@ The clients diverge in places where the underlying platform requires it. The lis
| Passkey API | `AuthenticationServices` (PRF on iOS 18+) | Credential Manager (`androidx.credentials`); PRF requires GMS 23.33+ and a PRF-supporting provider |
| iCloud Keychain factor | Supported (Main Factor) | **Not available** — no platform equivalent |
| OIDC providers | Google, Apple | Google, Apple |
| Sync Factor secure storage | Keychain (non-synced) | Android Keystore; falls back to `EncryptedSharedPreferences` on devices without Keystore |
| Sync Factor secure storage | Device-only Secure Enclave for eligible enrollment; legacy non-synced Keychain until replacement | Android Keystore; falls back to `EncryptedSharedPreferences` on devices without Keystore |
| Passkey rename | iOS 26.2+ only | Provider-dependent |
| OIDC sign-in | `GoogleSignInSDK` / `AppleSignInSDK` | `GetSignInWithGoogleOption` (Credential Manager) |
17 changes: 14 additions & 3 deletions world-app/backup/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ The flows are numbered (BF-1, BF-2, …) and referenced from code and other docs

**Non-obvious.** The client generates a *fresh* <BackupEncryptionKeypair /> at this moment, encrypts the <SealedBackup />, encrypts the keypair with the <FactorSecret /> of each enrolled <MainFactor />, and then **discards the keypair's secret**. Only the public key is retained locally so subsequent sync operations can re-encrypt new <SealedBackup />s without authenticating a Main Factor. This is what makes background sync (BF-4) possible.

A <SyncFactor /> is also generated at creation and registered with the Backup Service in the same `/v1/create` call, so the device is immediately authorized to push updates.
A <SyncFactor /> is also generated at creation and registered with the Backup Service in the same `/v1/create` call.

**iOS Secure Enclave enrollment.** The device key remains pending until `/v1/create` is accepted and, for OIDC accounts, Turnkey user and policy setup has completed. Only then is the same key activated for background operations. If local activation fails after the server accepts the backup, the registration record is retained for retry; the client does not treat the accepted backup as a failed creation.

If a `/v1/create` response is lost, retry retains the pending key and checks authenticated metadata for the same backup ID and exact public key. When that confirms an accepted creation, the client completes local activation and preserves the existing duplicate-backup result. The user must recover the backup contents with the original Main Factor. This is not encrypted-archive replay or permission to substitute a new recovery provider. It does not solve recovery of a provisional Turnkey sub-organization when its creation authorization is lost.

**OIDC + Passkey one-tap enrollment.** When the user enrolls both a Passkey and an OIDC factor (Google/Apple) at creation, the client takes advantage of a *merged Turnkey passkey challenge*: the Passkey signs both a Backup Service challenge and a Turnkey activity stamp in a single user gesture. This is also the pattern used in BF-3 when adding an OIDC factor to an existing Passkey-only account.

**Cross-platform.** iOS and Android diverge only in the platform-specific factor enrollment (Passkey ceremony, OIDC sign-in SDK). The Bedrock-side sealing and backup-service interaction are identical.
**Cross-platform.** Factor enrollment uses platform-specific APIs (Passkey ceremony, OIDC sign-in SDK, Sync Factor storage). The iOS pending-key lifecycle above controls local activation; it does not change Bedrock sealing, backup encryption, or the Backup Service request format.

```mermaid
flowchart LR
Expand All @@ -43,6 +47,10 @@ The client identifies the backup by presenting the Main Factor's *public identif

After the backup is decrypted, the client generates a fresh <SyncFactor /> for the new device and registers it via `/v1/add-sync-factor`. This call uses a short-lived `sync_factor_token` issued by `/v1/retrieve/from-challenge`, which lets the client add the new device authorization without re-prompting for a Main Factor.

**iOS registration retries.** An uncertain registration response keeps the same pending key across retries and app restarts for that backup. Before resubmitting, the client uses that key to fetch authenticated metadata and verifies both the expected backup ID and exact public-key membership. A `factor_already_exists` response alone is insufficient confirmation. The pending key and Turnkey sub-organization must match the saved attempt. After an authenticated restore of a different backup, the client clears the abandoned local enrollment and registers a factor for the restored backup; it does not delete the previous remote backup.

For an OIDC account, the client also reconciles the Turnkey user and its deletion-only policy using a Turnkey-authorized Main Factor session (see [Turnkey User Setup](/world-app/backup/components#turnkey-user-setup)). Activation requires confirmation from both services. A pending attempt without the necessary Turnkey authorization requires Main Factor authentication to finish. The client does not discard the candidate merely because a response was lost, or remove the previous credential before the replacement is confirmed.

**OIDC recovery quirk.** For OIDC factors, decrypting the <BackupEncryptionKeypair /> requires the <FactorSecret /> held by Turnkey. The user authenticates to Turnkey via OIDC; Turnkey's enclave verifies the OIDC token *and the user's identity* before exporting the secret to the client. This is why OIDC recovery requires a network round-trip beyond the one to the Backup Service.

```mermaid
Expand All @@ -58,8 +66,11 @@ sequenceDiagram
Turnkey->>Client: factor secret
end
Client->>Client: decrypt EncryptedBackupKeypair → decrypt SealedBackup
opt Account has OIDC factors
Client->>Turnkey: Confirm Sync Factor user and deletion-only policy
end
Client->>BackupService: /v1/add-sync-factor (with token)
Client->>Turnkey: Add sync factor
Note over Client: iOS activates the pending key after required registrations are confirmed
```

# BF-3: Adding a Main Factor
Expand Down