diff --git a/.gitignore b/.gitignore index 872d5f6..dc4b44b 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,14 @@ dist # Firebase cache directory .firebase/ +# DBSC POC runtime files +certs/ +screenshot.png +server.out.log +server.err.log +edge-dbsc-profile/ +chrome-dbsc-profile/ + # TernJS port file .tern-port diff --git a/README.md b/README.md index 52b45fc..5de6316 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,156 @@ -# DBSC-Example -Device Bound Session Cookie Example Application for Testing +# DBSC Example + +A small HTTPS proof-of-concept web app for Device Bound Session Credentials +(DBSC). It demonstrates a login flow where the browser registers a device-bound +session key, receives a short-lived cookie, and silently refreshes that cookie +by proving possession of the DBSC private key. + +## What It Does + +- `POST /login` sends `Secure-Session-Registration`. +- `POST /dbsc/register` verifies the browser's ES256 `Secure-Session-Response`, stores the public key, and sets `__Host-dbsc_poc`. +- `POST /dbsc/refresh` verifies a signed refresh proof and rotates the cookie. +- `GET /protected` requires a valid DBSC-controlled cookie. +- `GET /device-bound` is only accessible with the current DBSC cookie. +- `GET /api/whoami` returns JSON showing whether the request is authenticated. + +The cookie lifetime defaults to 60 seconds. Sessions are stored in memory, so +restarting the server requires logging in again. + +## Requirements + +- Node.js 20 or newer +- OpenSSL, or Git for Windows, so the server can generate a local HTTPS cert +- Edge or Chrome with DBSC enabled + +The app has no npm dependencies. + +## Run The Server + +```powershell +cd C:\Users\AndrewGomez\Desktop\DBSC-Example +npm start +``` + +Equivalent explicit command: + +```powershell +node .\server.js --host 0.0.0.0 --port 8443 +``` + +Open: + +```text +https://localhost:8443/ +``` + +To use a different lifetime: + +```powershell +node .\server.js --host 0.0.0.0 --port 8443 --cookie-max-age 10 +``` + +## Trust The Local Cert + +On first run the server creates: + +```text +certs\dbsc-poc-cert.pem +certs\dbsc-poc-key.pem +``` + +Import the cert into the current user's trusted root store: + +```powershell +certutil -user -addstore Root .\certs\dbsc-poc-cert.pem +``` + +## Launch Edge For Testing + +Use the same origin consistently. Do not register on `localhost` and then test on +another hostname or IP address. + +Recommended Edge launch flow: + +1. Fully stop Edge. +2. Clear Edge's persisted DBSC state if you have restarted the Node server. +3. Relaunch Edge with DBSC enabled, DBSC persistence enabled, and the DBSC + refresh quota disabled for local testing. + +```powershell +Get-Process msedge -ErrorAction SilentlyContinue | Stop-Process -Force + +Remove-Item "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions*" -Force -ErrorAction SilentlyContinue + +Start-Process "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" -ArgumentList @( + "--remote-debugging-port=9333", + "--enable-features=DeviceBoundSessions,PersistDeviceBoundSessions", + "--disable-features=DeviceBoundSessionsRefreshQuota", + "https://localhost:8443/" +) +``` + +If you prefer Edge flags, enable: + +```text +edge://flags/#enable-standard-device-bound-session-credentials +edge://flags/#enable-standard-device-bound-session-persistence +``` + +For this POC, disable the refresh quota while testing: + +```text +edge://flags/#enable-standard-device-bound-session-credentials-refresh-quota +``` + +Edge stores DBSC metadata separately from the normal cookie DB: + +```text +%LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions +``` + +That store can survive a server restart. Because this example keeps sessions +only in memory, stale persisted DBSC sessions can make Edge send refreshes for +unknown session IDs. Clearing `Device Bound Sessions*` fixes that. + +Use `/api/whoami` and the server event log to confirm whether the browser +successfully registered or refreshed a DBSC session. + +## Test The API + +After logging in from Edge: + +```powershell +curl.exe -k -i https://localhost:8443/api/whoami +``` + +`curl` will only show authenticated if you manually supply the current cookie: + +```powershell +$cookie = '__Host-dbsc_poc=PASTE_COOKIE_VALUE_HERE' +curl.exe -k -i -H "Cookie: $cookie" https://localhost:8443/api/whoami +``` + +Once that cookie expires, `curl` cannot refresh it because it does not have the +browser's DBSC private key. + +## Reset Edge DBSC State + +If the server was restarted while Edge had persistent DBSC sessions, Edge may +try to refresh an old session ID. Close Edge and clear the DBSC store: + +```powershell +Get-Process msedge -ErrorAction SilentlyContinue | Stop-Process -Force +Remove-Item "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Network\Device Bound Sessions*" -Force +``` + +Then relaunch Edge with the command above. + +## Notes + +- The server listens on `0.0.0.0` by default. +- Runtime certs, logs, screenshots, and browser test profiles are intentionally ignored by git. +- This POC verifies ES256/P-256 DBSC JWT signatures server-side, but it does not use a database. + +## Reference +Based on the docs in https://github.com/w3c/webappsec-dbsc \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..564a0b5 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "dbsc-example", + "version": "0.1.0", + "private": true, + "description": "Standalone Device Bound Session Credentials example web app.", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..104617c --- /dev/null +++ b/server.js @@ -0,0 +1,826 @@ +"use strict"; + +const https = require("node:https"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const options = parseArgs(process.argv.slice(2)); +const PORT = Number(options.port || process.env.PORT || 8443); +const BIND_HOST = options.host || process.env.HOST || "0.0.0.0"; +const COOKIE_MAX_AGE_SECONDS = Number(options.cookieMaxAge || process.env.COOKIE_MAX_AGE_SECONDS || 60); +const ALLOW_NONE_ALGORITHM = Boolean(options.allowNone || process.env.DBSC_ALLOW_NONE === "1"); +const COOKIE_NAME = "__Host-dbsc_poc"; + +const state = { + pending: new Map(), + sessions: new Map(), + cookieIndex: new Map(), + events: [], +}; + +function parseArgs(argv) { + const parsed = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--allow-none") { + parsed.allowNone = true; + } else if (arg === "--port") { + parsed.port = argv[++i]; + } else if (arg === "--host") { + parsed.host = argv[++i]; + } else if (arg === "--cookie-max-age") { + parsed.cookieMaxAge = argv[++i]; + } + } + return parsed; +} + +function addEvent(message) { + const line = `${new Date().toISOString()} ${message}`; + state.events.push(line); + while (state.events.length > 80) state.events.shift(); + console.log(line); +} + +function findOpenSsl() { + const candidates = [ + process.env.OPENSSL, + "openssl", + "C:\\Program Files\\Git\\usr\\bin\\openssl.exe", + "C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe", + ].filter(Boolean); + + for (const candidate of candidates) { + const result = spawnSync(candidate, ["version"], { stdio: "ignore" }); + if (result.status === 0) return candidate; + } + return null; +} + +function ensureCertificate() { + const certDir = path.join(__dirname, "certs"); + const keyPath = path.join(certDir, "dbsc-poc-key.pem"); + const certPath = path.join(certDir, "dbsc-poc-cert.pem"); + + if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) { + fs.mkdirSync(certDir, { recursive: true }); + const openssl = findOpenSsl(); + if (!openssl) { + throw new Error("OpenSSL was not found. Install Git for Windows or set OPENSSL to an openssl.exe path."); + } + + const altNames = [ + "DNS:localhost", + `DNS:${os.hostname()}`, + "IP:127.0.0.1", + ...getLocalIpv4Addresses().map((address) => `IP:${address}`), + ]; + + const result = spawnSync(openssl, [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-sha256", + "-days", + "365", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-subj", + "/CN=localhost", + "-addext", + `subjectAltName=${[...new Set(altNames)].join(",")}`, + ], { stdio: "ignore" }); + + if (result.status !== 0) { + throw new Error("OpenSSL failed to generate the localhost certificate."); + } + } + + return { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }; +} + +function getLocalIpv4Addresses() { + const addresses = []; + for (const interfaces of Object.values(os.networkInterfaces())) { + for (const entry of interfaces || []) { + if (entry.family === "IPv4" && !entry.internal) { + addresses.push(entry.address); + } + } + } + return addresses; +} + +function requestOrigin(req) { + const host = req.headers.host || `localhost:${PORT}`; + return `https://${host}`; +} + +function token(bytes = 24) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function nowSeconds() { + return Math.floor(Date.now() / 1000); +} + +function htmlEscape(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function quoteSfString(value) { + return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function parseStructuredString(value) { + if (!value) return null; + const input = String(value).trim(); + if (!input.startsWith('"')) return input.split(";", 1)[0].trim(); + + let output = ""; + let escaped = false; + for (let i = 1; i < input.length; i += 1) { + const ch = input[i]; + if (escaped) { + output += ch; + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === '"') break; + output += ch; + } + return output; +} + +function parseCookies(req) { + const cookies = new Map(); + const raw = req.headers.cookie; + if (!raw) return cookies; + + for (const part of raw.split(";")) { + const [name, ...rest] = part.trim().split("="); + if (!name) continue; + cookies.set(name, decodeURIComponent(rest.join("="))); + } + return cookies; +} + +function pruneSessionCookies(session) { + const now = nowSeconds(); + for (const [value, expires] of session.cookies) { + if (expires <= now) { + session.cookies.delete(value); + state.cookieIndex.delete(value); + } + } +} + +function getSessionFromCookie(req) { + const cookies = parseCookies(req); + const cookieValue = cookies.get(COOKIE_NAME); + if (!cookieValue) return null; + + const sessionId = state.cookieIndex.get(cookieValue); + if (!sessionId) return null; + + const session = state.sessions.get(sessionId); + if (!session) return null; + pruneSessionCookies(session); + if (!session.cookies.has(cookieValue)) return null; + return session; +} + +function boundCookie(value, maxAge = COOKIE_MAX_AGE_SECONDS) { + return `${COOKIE_NAME}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; Secure; HttpOnly; SameSite=Lax`; +} + +function sessionInstructions(session) { + return { + session_identifier: session.id, + refresh_url: "/dbsc/refresh", + scope: { + include_site: false, + scope_specification: [ + { type: "exclude", path: "/dbsc" }, + { type: "exclude", path: "/login" }, + { type: "exclude", path: "/logout" }, + ], + }, + credentials: [ + { + type: "cookie", + name: COOKIE_NAME, + attributes: "Path=/; Secure; HttpOnly; SameSite=Lax", + }, + ], + allowed_refresh_initiators: [], + }; +} + +function decodeJwt(jwt) { + const parts = String(jwt).split("."); + if (parts.length !== 3) throw new Error("DBSC proof is not a compact JWS."); + return { + parts, + header: JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")), + payload: JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")), + signature: parts[2] ? Buffer.from(parts[2], "base64url") : Buffer.alloc(0), + signingInput: Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), + }; +} + +function verifyDbscJwt({ jwt, expectedChallenge, expectedAuthorization = null, storedJwk = null, registration = false }) { + const proof = decodeJwt(jwt); + const { header, payload } = proof; + + if (header.typ !== "dbsc+jwt") throw new Error("JWT typ must be dbsc+jwt."); + if (payload.jti !== expectedChallenge) throw new Error("JWT challenge mismatch."); + if (expectedAuthorization && payload.authorization !== expectedAuthorization) { + throw new Error("JWT authorization mismatch."); + } + + const alg = String(header.alg || ""); + let jwk = storedJwk; + if (registration) { + if (alg !== "none" && !header.jwk) { + throw new Error("Registration proof must include the public JWK."); + } + jwk = header.jwk || null; + } + + if (alg === "none") { + if (!ALLOW_NONE_ALGORITHM) throw new Error("alg=none is disabled."); + if (proof.signature.length !== 0) throw new Error("alg=none JWT must have an empty signature."); + return { header, payload, jwk, algorithm: alg }; + } + + if (alg !== "ES256") throw new Error(`Unsupported JWT alg '${alg}'.`); + if (!jwk) throw new Error("Missing JWK for ES256 verification."); + if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new Error("Only EC P-256 JWKs are supported."); + if (proof.signature.length !== 64) throw new Error("ES256 signature must be 64 bytes."); + + const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" }); + const ok = crypto.verify( + "sha256", + proof.signingInput, + { key: publicKey, dsaEncoding: "ieee-p1363" }, + proof.signature, + ); + if (!ok) throw new Error("ES256 signature verification failed."); + return { header, payload, jwk, algorithm: alg }; +} + +function send(res, status, body = "", contentType = "text/html; charset=utf-8", headers = {}) { + const bytes = Buffer.from(body); + res.statusCode = status; + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", bytes.length); + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } + res.end(bytes); +} + +function sendJson(res, status, value, headers = {}) { + send(res, status, JSON.stringify(value, null, 2), "application/json", headers); +} + +function page(title, body) { + return ` + +
+ + +${eventLog()}${eventLog()}${eventLog()}${eventLog()}