diff --git a/cspell.json b/cspell.json
index 481f799..27f1888 100644
--- a/cspell.json
+++ b/cspell.json
@@ -12,7 +12,23 @@
"cspell",
"spellcheck",
"pimlico",
- "bdrck"
+ "bdrck",
+ "androidx",
+ "Synchronizable",
+ "secp",
+ "Nethermind",
+ "Turnkey",
+ "Keystore",
+ "Keychain",
+ "WebAuthn",
+ "passkey",
+ "passkeys",
+ "OIDC",
+ "Curve",
+ "libsodium",
+ "Salsa",
+ "Poly",
+ "Bedrock"
],
"ignorePaths": [
"node_modules",
diff --git a/docs.json b/docs.json
index ddfe1ff..8cde8a5 100644
--- a/docs.json
+++ b/docs.json
@@ -50,8 +50,10 @@
"group": "Backup & Recovery",
"pages": [
"world-app/backup/index",
+ "world-app/backup/factors",
"world-app/backup/structure-and-sync",
"world-app/backup/flows",
+ "world-app/backup/components",
"world-app/backup/advanced"
]
},
diff --git a/fonts/excalifont.woff2 b/fonts/excalifont.woff2
new file mode 100644
index 0000000..24ce44a
Binary files /dev/null and b/fonts/excalifont.woff2 differ
diff --git a/snippets/backup/terms.mdx b/snippets/backup/terms.mdx
index 7cdb804..876a45d 100644
--- a/snippets/backup/terms.mdx
+++ b/snippets/backup/terms.mdx
@@ -40,4 +40,16 @@ export const EncryptedBackupEncryptionKeypair = () => {
return (
Encrypted Backup Encryption Keypair
)
-}
\ No newline at end of file
+}
+
+export const FactorSecret = () => {
+ return (
+ Factor Secret
+ )
+}
+
+export const BackupAccountId = () => {
+ return (
+ Backup Account ID
+ )
+}
diff --git a/snippets/terms.mdx b/snippets/terms.mdx
index 0bf866d..3ee0027 100644
--- a/snippets/terms.mdx
+++ b/snippets/terms.mdx
@@ -7,6 +7,6 @@ export const RootKey = () => {
export const PersonalCustodyPackage = () => {
return (
- Personal Custody Package
+ Personal Custody Package
)
-}
\ No newline at end of file
+}
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..d728ce2
--- /dev/null
+++ b/style.css
@@ -0,0 +1,7 @@
+@import url('/fonts/excalifont.woff2');
+@font-face {
+ font-family: 'Excalifont';
+ src: url('/fonts/excalifont.woff2') format('woff2');
+ font-weight: normal;
+ font-style: normal;
+}
\ No newline at end of file
diff --git a/world-app/backup/advanced.mdx b/world-app/backup/advanced.mdx
index 4f57258..ecc70ad 100644
--- a/world-app/backup/advanced.mdx
+++ b/world-app/backup/advanced.mdx
@@ -1,15 +1,68 @@
---
-title: "Advanced Topics on Backup"
+title: "Advanced Topics"
sidebarTitle: "Advanced Topics"
-description: "Internal advanced technical reference for the backup system."
+description: "Encryption details, edge cases, and disaster recovery."
---
-## Encryption
+import { SyncFactor, MainFactor, BackupEncryptionKeypair, EncryptedBackupEncryptionKeypair, FactorSecret, SealedBackup, UnsealedBackup, BackupAccountId, BackupMetadata } from "/snippets/backup/terms.mdx";
+import { RootKey } from "/snippets/terms.mdx";
-All encryption operations use libsodium's asymmetric [sealed_box](https://doc.libsodium.org/public-key_cryptography/sealed_boxes) primitive through the [crypto_box](https://crates.io/crates/crypto_box) crate. A randomness source is required for sealed boxes to generate ephemeral key pairs for sealing. See [Cryptography](/world-app/cryptography) for more details on the randomness source.
+# Encryption
-## Apple Keychain
+All asymmetric encryption in the backup uses libsodium's [sealed box](https://doc.libsodium.org/public-key_cryptography/sealed_boxes) primitive (X25519 key agreement + XSalsa20-Poly1305 AEAD), via the [`crypto_box`](https://crates.io/crates/crypto_box) crate. A sealed box generates an ephemeral sender keypair per encryption, so each ciphertext is independently sealed.
-TODO: Details coming soon.
+Two distinct seals exist in the system:
+- **Sealing the .** Done with the public key of the freshly-generated (X25519). The result is the . The keypair's secret key is destroyed after this seal, so even the original device cannot decrypt the without going through a Main Factor.
+- **Sealing the .** Done once per Main Factor, with a public key derived from that factor's (the secret is hashed into a Curve25519 secret key). The result is an stored in the .
+Randomness for ephemeral keys comes from the OS CSPRNG via Bedrock's `OsRng`. See [Cryptography](/world-app/cryptography).
+
+# Edge Cases
+
+## Multiple Device Sync
+
+A user authenticated on multiple devices will produce divergent local backup state if both devices make changes. The system handles this conservatively: a is *not* authorized to retrieve the encrypted backup, so a device cannot silently catch up to remote updates. Instead, the manifest hash mismatch detected at sync (or startup, see [BF-5](/world-app/backup/flows#bf-5-remote-update-detection)) escalates to a re-authentication and a fresh download.
+
+The Backup Service enforces that every sync transitions from the *current* manifest hash, so two concurrent devices cannot accidentally clobber each other — the second one to arrive will be rejected and forced to re-fetch.
+
+## Unauthorized Device
+
+A device's can become unauthorized in either or both of the systems it's registered with (Backup Service, Turnkey). Symptoms:
+
+- Backup Service returns `unauthorized_factor` (when the right `backup_id` was provided) or `backup_does_not_exist` (factor lookup found nothing).
+- Turnkey returns `PUBLIC_KEY_NOT_FOUND`.
+
+Triggers:
+
+- The user deleted and re-created the entire backup from another device. Sibling devices become unauthorized.
+- The user lost their (e.g., the device was reset).
+- (Future) The user explicitly revoked authorization for a specific device.
+
+The remediation path is always the same: re-authenticate a and register a fresh via `/add-sync-factor`. The clients have explicit handling at three call sites:
+
+1. On `/create` returning `backup_account_id_already_exists`, prompt for a Main Factor and run the recovery-style sync-factor refresh — this turns a duplicate creation attempt into an idempotent re-authorization.
+2. On `/retrieve-metadata` returning `unauthorized_factor`, prompt for a Main Factor and refresh the Sync Factor. If `backup_does_not_exist` is returned instead, the backup has been deleted and the local state is wiped.
+3. On `/delete-factor` returning `PUBLIC_KEY_NOT_FOUND` from Turnkey, fall back to the user's Passkey to perform the Turnkey operation, then continue with the Backup Service deletion.
+
+Error handling on `/add-factor` and `/delete-factor` against the Backup Service does not need its own unauthorized-factor branch because every such call is preceded by `/retrieve-metadata`, which surfaces the condition first.
+
+## Stale Local Backup
+
+If the local manifest hash differs from the remote on app startup or before a sync, Bedrock returns `RemoteAheadStaleError`. The UI escalates the user to a recovery-style prompt and runs [BF-5](/world-app/backup/flows#bf-5-remote-update-detection). The local state is overwritten unconditionally with the freshly-downloaded backup — the manifest itself is the arbiter.
+
+## Last Main Factor Restriction
+
+Removing the last deletes the entire backup (the data is no longer recoverable). The Backup Service enforces this server-side; clients confirm with the user before issuing the request. See [BF-7](/world-app/backup/flows#bf-7-factor-deletion) and [BF-8](/world-app/backup/flows#bf-8-backup-deletion).
+
+# Disaster Recovery via /reset
+
+The keypair (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.
+
+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 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.
diff --git a/world-app/backup/components.mdx b/world-app/backup/components.mdx
new file mode 100644
index 0000000..89a14a9
--- /dev/null
+++ b/world-app/backup/components.mdx
@@ -0,0 +1,119 @@
+---
+title: "Components & Parties"
+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";
+
+# Backup Service
+
+The Backup Service is a [Nethermind](https://www.nethermind.io/)-hosted REST service. Tools for Humanity has no production access; the service holds ciphertext and metadata only. Its open source repository is [`backup-service`](https://github.com/worldcoin/backup-service).
+
+## Role and guarantees
+
+The service:
+
+- Stores the and .
+- Authenticates clients via per-operation challenges signed by the relevant factor.
+- Enforces state transitions on sync via the manifest hash (see [Structure & Sync](/world-app/backup/structure-and-sync#state-transitions)).
+- Maps factor public identifiers back to `backup_id` so a recovering client can locate its backup from a single Main Factor.
+
+It does **not**:
+
+- Hold the 's decryption key, any , or any factor private key.
+- Inspect the contents of the .
+- Authenticate users beyond verifying signatures and tokens — there is no concept of a Backup Service user account.
+
+## Storage layout
+
+- **S3** — blobs and JSON, keyed by `backup_id`.
+- **DynamoDB** — A `FactorLookup` table mapping `{scope}#{type}#{factor_value}` (e.g. `MAIN#PASSKEY#`) back to `backup_id`. This is a lookup utility — the source of truth for *which factors authorize a backup* is the metadata in S3.
+- **Redis** — Ephemeral state: JWE-encrypted challenge tokens (single-use, ~5 min TTL), short-lived per-`backup_id` locks during create/sync, and post-recovery `sync_factor_token`s used to register a new without re-prompting a Main Factor.
+
+The list of endpoints, request shapes, and the `ChallengeContext` enum live in [`backup-service/src/routes`](https://github.com/worldcoin/backup-service/tree/main/src/routes); the OpenAPI spec is served at `/docs` on each environment.
+
+## Authorization
+
+Three factor types are recognized:
+
+| Factor type | Verification | Allowed scope |
+| --- | --- | --- |
+| Passkey | WebAuthn assertion (COSE `ES256`) verified via `webauthn-rs` | Main only |
+| OIDC account | OIDC token signature + claim verification (nonce = SHA256(ephemeral session pubkey)) | Main only |
+| EC keypair (P-256) | ECDSA signature, DER-encoded, verified with `p256` | Main or Sync |
+
+The challenge token is bound to the operation. A `Sync` challenge cannot be replayed against `/delete-factor` because the embedded `ChallengeContext` differs. Challenges are single-use (Redis enforces).
+
+## What the service rejects
+
+A handful of named errors are surfaced in client code paths and worth knowing by name:
+
+- `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` — `/create` collision; some prior creation attempt registered this backup ID. The client retries with a fresh sync factor authorization on the existing record.
+- `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.
+
+The full enumeration is in [`backup-service/src/types/error.rs`](https://github.com/worldcoin/backup-service/blob/main/src/types/error.rs).
+
+# Turnkey
+
+[Turnkey](https://turnkey.com/) is the custodian of s for OIDC factors. It runs key material inside TEE-backed enclaves and exposes a REST API; we picked it because no other provider met the combined requirements of OIDC-aware authentication, non-custodial key handling, and an API-first integration model.
+
+All short-lived keys used with Turnkey are set for a duration of **5 minutes** unless otherwise specified.
+
+## Turnkey User Setup
+
+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:
+
+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).
+ - Permission policy: explicit `ALLOW` for all activities (`condition = true`).
+
+ ```json
+ {
+ "effect": "EFFECT_ALLOW",
+ "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.
+
+ ```json
+ {
+ "effect": "EFFECT_ALLOW",
+ "consensus": "approvers.any(user, user.id == '')",
+ "condition": "activity.action == 'DELETE' && (activity.resource == 'CREDENTIAL' || activity.resource == 'PRIVATE_KEY' || activity.resource == 'ORGANIZATION')"
+ }
+ ```
+
+ > 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`).
+
+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:
+
+- `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.
+
+## What Turnkey holds vs what the Backup Service holds
+
+| Held by Turnkey | Held by Backup Service |
+| --- | --- |
+| OIDC factor secrets (inside enclave) | blob (encrypted) |
+| OIDC provider configurations | (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) |
+
+Neither holds the 's secret key — that is destroyed at backup creation.
+
+## Direct vs proxied activities
+
+Most Turnkey activities (importing the OIDC factor secret at enrollment, exporting it at recovery) are sent **directly** from the client to Turnkey using a stamp computed in Bedrock. The exception is *creating a new sub-organization*, which requires a stamp from Tools for Humanity's parent-organization API key — that single activity goes through TFH's app-backend.
diff --git a/world-app/backup/factors.mdx b/world-app/backup/factors.mdx
new file mode 100644
index 0000000..0d8f5be
--- /dev/null
+++ b/world-app/backup/factors.mdx
@@ -0,0 +1,87 @@
+---
+title: "Authentication Factors"
+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 { RootKey } from "/snippets/terms.mdx";
+
+# Main Factors vs Sync Factors
+
+A backup distinguishes two factor categories. They differ in *what they can do*, not just *how strongly they authenticate*.
+
+| | | |
+| --- | --- | --- |
+| Purpose | Authenticate the user; unlock the backup; manage other factors | Update existing backup contents; delete the backup; delete factors |
+| Requires user interaction | Yes (passkey tap, OIDC sign-in, biometric on Keychain) | No (silent — background sync) |
+| Can decrypt the backup? | Yes (holds a ) | **No** |
+| Can add new factors? | Yes | **No** |
+| Per-device | No (Passkey/OIDC roam) | Yes — one Sync Factor per device |
+| Allowed types | Passkey, OIDC + Turnkey, iCloud Keychain (iOS) | EC keypair only |
+
+The split exists for two reasons. First, sync needs to happen silently in the background; requiring a passkey tap on every change would be untenable. Second, the Sync Factor is long-lived on the device. If it was granted the power to *enroll* new recovery methods would mean a stolen device could silently add factors and gain persistent access. The Sync Factor can *delete* but not *add*; this is enforced by both Backup Service authorization and Turnkey policy (see [Components & Parties](/world-app/backup/components#turnkey)).
+
+# Factor Secrets
+
+A is a 32-byte secret used to encrypt the . Each either *derives* its secret deterministically (in a cryptographically-secure form) or *holds* it in a system made for cryptographic storage; the client never persists secrets in plaintext.
+
+## Passkey (PRF)
+
+The factor secret is the output of the [WebAuthn PRF extension](https://www.w3.org/TR/webauthn-3/#prf-extension) evaluated over a fixed salt (`"world-app-backup"`). PRF is deterministic per credential, so the same passkey always produces the same secret.
+
+Platform notes:
+
+- **iOS** — Requires iOS 18+. Implemented with `AuthenticationServices` (`ASAuthorizationPublicKeyCredentialPRFRegistrationInput` / assertion input). The credential may be stored in iCloud Keychain by the system (out of scope of mobile apps).
+- **Android** — Implemented with the [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager) (`androidx.credentials`). PRF support depends on the active credential provider — typically Google Password Manager (requires Google Play Services 23.33+); Samsung Pass and others are not yet supported. Minimum API level 29; device screen lock must be set up or registration fails.
+
+## OIDC + Turnkey
+
+The factor secret is a random 32-byte sequence generated by the client at enrollment, then *imported* into Turnkey's TEE-backed enclave under a sub-organization owned by the user. From that point on, the secret only leaves the enclave when the user authenticates the OIDC factor. Turnkey's enclave performs OIDC token verification itself before releasing the secret — the user proves possession of their Google or Apple identity, not just to the Backup Service but to the enclave too.
+
+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.
+
+## iCloud Keychain (iOS only)
+
+A P-256 keypair is generated by the iOS [`Security`](https://developer.apple.com/documentation/security) framework via `SecKeyCreateRandomKey` (key type `kSecAttrKeyTypeECSECPrimeRandom`). The private key scalar (32 bytes) is the factor secret.
+
+iCloud Keychain is intentionally **not** synced (`kSecAttrSynchronizable: false`) — it is an iOS-only, device-local factor. Its purpose is to provide an additional recovery path on iOS without requiring the user to set up a passkey or sign in with OIDC. Android has no equivalent.
+
+# Sync Factor
+
+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).
+- **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 . Each Sync Factor authorizes exactly one device; recovery onto a new device produces a new Sync Factor and registers it via `/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.
+
+# 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:
+
+```
+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).
+
+# Platform Differences
+
+The clients diverge in places where the underlying platform requires it. The list below is the *intentional* divergence, anything else is a bug.
+
+| | iOS | Android |
+| --- | --- | --- |
+| 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 |
+| Passkey rename | iOS 26.2+ only | Provider-dependent |
+| OIDC sign-in | `GoogleSignInSDK` / `AppleSignInSDK` | `GetSignInWithGoogleOption` (Credential Manager) |
diff --git a/world-app/backup/flows.mdx b/world-app/backup/flows.mdx
index e78d41e..a048f56 100644
--- a/world-app/backup/flows.mdx
+++ b/world-app/backup/flows.mdx
@@ -1,82 +1,109 @@
---
title: "Backup Flows"
sidebarTitle: "Flows"
-description: "Detailed documentation on the key flows related to the World App backup system."
+description: "The key user-facing flows. Focused on non-obvious behavior and decisions rather than step-by-step UI scripts."
---
-import { MainFactor } from "/snippets/backup/terms.mdx";
+import { MainFactor, SyncFactor, BackupEncryptionKeypair, FactorSecret, SealedBackup, BackupMetadata, EncryptedBackupEncryptionKeypair } from "/snippets/backup/terms.mdx";
-## BF-7. Factor Deletion
-- This flow is triggered when the user deletes a from the UI.
-- Sync Factors are not manually removed by the user. This is usually done through other flows (e.g. when logging out).
-- Currently, Passkeys are enrolled into Turnkey to provide a fallback mechanism in case a Sync Factor is no longer available. When the requested operation cannot be completed with the Sync Factor (see `PUBLIC_KEY_NOT_FOUND` error), the client will use the Passkey to execute the operation instead.
-- **Transitory**: Currently, removing a Passkey factor while there is still an OIDC factor is not supported. This is because a Passkey factor is required to manage other factors (restriction on `backup-service` when adding other factors) and the Passkey is used to recover access to Turnkey should the Sync Factor fail.
-- **Future Note**: The current flow assumes that Turnkey is only used for OIDC Factors. This may change in the future (e.g. to support non-PRF Passkeys).
+# Conventions
+
+The flows are numbered (BF-1, BF-2, …) and referenced from code and other docs. Each section below covers **purpose, what's non-obvious, and where the iOS and Android implementations diverge** — not a step-by-step script. The authoritative sequencing lives in the client code; this page exists to explain *why* a flow looks the way it does.
+
+# BF-1: Backup Creation
+
+**When.** A user finishes onboarding and creates their first backup, picking one or two recovery methods.
+
+**Non-obvious.** The client generates a *fresh* at this moment, encrypts the , encrypts the keypair with the of each enrolled , and then **discards the keypair's secret**. Only the public key is retained locally so subsequent sync operations can re-encrypt new s without authenticating a Main Factor. This is what makes background sync (BF-4) possible.
+
+A is also generated at creation and registered with the Backup Service in the same `/create` call, so the device is immediately authorized to push updates.
+
+**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.
```mermaid
-flowchart TD
- A{"Removing the last Main factor?"} -- Yes --> EndDelete(["Delete the backup: BF-8"])
- A -- No --> C["backup-service: /delete-factor/challenge/keypair"]
- C --> D["backup-service: /delete-factor with factorId with Sync Factor"]
- D --> E{"Deleting an OIDC factor?"}
- E -- Yes --> F{"Deleting the last OIDC factor?"}
- F -- Yes --> G["Delete Turnkey Account (ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION) with Sync Factor"]
- F -- No --> H["Delete OIDC account from Turnkey (ACTIVITY_TYPE_DELETE_OAUTH_PROVIDERS) with Sync Factor"]
- G --> EndSuccess
- H --> EndSuccess
-
- E -- No -->I{"Deleting iCloud Keychain?"}
- I -- No --> EndSuccess
- I -- Yes --> J["Delete keypair from Keychain"]
- J --> EndSuccess(["End: Show Success"])
-
- G .- fail .-> K{Failed with PUBLIC_KEY_NOT_FOUND?}
- K -- No --> Fail["Retry 2 times with back-off. Log errors. After max retries, continue."]
- K -- Yes --> L{"User has Passkey?"}
- L -- Yes --> M["Delete Turnkey Account (ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION) with Main Factor (Passkey)"]
- L -- No --> EndSuccess
- M --> EndSuccess
-
- H -. fail .-> N{Failed with PUBLIC_KEY_NOT_FOUND?}
- N -- Yes --> Q{"User has Passkey?"}
- Q -- No --> EndSuccess
- Q -- Yes --> O["Create new Sync Factor in Turnkey Account using Passkey"]
- O --> P["Delete OIDC account from Turnkey (ACTIVITY_TYPE_DELETE_OAUTH_PROVIDERS) with NEW Sync Factor"]
- P --> EndSuccess
-
- O -. fail .-> Fail
- P -. fail .-> Fail
-
- C -. fail .-> EndError(["End: Show Failure to User"])
- D -. fail .-> EndError
+flowchart LR
+ User -->|enroll factor| Client
+ Client -->|generate BackupEncryptionKeypair| Client
+ Client -->|seal backup| Client
+ Client -->|encrypt keypair with FactorSecret| Client
+ Client -->|POST /create| BackupService
+ Client -->|register Sync Factor| BackupService
+ Client -.->|if OIDC| Turnkey
```
-## BF-8. Backup Deletion
-- Backup Deletion is triggered from either:
- - The user removing the last (see BF-7).
- - The user requesting full account deletion of World App.
-- Backup Deletion will clear all data related to the backup.
+# BF-2: Backup Retrieval
+
+**When.** A user signs into World App on a new device.
+
+**Non-obvious.** Retrieval (or previously "Recovery") is the most architecturally distinct flow because the user has no local state. The client has to bootstrap from a single Main Factor.
+The client identifies the backup by presenting the Main Factor's *public identifier* (passkey credential ID, OIDC `sub`, or Keychain public key) — the Backup Service has a reverse lookup table that maps these to a `backup_id`. Once the backup is located, the user authenticates the factor and the Backup Service returns the together with the metadata.
+
+After the backup is decrypted, the client generates a fresh for the new device and registers it via `/add-sync-factor`. This call uses a short-lived `sync_factor_token` issued by `/retrieve/from-challenge`, which lets the client add the new device authorization without re-prompting for a Main Factor.
+
+**OIDC recovery quirk.** For OIDC factors, decrypting the requires the 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
-flowchart TD
- A["backup-service: /delete-backup/challenge/keypair with Sync Factor"] --> B["backup-service: /delete-backup"]
- B --> C{"User has Turnkey account?"}
- C -- Yes --> D["Delete Turnkey Account (ACTIVITY_TYPE_DELETE_SUB_ORGANIZATION) with Sync Factor"]
- C -- No --> E["Clear Bedrock Data"]
- D --> E
- E --> F["Clear local keypair for Sync Factor"]
- F --> G{"User on iOS and has iCloud Keychain Factor"}
- G -- Yes --> H["Delete iCloud Keychain Factor (Main)"]
- G -- No --> EndSuccess(["End: Show Success"])
- H --> EndSuccess
-
-
- D -. fail .-> Fail["Retry 2 times with back-off. Log errors. After max retries, continue."]
- F -. fail .-> Fail
- E -. fail .-> Fail
- H -. fail .-> Fail
-
- A -. fail .-> EndError(["End: Show Failure to User"])
- B -. fail .-> EndError
-```
\ No newline at end of file
+sequenceDiagram
+ participant Client
+ participant BackupService
+ participant Turnkey
+ Client->>BackupService: identify backup by factor public ID
+ Client->>BackupService: /retrieve/challenge/* + auth Main Factor
+ BackupService->>Client: Sealed Backup + Metadata + sync_factor_token
+ opt OIDC factor
+ Client->>Turnkey: authenticate (OIDC)
+ Turnkey->>Client: factor secret
+ end
+ Client->>Client: decrypt EncryptedBackupKeypair → decrypt SealedBackup
+ Client->>BackupService: /add-sync-factor (with token)
+ Client->>Turnkey: Add sync factor
+```
+
+# BF-3: Adding a Main Factor
+
+**When.** A user adds a second (or third) recovery method from Settings.
+
+**Non-obvious.** Adding a factor *does not* require re-encrypting the entire backup. The client only needs to encrypt the existing with the new and append the resulting to the metadata. This is the entire point of the keypair indirection layer (see [Layered Encryption Model](/world-app/backup/index#layered-encryption-model)).
+
+To add an OIDC factor with a Passkey already enrolled, the merged Turnkey passkey challenge from BF-1 is reused — one passkey tap authorizes both `/add-factor` on the Backup Service and the Turnkey activity that creates the OIDC provider.
+
+**Currently disabled.** Adding a Passkey when an OIDC factor exists is *not* supported today. Removing a Passkey while an OIDC factor exists is also not supported, because the Passkey is the fallback path for Turnkey when the Sync Factor's Turnkey credentials are no longer valid (see [Unauthorized Device](/world-app/backup/advanced#unauthorized-device)).
+
+# BF-4: Backup Sync
+
+**When.** The user takes an action that produces a new file (e.g., orb verification yields a new PCP) or modifies the credential vault.
+
+**Non-obvious.** Sync runs silently — it only uses the , not a . The client retains the public half of the from creation/recovery, which is enough to re-encrypt the new without unlocking any factor secret.
+
+The flow is gated by the manifest hash. If the local manifest is stale (the user updated their backup on another device), the sync attempt fails with `manifest_hash_mismatch` and the UI escalates to BF-5.
+
+# BF-5: Remote-Update Detection
+
+**When.** App launch, and on demand from sync failures.
+
+**Non-obvious.** This is the only flow that prompts re-authentication of a Main Factor *outside* of explicit user intent. Because background sync cannot fetch encrypted backup contents (the can't decrypt), reconciling a remote update requires a Main Factor.
+
+The client simply fetches the remote manifest hash; if it doesn't match the local hash, the UI presents a recovery-style prompt and the user re-authenticates a Main Factor to download the updated backup. After download, local sync resumes normally.
+
+# BF-6: Multi-Device Sync
+
+This is not a separate flow but a consequence of BF-4 and BF-5 interacting. See [Advanced Topics — Multiple Device Sync](/world-app/backup/advanced#multiple-device-sync).
+
+# BF-7: Factor Deletion
+
+**When.** The user removes a recovery method from Settings.
+
+**Non-obvious.** Two systems need updating: the Backup Service (remove from ) and Turnkey (remove the corresponding OIDC provider, or delete the sub-organization if it's the last OIDC factor). Both deletions happen using the , not a Main Factor — Turnkey policy explicitly authorizes the Sync Factor for these specific deletion activities (see [Components & Parties — Turnkey](/world-app/backup/components#turnkey)). This avoids a passkey prompt on every Settings interaction.
+
+**Failure handling.** If the Sync Factor is no longer valid in Turnkey (`PUBLIC_KEY_NOT_FOUND` — see [Unauthorized Device](/world-app/backup/advanced#unauthorized-device)), the client falls back to the user's Passkey to perform the Turnkey operation. This is one of the reasons a Passkey is currently required to manage other factors.
+
+**Last factor.** Deleting the last cascades into BF-8.
+
+# BF-8: Backup Deletion
+
+**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.
diff --git a/world-app/backup/index.mdx b/world-app/backup/index.mdx
index 0859fb1..b56e6f4 100644
--- a/world-app/backup/index.mdx
+++ b/world-app/backup/index.mdx
@@ -1,48 +1,59 @@
---
title: "Introduction to Backup"
sidebarTitle: "Introduction"
-description: "The backup system enables logging in to World App."
+description: "How World App backs up and recovers a user's self-custodial data."
---
-import { UnsealedBackup, BackupEncryptionKeypair, EncryptedBackupEncryptionKeypair, BackupMetadata, MainFactor, SealedBackup, SyncFactor } from "/snippets/backup/terms.mdx";
-
-[turnkey]: https://turnkey.com/
+import { UnsealedBackup, BackupEncryptionKeypair, EncryptedBackupEncryptionKeypair, BackupMetadata, MainFactor, SealedBackup, SyncFactor, FactorSecret } from "/snippets/backup/terms.mdx";
+import { RootKey, PersonalCustodyPackage } from "/snippets/terms.mdx";
# Introduction
-Introduction and high-level context coming soon.
+World App is self-custodial: the user's [Root Key](/world-app/cryptography), wallet, World ID, etc. all live on their device. Backup & Recovery is what lets the user re-derive that state on a new device when the old one is lost/replaced without trusting any single party with the plaintext data. This system is particularly designed with distributed trust where no single party can compromise a user's backup, in essence the _lock_ and _key_ are handled by independent parties and hardened independently.
+
+The backup is end-to-end encrypted on the client, stored as ciphertext by the **Backup Service**, and decryptable only with one of the user's enrolled s (Passkey, OIDC account, iCloud Keychain keypair).
+# System Overview
-# Technical Reference
+There are four main components involved in the system.
-## High-Level Overview
-Coming soon.
+- **Client (iOS, Android)**: User-facing flows. Implements platform-specific factor logic and calls into Bedrock for shared crypto and manifest logic.
+- **[Bedrock](/world-app/bedrock)**: Cross-platform library shared by both clients. Owns the backup file format, manifest computation, sealing/unsealing, and the Turnkey stamping primitives. See [`bedrock`](https://github.com/worldcoin/bedrock/tree/main/bedrock/src/backup).
+- **Backup Service**: A secure backend storage layer which holds the and , validates state transitions on sync, and exposes endpoints for factor management. It never sees the decrypted plain text, nor has it any way of accessing the ciphertext. The keys with which the ciphertext is encrypted are never seen by this service. See [`backup-service`](https://github.com/worldcoin/backup-service). Currently this service is operated independently by [Nethermind](https://www.nethermind.io/).
+- **[Turnkey][turnkey]** — Holds s for OIDC factors inside its Trusted Execution Environment (TEE on AWS Nitro) and provably authenticates the user (via OIDC). Internally, TFH is the parent organization; each user is a sub-organization. Neither TFH or Turnkey can access the user's secrets, this is enforced by the TEE.
-## Backup Composition
+The Client orchestrates everything; the Backup Service is intentionally narrow.
+
+# Layered Encryption Model
+
+The backup uses two layers of asymmetric encryption rather than a single password-derived key. This decouples factor lifecycle from backup content.

+- The is the plaintext: a collection of files (the , PCPs, the credential vault) plus a manifest. Its structure is owned by Bedrock's `BackupFormat`. See [Backup Structure & Sync](/world-app/backup/structure-and-sync).
+- The client encrypts the with a randomly generated at backup creation. The result is the .
+- The client then encrypts the once per with that factor's , producing $n$ s. The keypair's secret key is then **discarded** — only the public key is kept locally so the client can re-encrypt new versions of the backup without authenticating a Main Factor.
+- The s live in the alongside the list of factors.
-- The is a collection of raw files stored locally on the device only. The structure of the is described in the [Backup Composition & Sync](/world-app/backup/structure-and-sync) section.
-- The user encrypts the with a that's randomly generated by World App.
-- The user then encrypts the with each _Factor Secret_, creating $n$ . After the is encrypted with each _Factor Secret_, the user discards the secret key of the , and stores the public key (which is used to encrypt new versions of the to **Sync** the backup).
-- All the s are stored in the .
+The reason for the keypair indirection is that adding a new only requires re-encrypting the keypair (small) under the new , not the entire backup. Without it, $n$ copies of the would have to be maintained.
-The reason for the layer is that when adding a new , the factor can be added without requiring to create a new . If _Factor Secrets_ were used directly, $n$ versions of the would need to be stored.
+For the encryption primitives themselves, see [Advanced Topics — Encryption](/world-app/backup/advanced#encryption).
+# Threat Model
-### Factor Secrets
-Main Factors are able to derive or store _Factor Secrets_. A _Factor Secret_ is a key that is used to encrypt the . When decrypting the , the is decrypted first with the _Factor Secret_ and then the Sealed Backup to retrieve the raw files. Each derives its _Factor Secret_ this way:
+The system is designed to defend against:
-- For Passkeys, the _Factor Secret_ is a [PRF extension key](https://www.w3.org/TR/webauthn-3/#prf-extension) which allows deriving a deterministic symmetric key.
-- For OIDC, the _Factor Secret_ is a random 32 byte sequence that is generated in the user's World App and then stored inside [Turnkey][turnkey]'s Secure Enclaves as a _Private Key_. On recovery, the Secure Enclave provably authenticates the user before providing the secret.
-- For iCloud Keychain, a P-256 keypair is generated by the `Security` framework on iOS and stored in the keychain. The private key is used as a secret to encrypt the .
+- **Backup Service compromise.** A breach of the Backup Service exposes ciphertext and metadata only. s never reach the service; the private key is destroyed after sealing.
+- **TFH backend compromise.** TFH's app-backend stamps Turnkey sub-organization creation but does not custody factor secrets or backup contents. An attacker with TFH backend access cannot decrypt a backup or even retrieve the ciphertext.
+- **Loss of a single device.** Recovery only requires one enrolled .
+- **Loss of a single factor.** Multiple Main Factors can be enrolled. Sync Factors are scoped narrowly (see [Authentication Factors](/world-app/backup/factors)).
+- **Single party coercion.** If a single party is coerced or malicious, they cannot compromise a user's backup.
+Out of scope:
-### Backup Metadata
+- Compromise of the user's passkey provider or OIDC identity provider.
+- Malware on the user's device with access to plaintext after recovery.
+- Coercion-resistant recovery (no duress codes).
+- Rotation of long-lived keys (factor secrets, the backup keypair) is not currently supported.
-The is a plain-text file that is stored in the backup service. It generally contains the following information (see [source code](https://github.com/worldcoin/backup-service/blob/main/src/types/backup_metadata.rs) for most up-to-date information):
-- The ID of the backup
-- The list of .
-- The list of s.
-- The list of s.
\ No newline at end of file
+[turnkey]: https://turnkey.com/
diff --git a/world-app/backup/structure-and-sync.mdx b/world-app/backup/structure-and-sync.mdx
index ed2d014..c86f858 100644
--- a/world-app/backup/structure-and-sync.mdx
+++ b/world-app/backup/structure-and-sync.mdx
@@ -1,87 +1,74 @@
---
-title: "Backup Sync & Internal Structure"
+title: "Backup Structure & Sync"
sidebarTitle: "Structure & Sync"
-description: "Describes how the backup is synced between devices and used for authenticating new devices."
+description: "What's inside the backup, how the manifest enforces consistent updates, and how sync works."
---
-import { UnsealedBackup, SealedBackup } from "/snippets/backup/terms.mdx";
+import { UnsealedBackup, SealedBackup, MainFactor, SyncFactor, BackupEncryptionKeypair } from "/snippets/backup/terms.mdx";
import { RootKey, PersonalCustodyPackage } from "/snippets/terms.mdx";
+# What's in the Backup
-Syncing is the operation by which a user **updates** their backup from an authenticated device. A backup sync (`/sync`) is unidirectional, with a mobile client providing an update when new files are included in the backup. The client only fetches remote updates in certain scenarios (see below).
+The contains the user's plus a set of files needed to fully restore World App on a new device. Today this includes the user's s (Orb, Document, Face) and the credential vault. Each file is tagged with a *designator* identifying its kind.
-The contains the encoded for World App plus a set of raw files. The source of truth is the `BackupFormat` structure in [Bedrock](https://github.com/worldcoin/bedrock). The files back up all required self-custodial information that a user holds in their World App. In particular, today this is the user's World ID s (or PCPs).
+The exact list of designators is owned by [Bedrock](/world-app/bedrock) and evolves as new credentials are added. The authoritative enumeration lives in [`BackupFileDesignator`](https://github.com/worldcoin/bedrock/blob/main/bedrock/src/backup/mod.rs); refer to it directly rather than duplicating the list here.
-One of the challenges the system faces is that because the backup is end-to-end encrypted, the **Backup Service** cannot enforce any type of rules that prevent accidental data loss. For example, consider Client M in which the User gets their passport verified and hence obtains a PCP. If the user is also authenticated on Client N, and a backup sync is triggered from Client N, the PCP would be removed from the backup because Client N does not have it.
+The backup format itself is versioned ([`BackupFormat`](https://github.com/worldcoin/bedrock/blob/main/bedrock/src/backup/backup_format)). The current version is V0: a tar+gzip archive of CBOR-encoded files with per-file BLAKE3 checksums.
+# The Manifest
-## Backup State Management
+Because the backup is end-to-end encrypted, the Backup Service cannot enforce content-level rules. If Client M observes that the user just verified their passport (and now holds a new PCP), the Backup Service has no way to require that a sync from Client N — which doesn't have that file — must include it. The manifest mechanism described below is what prevents accidental data loss in that case.
-The backup system uses a **manifest-based approach** to prevent accidental data loss during syncs. Each backup contains a manifest that describes its contents and enforces proper versioning.
+Every backup ships with a **manifest**: a versioned, hashed ledger of every file in the . The manifest entries record each file's path, designator, and content checksum. The hash of the manifest is the backup's *state*.
-### The Manifest
+Both the client and the Backup Service track this hash. The Backup Service uses it to enforce that every update transitions from the *current* state — preventing an out-of-date client from silently overwriting a newer backup with a stale one.
-Each backup ships with a manifest that describes the backup's contents. The manifest serves as a versioned ledger, ensuring clients always begin from the current state ($m_t$) and apply incremental updates to produce the next state ($m_{t+1}$).
+The canonical encoding and BLAKE3 hashing are computed by Bedrock so they're identical across iOS and Android. See [`BackupManifest`](https://github.com/worldcoin/bedrock/blob/main/bedrock/src/backup/manifest.rs) for the implementation.
-```json
-{
- "version": 0,
- "previous_manifest_hash": "0x1234567890000000000000000000000",
- "files": [
- {
- "checksum": "0x1234567890000000000000000000000",
- "file_path": "/personal_custody/unpack/me/here",
- "designator": "orb_pcp"
- }
- ]
-}
-```
+# State Transitions
-Each state is defined as the `blake3` hash of the manifest file (raw bytes).
+A successful update transitions the backup from state $m_t$ (the current manifest hash) to $m_{t+1}$ (the updated manifest hash). The rules:
-### Responsibilities
+- **Client.** Bears responsibility for performing updates against the current state. Verifies it holds the latest manifest before making changes.
+- **Backup Service.** Maintains the current state hash. Validates each update is a transition from the recorded state. **Cannot** inspect encrypted contents.
-- **Client**: Bears sole responsibility for performing backup updates based on the current state. Must verify it's operating on the latest manifest before making changes.
-- **Server**: Maintains a pointer to the current state hash. Validates state transitions during updates but cannot inspect encrypted contents.
+If the client tries to sync from a stale state, the service rejects with `manifest_hash_mismatch` (HTTP 412). The client must re-authenticate a , download the latest , apply local changes on top, and retry. See [Multiple Device Sync](/world-app/backup/advanced#multiple-device-sync) for why this happens in practice.
-### ManifestManager API (Client-Side)
+# ManifestManager API
-The `ManifestManager` provides controlled access to backup modifications. Before any operation, it fetches the remote state hash to ensure consistency:
+Clients interact with the manifest through Bedrock's `ManifestManager`. It gates every operation on a fresh remote-hash check before mutating local state, so the client cannot accidentally produce an update from a stale base:
-- If the local manifest is missing → prompts user to authenticate and download the remote state
-- If `remote_manifest_hash == local_manifest_hash` → proceeds with updates
-- If hashes differ → prompts user to authenticate and download the remote state
+- `store_file(designator, path)` — Add a file. Computes its checksum, appends to the manifest, packs and uploads.
+- `list_files(designator)` — List files for a designator.
+- `replace_all_files_for_designator(designator, new_path)` — Atomically replace every file under a designator with a new one.
+- `remove_file(path)` — Remove a file entry.
-#### Available Methods
+If the local manifest is missing or its hash differs from the remote, every method short-circuits with `RemoteAheadStaleError`. The client surfaces this as a prompt to re-authenticate and download the latest backup before retrying the change.
-- **`store_file(designator, filePath)`** - Adds a new file to the backup. Verifies path uniqueness, computes checksum, triggers pack & upload.
-- **`list_files(designator)`** - Returns all entries for the given designator.
-- **`replace_all_files_for_designator(designator, newFilePath)`** - Replaces all existing entries for a designator with a new file.
-- **`remove_file(filePath)`** - Removes a file entry from the manifest.
+See [`ManifestManager`](https://github.com/worldcoin/bedrock/blob/main/bedrock/src/backup/manifest.rs) for signatures and behavior.
-### Pack & Upload Process
+# Pack & Upload
-When building and uploading a new backup version:
+When building a new backup version (see [BF-4: Sync](/world-app/backup/flows#bf-4-backup-sync)):
-1. **Validate local manifest** - Error if missing; prompt to download remote.
-2. **Build unsealed backup** - Collect all files from the manifest. Verify checksums match for every file.
-3. **Submit sealed backup** - Include updated manifest inside. Report current and new manifest hashes to backend.
-4. **Backend validation** - Verifies current hash matches recorded state before accepting update.
+1. Validate the local manifest exists and matches the remote state.
+2. Build the from the files referenced in the manifest. Verify each file's checksum.
+3. Encrypt with the 's keypair public key.
+4. POST `/sync` with the new , the current manifest hash, and the new manifest hash.
+5. Backup Service checks the current hash matches its recorded state, then atomically swaps the blob and the recorded hash.
-### Download & Unpack Process
+# Download & Unpack
-When downloading and unpacking a remote backup:
+When restoring or catching up (see [BF-2](/world-app/backup/flows#bf-2-backup-recovery) and [BF-5](/world-app/backup/flows#bf-5-remote-update-detection)):
-1. **Decrypt sealed backup** - Extract and replace local manifest unconditionally.
-2. **Unpack files** - Write each file to its declared `filePath`. Replace existing files (log errors for checksum mismatches).
+1. Decrypt the with the -unlocked .
+2. Replace the local manifest unconditionally with the manifest from the unpacked backup.
+3. Write each file to its declared path. On checksum mismatch the file is overwritten with the version from the backup and the mismatch is logged — the backup is the source of truth.
-This approach is safe because clients enforce explicit state transitions based on the current manifest.
+# Startup Verification
-### Startup Verification
+On every app launch the client fetches the remote manifest hash. If it differs from the local hash, the user is prompted to re-authenticate a and download the remote backup. This handles the multi-device case where the user updated their backup from Device A and then opens World App on Device B.
-On app startup, the client fetches the remote state checksum. If it differs from the local manifest, the user is prompted to authenticate and download the remote backup.
+# Backend Safety Measures
-### Additional Safety Measures
-
-- **Backend short-lived locks** - Prevents narrow race windows during updates, only one update at a time. Authoritative enforcement remains via manifest hash validation.
-- **Hard links (Android Only)** - Hard links are created under `/backup/files/{checksum}` to guard against accidental deletion of source files. Links are removed on file removal.
\ No newline at end of file
+- **Short-lived locks.** The Backup Service holds a short-lived per-`backup_id` lock during create and sync to close the narrow race window between the hash check and the write. The authoritative consistency check remains the manifest hash comparison.
diff --git a/world-app/cryptography.mdx b/world-app/cryptography.mdx
index e1667bb..1cc00b4 100644
--- a/world-app/cryptography.mdx
+++ b/world-app/cryptography.mdx
@@ -5,4 +5,4 @@ description: "Cryptography reference for World App."
## CSPRNG and Randomness Source
-Various operations in World App require a [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator) (Cryptographically Secure Pseudo-Random Number Generator). For all such operations, we use the standard OS-provided CSPRNG. Most cryptographically sensitive operations in World App are handled by [Bedrock](/world-app/bedrock) foundational library. Bedrock accomplishes this by using the `OsRng` from the [rand](https://docs.rs/rand/latest/rand/rngs/struct.OsRng.html) crate. Under the hood, `OsRng` uses `getrandom` which uses the OS-provided CSPRNG (e.g. `getrandom(2)` syscall on Android (Linux), `SecRandomCopyBytes` on iOS).
\ No newline at end of file
+Various operations in World App require a [CSPRNG](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator) (Cryptographically Secure Pseudo-Random Number Generator). For all such operations, we use the standard OS-provided CSPRNG. Most cryptographically sensitive operations in World App are handled by [Bedrock](/world-app/bedrock). Bedrock accomplishes this by using the `OsRng` from the [rand](https://docs.rs/rand/latest/rand/rngs/struct.OsRng.html) crate. Under the hood, `OsRng` uses `getrandom` which uses the OS-provided CSPRNG (e.g. `getrandom(2)` syscall on Android (Linux), `SecRandomCopyBytes` on iOS).
\ No newline at end of file