OverviewFilesCommitsIssuesDocsReleasesPeers

anproto / examples / web.js

357 lines · javascript ref:
#!/usr/bin/env node
/**
 * Mini web example for anproto
 *
 * Demonstrates a complete SSR browser login flow:
 *   1. Browser visits / and gets a login page with a fresh challenge
 *   2. Browser generates an Ed25519 keypair (stored in localStorage)
 *   3. Browser hashes the challenge and signs it (canonical anproto)
 *   4. Browser POSTs the signature to /login
 *   5. Server verifies the signature, consumes the challenge, sets a cookie
 *   6. Subsequent requests use the cookie to identify the user
 *
 * Usage: node examples/web.js [--port 9093]
 *
 * Browser side uses noble-ed25519 (vendored from canonical anproto via
 * lib/nacl-fast-es.js) — but for the demo we use a tiny inline shim that
 * uses WebCrypto's Ed25519 (Chrome 137+, Firefox 130+, Safari 17+).
 */

import http from 'http'
import { an, Challenges } from '../index.js'
import b4a from 'b4a'

const args = process.argv.slice(2)
function getFlag (name) {
  const i = args.indexOf('--' + name)
  return i !== -1 && args[i + 1] ? args[i + 1] : null
}

const port = parseInt(getFlag('port')) || 9093

// ── State ─────────────────────────────────────────────────────

const challenges = new Challenges({ ttl: 5 * 60 * 1000 })
// pubkey → { firstSeen, lastSeen, loginCount }
const sessions = new Map()
// Cookie value → pubkey
const cookieToPubkey = new Map()

function newCookieValue () {
  return Math.random().toString(36).slice(2) + Date.now().toString(36)
}

function parseCookies (req) {
  const out = {}
  const header = req.headers.cookie
  if (!header) return out
  for (const part of header.split(';')) {
    const [k, v] = part.trim().split('=')
    out[k] = v
  }
  return out
}

