# Cross-Device Sync — Auth Methodology

**Lane:** thv2-64 (background)
**Date:** 2026-04-28
**Status:** Endpoints + UI shipped (scaffold). NOT deployed. NOT activated until user pairs.
**Companion:** `docs/cross-device-sync-architecture-2026-04-28.md` (lane 31), `docs/sync-onboarding-spec-2026-04-28.md` (lane 31)

---

## 1. Why this exists

Lane 31 specced + scaffolded the `engine/cross-device-sync.js` merge core and the gated `/api/sync/get` + `/api/sync/put` endpoints. Both expect `Authorization: Bearer <session_token>`. Until lane 64, no path existed to **mint** that token — sync was inert.

This methodology doc documents the auth layer that makes pairing real:

- `POST /api/sync/auth/request-magic-link` — issues a 6-digit code via Resend
- `POST /api/sync/auth/verify-code` — verifies code, mints opaque session token
- `POST /api/sync/auth/logout` — revokes a session token

Plus the pairing UI (`/pages/sync-pair.html` + `pages/sync-pair-render.js`) and the client-side token plumbing helper (`engine/sync-client.js`).

---

## 2. Auth model — at a glance

```
  ┌────────────────┐     1. POST /request    ┌──────────────────────┐
  │ /sync-pair UI  │  ────────────────────►  │  /api/sync/auth/     │
  │  (browser)     │   { email }             │   request-magic-link │
  │                │                         │                      │
  │                │                         │ ┌─ Generates 6-digit │
  │                │                         │ │  code              │
  │                │                         │ ├─ Stores hashed     │
  │                │                         │ │  pending in KV     │
  │                │                         │ │  (10-min TTL)      │
  │                │                         │ └─ Sends via Resend  │
  │                │  ◄──────────────────    │   { sent: true }     │
  │                │                         └──────────────────────┘
  │                │
  │                │     2. Email arrives    ┌──────────────────────┐
  │                │  ◄────────────────────  │  Resend delivery     │
  │                │   "Code: 123456"        └──────────────────────┘
  │                │
  │                │     3. POST /verify     ┌──────────────────────┐
  │                │  ────────────────────►  │  /api/sync/auth/     │
  │                │   { email, code }       │   verify-code        │
  │                │                         │                      │
  │                │                         │ ┌─ Constant-time     │
  │                │                         │ │  hash compare      │
  │                │                         │ ├─ Mints 32-byte     │
  │                │                         │ │  opaque token      │
  │                │                         │ ├─ Stores in KV      │
  │                │                         │ │  (90-day TTL)      │
  │                │                         │ └─ Deletes pending   │
  │                │  ◄──────────────────    │   { session_token }  │
  │                │                         └──────────────────────┘
  │                │
  │ stores token   │     4. Bearer-attached  ┌──────────────────────┐
  │ in localStorage│  ────────────────────►  │  /api/sync/get       │
  │                │   Authorization:        │  /api/sync/put       │
  │                │   Bearer <token>        │                      │
  │                │                         │ Resolves token →     │
  │                │                         │ userId via KV.       │
  └────────────────┘                         └──────────────────────┘
```

---

## 3. Identity derivation

```
userId = SHA-256(normalize(email))
       = 64 hex chars; stable; no PII
```

Normalization: trim + lowercase. Same email on different devices → same userId → same sync surface. Email is **never** stored in KV; only the SHA-256 of it forms the key namespace.

The first 8 hex chars (`userId_hash_prefix`) are the only user-identifiable token in logs.

---

## 4. Code generation

- **Format:** 6 digits, zero-padded
- **Source:** `crypto.randomInt(0, 1_000_000)` — cryptographic PRNG
- **Storage:** SHA-256 of code, NOT the plaintext
- **TTL:** 10 minutes (Upstash `EX` 600)

Why hash the code: even if KV were exfiltrated, plaintext codes do not leak.

---

## 5. Verification flow

