Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions docs/features/11-presence-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# 11 — Presence System

**NEW document** — Online/offline/away states, Socket.IO /presence namespace, lastSeen tracking, 30s grace period

---

## Feature Summary

The presence system tracks which users are online, offline, or away in real-time. It uses a Socket.IO `/presence` namespace with an in-memory `Map` of online users. On connect, the user is added to the map and their status is broadcast to all other clients. On disconnect, a 30-second grace period prevents churn from brief network blips.

---

## Architecture Diagram

```
┌─────────────────── CLIENT ───────────────────────────┐
│ │
│ src/hooks/usePresence.ts │
│ ├─ Connects to /presence namespace │
│ ├─ Passes userId in handshake query │
│ ├─ Listens: 'initial-status' → populate online list │
│ ├─ Listens: 'user-status-changed' → update UI │
│ └─ Emits: 'update-status' → set away/dnd/online │
│ │
│ src/hooks/useMe.ts │
│ └─ Reads user.status + user.lastSeen from /api/users │
│ │
│ UI Indicators: │
│ ├─ Green dot = online │
│ ├─ Yellow dot = away │
│ ├─ Grey dot = offline + "last seen X min ago" │
│ └─ Shown in: PeopleView, ChatView, TeamMembers │
└──────────────────────┬────────────────────────────────┘
│ Socket.IO /presence
┌─────────────────── BACKEND ──────────────────────────┐
│ │
│ backend/sockets/presenceSocketHandler.js (143 lines) │
│ │
│ In-memory state: onlineUsers = Map<userId, {status, │
│ lastSeen}> │
│ │
│ Events: │
│ ├─ On connect: │
│ │ ├─ Add to onlineUsers Map │
│ │ ├─ Emit 'initial-status' to connector │
│ │ └─ Broadcast 'user-status-changed' to all others │
│ ├─ On disconnect: │
│ │ ├─ Set status to 'offline' in Map │
│ │ ├─ Broadcast 'user-status-changed' │
│ │ └─ After 30s: delete from Map if still offline │
│ └─ On 'update-status': │
│ ├─ Update Map with new status │
│ └─ Broadcast 'user-status-changed' │
│ │
│ Also: MongoDB User.status + User.lastSeen │
│ └─ Updated on /api/users/sync (login) │
│ └─ Updated on disconnect (via API call) │
└───────────────────────────────────────────────────────┘
```

---

## Backend Trace

### File: `backend/sockets/presenceSocketHandler.js` (143 lines)

### In-Memory State (line 76)
```js
const onlineUsers = new Map();
```
- Key: `userId` (Firebase UID)
- Value: `{ status: 'online' | 'offline' | 'away', lastSeen: Date }`
- **Not persisted** — lost on server restart (clients reconnect and repopulate)

### Namespace Setup (line 79)
```js
const presenceNamespace = io.of('/presence');
```
- Isolated from `/chat`, `/notes`, `/tasks` namespaces
- Registered in `backend/index.js:148`: `require('./sockets/presenceSocketHandler')(io)`

### Connection Handler (lines 81-141)

#### On Connect (lines 81-109)
1. **Extract userId** from `socket.handshake.query` (line 82)
2. **Validate** — disconnect if no userId (lines 84-87)
3. **Join room** with userId — enables targeted events (line 89)
4. **Update Map** — `onlineUsers.set(userId, { status: 'online', lastSeen: now })` (line 93)
5. **Build initial status snapshot** — iterate all online users, exclude self (lines 96-101)
6. **Emit 'initial-status'** to connecting user only (line 102)
- Payload: `[{ uid, status, lastSeen }, ...]`
7. **Broadcast 'user-status-changed'** to all other clients (lines 105-109)
- Payload: `{ userId, status: 'online', lastSeen: now }`

#### On Disconnect (lines 111-128)
1. **Update Map** — set status to 'offline' with current timestamp (line 113)
2. **Broadcast 'user-status-changed'** with offline status (lines 115-119)
3. **30-second grace period** (lines 122-127):
- `setTimeout(30000)` — wait 30 seconds
- Check if user is still offline in Map
- If still offline: `onlineUsers.delete(userId)` — free memory
- If reconnected (status changed back to 'online'): keep in Map
- **Purpose:** prevents churn from brief network blips, tab switches, etc.

#### On 'update-status' Event (lines 131-140)
1. **Update Map** with new status (`'away'`, `'dnd'`, `'online'`, etc.) (line 133)
2. **Broadcast 'user-status-changed'** to all other clients (lines 135-139)
- Payload: `{ userId, status: newStatus, lastSeen: now }`

---

## Frontend Trace

### usePresence Hook
**File:** `src/hooks/usePresence.ts`
- Connects to `/presence` namespace via `socket.io-client`
- Passes `userId` in connection query
- Maintains local state of online users
- Exposes `onlineUsers` map and `updateStatus()` function