function escapeHtml (s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

// ── HTTP server ───────────────────────────────────────────────

const httpServer = http.createServer(async (req, res) => {
  const url = req.url.split('?')[0]
  const cookies = parseCookies(req)
  const sessionPubkey = cookies.session ? cookieToPubkey.get(cookies.session) : null

  // ── GET / — landing page ─────────────────────────────────────

  if (req.method === 'GET' && url === '/') {
    const challenge = challenges.generate()
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end(renderHomePage(challenge, sessionPubkey))
    return
  }

  // ── POST /login — verify the signature ──────────────────────

  if (req.method === 'POST' && url === '/login') {
    const body = await collectBody(req)
    const params = new URLSearchParams(body)
    const challenge = params.get('challenge')
    const signature = params.get('signature')

    if (!challenge || !signature) {
      res.writeHead(400, { 'Content-Type': 'text/plain' })
      res.end('Missing challenge or signature')
      return
    }

    // Verify the challenge is one we issued and not yet consumed
    if (!challenges.consume(challenge)) {
      res.writeHead(400, { 'Content-Type': 'text/plain' })
      res.end('Unknown or expired challenge')
      return
    }

    // Verify the signature is valid ANProto
    let opened
    try {
      opened = await an.open(signature)
    } catch (err) {
      res.writeHead(400, { 'Content-Type': 'text/plain' })
      res.end('Invalid signature: ' + err.message)
      return
    }

    // Verify the signed hash matches the challenge we issued
    const expectedHash = await an.hash(challenge)
    const parsed = an.parse(opened)
    if (parsed.hash !== expectedHash) {
      res.writeHead(400, { 'Content-Type': 'text/plain' })
      res.end('Signature does not match challenge hash')
      return
    }

    // Verify the timestamp is recent (defence in depth — challenge consumption
    // already prevents replay, but the timestamp catches stale signatures)
    if (Math.abs(Date.now() - parsed.timestamp) > 5 * 60 * 1000) {
      res.writeHead(400, { 'Content-Type': 'text/plain' })
      res.end('Signature timestamp out of window')
      return
    }

    // Extract the pubkey from the signature
    const pubkey = signature.substring(0, 44)

    // Update session info
    const existing = sessions.get(pubkey)
    sessions.set(pubkey, {
      firstSeen: existing ? existing.firstSeen : Date.now(),
      lastSeen: Date.now(),
      loginCount: (existing?.loginCount || 0) + 1
    })

    // Issue a session cookie
    const cookieValue = newCookieValue()
    cookieToPubkey.set(cookieValue, pubkey)

    res.writeHead(303, {
      'Set-Cookie': `session=${cookieValue}; HttpOnly; Path=/; Max-Age=${60 * 60 * 24}`,
      Location: '/'
    })
    res.end()
    return
  }

  // ── POST /logout ─────────────────────────────────────────────

  if (req.method === 'POST' && url === '/logout') {
    if (cookies.session) cookieToPubkey.delete(cookies.session)
    res.writeHead(303, {
      'Set-Cookie': 'session=; HttpOnly; Path=/; Max-Age=0',
      Location: '/'
    })
    res.end()
    return
  }

  // ── 404 ──────────────────────────────────────────────────────

  res.writeHead(404, { 'Content-Type': 'text/plain' })
  res.end('Not found')
})

function collectBody (req) {
  return new Promise((resolve, reject) => {
    const parts = []
    req.on('data', chunk => parts.push(chunk))
    req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
    req.on('error', reject)
  })
}

// ── HTML pages (SSR — no JSON API) ────────────────────────────

function renderHomePage (challenge, sessionPubkey) {
  const session = sessionPubkey ? sessions.get(sessionPubkey) : null

  return `<!DOCTYPE html>
<html><head><title>anproto demo</title>
<style>
  body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 720px; margin: 0 auto; }
  h1 { color: #58a6ff; }
  .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
  .card h2 { font-size: 0.75rem; text-transform: uppercase; color: #8b949e; margin: 0 0 0.5rem; }
  .row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
  .row:last-child { border-bottom: none; }
  .label { color: #8b949e; }
  .value { font-family: monospace; color: #79c0ff; word-break: break-all; max-width: 70%; text-align: right; }
  .btn { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; font-family: inherit; }
  .btn:hover { background: #30363d; }
  .btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
  .btn-primary:hover { background: #1158c7; }
  #log { font-size: 0.8rem; color: #8b949e; min-height: 4em; }
  #log div { padding: 2px 0; }
  .ok { color: #3fb950; }
  .err { color: #f85149; }
</style></head><body>
<h1>anproto SSR login demo</h1>

${sessionPubkey ? renderLoggedIn(sessionPubkey, session) : renderLoginForm(challenge)}

<div class="card">
  <h2>How this works</h2>
  <ol style="line-height: 1.6; font-size: 0.85rem;">
    <li>Server issues a random <strong>challenge</strong> in the page</li>
    <li>Browser generates an Ed25519 keypair (stored in localStorage)</li>
    <li>Browser hashes the challenge and signs it via canonical ANProto</li>
    <li>Browser POSTs the signature to <code>/login</code></li>
    <li>Server verifies the signature, consumes the challenge, sets a cookie</li>
  </ol>
</div>

</body></html>`
}

function renderLoginForm (challenge) {
  return `<div class="card">
  <h2>Not logged in</h2>
  <div class="row"><span class="label">Challenge</span><span class="value">${escapeHtml(challenge)}</span></div>
  <div style="margin-top: 1rem">
    <button class="btn btn-primary" id="login-btn">Sign in with Ed25519</button>
    <button class="btn" id="reset-btn">Generate new keypair</button>
    <span id="log"></span>
  </div>
</div>

<script type="module">
const log = (msg, cls = '') => {
  const d = document.createElement('div')
  d.textContent = msg
  if (cls) d.className = cls
  document.getElementById('log').appendChild(d)
}

// ── Browser ANProto using WebCrypto Ed25519 ──
//
// We re-implement the canonical ANProto wire format on the browser side.
// Modern browsers (Chrome 137+, Firefox 130+, Safari 17+) have Ed25519 in
// WebCrypto. For older browsers, swap in noble-ed25519 (~10 KB JS bundle).

const enc = new TextEncoder()
const dec = new TextDecoder()

const b64encode = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
const b64decode = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0))

async function gen() {
  const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
  // Export raw pubkey (32 bytes)
  const pubRaw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey))
  // Export pkcs8 secret key, extract the seed
  const secPkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey))
  // pkcs8 format: ASN.1 wrapping. The 32-byte seed is at the end (last 32 bytes of the OCTET STRING)
  // Quick extraction: seed is the last 32 bytes of the pkcs8 buffer
  const seed = secPkcs8.slice(-32)
  // ANProto secret key = seed (32 bytes) || pubkey (32 bytes) — that's the libsodium 64-byte secret key format
  const sec = new Uint8Array(64)
  sec.set(seed, 0)
  sec.set(pubRaw, 32)
  return b64encode(pubRaw) + b64encode(sec)
}

async function hash(data) {
  const buf = enc.encode(data)
  const h = await crypto.subtle.digest('SHA-256', buf)
  return b64encode(h)
}

async function sign(h, keypair) {
  const ts = Date.now()
  const message = enc.encode(String(ts) + h)
  // Reconstruct the WebCrypto private key from the seed (first 32 of the 64-byte sec key)
  const secBytes = b64decode(keypair.substring(44))
  const seed = secBytes.slice(0, 32)
  // Build a minimal pkcs8 wrapper for an Ed25519 seed
  const pkcs8 = new Uint8Array([
    0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
    ...seed
  ])
  const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign'])
  const sigBytes = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, message))
  // Combined-mode envelope: sig (64) || message
  const combined = new Uint8Array(sigBytes.length + message.length)
  combined.set(sigBytes, 0)
  combined.set(message, sigBytes.length)
  return keypair.substring(0, 44) + b64encode(combined)
}

// ── Login flow ──

document.getElementById('reset-btn').onclick = () => {
  localStorage.removeItem('anproto-keypair')
  log('Keypair cleared from localStorage')
}

document.getElementById('login-btn').onclick = async () => {
  try {
    let kp = localStorage.getItem('anproto-keypair')
    if (!kp) {
      log('No keypair in localStorage, generating new one...')
      kp = await gen()
      localStorage.setItem('anproto-keypair', kp)
      log('Generated keypair (saved to localStorage)', 'ok')
      log('  pubkey: ' + kp.substring(0, 44))
    } else {
      log('Loaded existing keypair from localStorage', 'ok')
      log('  pubkey: ' + kp.substring(0, 44))
    }

    const challenge = ${JSON.stringify(challenge)}
    log('Hashing challenge...')
    const h = await hash(challenge)
    log('  hash: ' + h)

    log('Signing hash...')
    const sig = await sign(h, kp)
    log('  sig: ' + sig.substring(0, 60) + '...')

    log('POSTing /login...')
    const body = new URLSearchParams({ challenge, signature: sig })
    const res = await fetch('/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body
    })
    if (res.ok || res.redirected) {
      log('Login successful — reloading', 'ok')
      window.location.href = '/'
    } else {
      const text = await res.text()
      log('Login failed: ' + text, 'err')
    }
  } catch (err) {
    log('Error: ' + err.message, 'err')
  }
}
</script>`
}

function renderLoggedIn (pubkey, session) {
  return `<div class="card">
  <h2>Logged in</h2>
  <div class="row"><span class="label">Pubkey</span><span class="value">${escapeHtml(pubkey)}</span></div>
  <div class="row"><span class="label">First seen</span><span class="value">${session ? new Date(session.firstSeen).toLocaleString() : '?'}</span></div>
  <div class="row"><span class="label">Last seen</span><span class="value">${session ? new Date(session.lastSeen).toLocaleString() : '?'}</span></div>
  <div class="row"><span class="label">Login count</span><span class="value">${session ? session.loginCount : 1}</span></div>
  <form action="/logout" method="POST" style="margin-top: 1rem">
    <button class="btn" type="submit">Log out</button>
  </form>
</div>`
}

httpServer.listen(port, () => {
  console.log('anproto SSR demo running:')
  console.log('  http://localhost:%d', port)
})