Date: 2026-10-03 · Status: Draft · Scope: Desktop (Electron) + Mobile (React Native) + Backend (Express/WebSocket)
1. Executive Summary
Vibe Sensei's current auth tokens (15-min access JWTs, 30-day opaque refresh tokens) are portable — a stolen token works from any machine. OS-level encryption (safeStorage/Keychain) gives strong at-rest protection on desktop, but does not stop a token stolen from memory or via a privileged malware process from being replayed elsewhere.
This document gives the threat model, evaluates four binding strategies, and recommends a two-phase approach preceded by an immediate revocation fix:
- Phase 1 (low effort): embed a
did(device-fingerprint) claim in access tokens and validate it server-side. Eliminates the cross-device replay class with minimal churn. - Phase 2 (medium effort): RFC 9449 DPoP asymmetric proof-of-possession for desktop, backed by keys in
safeStorage. Cryptographically binds every token to the issuing device's private key.
2. Current State (verified in-repo)
| Token | File | TTL | Storage | Claims |
|---|---|---|---|---|
| Access JWT | back-end/src/auth/tokens.ts:13 | 15 min | Memory only (desktop) | sub, email |
| Refresh token | back-end/src/auth/tokens.ts:26 | 30 days | DB (Session table) | opaque randomBytes(32) |
| Mirror connect JWT | back-end/src/realtime/mirrorConnectToken.ts:13 | 60 s | Never persisted | accountId, workspaceId, role, deviceId, jti |
Desktop persistence (src/main/backend/tokenStorage.ts:59): access + refresh encrypted with safeStorage (Keychain/DPAPI/libsecret) before write to userData/backend-auth.json (0o600). Fail-closed: if safeStorage is unavailable, tokens are not persisted — in-memory only, re-login on next boot. No plaintext fallback.
What is NOT device-bound today: access JWTs carry {sub, email} only; refresh tokens are opaque random bytes; a stolen access+refresh pair works from any machine; the Device registry is a server record, not cryptographically linked to the token.
3. Threat Model
Attacker profiles A1–A6: disk thief, Keychain-capable malware, memory inspector, server-side DB breach, network MITM, rogue insider.
| Threat | Likelihood | Impact | Current | After P1 | After P2 |
|---|---|---|---|---|---|
| T1 Disk theft | Low | High | Medium | Low | Low |
| T2 Keychain malware | Low | Critical | High | Medium | Low |
| T3 Memory inspection | Medium | High | High | Medium | Low |
| T4 DB breach refresh | Low | Critical | High | Medium | Low |
| T5 Mirror token intercept | Very Low | Medium | Low | Low | Low |
| T6 Refresh token replay | Low | Critical | High | Medium | Low |
| T7 Mobile token storage | Medium | High | Unknown | Medium | Low |
4. Immediate Finding — Revocation Does Not Invalidate Sessions
POST /v1/devices/:deviceId/revoke (back-end/src/routes/devices.routes.ts:351) sets Device.revokedAt and closes the live WebSocket (close code 4408), but does not delete the Session rows for that device. Consequence: a revoked device's refresh token stays valid for up to 30 days.
Fix (backend-only, <1 day):
await prisma.session.deleteMany({ where: { deviceId } })
Should ship as its own standalone PR, independent of Phase 1/2.
5. Binding Strategy Evaluation
| Strategy | Proof-of-possession | Platform reach | Effort | Verdict |
|---|---|---|---|---|
| A — DPoP (RFC 9449) | ✅ asymmetric | All desktop (+ mobile) | High | Recommended |
| B — Device claim | ❌ none | All | Low | Phase 1 stopgap |
| C — Platform keys (Secure Enclave/TPM) | ✅ hardware | Fragmented | Very high | Phase 3 layer |
| D — Session-pinned HMAC | ⚠️ symmetric | All | Medium | Rejected |
Option A (DPoP): EC P-256 keypair per device at install, private key in safeStorage; every request carries a signed DPoP proof (htm, htu, iat, jti, jwk); server binds the access token to the key thumbprint (cnf.jkt). A stolen token is useless without the device private key. Fully mitigates T6/T4; substantially reduces T2/T3. Needs a nonce/replay store (RFC 9449 §8) and graceful degradation for pre-DPoP clients.
6. Recommendation & Roadmap
- Phase 0 — Revocation closure (<1 day, backend only, IMMEDIATE): delete
Sessionrows on device revoke (§4). - Phase 1 — Device-claim binding (low effort): embed a
didclaim; validate on/auth/refreshand authed requests; reject refresh from a mismatched device. Stopgap, not proof-of-possession. - Phase 2 — Desktop DPoP (~3–4 weeks): register-device-key endpoint,
requireDPoP()middleware,cnf.jktclaim,authed<T>()proof wrapper,Device.dpopPublicKeyJwk/dpopKeyRegisteredAtschema, feature-flagged degradation. - Phase 3 — Mobile + hardware keys (optional, ~4–6 weeks): mobile DPoP; Apple Silicon Secure Enclave; Android API 28+ Keystore.
7. Open Questions
- Redis vs DB-backed store for DPoP
jtireplay prevention? - Key rotation on re-install — invalidate old-key sessions immediately or let them expire?
- Mobile enforcement timeline — defer to Phase 3 with a firm deadline?
- Backward-compat gate — feature-flag strategy for
requireDPoP()enforcement.
8. Feasibility Summary
| Phase | Effort | Risk | Impact |
|---|---|---|---|
| 0 — Revocation closure | 0.5 day | Very low | Closes post-revocation refresh window |
| 1 — Device-claim binding | Low | Low | Eliminates cross-device replay |
| 2 — Desktop DPoP | 3–4 weeks | Medium | Closes T6; reduces T2, T3, T4 |
| 3 — Mobile + HW keys | 4–6 weeks | High | Eliminates disk/memory theft on supported HW |
Phase 0 should ship immediately as a standalone backend PR, independent of the binding work.