intial commit

This commit is contained in:
KingOfTheNOPs
2026-05-09 12:18:52 -04:00
parent 90f24b5ca5
commit f17f0a9dfd
4 changed files with 1002 additions and 2 deletions
+8
View File
@@ -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
+156 -2
View File
@@ -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
+12
View File
@@ -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"
}
}
+826
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(title)}</title>
<style>
:root {
color-scheme: light dark;
--bg: #f7f7f3;
--panel: #ffffff;
--ink: #1c2522;
--muted: #5f6b66;
--line: #d9ded8;
--accent: #006b5f;
--accent-2: #b4492d;
--accent-3: #335c98;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font: 15px/1.45 "Segoe UI", system-ui, sans-serif;
color: var(--ink);
background: var(--bg);
}
main {
width: min(980px, calc(100vw - 32px));
margin: 0 auto;
padding: 34px 0;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.subtle { color: var(--muted); }
.grid {
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
gap: 18px;
margin-top: 22px;
align-items: start;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
}
label {
display: block;
font-weight: 600;
margin: 12px 0 6px;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 11px;
font: inherit;
background: transparent;
color: inherit;
}
button, .button {
appearance: none;
border: 0;
border-radius: 6px;
padding: 10px 13px;
font: inherit;
font-weight: 700;
color: #fff;
background: var(--accent);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
}
.button.secondary, button.secondary { background: var(--accent-3); }
.button.warn, button.warn { background: var(--accent-2); }
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
}
dl {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px 12px;
margin: 0;
}
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
pre {
margin: 0;
white-space: pre-wrap;
font: 12px/1.45 Consolas, monospace;
color: #24332f;
max-height: 360px;
overflow: auto;
}
.status {
margin-top: 14px;
min-height: 24px;
color: var(--muted);
}
@media (max-width: 760px) {
main { width: min(100vw - 24px, 980px); padding-top: 20px; }
header { align-items: flex-start; flex-direction: column; }
.grid { grid-template-columns: 1fr; }
dl { grid-template-columns: 1fr; }
}
</style>
</head>
<body><main>${body}</main></body>
</html>`;
}
function eventLog() {
return htmlEscape(state.events.join("\n"));
}
function loginPage(origin, notice = "") {
return page("DBSC POC", `
<header>
<div>
<h1>DBSC POC</h1>
<div class="subtle">${htmlEscape(origin)}</div>
</div>
<div class="actions">
<a class="button secondary" href="/protected">Protected</a>
<a class="button secondary" href="/device-bound">Bound-only</a>
</div>
</header>
<section class="grid">
<form class="panel" method="post" action="/login">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" value="demo">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" value="password">
<div class="actions"><button type="submit">Sign in</button></div>
<div class="status">${htmlEscape(notice)}</div>
</form>
<section class="panel"><pre>${eventLog()}</pre></section>
</section>`);
}
function pendingLoginPage(username, origin) {
return page("DBSC POC", `
<header>
<div>
<h1>DBSC POC</h1>
<div class="subtle">Signed in as ${htmlEscape(username)}</div>
</div>
<a class="button secondary" href="/protected">Protected</a>
</header>
<section class="grid">
<section class="panel">
<dl>
<dt>Registration</dt><dd id="state">waiting</dd>
<dt>Cookie lifetime</dt><dd>${COOKIE_MAX_AGE_SECONDS} seconds</dd>
<dt>Origin</dt><dd>${htmlEscape(origin)}</dd>
</dl>
<div class="actions">
<a class="button" href="/protected">Open protected view</a>
<a class="button secondary" href="/device-bound">Open bound-only page</a>
<a class="button warn" href="/logout">Logout</a>
</div>
</section>
<section class="panel"><pre>${eventLog()}</pre></section>
</section>
<script>
const state = document.getElementById("state");
let attempts = 0;
async function poll() {
attempts += 1;
const res = await fetch("/api/status", { credentials: "include" });
const body = await res.json();
if (body.authenticated) {
location.href = "/protected";
return;
}
state.textContent = attempts > 8 ? "not registered" : "waiting";
if (attempts < 20) setTimeout(poll, 600);
}
setTimeout(poll, 450);
</script>`);
}
function protectedPage(session) {
const ttl = Math.max(0, session.cookieExpires - nowSeconds());
return page("Protected", `
<header>
<div>
<h1>Protected</h1>
<div class="subtle">Bound cookie accepted</div>
</div>
<div class="actions">
<form method="post" action="/expire"><button class="secondary" type="submit">Expire cookie</button></form>
<a class="button warn" href="/logout">Logout</a>
</div>
</header>
<section class="grid">
<section class="panel">
<dl>
<dt>DBSC session</dt><dd>${htmlEscape(session.id)}</dd>
<dt>JWT alg</dt><dd>${htmlEscape(session.algorithm)}</dd>
<dt>Cookie TTL</dt><dd>${ttl} seconds</dd>
<dt>Refresh endpoint</dt><dd>/dbsc/refresh</dd>
</dl>
<div class="actions">
<a class="button" href="/protected">Reload</a>
<a class="button secondary" href="/device-bound">Bound-only page</a>
<a class="button secondary" href="/api/whoami">Whoami JSON</a>
<a class="button secondary" href="/api/status">Status JSON</a>
</div>
</section>
<section class="panel"><pre>${eventLog()}</pre></section>
</section>`);
}
function deviceBoundPage(session) {
const ttl = Math.max(0, session.cookieExpires - nowSeconds());
return page("Device Bound", `
<header>
<div>
<h1>Device Bound</h1>
<div class="subtle">DBSC cookie required</div>
</div>
<div class="actions">
<a class="button secondary" href="/protected">Protected</a>
<a class="button warn" href="/logout">Logout</a>
</div>
</header>
<section class="grid">
<section class="panel">
<dl>
<dt>User</dt><dd>${htmlEscape(session.username)}</dd>
<dt>DBSC session</dt><dd>${htmlEscape(session.id)}</dd>
<dt>Cookie name</dt><dd>${COOKIE_NAME}</dd>
<dt>Cookie TTL</dt><dd>${ttl} seconds</dd>
</dl>
<div class="actions">
<form method="post" action="/expire"><button class="secondary" type="submit">Expire cookie</button></form>
<a class="button" href="/device-bound">Reload</a>
<a class="button secondary" href="/api/whoami">Whoami JSON</a>
</div>
</section>
<section class="panel"><pre>${eventLog()}</pre></section>
</section>`);
}
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
async function handleLogin(req, res) {
const body = await readBody(req);
const form = new URLSearchParams(body);
const username = form.get("username") || "demo";
const challenge = token();
state.pending.set(challenge, {
challenge,
username,
created: nowSeconds(),
});
addEvent(`login accepted for '${username}'; sent Secure-Session-Registration`);
send(res, 200, pendingLoginPage(username, requestOrigin(req)), "text/html; charset=utf-8", {
"Secure-Session-Registration": `(ES256);path=${quoteSfString("/dbsc/register")};challenge=${quoteSfString(challenge)}`,
});
}
async function handleRegister(req, res) {
try {
const jwt = parseStructuredString(req.headers["secure-session-response"]);
if (!jwt) throw new Error("Missing Secure-Session-Response.");
const unverified = decodeJwt(jwt);
const pending = state.pending.get(unverified.payload.jti);
if (!pending) throw new Error("Missing or unknown registration challenge.");
const proof = verifyDbscJwt({
jwt,
expectedChallenge: pending.challenge,
registration: true,
});
const sessionId = token(18);
const cookieValue = token(32);
const nextChallenge = token();
const session = {
id: sessionId,
username: pending.username,
jwk: proof.jwk,
algorithm: proof.algorithm,
cookieValue,
cookieExpires: nowSeconds() + COOKIE_MAX_AGE_SECONDS,
cookies: new Map([[cookieValue, nowSeconds() + COOKIE_MAX_AGE_SECONDS]]),
challenge: nextChallenge,
challengeExpires: nowSeconds() + 90,
};
state.sessions.set(sessionId, session);
state.cookieIndex.set(cookieValue, sessionId);
state.pending.delete(pending.challenge);
addEvent(`registered DBSC session ${sessionId} with ${proof.algorithm}`);
sendJson(res, 200, sessionInstructions(session), {
"Set-Cookie": boundCookie(cookieValue),
"Secure-Session-Challenge": `${quoteSfString(nextChallenge)};id=${quoteSfString(sessionId)}`,
});
} catch (error) {
addEvent(`registration failed: ${error.message}`);
sendJson(res, 400, { error: error.message });
}
}
async function handleRefresh(req, res) {
try {
const sessionId = parseStructuredString(req.headers["sec-secure-session-id"]);
const session = sessionId ? state.sessions.get(sessionId) : null;
if (!session) throw new Error("Unknown Sec-Secure-Session-Id.");
const jwt = parseStructuredString(req.headers["secure-session-response"]);
if (!jwt) {
session.challenge = token();
session.challengeExpires = nowSeconds() + 90;
addEvent(`refresh challenge issued for ${session.id}`);
send(res, 403, "", "text/plain; charset=utf-8", {
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
return;
}
if (session.challengeExpires <= nowSeconds()) {
throw new Error("Refresh challenge expired.");
}
verifyDbscJwt({
jwt,
expectedChallenge: session.challenge,
storedJwk: session.jwk,
});
pruneSessionCookies(session);
session.cookieValue = token(32);
session.cookieExpires = nowSeconds() + COOKIE_MAX_AGE_SECONDS;
session.cookies.set(session.cookieValue, session.cookieExpires);
session.challenge = token();
session.challengeExpires = nowSeconds() + 90;
state.cookieIndex.set(session.cookieValue, session.id);
addEvent(`refreshed bound cookie for ${session.id}`);
sendJson(res, 200, sessionInstructions(session), {
"Set-Cookie": boundCookie(session.cookieValue),
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
} catch (error) {
addEvent(`refresh failed: ${error.message}`);
sendJson(res, 403, { continue: false, error: error.message });
}
}
function handleStatus(req, res) {
const session = getSessionFromCookie(req);
if (!session) {
sendJson(res, 401, { authenticated: false });
return;
}
sendJson(res, 200, {
authenticated: true,
session_identifier: session.id,
cookie_ttl_seconds: Math.max(0, session.cookieExpires - nowSeconds()),
}, {
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
}
function handleWhoami(req, res) {
const session = getSessionFromCookie(req);
if (!session) {
if (req.headers["secure-session-skipped"]) {
addEvent(`whoami request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
}
sendJson(res, 401, {
authenticated: false,
dbsc: {
active: false,
required_cookie: COOKIE_NAME,
},
});
return;
}
sendJson(res, 200, {
authenticated: true,
username: session.username,
dbsc: {
active: true,
session_identifier: session.id,
cookie_name: COOKIE_NAME,
cookie_ttl_seconds: Math.max(0, session.cookieExpires - nowSeconds()),
proof_algorithm: session.algorithm,
refresh_url: "/dbsc/refresh",
accepted_cookie_count: session.cookies.size,
},
}, {
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
}
function handleProtected(req, res) {
const session = getSessionFromCookie(req);
if (!session) {
if (req.headers["secure-session-skipped"]) {
addEvent(`protected request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
}
send(res, 401, loginPage(requestOrigin(req), "No valid bound cookie on this request."));
return;
}
send(res, 200, protectedPage(session), "text/html; charset=utf-8", {
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
}
function handleDeviceBound(req, res) {
const session = getSessionFromCookie(req);
if (!session) {
if (req.headers["secure-session-skipped"]) {
addEvent(`device-bound request arrived with Secure-Session-Skipped: ${req.headers["secure-session-skipped"]}`);
}
send(res, 401, loginPage(requestOrigin(req), "A current device-bound session cookie is required."));
return;
}
send(res, 200, deviceBoundPage(session), "text/html; charset=utf-8", {
"Secure-Session-Challenge": `${quoteSfString(session.challenge)};id=${quoteSfString(session.id)}`,
});
}
function handleExpire(req, res) {
const session = getSessionFromCookie(req);
if (session) {
const presentedCookie = parseCookies(req).get(COOKIE_NAME);
if (presentedCookie) {
session.cookies.delete(presentedCookie);
state.cookieIndex.delete(presentedCookie);
}
if (presentedCookie === session.cookieValue) {
session.cookieExpires = nowSeconds() - 1;
}
addEvent(`expired bound cookie for ${session.id}`);
}
res.writeHead(302, {
Location: "/protected",
"Cache-Control": "no-store",
"Set-Cookie": boundCookie("expired", 0),
});
res.end();
}
function handleLogout(req, res) {
state.sessions.clear();
state.cookieIndex.clear();
addEvent("cleared local sessions");
res.writeHead(302, {
Location: "/",
"Cache-Control": "no-store",
"Set-Cookie": boundCookie("logged-out", 0),
"Clear-Site-Data": '"cookies"',
});
res.end();
}
const tlsOptions = ensureCertificate();
const server = https.createServer(tlsOptions, async (req, res) => {
try {
const url = new URL(req.url, requestOrigin(req));
const route = `${req.method} ${url.pathname}`;
if (route === "GET /") return send(res, 200, loginPage(requestOrigin(req)));
if (route === "POST /login") return handleLogin(req, res);
if (route === "POST /dbsc/register") return handleRegister(req, res);
if (route === "POST /dbsc/refresh") return handleRefresh(req, res);
if (route === "GET /api/status") return handleStatus(req, res);
if (route === "GET /api/whoami") return handleWhoami(req, res);
if (route === "GET /protected") return handleProtected(req, res);
if (route === "GET /device-bound") return handleDeviceBound(req, res);
if (route === "POST /expire") return handleExpire(req, res);
if (route === "GET /logout") return handleLogout(req, res);
send(res, 404, "Not found", "text/plain; charset=utf-8");
} catch (error) {
addEvent(`request failed: ${error.stack || error.message}`);
send(res, 500, "Internal Server Error", "text/plain; charset=utf-8");
}
});
server.listen(PORT, BIND_HOST, () => {
addEvent(`DBSC POC listening on ${BIND_HOST}:${PORT}`);
addEvent(`open https://localhost:${PORT}/ or https://<this-host-ip>:${PORT}/`);
addEvent(`bound cookies last ${COOKIE_MAX_AGE_SECONDS} seconds`);
});