# Social Key Recovery (`@shade/recovery`)
V3.10 closes the biggest UX hole in any E2EE system: **"What happens
if I lose my phone?"**. Shade's social-recovery flow lets a user
designate `n` guardians (family / friends / co-workers) at setup time
such that any threshold-many `k` of them can together restore the
user's identity onto a new device — without any single guardian
being able to do it alone, and without the prekey server ever seeing
the recovered key material.
The whole flow ships entirely over existing 1:1 Shade sessions; no
server-side recovery agent, no escrow service, no "cloud guardian".
---
## Threat model recap
| # | Adversary | Recovered? |
|---|-----------|------------|
| 1 | Coalition of ≤ k-1 guardians | **No** (information-theoretic, by Shamir construction) |
| 2 | Prekey server alone | **No** (server only relays Double-Ratchet ciphertext) |
| 3 | Single malicious guardian who forges a share | **Detected** — AES-GCM tag mismatch on the backup blob; `requestRecovery` exhaustively tries threshold-sized subsets and rejects when none authenticate |
| 4 | Social engineering (impersonator calls a guardian) | **Mitigated, not eliminated** — guardians MUST OOB-confirm the new device's safety number before approving (see ``) |
| 5 | Compromised guardian device | **Out of scope** — see "Guardian compromise" below |
| 6 | Compromised primary device at setup time | **Out of scope** — recovery only protects the device; if setup material is exfiltrated, all bets are off |
---
## Setup
### What the user does
1. Pick `n` guardians from their existing peers.
2. Pick a threshold `k` (typically `⌈n/2⌉ + 1` to avoid pure-majority
dominance but still survive losing one or two).
3. Run `setupRecovery(...)`.
4. Print / record a **recovery card** with:
- The user's own address
- `setupId`
- `k` and `n`
- The list of guardian addresses
- Setup-time safety number
The recovery card is the only piece of state the user must remember
out-of-band (or store in a password manager). Without it, the user
cannot drive recovery on a new device — the new device needs to know
who the guardians are.
### What happens cryptographically
```text
recoveryKey = random(32 bytes)
backupBlob = Shade.exportBackup(passphrase = "shade-rk:" + base64url(recoveryKey),
knownAddresses = [...])
shares[i] = Shamir-split(recoveryKey, k, n)
```
For each guardian `i`:
```text
share-deposit envelope:
shadeRecovery: 1
type: "share-deposit"
flowId, setupId, originalAddress
threshold (k), guardianCount (n), shareIndex (i)
shareBytes: base64url( encodeShare(shares[i]) )
backupBlob: Shade.exportBackup output (identical for every guardian)
setupFingerprint, createdAt
```
The envelope rides through `Shade.send` like any other plaintext —
double-ratchet encrypted, AAD-bound, replay-safe.
The `recoveryKey` is **zeroized** on the primary device immediately
after the split returns. The primary therefore retains nothing
except `setupId` and the public roster.
### What each guardian stores
Per (`originalAddress`, `setupId`):
```text
{
shareIndex, // 1..n
shareBytes, // base64url-encoded Shamir share
backupBlob, // identical for every guardian
setupFingerprint, // for sanity-checks at recovery time
guardianCount, threshold,
receivedAt
}
```
The guardian's app provides a `RecoveryStore` implementation. The
package ships `MemoryRecoveryStore` for tests and small one-shot
demos; production guardian apps MUST supply a persistent store
(IndexedDB, AsyncStorage, SQLite, etc.). See "Persistence
recommendations" below.
---
## Recovery
### What the user does on the new device
1. Boot a fresh Shade with a temporary identity.
2. Read the recovery card.
3. In the recovery widget, type / paste:
- `originalAddress`
- `setupId`
- `threshold`
- The guardian roster
4. Read the new device's safety number (the widget displays it
prominently) to each guardian over a side channel — phone call,
in person, whatever they trust.
5. Wait for `≥ k` guardians to approve.
### What happens cryptographically
For each guardian, the new device sends:
```text
recovery-request envelope:
shadeRecovery: 1
type: "recovery-request"
flowId, originalAddress, setupId
requesterFingerprint (= safety number of the temporary identity)
requestedAt
```
Each guardian's `attachGuardian` handler:
1. Looks up its stored deposit by `(originalAddress, setupId)`. If
missing, replies with `share-decline` (`reason = "unknown setup"`).
2. Invokes the `approve` callback with the requester's address +
fingerprint + the original device's setup-time fingerprint. The
callback is the **OOB-confirmation gate** — it MUST require an
explicit user click after they verified the fingerprint. The
`` widget enforces this with a two-checkbox
gate.
3. On approve → ships `share-grant`. On reject → ships
`share-decline` with a short reason.
The new device collects grants, and as soon as `k` arrive:
1. Combines the `k` shares via Lagrange interpolation at `x = 0` to
reconstruct `recoveryKey`.
2. Derives `passphrase = "shade-rk:" + base64url(recoveryKey)`.
3. Calls `Shade.importBackup(backupBlob, passphrase)` — the
AES-GCM tag in the blob authenticates the reconstruction. **A
forged share is detected here.**
4. If a guardian forged a share, `importBackup` throws. The
reconstruction loop then tries every other threshold-sized subset
of grants until one authenticates (the V3.10 acceptance criterion
"no coalition of (k-1) guardians can rebuild the secret" is the
safety invariant; the AEAD authenticates which subset is
honest).
5. If every subset fails, `RecoveryReconstructionError` is raised
and the user is told that at least one guardian is malicious.
After `importBackup` succeeds, the new device hosts the original
identity and immediately calls `Shade.rotate()` to retire the
recovery-recovered key material from the conversation graph (the
old session keys persisted in the backup blob are now considered
"compromised — used for recovery").
> **The `Shade.beforeBackupImport` gate fires automatically.**
> Without a registered handler the SDK falls back to TOFU-with-warning
> (consistent with the V3.3 contract). Production apps SHOULD register
> a handler that pops the user one more confirmation before the
> identity rotates.
---
## Acceptance criteria status
- [x] **3-of-5 recovery works end-to-end on two separate Shade
instances.** See `tests/integration.test.ts`.
- [x] **No coalition of (k-1) guardians can reconstruct
`recoveryKey`.** Property test asserts this with `fast-check`
across random k/n configurations.
See `tests/shamir.test.ts` and
`tests/adversarial.test.ts`.
- [x] **Guardian-side widget requires fingerprint-confirmation
before sending.** `` enforces a
two-checkbox gate; `tests/adversarial.test.ts` exercises
both the matching-OOB and rejecting-OOB code paths.
---
## Persistence recommendations
The `RecoveryStore` interface is intentionally small (4 methods).
Pick the implementation that fits your platform:
| Platform | Suggested backing store |
|--------------------------|----------------------------------------|
| Browser (PWA) | IndexedDB (one object store, idb) |
| Browser (extension) | `chrome.storage.local` |
| React Native | AsyncStorage (with crypto-protected blob) |
| Bun / Node server | SQLite via `@shade/storage-sqlite` extension table OR a side file |
| Android (native) | Room / EncryptedSharedPreferences |
Whatever you pick, the records ARE NOT secret on their own — without
threshold-many other guardians' shares they're useless — but they
should still be stored encrypted-at-rest like any other Shade state.
Do not commit them to plaintext logs or network-replicated state.
---
## Guardian-UX guide
### How many guardians?
| n | Survives | Comment |
|---|----------|---------|
| 3, k=2 | 1 lost guardian | Minimum useful — one device away from danger |
| 5, k=3 | 2 lost guardians | Sweet spot for most users |
| 7, k=4 | 3 lost guardians | Suitable when you genuinely have 7+ trustworthy people |
| n=k | 0 lost | DO NOT USE — single point of failure |
The widget defaults to `k = ⌈n/2⌉` which is liberal but
collusion-resistant for `n ≥ 3`. Apps targeting paranoid users may
want to bump that to `⌈2n/3⌉`.
### Replacing a guardian
If a guardian dies, loses their device permanently, or you no longer
trust them:
1. Pick a replacement.
2. Run `setupRecovery` again with the new roster — this generates a
fresh `setupId` and a fresh `recoveryKey`. The old shares become
garbage (no guardian set can use them, because the
`backupBlob` is different).
The widget records the new `setupId` on the recovery card. Treat
this as a hard rotation; the user MUST re-record the card.
### Guardian health checks
Periodically (the V3.10 plan suggests a quarterly prompt), the user
should confirm each guardian is still reachable. Any guardian who
can't be reached in two consecutive prompts SHOULD trigger a
re-setup with a fresh roster. The widget UX track is to be added in
a follow-up release; the primitive is in place.
---
## Wiring example
```ts
import {
setupRecovery,
attachGuardian,
requestRecovery,
MemoryRecoveryStore,
} from '@shade/recovery';
// On the primary device:
const result = await setupRecovery({
shade,
guardians: ['bob', 'carol', 'dan', 'eve', 'faythe'],
threshold: 3,
deliver: async (to, envelope) => {
// wire to your app's existing message-delivery layer
await myMessageOutbox.send(to, envelope);
},
});
console.log(result.setupId);
// On each guardian device:
const stop = attachGuardian({
shade,
store: myPersistentStore, // see "Persistence" above
approve: async (ctx) => {
// Show ctx.requesterFingerprint to the user.
// Block until they confirm OOB and click "Release share".
return await myUI.askApproval(ctx);
},
deliver: myMessageOutbox.send,
});
// On the new device:
const recovered = await requestRecovery({
shade: temporaryShade, // fresh identity for now
originalAddress: 'alice',
setupId: 'sid-from-recovery-card',
threshold: 3,
guardians: ['bob', 'carol', 'dan', 'eve', 'faythe'],
deliver: myMessageOutbox.send,
onProgress: (p) => myUI.showProgress(p),
});
// `temporaryShade` now hosts the original identity.
```
---
## Out of scope (V3.10)
- **Cloud guardian / Shade-operated recovery agent.** Explicit
non-goal; the spec rejects any centralized component that can
recover on its own.
- **Auto-distribution.** The user must explicitly pick guardians.
- **Multi-share-per-guardian.** Each guardian holds exactly one
share. Apps that need redundancy should bump `n`, not give the
same guardian multiple shares.
- **Guardian ZK-proofs of liveness.** A guardian who refuses to
respond is treated as offline; we don't try to compel them.