diff --git a/world-app/backup/advanced.mdx b/world-app/backup/advanced.mdx index e4d5361..a4f71d4 100644 --- a/world-app/backup/advanced.mdx +++ b/world-app/backup/advanced.mdx @@ -71,9 +71,9 @@ This is **not** a recovery of the existing backup contents. It's a clean wipe th ## Registering the break_glass_user -The teardown is only possible if the sub-organization has a `break_glass_user` (see [Components & Parties — Turnkey](/world-app/backup/components#turnkey)). +The teardown is only possible if the sub-organization has a `break_glass_user` (see [Turnkey](/world-app/backup/components#turnkey)). -Support for deleting the stale Turnkey sub-organization was introduced around August 2026. Accounts set up before then initially could not be deleted — they had no `break_glass_user`, and the user (having lost every Main and Sync Factor) cannot authenticate any other way. Those accounts become deletable once a migration backfills the `break_glass_user`. +Support for deleting the stale Turnkey sub-organization was introduced around August 2026. Accounts set up before then initially could not be deleted as they had no `break_glass_user`, and the user (having lost every Main and Sync Factor) cannot authenticate any other way. Those accounts become deletable once a [migration](/world-app/backup/components#turnkey-migrations) backfills the `break_glass_user`. ## Tearing down the Turnkey sub-organization diff --git a/world-app/backup/components.mdx b/world-app/backup/components.mdx index 3d5ecf9..f6c88e6 100644 --- a/world-app/backup/components.mdx +++ b/world-app/backup/components.mdx @@ -68,7 +68,9 @@ The full enumeration is in [`backup-service/src/types/error.rs`](https://github. The initial release of this feature relied on a slightly different user setup. Some early World App Users will have a slightly different setup (with the Root Quorum being `auth_user_main` solely) before a migration is introduced. -Each World App user is a Turnkey *sub-organization*. Throughout the docs and the code, the terms "Turnkey sub-organization" and "Turnkey account" are used interchangeably. The sub-organization has four users with distinct roles: +The setup below is the *expected* state; see [Turnkey Migrations](#turnkey-migrations) for how accounts created by older clients are reconciled to the latest expected state. + +Each World App user is a Turnkey *sub-organization*. Throughout the docs and the code, the terms "Turnkey sub-organization" and "Turnkey account" are used interchangeably. Its users fall into four distinct roles: 1. **Ephemeral.** A `root_user_genesis` user is created by World App's backend to bootstrap the sub-organization. - It is a root user. @@ -94,23 +96,31 @@ Each World App user is a Turnkey *sub-organization*. Throughout the docs and the "condition": "activity.type == 'ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION'" } ``` -4. **`sync_factor_user`**. Represents operations. One long-lived API keypair is registered per device (each device holds its own private key locally; all are attached to the same user). The user's policy permits *deletion-only* activities, so a Sync Factor cannot grant itself recovery powers. +4. **`sync_factor_user_`**. Represents operations. Each device gets its own user, holding that device's long-lived API keypair, and its own policy bound to it. Every such policy permits *deletion-only* activities, so a Sync Factor cannot grant itself recovery powers. + + While the has permissions to delete credentials (e.g. passkey or OIDC factors), it cannot do so on `auth_user_main` because Turnkey rejects updates from non-root users for root users even when granted by a policy. This is a current limitation with the Turnkey setup, the user must authenticate with a to remove an OIDC Factor or use the `/reset` disaster recovery. ```json { "effect": "EFFECT_ALLOW", "consensus": "approvers.any(user, user.id == '')", - "condition": "activity.action == 'DELETE' && (activity.resource == 'CREDENTIAL' || activity.resource == 'PRIVATE_KEY' || activity.resource == 'ORGANIZATION')" + "condition": "activity.action == 'DELETE' && (activity.resource == 'CREDENTIAL' || activity.resource == 'PRIVATE_KEY' || activity.resource == 'ORGANIZATION' || activity.resource == 'USER')" } ``` - > The policy filters on `activity.action` and `activity.resource` instead of `activity.type` because the latter is version-specific. The current expansion covers `ACTIVITY_TYPE_DELETE_API_KEYS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_AUTHENTICATORS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_OAUTH_PROVIDERS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION` (`ORGANIZATION`), `ACTIVITY_TYPE_DELETE_PRIVATE_KEYS` (`PRIVATE_KEY`), `ACTIVITY_TYPE_DELETE_PRIVATE_KEY_TAGS` (`PRIVATE_KEY`), `ACTIVITY_TYPE_DISABLE_PRIVATE_KEY` (`PRIVATE_KEY`). + > The policy filters on `activity.action` and `activity.resource` instead of `activity.type` because the latter is version-specific. The current expansion covers `ACTIVITY_TYPE_DELETE_API_KEYS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_AUTHENTICATORS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_OAUTH_PROVIDERS` (`CREDENTIAL`), `ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION` (`ORGANIZATION`), `ACTIVITY_TYPE_DELETE_USERS` (`USER`), `ACTIVITY_TYPE_DELETE_PRIVATE_KEYS` (`PRIVATE_KEY`), `ACTIVITY_TYPE_DELETE_PRIVATE_KEY_TAGS` (`PRIVATE_KEY`), `ACTIVITY_TYPE_DISABLE_PRIVATE_KEY` (`PRIVATE_KEY`). + + > `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. + +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 + +A sub-organization is created once, by whichever client version the user onboarded with, and is only writable by the user's own factors. So when the expected setup above changes, only the client can update the account state. Bedrock's `TurnkeyManager.run_migrations` is the mechanism that does this: clients call it after every login and periodically, and it applies only the difference between the account and the expected state. An account already in shape performs zero writes. -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 2-of-2 with both `auth_user_main` and `sync_factor_user` as members. This ensures: +Reads are stamped by the ; changes need a . When no Main Factor is supplied the user is prompted to authenticate. See [BF-10](/world-app/backup/flows#bf-10-turnkey-account-migration). -- `sync_factor_user` can update `auth_user_main` *as granted by its policy* (delete OAuth providers, delete authenticators). -- `sync_factor_user` cannot take any action *not* explicitly granted by the policy, because it cannot reach the Root Quorum on its own — Root Quorum still requires `auth_user_main`'s approval. -- A user can always update `sync_factor_user` from `auth_user_main` after losing all devices. +The migration list, the per-environment audience tables, and the safety guards that make each migration skip rather than guess live in [`bedrock/src/backup/turnkey`](https://github.com/worldcoin/bedrock/tree/main/bedrock/src/backup/turnkey). ## What Turnkey holds vs what the Backup Service holds diff --git a/world-app/backup/factors.mdx b/world-app/backup/factors.mdx index d44404f..4afb584 100644 --- a/world-app/backup/factors.mdx +++ b/world-app/backup/factors.mdx @@ -44,7 +44,9 @@ Supported providers: - **Google** (iOS, Android) - **Apple** (iOS, Android) -The client uses Bedrock's `Turnkey` helper (P-256 ECDSA stamping over Turnkey API requests) to interact with the enclave. See [Components & Parties — Turnkey](/world-app/backup/components#turnkey) for the sub-organization structure (root user, auth user, sync factor user, root quorum) and policy details. +Apple issues a distinct `aud` per client, so an Apple factor has to be registered once per client: World App iOS, World ID iOS, and Android (which authenticates through a webview, so all Android clients share one audience). Apple assigns the same `sub` across a developer account, so the audiences all resolve to the same identity. Accounts missing an audience are backfilled by the [Turnkey Migrations](/world-app/backup/components#turnkey-migration). + +The client uses Bedrock's `Turnkey` helper (P-256 ECDSA stamping over Turnkey API requests) to interact with the enclave. See [Turnkey](/world-app/backup/components#turnkey) for the sub-organization structure (root user, auth user, sync factor user, root quorum) and policy details. ## iCloud Keychain (iOS only) @@ -61,7 +63,7 @@ The Sync Factor is a P-256 ECDSA keypair generated client-side at backup creatio 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 . 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`) with a policy permitting deletion-only actions. This is what makes background factor cleanup (e.g., removing an OIDC provider after the user removes the factor in Settings) possible without an additional passkey tap. +For OIDC users, the Sync Factor is *also* registered with Turnkey as a sub-organization user (`sync_factor_user_`, 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). # Backup Account ID diff --git a/world-app/backup/flows.mdx b/world-app/backup/flows.mdx index d0314e6..233af4a 100644 --- a/world-app/backup/flows.mdx +++ b/world-app/backup/flows.mdx @@ -116,3 +116,11 @@ This is not a separate flow but a consequence of BF-4 and BF-5 interacting. See 1. The Backup Service `/v1/reset` deletes the metadata, sealed blob, and factor-lookup entries. This deletion is authoritative. 2. The Turnkey sub-organization is torn down. The client submits a `deleteSubOrganization` activity stamped with the Backup Account Key acting as the `break_glass_user`. + +# BF-10: Turnkey Account Migration + +**When.** After every login, and periodically thereafter. Not user-initiated. + +**Non-obvious.** This is housekeeping, not a user flow, but it is the only mechanism that can correct a Turnkey sub-organization created by an older client or that has unexpectedly drifted as nothing server-side has the authority to write to it. The client calls Bedrock's `TurnkeyManager.run_migrations` with the and, when available, a . + +Migrations mostly require a to write, which the client does not have outside a login. So if a is required, the user is prompted to authenticate, similar to the **Authorize Device** flow. See [Turnkey Migrations](/world-app/backup/components#turnkey-migrations).