# Cross-device sync — device fingerprinting

**Lane thv2-69 (consumer) + lane thv2-64 (producer)** · Status: spec, awaiting lane 64 implementation hook

## Why fingerprint?

The Settings → Sync UI must let users see *which* device is paired and unpair specific ones. We need a stable per-device identifier that:

1. **Persists across sessions** on the same browser (so "Chrome on Mac" stays the same device after a logout/login).
2. **Differs across devices** (so "Chrome on Mac" and "Safari on iPhone" appear as two rows).
3. **Carries no PII** (no IP, no MAC, no user agent string in plaintext).
4. **Is human-recognizable in the UI** (so the user can pick "yes that's my old phone" with confidence).

## What we collect

Three coarse client-supplied signals are concatenated and hashed:

```
fingerprint_input = navigator.userAgent + screen.width + 'x' + screen.height + ':' + Intl.DateTimeFormat().resolvedOptions().timeZone
fingerprint_id    = SHA-256(fingerprint_input).slice(0, 16)   // 16-char hex prefix
```

Plus a parallel **display-only** UA summary:

```
ua_summary = browserFamily + ' on ' + osFamily            (e.g. "Chrome on macOS")
```

We DO NOT store:
- Full user agent string in plaintext (we only persist `ua_summary` + `ua_hash` as labels)
- IP addresses (Vercel handles them at the edge; never written to KV)
- Screen resolution (only used as a hash input; not retained)
- Locale beyond timezone (used as hash input only)

## Where it lives

KV key:

```
thv2:user-devices:<userId>  →  {
  "<deviceId>": {
    "fingerprint":          "<16-char SHA-256 prefix>",
    "ua_summary":           "Chrome on macOS",          // for display
    "paired_at":            <ms epoch>,
    "last_active":          <ms epoch>,
    "last_seen_version":    <int>,                      // sync envelope version
    "session_token":        "<full token>",             // server-side only
    "session_token_prefix": "<first 8 hex>",            // for current-device match
    "status":               "synced" | "drifting" | "offline"
  }
}
```

`<deviceId>` itself is a 22-char base62 random id minted at first pairing — independent of the fingerprint, so collisions across users don't matter.

## Producer responsibility — lane 64

When `/api/sync/auth/verify-code` mints a session token:

1. The pairing UI (lane 64's `pages/sync-pair-render.js`) computes `fingerprint_input` + `ua_summary` client-side, then includes them in the verify-code POST body alongside `email + code`.
2. `verify-code.js` derives `fingerprint = SHA-256(fingerprint_input).slice(0, 16)` server-side (so the client cannot forge an empty fingerprint that hides their device).
3. On success, `verify-code.js` upserts into `thv2:user-devices:<userId>`:
   - If a record with the same `fingerprint` exists → update `last_active`, `session_token`, `session_token_prefix` (re-pair from same browser).
   - Otherwise → mint a new `deviceId` and add a new record.

## Consumer responsibility — lane 69

This lane (Settings UI):

- READS `thv2:user-devices:<userId>` via `/api/sync/devices/list`.
- WRITES (delete only) via `/api/sync/devices/unpair`, which also invalidates the device's session token.
- NEVER recomputes the fingerprint client-side; it only displays what the server returned.

## Privacy posture

- Fingerprint is **one-way hashed**. It is not reversible to a user agent.
- Two different users on the same browser/OS/timezone get the same `fingerprint` value — but they live under different `userId`s, so there is no cross-user inference risk.
- The `fingerprint` is never logged in plaintext logs; only `userId_hash:8` and counts.

## Ratchet

If the entropy proves insufficient (collisions across siblings on the same household device), we can add `screen.colorDepth + navigator.hardwareConcurrency` to the input. We will NOT add canvas fingerprinting or audio fingerprinting — those are tracking-grade and violate `feedback_security_incident.md`.
