OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / lib / session.js

122 lines · javascript ref:
// Sessions — in memory, and deliberately nothing more.
//
// ── What a session is and is not ──────────────────────────────────────────────────────────────
//
// It maps an opaque cookie value to the browser public key that proved itself at login. That is
// ALL. It is not a credential, it carries no permissions, and it is never trusted on its own:
// every request re-asks the daemon whether that key is still allowed (`browser-status`).
//
// ⛔ So a revoke lands on the NEXT request, not when something lapses. That is the whole reason
// there is no expiry to configure — the check is live, so an expiry would only add a way to be
// logged out for no reason.
//
// ── Why in memory ─────────────────────────────────────────────────────────────────────────────
//
// The surface holds no storage — that is its boundary, and its test enforces it. So sessions die
// with the process, and a restart means logging in again. That is honest and stated rather than
// worked around: persisting them would make the renderer own durable security state, which is the
// thing it exists not to do.
//
// ⚠ The cookie value is 32 random bytes. It is a bearer token for the length of one process, so it
// is HttpOnly and SameSite=Strict — a page cannot read it, and it does not ride cross-site
// requests. It is not signed, because nothing verifies it but this map.

// ⚠ bare-crypto — NOT the storage layer's crypto package. The surface must not reach into the
// replication stack, and test/boundary.test.js caught the first version of this import. A renderer
// needs random bytes, nothing more.
//
// That test greps source TEXT, so naming the offending package here would trip it too; the guard is
// blunt on purpose and the comment moves instead.
import crypto from 'bare-crypto'

export const COOKIE = 'favela_session'

export class Sessions {
  constructor () {
    this._byId = new Map() // sessionId -> publicKey
    this._handoff = new Map() // one-time token -> sessionId
  }

  /**
   * A ONE-TIME token that can be exchanged for this session's cookie.
   *
   * ⛔ Exists because the session cookie is `HttpOnly`, so it cannot be handed to an embedded
   * WebView from the outside — `document.cookie` cannot write one and no native API portably can.
   * The cookie has to arrive on an HTTP response, which means a route. favela-web reached the same
   * conclusion and for the same reason.
   *
   * ⛔ ONE-TIME, unlike favela-web's, which accepts a token repeatedly and leans on the request
   * coming from loopback. On a desktop that is a real gate; on Android it is not — every app on the
   * device shares loopback, so a token that survives its first use is a replayable credential
   * sitting in a URL. Burning it on redemption costs one `delete`.
   */
  createHandoff (id) {
    if (!id || !this._byId.has(id)) return null
    const token = crypto.randomBytes(32).toString('hex')
    this._handoff.set(token, id)
    return token
  }

  /** Exchange a handoff token for its session id, consuming it. Null if unknown or already used. */
  redeemHandoff (token) {
    if (!token) return null
    const id = this._handoff.get(token)
    if (!id) return null
    this._handoff.delete(token)
    // ⚠ The session may have been destroyed between minting and redeeming — a revoke, or a restart.
    return this._byId.has(id) ? id : null
  }

  /** Start a session for a browser that has just proven its key. */
  create (publicKey) {
    const id = crypto.randomBytes(32).toString('hex')
    this._byId.set(id, publicKey)
    return id
  }

  /** The public key behind a cookie, or null. */
  keyFor (id) {
    if (!id) return null
    return this._byId.get(id) || null
  }

  destroy (id) {
    if (!id) return
    this._byId.delete(id)
    for (const [t, sid] of this._handoff) if (sid === id) this._handoff.delete(t)
  }

  /** Every session for a key — a revoked browser should not keep a second tab alive. */
  destroyKey (publicKey) {
    for (const [id, key] of this._byId) if (key === publicKey) this.destroy(id)
  }

  get size () { return this._byId.size }
}

/**
 * The session id from a Cookie header.
 *
 * ⚠ Parsed strictly: a cookie value is taken up to the first `;` and never unescaped, because the
 * only thing it is ever compared against is a hex string this process generated.
 */
export function sessionIdFrom (cookieHeader) {
  if (typeof cookieHeader !== 'string') return null
  for (const part of cookieHeader.split(';')) {
    const i = part.indexOf('=')
    if (i === -1) continue
    if (part.slice(0, i).trim() !== COOKIE) continue
    const v = part.slice(i + 1).trim()
    return /^[0-9a-f]{64}$/.test(v) ? v : null
  }
  return null
}

export const setCookie = (id) =>
  COOKIE + '=' + id + '; Path=/; HttpOnly; SameSite=Strict'

export const clearCookie = () =>
  COOKIE + '=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0'

export default { Sessions, sessionIdFrom, setCookie, clearCookie, COOKIE }