diff --git a/snippets/backup/terms.mdx b/snippets/backup/terms.mdx index 876a45d..08a7339 100644 --- a/snippets/backup/terms.mdx +++ b/snippets/backup/terms.mdx @@ -50,6 +50,12 @@ export const FactorSecret = () => { export const BackupAccountId = () => { return ( - Backup Account ID + Backup Account ID + ) +} + +export const BackupAccountKey = () => { + return ( + Backup Account Key ) } diff --git a/world-app/backup/advanced.mdx b/world-app/backup/advanced.mdx index ecc70ad..41f1ddf 100644 --- a/world-app/backup/advanced.mdx +++ b/world-app/backup/advanced.mdx @@ -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 @@ -57,12 +57,24 @@ Removing the last deletes the entire backup (the data is no longe # Disaster Recovery via /reset -The keypair (secp256k1, derived deterministically from the ) is a last-resort recovery mechanism for the case where: +The (secp256k1, derived deterministically from the ) is a last-resort recovery mechanism for the case where: - The user has lost every (no enrolled passkey, OIDC, or iCloud Keychain factor remains accessible). -- The user has *also* lost every (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 (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)). + +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`. + +## 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. diff --git a/world-app/backup/components.mdx b/world-app/backup/components.mdx index 89a14a9..d5ad92a 100644 --- a/world-app/backup/components.mdx +++ b/world-app/backup/components.mdx @@ -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 @@ -68,29 +68,38 @@ 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*. 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 credentials (passkeys and OIDC providers). +2. **`auth_user_main`**. Represents the user's primary authentication and holds their credentials (passkeys and OIDC providers). - Permission policy: explicit `ALLOW` for all activities (`condition = true`). ```json { "effect": "EFFECT_ALLOW", - "consensus": "approvers.any(user, user.id == '')", + "consensus": "approvers.any(user, user.id == '')", "condition": "true" } ``` -3. **`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. +3. **`break_glass_user`**. Represents the — 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_`. 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 == '')", + "consensus": "approvers.any(user, user.id == '')", + "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. + + ```json + { + "effect": "EFFECT_ALLOW", + "consensus": "approvers.any(user, user.id == '')", "condition": "activity.action == 'DELETE' && (activity.resource == 'CREDENTIAL' || activity.resource == 'PRIVATE_KEY' || activity.resource == 'ORGANIZATION')" } ``` diff --git a/world-app/backup/factors.mdx b/world-app/backup/factors.mdx index 0d8f5be..e69102d 100644 --- a/world-app/backup/factors.mdx +++ b/world-app/backup/factors.mdx @@ -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 @@ -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 derived from the user's 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 , a **secp256k1** keypair derived deterministically from the user's 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 , 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 diff --git a/world-app/backup/flows.mdx b/world-app/backup/flows.mdx index a048f56..ffc358e 100644 --- a/world-app/backup/flows.mdx +++ b/world-app/backup/flows.mdx @@ -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 @@ -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 , 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 and every 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 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`.