mirror of
https://github.com/KingOfTheNOPs/DBSC-Example
synced 2026-06-29 08:59:52 +00:00
intial commit
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 `<!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`);
|
||||
});
|
||||
Reference in New Issue
Block a user