1. Client posts `{ email, code }`.
2. Server derives `userId`, looks up `thv2:auth-pending:<userId>`.
3. If absent or `Date.now() > expiresAt` → `410 expired_code`.
4. Compute `submittedHash = SHA-256(code)`.
5. **Constant-time** compare `submittedHash` to stored `codeHash` (Node `crypto.timingSafeEqual` on equal-length buffers; reject non-hex inputs to avoid Node's silent hex truncation).
6. On mismatch → `attempts++`. If `attempts >= 5` → delete pending, return `429 lockout`.
7. On match → mint 32-byte token (`crypto.randomBytes(32).toString('hex')` = 64 hex chars).
8. Store `thv2:session:<token> = userId` with **90-day TTL**.
9. Delete pending entry. Return `{ session_token, userId_hash_prefix, ttl_days: 90 }`.

---

## 6. Token TTL + revocation

| Surface | TTL | Notes |
|---|---|---|
| `thv2:auth-pending:<userId>` | 10 min | Hashed code + attempts counter |
| `thv2:auth-rate:<userId>` | 60 min | Per-email request counter (5/hour) |
| `thv2:session:<token>` | 90 days | Opaque session, refresh-on-use deferred to M5 |

Revocation paths:
- **User clicks "logout"** → `POST /api/sync/auth/logout` with Bearer header → `DEL thv2:session:<token>`. Idempotent.
- **Future "Sign out everywhere"** (M5) → server scans all `thv2:session:*` for `userId`, deletes them.
- **401 from sync endpoints** → client clears localStorage token and emits `thv2:sync:reauth` event, surfacing a re-pair banner (per `feedback_silent_failures.md` — never silent).

---

## 7. Rate limits

| Layer | Limit | Backend |
|---|---|---|
| Per-IP (request) | 10/min | `applyGate` (Upstash counter) |
| Per-IP (verify) | 10/min | `applyGate` |
| Per-IP (logout) | 30/min | `applyGate` |
| **Per-email (request)** | **5/hour** | `thv2:auth-rate:<userId>` window counter |
| Per-pending (verify) | **5 wrong attempts** → lockout | inline in pending blob |

The two-tier rate limit (per-IP + per-email) blocks both single-IP brute-force AND distributed enumeration.

---

## 8. Privacy / what gets logged

**Never logged:**
- Email plaintext
- 6-digit codes (plaintext or hashed)
- Session tokens
- `RESEND_API_KEY` / `KV_REST_API_TOKEN` / any secret value (per `feedback_doppler_value_free_probes.md`)

**Logged (audit-worthy):**
- `userId_hash_prefix` (8 chars)
- `op` (request | verify | logout)
- `code` (state code: paired | invalid_code | lockout | expired_code | rate_limited)
- `attempts` (verify path)

This matches the architecture spec §12.

---

## 9. Constant-time compare — implementation note

Node's `Buffer.from(s, 'hex')` silently truncates at the first non-hex character (e.g. `'zz'` → empty buffer). Two empty buffers `timingSafeEqual` to `true`, which would have allowed any garbage-vs-garbage to verify. The implementation defends with explicit `/^[0-9a-fA-F]+$/` validation before buffering. Captured as a unit test (`sync-auth.test.js` test 14).

---

## 10. UI surfaces

| Path | Purpose |
|---|---|
| `/sync-pair` (page) | Two-state form: email → code |
| `/api/sync/auth/request-magic-link` | Step 1 |
| `/api/sync/auth/verify-code` | Step 2 |
| `/api/sync/auth/logout` | Settings → "Sign out of this device" |
| `engine/sync-client.js` | Token plumbing for `/api/sync/{get,put}` |
| Elite-nav settings entry | Direct-link to `/sync-pair` (lane 30 update) |

UI complies with:
- **CSP-safe:** all logic in `pages/sync-pair-render.js` (no inline JS).
- **Honest errors** (`feedback_silent_failures.md`): every error code maps to a user-visible message.
- **Sequential** (`feedback_sequential_questions.md`): one decision per screen — email, then code.
- **Progressive disclosure** (`feedback_progressive_disclosure.md`): pairing surface only appears when user navigates there.

---

## 11. Activation gates

Endpoints stay 503 until **all** of the following are true:
1. `FF_CROSS_DEVICE_SYNC=1` — feature flag
2. `KV_REST_API_URL` + `KV_REST_API_TOKEN` (or `UPSTASH_REDIS_REST_*`) — KV provisioned
3. `RESEND_API_KEY` — email delivery configured

User has confirmed `FF_CROSS_DEVICE_SYNC=1` was flipped in this session, KV is live (verified via 401 from `/api/sync/get`), and Resend is already wired in production for transactional alerts.

---

## 12. Open follow-ups

- **M5 "Sign out everywhere":** add `/api/sync/auth/revoke-all` that scans `thv2:session:*` for matching `userId`. Requires SMEMBER set or KEYS-pattern; cheaper alternative is to maintain `thv2:user:<userId>:sessions` set with each token, and DEL each on revoke.
- **Token rotation:** at present, no auto-rotate. Could add refresh on `/api/sync/get` 200 response to slide TTL forward.
- **Plaid identity link (deferred):** when Plaid wires, store `thv2:plaid:<item_id> → userId` so holdings auto-attach to the same sync surface (architecture spec §4 last paragraph).
- **2FA escalation (out of scope):** only justifies once dollar-touching surfaces (autonomous trading) hang off the same identity.

---

## 13. Test coverage

`src/lib/sync-auth.test.js` — 33 vitest cases, 33/33 pass:
- Email normalization + validation (4)
- Stable userId derivation (3)
- Code generation + hashing (4)
- Constant-time compare (3)
- Session token shape (2)
- Email body — no key leak (3)
- Request endpoint (3)
- Verify endpoint (4)
- Logout endpoint (2)
- Sync-client token plumbing (4)
- End-to-end pairing roundtrip (1)

Runs via `npm run test:vitest` (no new config needed).
