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
18 changes: 17 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
Expand Down
Binary file added fonts/excalifont.woff2
Binary file not shown.
14 changes: 13 additions & 1 deletion snippets/backup/terms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,16 @@ export const EncryptedBackupEncryptionKeypair = () => {
return (
<Tooltip tip="An encrypted version of the Backup Encryption Keypair. It is encrypted with a Factor Secret.">Encrypted Backup Encryption Keypair</Tooltip>
)
}
}

export const FactorSecret = () => {
return (
<Tooltip tip="A 32-byte secret derived from or held by a Main Factor. Used to encrypt the Backup Encryption Keypair.">Factor Secret</Tooltip>
)
}

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>
)
}
4 changes: 2 additions & 2 deletions snippets/terms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export const RootKey = () => {

export const PersonalCustodyPackage = () => {
return (
<Tooltip tip="A Personal Custody Package (PCP) is a collection of files that represent a holder's World ID credential. It is stored on the user's device and optionally the user's backup.">Personal Custody Package</Tooltip>
<Tooltip tip="A Personal Custody Package (PCP) is a collection of files that accompany a user's World ID Credential. It is stored on the user's device and optionally the user's backup.">Personal Custody Package</Tooltip>
)
}
}
7 changes: 7 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 59 additions & 6 deletions world-app/backup/advanced.mdx
Original file line number Diff line number Diff line change
@@ -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 <UnsealedBackup />.** Done with the public key of the freshly-generated <BackupEncryptionKeypair /> (X25519). The result is the <SealedBackup />. The keypair's secret key is destroyed after this seal, so even the original device cannot decrypt the <SealedBackup /> without going through a Main Factor.
- **Sealing the <BackupEncryptionKeypair />.** Done once per Main Factor, with a public key derived from that factor's <FactorSecret /> (the secret is hashed into a Curve25519 secret key). The result is an <EncryptedBackupEncryptionKeypair /> stored in the <BackupMetadata />.
Comment thread
paolodamico marked this conversation as resolved.

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 <SyncFactor /> 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 <MainFactor /> 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 <SyncFactor /> 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 <SyncFactor /> (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 <MainFactor /> and register a fresh <SyncFactor /> 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.

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

## 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 <MainFactor /> 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 <BackupAccountId /> keypair (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.

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.
Comment thread
paolodamico marked this conversation as resolved.

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.
119 changes: 119 additions & 0 deletions world-app/backup/components.mdx
Original file line number Diff line number Diff line change
@@ -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 <SealedBackup /> and <BackupMetadata />.
- 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 <SealedBackup />'s decryption key, any <FactorSecret />, or any factor private key.
- Inspect the contents of the <SealedBackup />.
- Authenticate users beyond verifying signatures and tokens — there is no concept of a Backup Service user account.

## Storage layout

- **S3** — <SealedBackup /> blobs and <BackupMetadata /> JSON, keyed by `backup_id`.
- **DynamoDB** — A `FactorLookup` table mapping `{scope}#{type}#{factor_value}` (e.g. `MAIN#PASSKEY#<credential_id>`) back to `backup_id`. This is a lookup utility — the source of truth for *which factors authorize a backup* is the metadata in S3.
Comment thread
paolodamico marked this conversation as resolved.
- **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 <SyncFactor /> without re-prompting a Main Factor.
Comment thread
paolodamico marked this conversation as resolved.

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 |
Comment thread
paolodamico marked this conversation as resolved.
| 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 <FactorSecret />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.

<Note>All short-lived keys used with Turnkey are set for a duration of **5 minutes** unless otherwise specified.</Note>

## Turnkey User Setup

<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:

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).
- Permission policy: explicit `ALLOW` for all activities (`condition = true`).

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

```json
{
"effect": "EFFECT_ALLOW",
"consensus": "approvers.any(user, user.id == '<syncFactorUserId>')",
"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) | <SealedBackup /> blob (encrypted) |
| OIDC provider configurations | <BackupMetadata /> (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 <BackupEncryptionKeypair />'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.
Loading
Loading