### UI Components Using Presence
| Component | File | Usage |
|---|---|---|
| PeopleView | `src/components/views/PeopleView.tsx` | Green/grey dots on user cards |
| ChatView | `src/components/views/ChatView.tsx` | Online indicator on chat header |
| MessagesPage | `src/components/views/MessagesPage.tsx` | Online status in conversation list |
| TeamMembers | `src/components/views/PeopleView.tsx` | Team member presence |
| DashboardHome | `src/components/views/DashboardHome.tsx` | Quick presence overview |

---

## Socket Events Reference

| Event | Direction | Payload | Purpose |
|---|---|---|---|
| `initial-status` | Server → Client (on connect) | `[{ uid, status, lastSeen }, ...]` | Snapshot of all online users |
| `user-status-changed` | Server → All (broadcast) | `{ userId, status, lastSeen }` | Notify status change |
| `update-status` | Client → Server | `string` (e.g., 'away', 'online') | User manually changes status |
| `disconnect` | Client → Server | — | User disconnected (tab close, network loss) |

---

## Database Persistence

### MongoDB User Document
| Field | Type | Updated When | Source |
|---|---|---|---|
| `status` | String | Login (`/api/users/sync`) | Set to `'online'` |
| `lastSeen` | Date | Login, activity | `new Date()` |

- MongoDB persistence is **secondary** to the in-memory Map
- MongoDB `lastSeen` is used for "last seen X ago" when user is offline and server has restarted (Map is empty)
- The in-memory Map is the real-time source of truth

---

## Edge Cases & Error Handling

| Scenario | Behavior |
|---|---|
| Server restart | All presence data lost. Clients reconnect, Map repopulates. MongoDB `lastSeen` fills gap. |
| Brief network blip (<30s) | Grace period keeps user in Map. On reconnect, status returns to 'online'. |
| Multiple tabs | Each tab creates a separate socket connection. User appears online as long as one tab is open. |
| Mobile app backgrounded | Socket disconnects → user goes offline after 30s grace period. |
| No userId in handshake | Socket immediately disconnected (line 85-87). |

---

## Cross-References

- [06-middleware-stack.md](./06-middleware-stack.md) — Socket.IO setup in index.js
- [08-firebase-auth-flow.md](./08-firebase-auth-flow.md) — User sync on login sets initial status
- [09-user-profile-management.md](./09-user-profile-management.md) — /api/users/sync updates status
- [26-instant-chat-system.md](./26-instant-chat-system.md) — Chat uses presence for online indicators
223 changes: 223 additions & 0 deletions docs/features/12-haveibeenpwned-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# 12 — HaveIBeenPwned Integration

**NEW document** — K-anonymity SHA-256 prefix matching for password breach checks

---

## Feature Summary

Zync integrates the Have I Been Pwned (HIBP) Pwned Passwords API to check if a user's password has appeared in known data breaches. The integration uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent to the API, ensuring the actual password or full hash is never transmitted. The service fails open — if the API is down, users are not blocked.

---

## Architecture Diagram

```
┌─────────────────── FRONTEND (Signup/Settings) ───────────┐
│ │
│ User enters password │
│ │ │
│ ▼ │
│ POST /api/users/check-breached-password │
│ { password: "user_input" } │
│ │ │
│ ▼ │
│ ┌─────────────────── BACKEND ─────────────────────────┐ │
│ │ │ │
│ │ haveIBeenPwnedService.js │ │
│ │ │ │
│ │ Step 1: SHA-1 hash the password │ │
│ │ crypto.createHash('sha1') │ │
│ │ .update(password).digest('hex').toUpperCase() │ │
│ │ → e.g., "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8"│ │
│ │ │ │
│ │ Step 2: Split into prefix + suffix │ │
│ │ prefix = first 5 chars → "5BAA6" │ │
│ │ suffix = remaining 35 chars → "1E4C9B93F3F..." │ │
│ │ │ │
│ │ Step 3: Query HIBP API with prefix ONLY │ │
│ │ GET https://api.pwnedpasswords.com/range/5BAA6 │ │
│ │ Headers: { 'Add-Padding': 'true' } │ │
│ │ Timeout: 5000ms │ │
│ │ │ │
│ │ Step 4: API returns ~500 hash suffixes │ │
│ │ "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3" │ │
│ │ "2BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8:1" │ │
│ │ ... │ │
│ │ │ │
│ │ Step 5: Local match — find our suffix in results │ │
│ │ if hashSuffix === suffix → COMPROMISED │ │
│ │ return { isCompromised: true, count: 3 } │ │
│ │ │ │
│ │ Fail-open: on API error → { isCompromised: false } │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Frontend shows warning if isCompromised === true │
│ "This password has been found in N data breaches" │
└────────────────────────────────────────────────────────────┘
```

---

## Backend Trace

### File: `backend/services/haveIBeenPwnedService.js` (122 lines)

### Imports (lines 81-82)
```js
const crypto = require('crypto');
const axios = require('axios');
```

### API Endpoint (line 84)
```js
const PWNED_PASSWORDS_BASE = 'https://api.pwnedpasswords.com/range/';
```

