- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing codes, per-device keys (hashed), revoke/remove - /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with per-device cookie minting; slave->slave relay through the master - Devices sidebar panel (all devices' sessions, new session on device), Remote devices settings page, finished-session toasts - plugin + config sync master->slave: registry bundles via pluginManager, link: plugins as tarballs, cordis.patch.yml into a managed bundle layer (!!js preserved), allow/deny, opt-in per-secret credential sync - deploy helpers + e2e probes, unit tests
59 lines
2.3 KiB
JavaScript
59 lines
2.3 KiB
JavaScript
// Mint DSH's own browser-session cookie for a given authority.
|
|
//
|
|
// Format (dsh-client-connection BrowserAuth): cookie name `dsh-auth-` +
|
|
// base64url(sha256(authority)), value `v1.<b64u(json)>.<b64u(hmac-sha256(secret, body))>`
|
|
// with json {version:1, authority, issuedAt, expiresAt}. The 32-byte secret is the
|
|
// credential record `client-connection/browser-session` (kind grant, payload.secret).
|
|
//
|
|
// The target DSH accepts proxied requests only when Host matches the cookie's authority,
|
|
// so the proxy rewrites Host to the loopback authority it forwards to and attaches a
|
|
// cookie minted for exactly that authority. The browser never sees this cookie.
|
|
import crypto from 'node:crypto'
|
|
|
|
const DAY = 86400000
|
|
const b64u = (b) => Buffer.from(b).toString('base64url')
|
|
|
|
export function cookieName(authority) {
|
|
return 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest())
|
|
}
|
|
|
|
export function mintCookie(authority, secretB64u, maxAgeDays = 1) {
|
|
const secret = Buffer.from(String(secretB64u || ''), 'base64url')
|
|
if (secret.length !== 32) return null
|
|
const issuedAt = Date.now()
|
|
const body = b64u(JSON.stringify({ version: 1, authority, issuedAt, expiresAt: issuedAt + maxAgeDays * DAY }))
|
|
const sig = b64u(crypto.createHmac('sha256', secret).update(body).digest())
|
|
return `${cookieName(authority)}=v1.${body}.${sig}`
|
|
}
|
|
|
|
/** Read the browser-session signing secret through ctx.credentials (no file access). */
|
|
export async function readBrowserSecret(ctx) {
|
|
let creds
|
|
try { creds = typeof ctx.get === 'function' ? ctx.get('credentials') : ctx.credentials } catch { creds = undefined }
|
|
if (!creds || typeof creds.readRecord !== 'function') return ''
|
|
try {
|
|
const rec = await creds.readRecord('client-connection/browser-session')
|
|
return rec?.payload?.secret || ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** Small cache: re-mint a cookie per authority once an hour. */
|
|
export class CookieJar {
|
|
constructor(ctx) {
|
|
this.ctx = ctx
|
|
this.cache = new Map()
|
|
this.secret = ''
|
|
}
|
|
|
|
async get(authority) {
|
|
const hit = this.cache.get(authority)
|
|
if (hit && hit.at > Date.now() - 3600000) return hit.cookie
|
|
if (!this.secret) this.secret = await readBrowserSecret(this.ctx)
|
|
const cookie = mintCookie(authority, this.secret, 1)
|
|
if (cookie) this.cache.set(authority, { cookie, at: Date.now() })
|
|
return cookie
|
|
}
|
|
}
|