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
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ jobs:
- name: Extract pnpm version from package.json
id: get-pnpm-version
run: |
echo "version=$(jq -r '.packageManager' package.json | sed -E 's/^pnpm@([0-9.]+).*$/\1/')" >> $GITHUB_OUTPUT
echo "version=$(jq -r '.packageManager' package.json | sed -E 's/^pnpm@([0-9.]+).*$/\1/')" >> "$GITHUB_OUTPUT"

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ steps.get-pnpm-version.outputs.version }}
run_install: false


- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
"keywords": [],
"author": "",
"license": "MIT",
"engines": {
"node": ">=22.18.0"
},
"packageManager": "[email protected]",
Comment thread
paolodamico marked this conversation as resolved.
Resolved
"dependencies": {
"mint": "^4.2.365"
"mint": "^4.2.598"
},
"devDependencies": {
"cspell": "^9.2.0"
"cspell": "^10.0.1"
}
}
}
1,856 changes: 1,046 additions & 810 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion snippets/backup/terms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,6 @@ export const BackupAccountId = () => {

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>
<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 /v1/reset and the deletion of the user's Turnkey sub-organization.">Backup Account Key</Tooltip>
)
}
22 changes: 12 additions & 10 deletions world-app/backup/advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ All asymmetric encryption in the backup uses libsodium's [sealed box](https://do

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 />.
- **Sealing the <UnsealedBackup />.** Done with the public key of the freshly-generated <BackupEncryptionKeypair /> (`Curve25519`). 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 encoded to a `Curve25519` point with modular reduction). The result is an <EncryptedBackupEncryptionKeypair /> stored in the <BackupMetadata />.

Randomness for ephemeral keys comes from the OS CSPRNG via Bedrock's `OsRng`. See [Cryptography](/world-app/cryptography).

Expand All @@ -39,13 +39,13 @@ Triggers:
- 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:
The remediation path is always the same: re-authenticate a <MainFactor /> and register a fresh <SyncFactor /> via `/v1/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.
1. On `/v1/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 `/v1/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 `/v1/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>
<Note>Error handling on `/v1/add-factor` and `/v1/delete-factor` against the Backup Service does not need its own unauthorized-factor branch because every such call is preceded by `/v1/retrieve-metadata`, which surfaces the condition first.</Note>

## Stale Local Backup

Expand All @@ -55,23 +55,25 @@ If the local manifest hash differs from the remote on app startup or before a sy

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
# Disaster Recovery via /v1/reset

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). _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 `/v1/reset` challenge with the Backup Account Key's secret key. The Backup Service verifies the signature against the public key encoded in the `backup_account_id`, then wipes the backup so the user can re-enroll 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.

`/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.
`/v1/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>
<Note>Support for deleting the stale Turnkey sub-organization was introduced around August 2026. Accounts set up before then initially could not be deleted — they had no `break_glass_user`, and the user (having lost every Main and Sync Factor) cannot authenticate any other way. Those accounts become deletable once a migration backfills the `break_glass_user`.</Note>

## Tearing down the Turnkey sub-organization

Expand Down
12 changes: 6 additions & 6 deletions world-app/backup/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ It does **not**:
## 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.
- **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.
- **DynamoDB** — A `FactorLookup` table using a composite primary key combining the scope (MAIN/SYNC) and a factor identifier (credential ID for passkey, iss+sub for OIDC, public key for EC keypair) mapping 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: used-challenge hashes for replay protection (15 min TTL), hashed OIDC nonces (1 year 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.

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.

Expand All @@ -40,10 +40,10 @@ 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 |
| OIDC account | OIDC token signature + claim verification ([nonce = SHA256(ephemeral session pubkey)](https://docs.turnkey.com/authentication/social-logins#nonce-restrictions-in-oidc-tokens)) | 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).
The challenge token is bound to the operation. A `Sync` challenge cannot be replayed against `/v1/delete-factor` because the embedded `ChallengeContext` differs. Challenges are single-use (Redis enforces).
Comment thread
paolodamico marked this conversation as resolved.
Resolved

## What the service rejects

Expand All @@ -52,7 +52,7 @@ A handful of named errors are surfaced in client code paths and worth knowing by
- `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.
- `backup_account_id_already_exists` — `/v1/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.

Expand Down Expand Up @@ -85,7 +85,7 @@ Each World App user is a Turnkey *sub-organization*. Throughout the docs and the
"condition": "true"
}
```
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:
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-v1-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
{
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 @@ -50,7 +50,7 @@ The client uses Bedrock's `Turnkey` helper (P-256 ECDSA stamping over Turnkey AP

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.
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. It also serves as the fallback on iOS versions below 18.0, where the [WebAuthn PRF extension](#passkey-prf) the passkey factor depends on is unavailable.

# Sync Factor

Expand All @@ -59,7 +59,7 @@ The Sync Factor is a P-256 ECDSA keypair generated client-side at backup creatio
- **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 <BackupMetadata />. Each Sync Factor authorizes exactly one device; recovery onto a new device produces a new Sync Factor and registers it via `/add-sync-factor`.
When the client signs a Backup Service challenge with the Sync Factor, the server verifies the P-256 ECDSA signature (DER-encoded) against the public key in the <BackupMetadata />. Each Sync Factor authorizes exactly one device; recovery onto a new device produces a new Sync Factor and registers it via `/v1/add-sync-factor`.

For OIDC users, the Sync Factor is *also* registered with Turnkey as a sub-organization user (`sync_factor_user`) with a policy permitting deletion-only actions. This is what makes background factor cleanup (e.g., removing an OIDC provider after the user removes the factor in Settings) possible without an additional passkey tap.

Expand All @@ -71,7 +71,7 @@ Independent of the factor system, every backup has a <BackupAccountKey />, a **s
backup_account_<33-byte_compressed_pubkey_hex>
```

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).
The Backup Account Key is **not** a factor — it cannot decrypt the backup. Its sole purpose is the disaster-recovery `/v1/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 /v1/reset](/world-app/backup/advanced#disaster-recovery-via-v1-reset).

# Platform Differences

Expand Down
Loading
Loading