### checkPassword Function (lines 94-119)

#### Step 1: SHA-1 Hash (line 95)
```js
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
```
- Uses Node.js built-in `crypto` module (no external dependency)
- Uppercase required by HIBP API specification

#### Step 2: Split Hash (lines 96-97)
```js
const prefix = sha1.substring(0, 5);
const suffix = sha1.substring(5);
```
- **Prefix (5 chars):** Sent to API — shared by ~500 other hashes
- **Suffix (35 chars):** Kept locally — used for matching

#### Step 3: API Request (lines 100-103)
```js
const response = await axios.get(`${PWNED_PASSWORDS_BASE}${prefix}`, {
headers: { 'Add-Padding': 'true' },
timeout: 5000,
});
```
- **Add-Padding header:** Obfuscates actual match count — adds zero-count entries to prevent timing attacks
- **5-second timeout:** Prevents backend from hanging if API is unresponsive

#### Step 4: Parse Response (lines 105-111)
```js
const lines = response.data.split('\n');
for (const line of lines) {
const [hashSuffix, count] = line.trim().split(':');
if (hashSuffix === suffix) {
return { isCompromised: true, count: parseInt(count, 10) };
}
}
```
- Response is plain text, one hash suffix per line
- Format: `SUFFIX:COUNT` (e.g., `1E4C9B93F3F0682250B6CF8331B7EE68FD8:3`)
- Local comparison only — full hash never leaves the server

#### Step 5: No Match (line 113)
```js
return { isCompromised: false, count: 0 };
```

#### Error Handling — Fail Open (lines 114-118)
```js
catch (error) {
console.error('HIBP password check failed:', error.message);
return { isCompromised: false, count: 0 };
}
```
- **Fail-open design:** If HIBP API is down, return "not compromised"
- **Rationale:** Don't block user registration/login because a third-party API is unavailable
- Error is logged for monitoring

---

### Route Integration
**File:** `backend/routes/userRoutes.js:161-174`

```js
router.post('/check-breached-password', async (req, res) => {
const { password } = req.body;
if (!password || typeof password !== 'string') {
return res.status(400).json({ message: 'Password is required' });
}
try {
const result = await checkPassword(password);
res.json(result);
} catch (error) {
console.error('Breached password check error:', error.message);
res.status(429).json({ message: error.message });
}
});
```

- **No auth required** — endpoint is called during signup before user exists
- **Input validation:** password must be a non-empty string
- **429 on rate limit:** HIBP API may rate-limit aggressive callers

---

## Frontend Trace

### Signup Page
**File:** `src/pages/Signup.tsx`
- Password input field with real-time breach check
- On password entry (debounced), calls `POST /api/users/check-breached-password`
- If `isCompromised === true`: shows warning banner
- "This password has been found in {count} data breaches. Please choose a different password."
- If `isCompromised === false`: shows green checkmark
- User can still proceed even with compromised password (warning, not block)

### SettingsView — Security Tab
**File:** `src/components/views/SettingsView.tsx`
- Password change form includes breach check
- Same warning UI as signup

---

## Privacy & Security Analysis

### K-Anonymity Model
1. **What is sent:** Only first 5 chars of SHA-1 hash (e.g., "5BAA6")
2. **What is NOT sent:** Password, full hash, user identity, IP address (axios doesn't forward)
3. **API response:** ~500 hash suffixes matching the prefix
4. **Local matching:** Full hash suffix compared locally — HIBP never knows which hash was queried
5. **Result:** HIBP cannot determine which password was checked — privacy preserved

### Add-Padding Header
- Without padding: response size correlates with match count → timing attack possible
- With padding: all responses have similar size → timing attack mitigated
- Adds fake zero-count entries to response

### Fail-Open Design
- If HIBP API is unavailable, the check returns "not compromised"
- User experience is not degraded by third-party outage
- Trade-off: a compromised password might be accepted during API downtime

---

## Error Paths

| Scenario | HTTP Status | Response | User Impact |
|---|---|---|---|
| No password provided | 400 | `{ message: "Password is required" }` | Validation error |
| HIBP API timeout (>5s) | 200 | `{ isCompromised: false, count: 0 }` | No warning shown (fail-open) |
| HIBP API error | 200 | `{ isCompromised: false, count: 0 }` | No warning shown (fail-open) |
| HIBP API rate limit | 429 | `{ message: error.message }` | Error toast shown |
| Password compromised | 200 | `{ isCompromised: true, count: N }` | Warning banner shown |
| Password safe | 200 | `{ isCompromised: false, count: 0 }` | Green checkmark shown |

---

## Environment Variables

None required — HIBP Pwned Passwords API is free and public.

---

## Cross-References

- [02-security-auth-architecture.md](./02-security-auth-architecture.md) — Security overview
- [08-firebase-auth-flow.md](./08-firebase-auth-flow.md) — Signup flow where breach check is used
- [09-user-profile-management.md](./09-user-profile-management.md) — Settings security tab
Loading
Loading