@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/src/login.ts
CHANGED
|
@@ -14,12 +14,9 @@ export const LOGIN_PATH = '/__login' as const
|
|
|
14
14
|
/** Path the gateway owns and never forwards: signs the session out. */
|
|
15
15
|
export const LOGOUT_PATH = '/__logout' as const
|
|
16
16
|
|
|
17
|
-
/** The cookie name used for the signed session. */
|
|
18
|
-
export const COOKIE_NAME = 'dsh_gw_auth' as const
|
|
19
|
-
|
|
20
17
|
export interface LoginPageOptions {
|
|
21
18
|
error?: string
|
|
22
|
-
/**
|
|
19
|
+
/** The attempt was refused by the rate limiter, not by a wrong password. */
|
|
23
20
|
limited?: boolean
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -95,19 +92,6 @@ export function serveLoginGet(res: ServerResponse, extraHeaders: OutgoingHttpHea
|
|
|
95
92
|
res.end(renderLoginPage())
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
/** Parse an application/x-www-form-urlencoded body into its fields. */
|
|
99
|
-
export function parseFormBody(body: string): Record<string, string> {
|
|
100
|
-
const out: Record<string, string> = {}
|
|
101
|
-
for (const pair of body.split('&')) {
|
|
102
|
-
if (pair === '') continue
|
|
103
|
-
const eq = pair.indexOf('=')
|
|
104
|
-
const key = eq === -1 ? pair : pair.slice(0, eq)
|
|
105
|
-
const value = eq === -1 ? '' : pair.slice(eq + 1)
|
|
106
|
-
out[decodeURIComponent(key.replaceAll('+', ' '))] = decodeURIComponent(value.replaceAll('+', ' '))
|
|
107
|
-
}
|
|
108
|
-
return out
|
|
109
|
-
}
|
|
110
|
-
|
|
111
95
|
/** Read a request body up to a byte ceiling, rejecting anything larger. */
|
|
112
96
|
export function readBody(req: IncomingMessage, maxBytes: number, res: ServerResponse): Promise<string | undefined> {
|
|
113
97
|
return new Promise((resolve) => {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every request decision the gateway makes, as pure functions over headers,
|
|
3
|
+
* paths and config — the same treatment `auth.ts` already gives
|
|
4
|
+
* `classifySource` / `signCookie` / `originMatchesHost`. `LanGateway` keeps the
|
|
5
|
+
* `http.Server`, the socket bookkeeping and the relay; who may pass, which path
|
|
6
|
+
* the gateway owns, and what the forwarded headers look like are decided here,
|
|
7
|
+
* where a test can reach them with a literal object instead of a live socket.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here reads a clock, a socket or a config file.
|
|
10
|
+
*
|
|
11
|
+
* @module @riceawa/dsh-lan-gateway/request-policy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingHttpHeaders, OutgoingHttpHeaders } from 'node:http'
|
|
15
|
+
import { originMatchesHost, type SourceClass } from './auth.ts'
|
|
16
|
+
import { isUpstreamCookiePair, isUpstreamSessionCookie } from './upstream-session.ts'
|
|
17
|
+
|
|
18
|
+
/** The request facts a policy decision reads. */
|
|
19
|
+
export interface RequestHead {
|
|
20
|
+
/**
|
|
21
|
+
* Optional *and* explicitly undefined-able, matching how Node declares
|
|
22
|
+
* `IncomingMessage.method`. Under `exactOptionalPropertyTypes` those are two
|
|
23
|
+
* different types, and the shorter `method?: string` would reject a plain
|
|
24
|
+
* `IncomingMessage` at every call site.
|
|
25
|
+
*/
|
|
26
|
+
method?: string | undefined
|
|
27
|
+
headers: IncomingHttpHeaders
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
31
|
+
export const READ_ONLY_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
35
|
+
* the non-standard proxy-connection.
|
|
36
|
+
*/
|
|
37
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
38
|
+
'connection',
|
|
39
|
+
'keep-alive',
|
|
40
|
+
'proxy-authenticate',
|
|
41
|
+
'proxy-authorization',
|
|
42
|
+
'proxy-connection',
|
|
43
|
+
'te',
|
|
44
|
+
'trailer',
|
|
45
|
+
'transfer-encoding',
|
|
46
|
+
'upgrade',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
|
|
51
|
+
* exchange that negotiates Connection/Upgrade, so they survive there and
|
|
52
|
+
* nowhere else.
|
|
53
|
+
*/
|
|
54
|
+
const UPGRADE_HANDSHAKE_HEADERS = new Set(['connection', 'upgrade'])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
58
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
59
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
60
|
+
*/
|
|
61
|
+
const FORWARDING_HEADERS = [
|
|
62
|
+
'forwarded',
|
|
63
|
+
'x-forwarded-for',
|
|
64
|
+
'x-forwarded-host',
|
|
65
|
+
'x-forwarded-port',
|
|
66
|
+
'x-forwarded-proto',
|
|
67
|
+
'x-real-ip',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
71
|
+
export function isOwnedPath(pathname: string): boolean {
|
|
72
|
+
return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
77
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
78
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
79
|
+
*
|
|
80
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
81
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
82
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
83
|
+
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
84
|
+
* config route once Host has been rewritten to loopback. Forwarding still
|
|
85
|
+
* relays the raw target: dsh applies the same normalization itself.
|
|
86
|
+
*
|
|
87
|
+
* WHATWG parsing does not drop a trailing slash, and neither does dsh's
|
|
88
|
+
* router, so `/__login/` is not the login page to either of them. The gateway
|
|
89
|
+
* recognizes its own surfaces there anyway: `/__logout/` must still sign out,
|
|
90
|
+
* and `/lan-gateway/config/` must be refused rather than relayed into dsh's
|
|
91
|
+
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
92
|
+
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
93
|
+
*/
|
|
94
|
+
export function pathOf(url: string): string {
|
|
95
|
+
try {
|
|
96
|
+
return new URL(url, 'http://gateway.invalid').pathname.replace(/\/+$/, '') || '/'
|
|
97
|
+
} catch {
|
|
98
|
+
// Unparseable here means unparseable for dsh too; the raw target routes
|
|
99
|
+
// nowhere and is relayed as-is.
|
|
100
|
+
return url
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether `hostname` is loopback (127/8, localhost, ::1).
|
|
106
|
+
*
|
|
107
|
+
* This validates a URL *hostname* — the loopback fence on the gateway's own
|
|
108
|
+
* config route, where the input is the browser's Host header — so it accepts
|
|
109
|
+
* the spellings a URL parser produces, `[::1]` included. `classifySource` in
|
|
110
|
+
* `auth.ts` answers a different question about a different input (a socket
|
|
111
|
+
* address, unwrapped from its `::ffff:` mapping, and including LAN space); the
|
|
112
|
+
* two are related but not interchangeable, and neither should be rewritten in
|
|
113
|
+
* terms of the other without moving its input domain too.
|
|
114
|
+
*/
|
|
115
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
116
|
+
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
117
|
+
const parts = hostname.split('.')
|
|
118
|
+
return (
|
|
119
|
+
parts.length === 4
|
|
120
|
+
&& parts[0] === '127'
|
|
121
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether this source must present a gateway session (default: everyone). */
|
|
126
|
+
export function requiresLogin(source: SourceClass, lanPasswordless: boolean): boolean {
|
|
127
|
+
return !(lanPasswordless && source !== 'internet')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parse the session cookie out of a Cookie header. */
|
|
131
|
+
export function sessionCookie(headers: IncomingHttpHeaders, cookieName: string): string | undefined {
|
|
132
|
+
const header = headers.cookie
|
|
133
|
+
if (typeof header !== 'string') return undefined
|
|
134
|
+
for (const part of header.split(';')) {
|
|
135
|
+
const trimmed = part.trim()
|
|
136
|
+
if (trimmed.startsWith(`${cookieName}=`)) {
|
|
137
|
+
return trimmed.slice(cookieName.length + 1)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The cross-site test shared by every gateway-owned entry point, applied before
|
|
145
|
+
* any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
|
|
146
|
+
* does not name the authority the browser actually used.
|
|
147
|
+
*
|
|
148
|
+
* Only claims a cross-site page cannot suppress are read, which is what makes
|
|
149
|
+
* this usable on the login POST too (see {@link loginOriginAllowed}).
|
|
150
|
+
*/
|
|
151
|
+
export function isCrossSiteRequest(headers: IncomingHttpHeaders): boolean {
|
|
152
|
+
if (headers['sec-fetch-site'] === 'cross-site') return true
|
|
153
|
+
const origin = headers.origin
|
|
154
|
+
if (origin !== undefined && !originMatchesHost(origin, headers.host)) return true
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
|
|
160
|
+
* applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
161
|
+
* state-changing requests and to every WebSocket handshake; reads without an
|
|
162
|
+
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
163
|
+
*/
|
|
164
|
+
export function sameSiteAllowed(req: RequestHead, upgrade: boolean): boolean {
|
|
165
|
+
if (isCrossSiteRequest(req.headers)) return false
|
|
166
|
+
const origin = req.headers.origin
|
|
167
|
+
if (upgrade) return origin !== undefined
|
|
168
|
+
if (!READ_ONLY_METHODS.has(req.method ?? 'GET')) return origin !== undefined
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The fence on the login POST. Issuing a session is as much a state change as
|
|
174
|
+
* retiring one — and a cross-site form post burns the victim's source address
|
|
175
|
+
* through the login rate limiter — so the login route runs the same cross-site
|
|
176
|
+
* test as everything else.
|
|
177
|
+
*
|
|
178
|
+
* It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
|
|
179
|
+
* request must carry an Origin" rule: a browser always sends an Origin on a
|
|
180
|
+
* form POST, but curl, the dsh CLI and other non-browser clients legitimately
|
|
181
|
+
* do not, and requiring one would lock them out of signing in. What remains is
|
|
182
|
+
* what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
|
|
183
|
+
* that disagrees with the Host the request names.
|
|
184
|
+
*/
|
|
185
|
+
export function loginOriginAllowed(headers: IncomingHttpHeaders): boolean {
|
|
186
|
+
return !isCrossSiteRequest(headers)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
191
|
+
* (possibly '').
|
|
192
|
+
*
|
|
193
|
+
* The relay's session is appended to the client's own cookie, and upstream
|
|
194
|
+
* reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
|
|
195
|
+
* typically one minted before dsh's signing secret was reset, so still present
|
|
196
|
+
* but no longer verifying — would therefore shadow the relay's session on every
|
|
197
|
+
* request. That draws a 401, the gateway reads the 401 as "upstream revoked our
|
|
198
|
+
* session" and discards it, the next request re-acquires, and the client's
|
|
199
|
+
* stale cookie shadows that one too: a loop that never converges. Stripping the
|
|
200
|
+
* namespace makes the relay's copy the only one.
|
|
201
|
+
*
|
|
202
|
+
* This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
|
|
203
|
+
* be *accepted* from upstream. The two are deliberately different rules.
|
|
204
|
+
*/
|
|
205
|
+
export function withoutUpstreamSessionPairs(cookie: string): string {
|
|
206
|
+
return cookie
|
|
207
|
+
.split(';')
|
|
208
|
+
.map((pair) => pair.trim())
|
|
209
|
+
.filter((pair) => pair !== '' && !isUpstreamCookiePair(pair))
|
|
210
|
+
.join('; ')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How the outbound request headers are built. */
|
|
214
|
+
export interface UpstreamRequestOptions {
|
|
215
|
+
/** The loopback dsh port the Host/Origin rewrite names. */
|
|
216
|
+
dshPort: number
|
|
217
|
+
/** Keep the WebSocket handshake's Connection/Upgrade headers. */
|
|
218
|
+
keepUpgrade: boolean
|
|
219
|
+
/** The shared upstream session to ride, when the relay holds one. */
|
|
220
|
+
upstreamCookie?: string | undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the outbound headers for one relayed request: rewrite Host/Origin to
|
|
225
|
+
* the loopback upstream, drop hop-by-hop and caller-supplied forwarding
|
|
226
|
+
* headers, clear the upstream cookie namespace the relay owns, and attach the
|
|
227
|
+
* relayed session.
|
|
228
|
+
*/
|
|
229
|
+
export function upstreamRequestHeaders(
|
|
230
|
+
headers: IncomingHttpHeaders,
|
|
231
|
+
options: UpstreamRequestOptions,
|
|
232
|
+
): OutgoingHttpHeaders {
|
|
233
|
+
const out: OutgoingHttpHeaders = { ...headers }
|
|
234
|
+
out.host = `127.0.0.1:${options.dshPort}`
|
|
235
|
+
if (typeof out.origin === 'string') {
|
|
236
|
+
out.origin = `http://127.0.0.1:${options.dshPort}`
|
|
237
|
+
}
|
|
238
|
+
delete out['proxy-connection']
|
|
239
|
+
if (!options.keepUpgrade) {
|
|
240
|
+
delete out.connection
|
|
241
|
+
delete out.upgrade
|
|
242
|
+
}
|
|
243
|
+
for (const name of FORWARDING_HEADERS) delete out[name]
|
|
244
|
+
if (typeof out.cookie === 'string') {
|
|
245
|
+
const kept = withoutUpstreamSessionPairs(out.cookie)
|
|
246
|
+
if (kept === '') delete out.cookie
|
|
247
|
+
else out.cookie = kept
|
|
248
|
+
}
|
|
249
|
+
const relayed = options.upstreamCookie
|
|
250
|
+
if (relayed !== undefined && relayed !== '') {
|
|
251
|
+
const existing = out.cookie
|
|
252
|
+
out.cookie = typeof existing === 'string' && existing !== ''
|
|
253
|
+
? `${existing}; ${relayed}`
|
|
254
|
+
: relayed
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Filter one direction's worth of headers through the same cookie rule, so the
|
|
261
|
+
* HTTP and WebSocket branches cannot drift apart on it.
|
|
262
|
+
*/
|
|
263
|
+
function stripUpstreamCookies(entry: string): boolean {
|
|
264
|
+
return !isUpstreamSessionCookie(entry.trim())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and the
|
|
269
|
+
* upstream session cookie withheld. Upstream's one cookie-minting route is the
|
|
270
|
+
* launch-token exchange at `/`, so a client that already holds a gateway session
|
|
271
|
+
* could otherwise post the token through the gateway and walk away with a
|
|
272
|
+
* durable upstream credential the relay exists to keep on this side. Cookies
|
|
273
|
+
* from other routes (plugins) still pass through.
|
|
274
|
+
*/
|
|
275
|
+
export function downstreamResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
|
|
276
|
+
const headers: OutgoingHttpHeaders = {}
|
|
277
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
278
|
+
if (value === undefined) continue
|
|
279
|
+
const lower = key.toLowerCase()
|
|
280
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue
|
|
281
|
+
if (lower === 'set-cookie') {
|
|
282
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
|
|
283
|
+
if (list.length > 0) headers[key] = list
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
headers[key] = value
|
|
287
|
+
}
|
|
288
|
+
return headers
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The headers of a 101 Switching Protocols response, replayed to the client on
|
|
293
|
+
* the socket the gateway just spliced.
|
|
294
|
+
*
|
|
295
|
+
* {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
|
|
296
|
+
* upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
|
|
297
|
+
* response. The cookie rule is not relaxed with them — the relay's session is
|
|
298
|
+
* withheld on this path too, so upstream cannot hand a client a durable
|
|
299
|
+
* credential by attaching it to the handshake.
|
|
300
|
+
*/
|
|
301
|
+
export function upgradeResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
|
|
302
|
+
const headers: OutgoingHttpHeaders = {}
|
|
303
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
304
|
+
if (value === undefined) continue
|
|
305
|
+
const lower = key.toLowerCase()
|
|
306
|
+
if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue
|
|
307
|
+
if (lower === 'set-cookie') {
|
|
308
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
|
|
309
|
+
if (list.length > 0) headers[key] = list
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
headers[key] = value
|
|
313
|
+
}
|
|
314
|
+
return headers
|
|
315
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
-
import { randomBytes,
|
|
12
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
|
|
15
15
|
/** The state directory: `~/.dsh/lan-gateway`. */
|
|
@@ -37,17 +37,43 @@ export interface GatewayState {
|
|
|
37
37
|
* immediately. Old state files without the field load as epoch 0.
|
|
38
38
|
*/
|
|
39
39
|
sessionEpoch: number
|
|
40
|
+
/**
|
|
41
|
+
* Revoked session ids, each mapped to the epoch millis at which that
|
|
42
|
+
* session's own cookie expires. Signing out revokes the single id the
|
|
43
|
+
* browser presented, so the account's other sessions keep working, and it
|
|
44
|
+
* has to be persisted: the cookie it names stays unforgeable until it
|
|
45
|
+
* expires, and a restart must not resurrect it. Entries lapse once the
|
|
46
|
+
* cookie they name could no longer be presented anyway, which bounds the
|
|
47
|
+
* list by the sessions that are still live somewhere. Absent on state
|
|
48
|
+
* written before 0.5.4.
|
|
49
|
+
*/
|
|
50
|
+
revokedSessions?: Record<string, number>
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
const STATE_FILENAME = 'state.json'
|
|
43
54
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
55
|
+
/** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
|
|
56
|
+
function deriveKey(password: string, salt: Buffer, keylen: number): Promise<Buffer> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
scrypt(password, salt, keylen, (error, derived) => {
|
|
59
|
+
if (error !== null) reject(error)
|
|
60
|
+
else resolve(derived)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a password is present and passes scrypt verification. Asynchronous
|
|
67
|
+
* on purpose: `scryptSync` occupies the event loop for tens of milliseconds
|
|
68
|
+
* per attempt, and that loop is shared with the dsh process the gateway is
|
|
69
|
+
* forwarding to.
|
|
70
|
+
*/
|
|
71
|
+
export async function verifyPassword(state: GatewayState, password: string): Promise<boolean> {
|
|
46
72
|
if (state.password === undefined) return false
|
|
47
73
|
const { hash, salt } = state.password
|
|
48
74
|
try {
|
|
49
75
|
const expected = Buffer.from(hash, 'hex')
|
|
50
|
-
const actual =
|
|
76
|
+
const actual = await deriveKey(password, Buffer.from(salt, 'hex'), expected.length)
|
|
51
77
|
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
52
78
|
} catch {
|
|
53
79
|
return false
|
|
@@ -58,24 +84,76 @@ export function verifyPassword(state: GatewayState, password: string): boolean {
|
|
|
58
84
|
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
59
85
|
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
60
86
|
* password change must invalidate sessions the old password authorized.
|
|
87
|
+
*
|
|
88
|
+
* Deriving the key is asynchronous for the same reason
|
|
89
|
+
* {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
|
|
90
|
+
* milliseconds, and that loop is shared with the dsh process the gateway
|
|
91
|
+
* forwards to. Every caller is already async.
|
|
61
92
|
*/
|
|
62
|
-
export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
export async function setPassword(state: GatewayState, password: string | undefined): Promise<GatewayState> {
|
|
94
|
+
// The new epoch invalidates every cookie on its own account, so the list of
|
|
95
|
+
// individually revoked sessions has nothing left to say: drop it rather than
|
|
96
|
+
// carry entries that can never match again.
|
|
97
|
+
const base: GatewayState = {
|
|
98
|
+
cookieSecret: state.cookieSecret,
|
|
99
|
+
sessionEpoch: state.sessionEpoch + 1,
|
|
66
100
|
}
|
|
101
|
+
if (password === undefined) return base
|
|
67
102
|
const salt = randomBytes(16)
|
|
68
|
-
const hash =
|
|
103
|
+
const hash = await deriveKey(password, salt, 64)
|
|
69
104
|
return {
|
|
70
105
|
...base,
|
|
71
106
|
password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
|
|
72
107
|
}
|
|
73
108
|
}
|
|
74
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Record a session id as revoked.
|
|
112
|
+
* @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
|
|
113
|
+
* rejected on its own account, so the entry is no longer needed; dropping
|
|
114
|
+
* expired entries here is what keeps the list bounded.
|
|
115
|
+
* @param now - epoch millis to judge the existing entries against, injected so
|
|
116
|
+
* a test can age the list without fake timers.
|
|
117
|
+
*/
|
|
118
|
+
export function revokeSession(
|
|
119
|
+
state: GatewayState,
|
|
120
|
+
sid: string,
|
|
121
|
+
expiresMs: number,
|
|
122
|
+
now: number = Date.now(),
|
|
123
|
+
): GatewayState {
|
|
124
|
+
const revoked: Record<string, number> = {}
|
|
125
|
+
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
|
|
126
|
+
if (exp > now) revoked[id] = exp
|
|
127
|
+
}
|
|
128
|
+
revoked[sid] = expiresMs
|
|
129
|
+
return { ...state, revokedSessions: revoked }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `sid` names a session that has been signed out. */
|
|
133
|
+
export function isSessionRevoked(state: GatewayState, sid: string | undefined): boolean {
|
|
134
|
+
if (sid === undefined) return false
|
|
135
|
+
return Object.hasOwn(state.revokedSessions ?? {}, sid)
|
|
136
|
+
}
|
|
137
|
+
|
|
75
138
|
function defaultState(): GatewayState {
|
|
76
139
|
return { cookieSecret: randomBytes(32).toString('base64'), sessionEpoch: 0 }
|
|
77
140
|
}
|
|
78
141
|
|
|
142
|
+
/** Keep the still-live entries of a persisted revocation list, or undefined. */
|
|
143
|
+
function parseRevokedSessions(raw: unknown): Record<string, number> | undefined {
|
|
144
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
|
|
145
|
+
const now = Date.now()
|
|
146
|
+
const out: Record<string, number> = {}
|
|
147
|
+
let anyLive = false
|
|
148
|
+
for (const [sid, exp] of Object.entries(raw)) {
|
|
149
|
+
if (typeof exp === 'number' && Number.isFinite(exp) && exp > now) {
|
|
150
|
+
out[sid] = exp
|
|
151
|
+
anyLive = true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return anyLive ? out : undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
79
157
|
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
80
158
|
export function loadState(home: string = homedir()): GatewayState {
|
|
81
159
|
const dir = stateDir(home)
|
|
@@ -91,6 +169,9 @@ export function loadState(home: string = homedir()): GatewayState {
|
|
|
91
169
|
: 0
|
|
92
170
|
const base: GatewayState = { cookieSecret: parsed.cookieSecret, sessionEpoch }
|
|
93
171
|
if (parsed.password !== undefined) base.password = parsed.password
|
|
172
|
+
// Anything the file lists past its own expiry has lapsed on its own.
|
|
173
|
+
const revoked = parseRevokedSessions(parsed.revokedSessions)
|
|
174
|
+
if (revoked !== undefined) base.revokedSessions = revoked
|
|
94
175
|
return base
|
|
95
176
|
}
|
|
96
177
|
return defaultState()
|
package/src/tls.ts
CHANGED
|
@@ -71,6 +71,27 @@ export function loadOrCreateSelfSigned(
|
|
|
71
71
|
return { material, created: true }
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Load the self-signed material a listener should serve: generate it on first
|
|
76
|
+
* use, reuse the persisted pair otherwise, and replace a persisted certificate
|
|
77
|
+
* whose validity has already lapsed.
|
|
78
|
+
*
|
|
79
|
+
* Nothing renews a self-signed certificate in place, and a browser refuses a
|
|
80
|
+
* lapsed one outright, so without this a certificate that ran out would keep
|
|
81
|
+
* being served until an operator happened to read the expiry date out of
|
|
82
|
+
* `status` and act on it. Renewing mints a fresh key, so a client that had
|
|
83
|
+
* trusted the old certificate has to trust the new one — but that is the case
|
|
84
|
+
* either way, the old one having lapsed.
|
|
85
|
+
*/
|
|
86
|
+
export function loadOrRenewSelfSigned(
|
|
87
|
+
opts: SelfSignedTlsOptions,
|
|
88
|
+
home: string = homedir(),
|
|
89
|
+
): { material: TlsMaterial; renewed: boolean } {
|
|
90
|
+
const { material, created } = loadOrCreateSelfSigned(opts, home)
|
|
91
|
+
if (created || !isCertExpired(material.cert)) return { material, renewed: false }
|
|
92
|
+
return { material: regenerateSelfSigned(opts, home), renewed: true }
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
/**
|
|
75
96
|
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
76
97
|
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
@@ -84,6 +105,29 @@ export function regenerateSelfSigned(opts: SelfSignedTlsOptions, home: string =
|
|
|
84
105
|
return material
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read the persisted self-signed certificate for a status report, or undefined
|
|
110
|
+
* when none has been generated yet.
|
|
111
|
+
*
|
|
112
|
+
* Nothing is created or written here. The status path answers a question about
|
|
113
|
+
* a listener that is already running (or was), and minting a key pair — an RSA
|
|
114
|
+
* generation plus two file writes — to answer a read would both be slow and
|
|
115
|
+
* leave material on disk for a gateway that never started. Generation belongs
|
|
116
|
+
* to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
|
|
117
|
+
* @param home - dsh home override (tests).
|
|
118
|
+
* @returns the certificate material as persisted, or undefined.
|
|
119
|
+
*/
|
|
120
|
+
export function readSelfSignedStatus(home: string = homedir()): TlsMaterial | undefined {
|
|
121
|
+
const dir = tlsDir(home)
|
|
122
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE)
|
|
123
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE)
|
|
124
|
+
if (!existsSync(certPath) || !existsSync(keyPath)) return undefined
|
|
125
|
+
const cert = readFileSync(certPath, 'utf8')
|
|
126
|
+
const key = readFileSync(keyPath, 'utf8')
|
|
127
|
+
new X509Certificate(cert) // sanity: must parse as a certificate
|
|
128
|
+
return { cert, key }
|
|
129
|
+
}
|
|
130
|
+
|
|
87
131
|
function generateSelfSignedMaterial(opts: SelfSignedTlsOptions): TlsMaterial {
|
|
88
132
|
const hosts = opts.hosts.map(h => h.trim()).filter(h => h !== '')
|
|
89
133
|
if (hosts.length === 0) {
|
|
@@ -146,6 +190,18 @@ export interface CertInfo {
|
|
|
146
190
|
san?: string
|
|
147
191
|
}
|
|
148
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Whether a PEM certificate's validity window has already closed. A lapsed
|
|
195
|
+
* certificate is a hard failure browsers will not let the user proceed past,
|
|
196
|
+
* so the listener replaces one rather than keep serving it.
|
|
197
|
+
*/
|
|
198
|
+
export function isCertExpired(certPem: string, now: number = Date.now()): boolean {
|
|
199
|
+
const expiresAt = Date.parse(new X509Certificate(certPem).validTo)
|
|
200
|
+
// An unparseable date reads as "not expired": serving the certificate we were
|
|
201
|
+
// handed beats discarding it over a date-parsing quirk.
|
|
202
|
+
return Number.isFinite(expiresAt) && expiresAt <= now
|
|
203
|
+
}
|
|
204
|
+
|
|
149
205
|
/** Describe a PEM certificate (throws on malformed input). */
|
|
150
206
|
export function describeCert(certPem: string): CertInfo {
|
|
151
207
|
const cert = new X509Certificate(certPem)
|
package/src/upstream-session.ts
CHANGED
|
@@ -35,10 +35,14 @@ interface HeldCookie {
|
|
|
35
35
|
expiresAt: number
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The minimal shared-session contract the gateway consumes.
|
|
40
|
+
*
|
|
41
|
+
* Two methods, and deliberately no reader: a caller that needs the value
|
|
42
|
+
* already holds the one `cookie()` returned, and a second way to read the
|
|
43
|
+
* cache only invites the caller to skip the acquisition it just awaited.
|
|
44
|
+
*/
|
|
39
45
|
export interface UpstreamSession {
|
|
40
|
-
/** The current `name=value` without triggering a re-acquisition. */
|
|
41
|
-
peek(): string | undefined
|
|
42
46
|
/** The current `name=value`, re-acquiring when missing or stale. Never throws. */
|
|
43
47
|
cookie(): Promise<string | undefined>
|
|
44
48
|
/** Forget a session upstream rejected, so the next request re-acquires. */
|
|
@@ -73,20 +77,49 @@ function nameValueOnly(setCookie: string): string {
|
|
|
73
77
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The pathname of a URL, for logging. Never the whole URL: the authenticated
|
|
82
|
+
* URL carries the launch token as a query parameter, and that token is a
|
|
83
|
+
* bearer credential for the upstream harness.
|
|
84
|
+
*/
|
|
85
|
+
function pathOf(url: string): string {
|
|
86
|
+
try {
|
|
87
|
+
return new URL(url).pathname
|
|
88
|
+
} catch {
|
|
89
|
+
return '<unparseable>'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
76
93
|
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
77
94
|
function cookieNameOf(setCookie: string): string {
|
|
78
95
|
const eq = setCookie.indexOf('=')
|
|
79
96
|
return eq === -1 ? '' : setCookie.slice(0, eq).trim()
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Whether one `name=value` fragment of a request `Cookie` header names the
|
|
101
|
+
* upstream session namespace, and so must be dropped before the relay's own
|
|
102
|
+
* copy is appended. This is the *filter* rule: it matches the whole reserved
|
|
103
|
+
* namespace, name only, whether or not the pair is a well-formed session.
|
|
104
|
+
*/
|
|
105
|
+
export function isUpstreamCookiePair(pair: string): boolean {
|
|
106
|
+
return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX)
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
/**
|
|
83
110
|
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
84
111
|
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
85
112
|
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
86
113
|
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
87
114
|
* and silently relays every request anonymously.
|
|
115
|
+
*
|
|
116
|
+
* This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
|
|
117
|
+
* on purpose: filtering drops a whole namespace the gateway owns, whereas
|
|
118
|
+
* accepting a session has to recognize the one cookie upstream actually mints.
|
|
119
|
+
* Both live here because this module owns the protocol fact; a consumer that
|
|
120
|
+
* re-derives it is how the two rules drifted apart before.
|
|
88
121
|
*/
|
|
89
|
-
function isUpstreamSessionCookie(setCookie: string): boolean {
|
|
122
|
+
export function isUpstreamSessionCookie(setCookie: string): boolean {
|
|
90
123
|
const name = cookieNameOf(setCookie)
|
|
91
124
|
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
|
|
92
125
|
}
|
|
@@ -121,7 +154,7 @@ function exchange(
|
|
|
121
154
|
try {
|
|
122
155
|
target = new URL(url)
|
|
123
156
|
} catch {
|
|
124
|
-
log(
|
|
157
|
+
log('exchange: authenticatedUrl is not parseable')
|
|
125
158
|
resolve(undefined)
|
|
126
159
|
return
|
|
127
160
|
}
|
|
@@ -195,10 +228,6 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
195
228
|
return Date.now() < held.expiresAt - 60_000
|
|
196
229
|
}
|
|
197
230
|
|
|
198
|
-
peek(): string | undefined {
|
|
199
|
-
return this.held?.header
|
|
200
|
-
}
|
|
201
|
-
|
|
202
231
|
invalidate(): void {
|
|
203
232
|
if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
|
|
204
233
|
this.held = undefined
|
|
@@ -228,7 +257,9 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
228
257
|
this.log('authenticatedUrl() returned undefined; keeping current session')
|
|
229
258
|
return this.held?.header
|
|
230
259
|
}
|
|
231
|
-
|
|
260
|
+
// Log only the path: the URL carries the launch token in its query string,
|
|
261
|
+
// and that token is a bearer credential for the upstream harness.
|
|
262
|
+
this.log(`acquiring session from ${pathOf(url)}`)
|
|
232
263
|
const result = await exchange(url, this.authority, this.port, this.log)
|
|
233
264
|
if (result !== undefined) {
|
|
234
265
|
this.held = result
|