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
8 changes: 7 additions & 1 deletion snippets/backup/terms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const FactorSecret = () => {

export const BackupAccountId = () => {
return (
<Tooltip tip="A deterministic identifier of the form backup_account_0..., derived from the user's Root Key. The corresponding secp256k1 keypair is the disaster-recovery key for /reset.">Backup Account ID</Tooltip>
<Tooltip tip="A deterministic identifier of the form backup_account_..., encoding the public key of the Backup Account Key (which is derived from the user's Root Key).">Backup Account ID</Tooltip>
)
}

export const BackupAccountKey = () => {
return (
<Tooltip tip="The secp256k1 keypair derived deterministically from the user's Root Key. Its public key is encoded into the Backup Account ID; it is the disaster-recovery key that authorizes /reset and the deletion of the user's Turnkey sub-organization.">Backup Account Key</Tooltip>
)
}
24 changes: 18 additions & 6 deletions world-app/backup/advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebarTitle: "Advanced Topics"
description: "Encryption details, edge cases, and disaster recovery."
---

import { SyncFactor, MainFactor, BackupEncryptionKeypair, EncryptedBackupEncryptionKeypair, FactorSecret, SealedBackup, UnsealedBackup, BackupAccountId, BackupMetadata } from "/snippets/backup/terms.mdx";
import { SyncFactor, MainFactor, BackupEncryptionKeypair, EncryptedBackupEncryptionKeypair, FactorSecret, SealedBackup, UnsealedBackup, BackupAccountKey, BackupMetadata } from "/snippets/backup/terms.mdx";
import { RootKey } from "/snippets/terms.mdx";

# Encryption
Expand Down Expand Up @@ -57,12 +57,24 @@ Removing the last <MainFactor /> deletes the entire backup (the data is no longe

# Disaster Recovery via /reset

The <BackupAccountId /> keypair (secp256k1, derived deterministically from the <RootKey />) is a last-resort recovery mechanism for the case where:
The <BackupAccountKey /> (secp256k1, derived deterministically from the <RootKey />) is a last-resort recovery mechanism for the case where:

- The user has lost every <MainFactor /> (no enrolled passkey, OIDC, or iCloud Keychain factor remains accessible).
- The user has *also* lost every <SyncFactor /> (no authorized device).
- But the user **still has the original device** (or the original Root Key in some other form) — and therefore can reproduce the deterministic backup-account secp256k1 keypair.
- The user has *also* lost every <SyncFactor /> (no authorized device). _If they have a Sync Factor they can simply remove all the Main Factors to start over._
- But the user **still has the original device** (or the original Root Key in some other form), and therefore can reproduce the Backup Account Key and produce a signature with it.

In that situation, the user can sign a `/reset` challenge with the backup-account secret key. The Backup Service verifies the signature against the public key encoded in the `backup_account_id`, then deletes the backup metadata, the sealed blob, and all factor lookup entries. The user can then run [BF-1](/world-app/backup/flows#bf-1-backup-creation) again from scratch.
This is **not** a recovery of the existing backup contents. It's a clean wipe that lets the user re-enroll fresh (otherwise the system would reject duplicate backups). The Root Key (and therefore the wallet and on-chain identity) is preserved because the user already has it locally; what they're rebuilding is the *backup* of their root key, PCPs and credential vault.

This is **not** a recovery of the existing backup contents — it's a clean wipe that lets the user re-enroll without an account-already-exists collision. The Root Key (and therefore the wallet and on-chain identity) is preserved because the user already has it locally; what they're rebuilding is the *backup* of their PCPs and credential vault.
`/reset` wipes the state across the board, and primarily: the entire `backup-service` state for the user (remote ciphertext backup), and their Turnkey sub-organization. The backup-service deletion is authoritative; the Turnkey teardown is described below.

## 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)).

<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 the migration above backfills the `break_glass_user`.</Note>

## Tearing down the Turnkey sub-organization

After the backup-service reset completes, the client tears down the Turnkey sub-organization by first resolving the Turnkey sub-organization ID using the [Auth Proxy](https://docs.turnkey.com/features/authentication/auth-proxy)'s endpoint `POST https://authproxy.turnkey.com/v1/account`, then sending a `deleteSubOrganization` stamped with the Backup Account Key (which is represented as the `break_glass_user` in Turnkey).

**Best-effort.** A Turnkey failure does **not** fail the reset. The `backup-service` deletion is authoritative and the user can still re-enroll.
21 changes: 15 additions & 6 deletions world-app/backup/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebarTitle: "Components & Parties"
description: "What the Backup Service and Turnkey actually do, what they store, and where their authority ends."
---

import { SyncFactor, MainFactor, BackupMetadata, SealedBackup, FactorSecret, BackupEncryptionKeypair, BackupAccountId } from "/snippets/backup/terms.mdx";
import { SyncFactor, MainFactor, BackupMetadata, SealedBackup, FactorSecret, BackupEncryptionKeypair, BackupAccountKey } from "/snippets/backup/terms.mdx";

# Backup Service

Expand Down Expand Up @@ -68,29 +68,38 @@ 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*. The sub-organization has three users with distinct roles:
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:

1. **Ephemeral.** A `root_user_genesis` user is created by World App's backend to bootstrap the sub-organization.
- It is a root user.
- It is created with a short-lived keypair provided by the client.
- It is also registered with a fallback `0` key (`Curve25519`) because Turnkey requires every user to have at least one valid authenticator. After the genesis user is no longer needed, the keypair is discarded — the fallback key remains but no one holds its private half.
- The client deletes this user immediately after `auth_user_main` is set up.
2. **`auth_user_main`** — represents the user's primary authentication and holds their <MainFactor /> credentials (passkeys and OIDC providers).
2. **`auth_user_main`**. Represents the user's primary authentication and holds their <MainFactor /> credentials (passkeys and OIDC providers).
- Permission policy: explicit `ALLOW` for all activities (`condition = true`).

```json
{
"effect": "EFFECT_ALLOW",
"consensus": "approvers.any(user, user.id == '<authUserMainId>')",
"consensus": "approvers.any(user, user.id == '<auth_user_main_id>')",
"condition": "true"
}
```
3. **`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.
3. **`break_glass_user`**. Represents the <BackupAccountKey /> — the `secp256k1` keypair whose public key is encoded into the [**Backup Account ID**](/world-app/backup/factors#backup-account-id). It exists so the Turnkey sub-organization can be deleted during a [Reset](/world-app/backup/advanced#disaster-recovery-via-reset). Because the Backup Account Key is deterministic and known ahead of time, this user is named `break_glass_user_<last 6 hex chars of the Backup Account Key's public key>`. It is **not** a member of the Root Quorum. The user's policy permits *deletion-only* of the sub-organization:

```json
{
"effect": "EFFECT_ALLOW",
"consensus": "approvers.any(user, user.id == '<syncFactorUserId>')",
"consensus": "approvers.any(user, user.id == '<break_glass_user_id>')",
"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.

```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')"
}
```
Expand Down
6 changes: 3 additions & 3 deletions world-app/backup/factors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebarTitle: "Factors"
description: "Main Factors, Sync Factors, and how each Factor Secret is derived."
---

import { MainFactor, SyncFactor, FactorSecret, BackupEncryptionKeypair, BackupAccountId, BackupMetadata } from "/snippets/backup/terms.mdx";
import { MainFactor, SyncFactor, FactorSecret, BackupEncryptionKeypair, BackupAccountId, BackupAccountKey, BackupMetadata } from "/snippets/backup/terms.mdx";
import { RootKey } from "/snippets/terms.mdx";

# Main Factors vs Sync Factors
Expand Down Expand Up @@ -65,13 +65,13 @@ For OIDC users, the Sync Factor is *also* registered with Turnkey as a sub-organ

# Backup Account ID

Independent of the factor system, every backup has a deterministic <BackupAccountId /> derived from the user's <RootKey /> via a BLAKE2b-based KDF (subkey ID `0x101`, context `OXIDEKEY`). The keypair is **secp256k1**, distinct from the P-256 keypairs used elsewhere in the system. Format:
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:

```
backup_account_<33-byte_compressed_pubkey_hex>
```

This keypair is **not** a factor — it cannot decrypt the backup. Its sole purpose is the disaster-recovery `/reset` endpoint: when a user has lost every Main and Sync Factor but still controls the original device (and therefore the Root Key), the Backup Account ID lets them prove ownership and wipe the existing backup so they can re-enroll fresh factors. See [Disaster Recovery via /reset](/world-app/backup/advanced#disaster-recovery-via-reset).
The Backup Account Key is **not** a factor — it cannot decrypt the backup. Its sole purpose is the disaster-recovery `/reset` endpoint: when a user has lost every Main and Sync Factor but still controls the original device (and therefore the Root Key), signing with the Backup Account Key proves ownership and wipes the existing backup so they can re-enroll fresh factors. See [Disaster Recovery via /reset](/world-app/backup/advanced#disaster-recovery-via-reset).

# Platform Differences

Expand Down
11 changes: 10 additions & 1 deletion world-app/backup/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebarTitle: "Flows"
description: "The key user-facing flows. Focused on non-obvious behavior and decisions rather than step-by-step UI scripts."
---

import { MainFactor, SyncFactor, BackupEncryptionKeypair, FactorSecret, SealedBackup, BackupMetadata, EncryptedBackupEncryptionKeypair } from "/snippets/backup/terms.mdx";
import { MainFactor, SyncFactor, BackupEncryptionKeypair, FactorSecret, SealedBackup, BackupMetadata, EncryptedBackupEncryptionKeypair, BackupAccountKey } from "/snippets/backup/terms.mdx";

# Conventions

Expand Down Expand Up @@ -107,3 +107,12 @@ This is not a separate flow but a consequence of BF-4 and BF-5 interacting. See
**When.** The user removes their last <MainFactor />, or initiates full account deletion in World App.

**Non-obvious.** Deletion clears state across three places — the Backup Service (metadata + sealed blob + factor lookup), Turnkey (entire sub-organization, if one exists), and local device (Sync Factor keypair, plus the iCloud Keychain factor on iOS). All three deletions are issued from the client with retries; failures are logged but do not block the user-visible "deleted" outcome, because the Backup Service's deletion is authoritative.

# BF-9: Disaster Recovery via /reset

**When.** The user has lost every <MainFactor /> and every <SyncFactor /> but still controls the original device (and therefore the Root Key). This is the last-resort path; see [Disaster Recovery via /reset](/world-app/backup/advanced#disaster-recovery-via-reset) for the full rationale and preconditions.

**Non-obvious.** This is a destructive *wipe*, not a recovery of backup contents. This process clears the backup so the user can re-enroll from scratch. It's authorized by the <BackupAccountKey /> the user reproduces locally, so nothing here depends on a Main or Sync Factor:

1. The Backup Service `/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`.
Loading