#!/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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
// ── 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)
})