# Device Approval Flow for End-to-End Encryption in Thunderbolt: Complete Technical Guide

> Discover the device approval flow for end-to-end encryption in Thunderbolt. Learn how trusted devices access the Content Key using ECDH-ML-KEM and canary proof.

- Repository: [Thunderbird/thunderbolt](https://github.com/thunderbird/thunderbolt)
- Tags: how-to-guide
- Published: 2026-04-19

---

**The device approval flow for end-to-end encryption in Thunderbolt ensures only explicitly trusted devices can access the Content Key (CK) through a hybrid ECDH-ML-KEM wrapping mechanism with canary-based proof of possession.**

Thunderbolt implements a zero-knowledge architecture where a single **Content Key (CK)** encrypts all user data, yet this key never leaves trusted devices. The device approval flow governs how new devices gain access to this critical key while preventing unauthorized access through cryptographic proof mechanisms.

## Overview of the Encryption Architecture

### Content Key and Device Envelopes

Thunderbolt centers its encryption around one **AES-256-GCM Content Key (CK)** shared across all trusted devices. Each device maintains a unique **device envelope** that wraps this CK using the device's public keys.

According to the source code in [`src/crypto/primitives.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/crypto/primitives.ts), devices generate:
- An **ECDH-P-256 key pair** for elliptic-curve Diffie-Hellman operations
- An **ML-KEM-768 key pair** for post-quantum key encapsulation

The private keys never leave the device. The public keys transmit to the server during registration and encrypt the CK via hybrid wrapping (ECDH + ML-KEM) implemented in [`src/services/encryption.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/services/encryption.ts).

### Device Status States

The approval flow manages three distinct device states defined in [`backend/src/db/powersync-schema.ts`](https://github.com/thunderbird/thunderbolt/blob/main/backend/src/db/powersync-schema.ts):

- **`APPROVAL_PENDING`**: Device registered but awaiting approval from a trusted device
- **`TRUSTED`**: Device approved and possesses the wrapped CK envelope
- **`REVOKED`**: Access terminated, envelope removed, device cannot sync

## Step-by-Step Device Approval Flow

### First Device Registration and Setup

When a user enables end-to-end encryption on their first device, the flow initializes the CK and establishes the initial trust chain:

1. **Device Registration**: The `registerThisDevice` function in [`src/services/encryption.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/services/encryption.ts) sends the device's public keys to `POST /devices`. The backend creates a device row with `status = TRUSTED` since this is the inaugural device.

2. **CK Generation**: `completeFirstDeviceSetup` generates an *extractable* CK temporarily to create a recovery key mnemonic. This recovery key represents the only way to restore access if all devices are lost.

3. **Canary Creation**: The system encrypts a known plaintext (canary) with the CK. This canary serves as proof of CK possession in subsequent approval operations.

4. **Enveloping**: The `wrapCK` function hybrid-encrypts the CK using the device's own ECDH and ML-KEM public keys. The resulting envelope and canary upload to `POST /devices/:deviceId/envelope`.

5. **Local Storage**: The CK converts to non-extractable form and stores in IndexedDB via [`src/crypto/key-storage.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/crypto/key-storage.ts).

```typescript
// First device onboarding (simplified)
import { getHttpClient } from '@/contexts';
import { registerThisDevice, completeFirstDeviceSetup } from '@/services/encryption';

async function onboardFirstDevice() {
  const http = getHttpClient();
  await registerThisDevice(http);  // Creates TRUSTED row
  const recoveryKey = await completeFirstDeviceSetup(http);
  // Display recoveryKey to user exactly once
}

```

### Adding a New Device (Approval Pending)

When a user attempts to add a second device, the approval flow ensures no device can self-authorize:

1. **New Device Registration**: The new device executes `registerThisDevice`, sending its public keys to `POST /devices`. The backend detects existing devices and sets `status = APPROVAL_PENDING`.

2. **Polling for Envelope**: The new device repeatedly calls `GET /devices/me/envelope`. Initially, this returns 404, and the UI displays "Waiting for approval."

3. **Trusted Device Notification**: The PowerSync synchronization pushes the new pending device to all trusted devices, displaying an approval prompt to the user.

### Trusted Device Approval Process

The critical security step occurs when a trusted device authorizes the pending device:

1. **Fetch Own Envelope**: The trusted device retrieves its wrapped CK via `GET /devices/me/envelope`.

2. **Extract Canary**: Using `extractCanarySecret` in [`src/services/encryption.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/services/encryption.ts), the device decrypts the canary stored on the server. This proves possession of the CK without transmitting the key itself.

3. **Re-wrap CK**: The `rewrapCK` function (implemented in [`src/crypto/primitives.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/crypto/primitives.ts)) hybrid-encrypts the CK using the pending device's ECDH-P-256 and ML-KEM-768 public keys.

4. **Submit Envelope**: The trusted device posts the new envelope to `POST /devices/:deviceId/envelope`, including the canary secret as proof of authorization.

5. **Status Update**: The backend verifies the canary secret, stores the envelope, and updates the device status to `TRUSTED`.

```typescript
// Approving a pending device from a trusted device
import { approveDevice } from '@/services/encryption';

async function approvePendingDevice(pendingId, ecdhPubB64, mlkemPubB64) {
  const http = getHttpClient();
  // This handles canary extraction and re-wrapping internally
  await approveDevice(http, pendingId, ecdhPubB64, mlkemPubB64);
}

```

### Device Denial and Revocation

If a user rejects a pending device or revokes an existing one:

- **Denial**: The trusted device calls `denyDeviceWithProof` (lines 86-93 in [`src/services/encryption.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/services/encryption.ts)), submitting the canary secret to prove authority. The server marks the device `REVOKED`.

- **Revocation**: When a device is revoked (either manually or via account deletion), the backend clears the envelope and sets `revoked_at`. Clients detect this via PowerSync synchronization and execute a global reset: `setSyncEnabled(false)`, `localStorage.clear()`, and removal of all local keys.

*Source*: [`docs/powersync-account-devices.md`](https://github.com/thunderbird/thunderbolt/blob/main/docs/powersync-account-devices.md) – *"Revoking a device"* section.

## Security Guarantees and Canary Proof

The device approval flow implements several zero-knowledge security properties:

**Zero-Knowledge Architecture**: Private keys (ECDH and ML-KEM) and the Content Key never leave the device. The server only stores wrapped envelopes and public keys.

**Canary-Based Proof of Possession**: Every approval or denial request must present the `canary secret`—a decryption of known plaintext using the CK. This prevents spoofing attacks where a malicious actor might know a device ID but not possess the actual key.

**Hybrid Post-Quantum Security**: The use of ECDH-P-256 combined with ML-KEM-768 ensures protection against both classical and quantum computing attacks when wrapping the Content Key.

## Implementation Examples

The following TypeScript functions demonstrate the complete device approval flow for end-to-end encryption in Thunderbolt:

```typescript
import { getHttpClient } from '@/contexts';
import {
  registerThisDevice,
  completeFirstDeviceSetup,
  approveDevice,
  denyDeviceWithProof,
} from '@/services/encryption';

// 1️⃣ First device onboarding
async function onboardFirstDevice() {
  const http = getHttpClient();
  await registerThisDevice(http);                     // creates TRUSTED row
  const recoveryKey = await completeFirstDeviceSetup(http);
  // recoveryKey is shown to the user once
}

// 2️⃣ Adding a new device (on the new device side)
async function waitForApproval() {
  const http = getHttpClient();
  await registerThisDevice(http);                     // creates APPROVAL_PENDING row
  // UI polls `GET /devices/me/envelope` until envelope appears
}

// 3️⃣ Approving a pending device (on an already-trusted device)
async function approvePending(pendingId, ecdhPubB64, mlkemPubB64) {
  const http = getHttpClient();
  await approveDevice(http, pendingId, ecdhPubB64, mlkemPubB64);
}

// 4️⃣ Denying a pending device
async function denyPending(pendingId) {
  const http = getHttpClient();
  await denyDeviceWithProof(http, pendingId);
}

```

## Summary

- **Single Content Key**: Thunderbolt uses one AES-256-GCM Content Key (CK) for all data, wrapped per-device using hybrid ECDH-P-256 and ML-KEM-768 encryption.
- **Explicit Trust Required**: New devices register with `APPROVAL_PENDING` status and cannot access the CK until a trusted device explicitly approves them via `approveDevice`.
- **Cryptographic Proof**: The approval flow requires presenting a canary secret (decrypted known plaintext) to prove possession of the CK, preventing spoofing attacks.
- **Zero-Knowledge Design**: Private keys and the CK never leave the device; the server only stores wrapped envelopes and public key material.
- **Revocation Support**: Devices can be denied or revoked via `denyDeviceWithProof`, triggering immediate removal of the envelope and local data cleanup.

## Frequently Asked Questions

### What cryptographic standards does Thunderbolt use for device approval?

Thunderbolt implements a hybrid post-quantum approach using **ECDH-P-256** for elliptic-curve Diffie-Hellman key exchange and **ML-KEM-768** (Kyber) for quantum-resistant key encapsulation. The Content Key uses **AES-256-GCM** for symmetric encryption. These implementations are located in [`src/crypto/primitives.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/crypto/primitives.ts) and [`src/services/encryption.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/services/encryption.ts).

### How does the canary secret prevent spoofing attacks?

The **canary secret** is a decryption of known plaintext encrypted with the Content Key, stored on the server as a "canary." When approving or denying a device, the trusted device must present this secret to prove it actually possesses the CK and is not merely spoofing a device ID. This prevents attackers who might intercept device IDs from approving unauthorized devices without the actual encryption key.

### What happens when a device is revoked in Thunderbolt?

When a device is revoked (either manually by the user or via account deletion), the backend clears the device's envelope from `POST /devices/:deviceId/envelope`, sets the `revoked_at` timestamp, and updates the status to `REVOKED`. The client detects this change via PowerSync synchronization and executes a global reset: disabling sync via `setSyncEnabled(false)`, clearing `localStorage`, and removing all local cryptographic keys. This ensures revoked devices immediately lose access to encrypted data.

### Can the server access the Content Key during the approval flow?

No. The server operates on a **zero-knowledge** principle and never possesses the unwrapped Content Key. During the device approval flow, the trusted device downloads its own wrapped envelope, unwraps the CK locally using its private keys, then re-wraps the CK using the pending device's public keys before uploading the new envelope. The server only facilitates storage of these wrapped envelopes and validation of the canary secret—it cannot decrypt the data itself.