Skip to content
Merged
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
4 changes: 2 additions & 2 deletions world-app/backup/advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

<Note>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`.</Note>
<Note>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`.</Note>

## Tearing down the Turnkey sub-organization

Expand Down
26 changes: 18 additions & 8 deletions world-app/backup/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ The full enumeration is in [`backup-service/src/types/error.rs`](https://github.

<Info>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.</Info>

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.
Expand All @@ -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 <SyncFactor /> 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_<suffix>`**. Represents <SyncFactor /> 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.

<Note>While the <SyncFactor /> 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 <MainFactor /> to remove an OIDC Factor or use the `/reset` disaster recovery.</Note>

```json
{
"effect": "EFFECT_ALLOW",
"consensus": "approvers.any(user, user.id == '<sync_factor_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 <SyncFactor />; changes need a <MainFactor />. 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

Expand Down
6 changes: 4 additions & 2 deletions world-app/backup/factors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<Note>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).</Note>
Comment thread
paolodamico marked this conversation as resolved.
Resolved

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)

Expand All @@ -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 <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`) 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_<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).

# Backup Account ID

Expand Down
8 changes: 8 additions & 0 deletions world-app/backup/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SyncFactor /> and, when available, a <MainFactor />.

Migrations mostly require a <MainFactor /> to write, which the client does not have outside a login. So if a <MainFactor /> is required, the user is prompted to authenticate, similar to the **Authorize Device** flow. See [Turnkey Migrations](/world-app/backup/components#turnkey-migrations).
Loading