Skip to content

THU-430: Missing canary proof-of-CK-possession on device revocation enables E2EE state reset#632

Open
raivieiraadriano92 wants to merge 3 commits intomainfrom
raivieiraadriano92/thu-430-missing-canary-proof-of-ck-possession-on-device-revocation
Open

THU-430: Missing canary proof-of-CK-possession on device revocation enables E2EE state reset#632
raivieiraadriano92 wants to merge 3 commits intomainfrom
raivieiraadriano92/thu-430-missing-canary-proof-of-ck-possession-on-device-revocation

Conversation

@raivieiraadriano92
Copy link
Copy Markdown
Collaborator

@raivieiraadriano92 raivieiraadriano92 commented Apr 17, 2026

Summary

  • Security fix: Device revocation (POST /account/devices/:id/revoke) now requires canary proof-of-CK-possession when E2EE is active, closing an attack vector where a stolen session could reset all encryption state
  • Defense-in-depth: First-device bootstrap path now verifies canary against existing metadata, preventing E2EE state reset even if revocation protections are bypassed
  • PowerSync hardening: Blocked DELETE operations on the devices table via PowerSync upload — devices can only be revoked through the dedicated API endpoint

Root cause

The revoke endpoint deleted envelopes without requiring canary proof, unlike approve/deny which both require it. An attacker with a stolen session could revoke all devices (deleting all envelopes), then re-bootstrap as first device with attacker-controlled keys — fully resetting E2EE state without ever possessing the Content Key.

Changes

Backend

  • Extracted hashCanarySecret, verifyCanaryProof, verifyCanaryProofWithMetadata into shared backend/src/lib/canary.ts
  • Revoke endpoint now requires X-Device-ID header + canarySecret body (when encryption metadata exists)
  • Added caller trusted-device check (defense-in-depth, consistent with deny endpoint)
  • Pre-encryption users can still revoke without canary (backward compatible)
  • Added uploadDenyDelete set in PowerSync upload handler blocking device hard-deletes
  • Added canary verification in first-device bootstrap when metadata already exists

Frontend

  • Added revokeDevice API function (src/api/encryption.ts)
  • Added revokeDeviceWithProof service function (mirrors denyDeviceWithProof pattern)
  • Rewired useRevokeDevice hook to extract canary proof before calling API

Test plan

  • 650 backend tests pass (0 failures)
  • TypeScript compiles cleanly on both backend and frontend
  • New test cases cover: missing X-Device-ID (400), missing canary (403), invalid canary (403), untrusted caller (403), pre-encryption backward compatibility (204), revoke with valid proof (204), idempotent re-revoke, cross-user isolation
  • Manual: revoke a device from Settings > Devices — should work seamlessly with canary proof sent automatically
  • Manual: verify recovery flow still works after all devices revoked (recovery key holder can re-bootstrap)

🤖 Generated with Claude Code


Note

High Risk
Changes enforcement on device revocation and first-device bootstrap in E2EE flows; mistakes could either reintroduce an account-takeover vector or block legitimate device recovery/revocation.

Overview
Closes an E2EE state-reset attack by tightening POST /account/devices/:id/revoke: the endpoint now requires X-Device-ID and, when encryption metadata exists, a valid canarySecret plus a trusted-caller device check before deleting envelopes/revoking devices.

Adds defense-in-depth in the encryption bootstrap path to block “first device” re-bootstrap when encryption metadata already exists unless the submitted canary secret matches existing metadata, and centralizes canary hashing/verification into new shared backend/src/lib/canary.ts.

Hardens PowerSync uploads by denying DELETE operations on the devices table (forcing use of the revoke API), and updates frontend revoke flows to automatically include canary proof via new revokeDevice API + revokeDeviceWithProof service and rewired useRevokeDevice hook; tests are expanded to cover the new security behaviors and backwards-compat pre-E2EE revocation.

Reviewed by Cursor Bugbot for commit 08e9ee2. Bugbot is set up for automated code reviews on this repo. Configure here.

…tion

- extract canary hashing/verification into shared backend/src/lib/canary.ts
- revoke endpoint now requires X-Device-ID header and canarySecret body when E2EE is active
- verify caller is a trusted device before allowing revocation (defense-in-depth)
- block PowerSync upload DELETE for devices table to force use of dedicated API
- add re-bootstrap canary check in first-device-bootstrap flow
- add frontend revokeDevice API helper and wire up use-revoke-device hook
- expand test coverage: canary proof, pre-encryption path, missing header, untrusted device
@raivieiraadriano92 raivieiraadriano92 self-assigned this Apr 17, 2026
@raivieiraadriano92 raivieiraadriano92 changed the title feat(THU-430): require canary proof-of-CK-possession on device revoca… THU-430: Missing canary proof-of-CK-possession on device revocation enables E2EE state reset Apr 17, 2026
@github-actions
Copy link
Copy Markdown

