Skip to content

Authentication & Security

pinjinx edited this page Aug 4, 2026 · 1 revision

Rein's security model is designed for local network use — it doesn't assume a public internet threat model, but does protect against unauthorized access from other devices on the same LAN.


Security Model

Threat Mitigation
Unauthorized LAN device controlling desktop Bearer token or ?token= required for all remote REST & WebSocket endpoints
Token theft via URL interception Tokens generated with 128-bit CSPRNG, stored in localStorage on mobile client
Token brute-force 128-bit cryptographic random UUID
Timing attacks on token comparison crypto.timingSafeEqual() used for all token verification
Remote code execution via config Server-side allowlist of config keys + type/range validation in POST /api/config
Request body attacks Max body size: 1 MB (MAX_BODY_BYTES)
Cross-origin attacks Server only responds to /api/* paths and /ws WebSocket upgrade
Token persistence File permissions 0o600 (tokens.json)
Sensitive data in logs Stream credentials redacted from log output

Token Generation

export function generateToken(): string {
  return crypto.randomUUID() // 128-bit CSPRNG — 2^122 entropy
}

A UUID v4 is used: 122 bits of cryptographic randomness. This is generated via Node.js crypto.randomUUID(), which uses the OS CSPRNG.


Token Lifecycle

POST /api/auth/token (localhost only)
  └─ getActiveToken()  → return existing if present (avoids QR regeneration)
  └─ generateToken()   → crypto.randomUUID()
  └─ storeToken(token) → persist to tokens.json
  └─ return { token }

Settings page encodes token into QR: http://<LAN_IP>:<PORT>/trackpad?token=<TOKEN>

Phone scans QR → opens /trackpad?token=<TOKEN>
  └─ Token saved to localStorage ("rein_auth_token")
  └─ All API calls include: Authorization: Bearer <TOKEN>
  └─ WebSocket connection connects to: ws://<LAN_IP>:<PORT>/ws?token=<TOKEN>

Token Store (src/server/tokenStore.ts)

Persistence

Tokens are persisted to tokens.json (adjacent to the server source):

const TOKENS_FILE = path.resolve(__dirname, "../tokens.json")
const EXPIRY_MS = 10 * 24 * 60 * 60 * 1000 // 10 days

File is written with mode 0o600:

await writeFile(TOKENS_FILE, JSON.stringify(tokens, null, 2), {
  encoding: "utf-8",
  mode: 0o600 // Owner read/write only — prevents other users from reading tokens
})

Write Throttling

To avoid frequent disk writes during an active session, saves are throttled to once per minute (except for forced saves on token create/delete):

const SAVE_THROTTLE_MS = 60 * 1000
if (!force && now - lastSaveTime < SAVE_THROTTLE_MS) return

Expiry

Tokens expire after 10 days of inactivity. purgeExpired() is called on read operations:

function purgeExpired(): void {
  tokens = tokens.filter((t) => now - t.lastUsed < EXPIRY_MS)
}

Each successful auth call runs touchToken(token) to reset the lastUsed timestamp.

Timing-Safe Comparison

All token lookups use crypto.timingSafeEqual() to prevent timing side-channel attacks:

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
}

Authorization Middleware

isLoopbackAddress(addr)

export function isLoopbackAddress(addr?: string): boolean {
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1"
}

Localhost requests bypass token checks. This allows the Settings page (running in the browser or Electron on the desktop itself) to load and execute API calls without requiring a token.

requireAuth(req, res)

Used for API endpoints:

function requireAuth(req: IncomingMessage, res: ServerResponse): boolean {
  const addr = req.socket.remoteAddress
  if (isLoopbackAddress(addr)) return true // Localhost always passes

  const authHeader = req.headers.authorization ?? ""
  let token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null

  if (!token) {
    const url = new URL(req.url ?? "", `http://${req.headers.host}`)
    token = url.searchParams.get("token")
  }

  if (!token || !isKnownToken(token)) {
    json(res, 401, { error: "Unauthorized" })
    return false
  }
  return true
}

WebSocket Authorization (/ws)

During WebSocket upgrade in WebRTCManager (src/server/webRTC.ts):

if (url.pathname === "/ws") {
  const addr = request.socket.remoteAddress
  const isLocal = isLoopbackAddress(addr)
  const token = url.searchParams.get("token")
  if (!isLocal && (!token || !isKnownToken(token))) {
    socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n")
    socket.destroy()
    return
  }
  this.wss.handleUpgrade(...)
}

QR Code & URL Structure

The QR code generated on the settings page encodes:

http://<LAN_IP>:<PORT>/trackpad?token=<TOKEN>

On the phone:

  1. Token is extracted from ?token= query parameter.
  2. Saved to localStorage as rein_auth_token.
  3. Sent with WebSocket signaling connection (/ws?token=<token>) and included as Authorization: Bearer <token> in API calls.

Threat Model Limitations

Rein is designed for trusted local networks:

  • Network sniffing: HTTP is used by default on LANs. For untrusted network environments, use a VPN or TLS reverse proxy.
  • Malicious LAN members: Anyone who captures the QR code or intercepts the token URL can interact with the desktop.
  • Physical access: Anyone who can see the QR code on screen can scan it.

For public or enterprise deployments, Rein should be run behind a reverse proxy with TLS.

Clone this wiki locally