Semgrep Security Scan

No security issues found.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 17, 2026

PR Metrics

Metric Value
Lines changed (prod code) +139 / -37
JS bundle size (gzipped) 🟢 1.02 MB → 1.02 MB (-5.1 KB, -0.5%)
Test coverage 🟢 70.57% → 70.53% (+-0.0%)
Load time (preview) Preview not ready — Render deploy may have timed out

Updated Fri, 17 Apr 2026 19:45:21 GMT · run #976

@raivieiraadriano92 raivieiraadriano92 marked this pull request as ready for review April 17, 2026 19:17
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@claude

This comment was marked as outdated.

Comment thread src/services/encryption.ts Outdated
raivieiraadriano92 and others added 2 commits April 17, 2026 16:33
- Full attack chain test: stolen session cannot revoke devices to reset E2EE state
- Defense-in-depth: re-bootstrap blocked with wrong canary when metadata exists
- Defense-in-depth: re-bootstrap allowed with correct canary (recovery flow)
- PowerSync upload DELETE on devices table blocked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

- gracefully handle missing canary when E2EE is not set up
- allow revokeDevice API to omit canarySecret for pre-E2EE users
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 08e9ee2. Configure here.

canarySecret = await extractCanarySecret(httpClient)
} catch {
// E2EE not set up (fetchCanary 404) or CK unavailable — proceed without proof
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch-all swallows real errors for E2EE users

Medium Severity

The bare catch in revokeDeviceWithProof swallows every error from extractCanarySecret, not just the expected fetchCanary 404 that indicates a pre-E2EE user. For E2EE users hitting transient failures (network timeouts, server 500s, crypto API errors, corrupted CK), the real error is silently discarded and the revoke proceeds without proof — the backend then returns a confusing 403 "Canary secret required" instead of surfacing the actual problem. Compare with denyDeviceWithProof, which correctly lets errors propagate. The catch here needs to distinguish the pre-E2EE case (e.g., HttpError with status 404) from unexpected failures and rethrow the latter.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 08e9ee2. Configure here.

@claude
Copy link
Copy Markdown

claude bot commented Apr 17, 2026

Review

Security

revokeDeviceWithProof swallows all errors, not just 404src/services/encryption.ts:205

The bare catch {} catches every failure from extractCanarySecret — network errors, IndexedDB failures, CK corruption — not only the intended "E2EE not set up" (404) case. For a user with E2EE active, any transient error silently drops canary proof and sends the revoke request without it. The backend will correctly 403, but the user sees "Invalid canary secret" instead of the actual error. Compare with denyDeviceWithProof (line 192) which propagates errors correctly. Fix: catch only HttpError with status 404.

Breaking Change

X-Device-ID header now required on revoke endpointbackend/src/api/account.ts:27

Previously optional; now returns 400 if absent. Existing deployed clients (mobile app versions in the field) that call POST /v1/account/devices/:id/revoke without this header will start receiving 400 errors. The updated frontend always sends it via authHeaders(), but any client on an older version will break silently.

Convention

let variable assigned inside try violates CLAUDE.mdsrc/services/encryption.ts:202

"Prefer const over let — create helper functions with early return instead of setting let variables inside conditionals"

let canarySecret is set inside try then read after. Extract a helper or use .catch(() => undefined) to eliminate the let.

Comment on lines +202 to +208
let canarySecret: string | undefined
try {
canarySecret = await extractCanarySecret(httpClient)
} catch {
// E2EE not set up (fetchCanary 404) or CK unavailable — proceed without proof
}
await revokeDeviceApi(httpClient, deviceId, canarySecret)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here: (1) the bare catch {} swallows everything — a CK cache miss or network error is indistinguishable from "E2EE not set up", so the backend ends up returning a confusing 403 instead of the real error; (2) let + assignment inside try violates CLAUDE.md's const-over-let rule.

Fix both at once — catch only 404, and eliminate the let:

Suggested change
let canarySecret: string | undefined
try {
canarySecret = await extractCanarySecret(httpClient)
} catch {
// E2EE not set up (fetchCanary 404) or CK unavailable — proceed without proof
}
await revokeDeviceApi(httpClient, deviceId, canarySecret)
const canarySecret = await extractCanarySecret(httpClient).catch((err: unknown) => {
if (err instanceof Error && 'status' in err && (err as { status: number }).status === 404) return undefined
throw err
})

Comment on lines +26 to +30
const callerDeviceId = request.headers.get('x-device-id')?.trim()
if (!callerDeviceId) {
set.status = 400
return { error: 'X-Device-ID header is required' }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change for existing clients that call this endpoint without X-Device-ID. Old app versions in the field will start receiving 400 instead of 204. Worth coordinating a client-version gate or making the header optional when metadata is null (pre-E2EE users don't need the caller device check anyway).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant