@riceawa/dsh-lan-gateway 0.5.2 → 0.5.4

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/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, scryptSync, timingSafeEqual } from 'node:crypto'
12
+ import { randomBytes, scrypt, scryptSync, 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
- /** Whether a password is present and passes scrypt verification. */
45
- export function verifyPassword(state: GatewayState, password: string): boolean {
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 = scryptSync(password, Buffer.from(salt, 'hex'), expected.length)
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
@@ -60,10 +86,14 @@ export function verifyPassword(state: GatewayState, password: string): boolean {
60
86
  * password change must invalidate sessions the old password authorized.
61
87
  */
62
88
  export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
63
- const base = { ...state, sessionEpoch: state.sessionEpoch + 1 }
64
- if (password === undefined) {
65
- return { cookieSecret: base.cookieSecret, sessionEpoch: base.sessionEpoch }
89
+ // The new epoch invalidates every cookie on its own account, so the list of
90
+ // individually revoked sessions has nothing left to say: drop it rather than
91
+ // carry entries that can never match again.
92
+ const base: GatewayState = {
93
+ cookieSecret: state.cookieSecret,
94
+ sessionEpoch: state.sessionEpoch + 1,
66
95
  }
96
+ if (password === undefined) return base
67
97
  const salt = randomBytes(16)
68
98
  const hash = scryptSync(password, salt, 64)
69
99
  return {
@@ -72,10 +102,47 @@ export function setPassword(state: GatewayState, password: string | undefined):
72
102
  }
73
103
  }
74
104
 
105
+ /**
106
+ * Record a session id as revoked.
107
+ * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
108
+ * rejected on its own account, so the entry is no longer needed; dropping
109
+ * expired entries here is what keeps the list bounded.
110
+ */
111
+ export function revokeSession(state: GatewayState, sid: string, expiresMs: number): GatewayState {
112
+ const now = Date.now()
113
+ const revoked: Record<string, number> = {}
114
+ for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
115
+ if (exp > now) revoked[id] = exp
116
+ }
117
+ revoked[sid] = expiresMs
118
+ return { ...state, revokedSessions: revoked }
119
+ }
120
+
121
+ /** Whether `sid` names a session that has been signed out. */
122
+ export function isSessionRevoked(state: GatewayState, sid: string | undefined): boolean {
123
+ if (sid === undefined) return false
124
+ return Object.hasOwn(state.revokedSessions ?? {}, sid)
125
+ }
126
+
75
127
  function defaultState(): GatewayState {
76
128
  return { cookieSecret: randomBytes(32).toString('base64'), sessionEpoch: 0 }
77
129
  }
78
130
 
131
+ /** Keep the still-live entries of a persisted revocation list, or undefined. */
132
+ function parseRevokedSessions(raw: unknown): Record<string, number> | undefined {
133
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
134
+ const now = Date.now()
135
+ const out: Record<string, number> = {}
136
+ let anyLive = false
137
+ for (const [sid, exp] of Object.entries(raw)) {
138
+ if (typeof exp === 'number' && Number.isFinite(exp) && exp > now) {
139
+ out[sid] = exp
140
+ anyLive = true
141
+ }
142
+ }
143
+ return anyLive ? out : undefined
144
+ }
145
+
79
146
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
80
147
  export function loadState(home: string = homedir()): GatewayState {
81
148
  const dir = stateDir(home)
@@ -91,6 +158,9 @@ export function loadState(home: string = homedir()): GatewayState {
91
158
  : 0
92
159
  const base: GatewayState = { cookieSecret: parsed.cookieSecret, sessionEpoch }
93
160
  if (parsed.password !== undefined) base.password = parsed.password
161
+ // Anything the file lists past its own expiry has lapsed on its own.
162
+ const revoked = parseRevokedSessions(parsed.revokedSessions)
163
+ if (revoked !== undefined) base.revokedSessions = revoked
94
164
  return base
95
165
  }
96
166
  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`.
@@ -146,6 +167,18 @@ export interface CertInfo {
146
167
  san?: string
147
168
  }
148
169
 
170
+ /**
171
+ * Whether a PEM certificate's validity window has already closed. A lapsed
172
+ * certificate is a hard failure browsers will not let the user proceed past,
173
+ * so the listener replaces one rather than keep serving it.
174
+ */
175
+ export function isCertExpired(certPem: string, now: number = Date.now()): boolean {
176
+ const expiresAt = Date.parse(new X509Certificate(certPem).validTo)
177
+ // An unparseable date reads as "not expired": serving the certificate we were
178
+ // handed beats discarding it over a date-parsing quirk.
179
+ return Number.isFinite(expiresAt) && expiresAt <= now
180
+ }
181
+
149
182
  /** Describe a PEM certificate (throws on malformed input). */
150
183
  export function describeCert(certPem: string): CertInfo {
151
184
  const cert = new X509Certificate(certPem)
@@ -57,6 +57,14 @@ export interface UpstreamSessionRelayOptions {
57
57
  * upstream restart is picked up.
58
58
  */
59
59
  authenticatedUrl: () => string | undefined
60
+ /**
61
+ * Optional low-frequency debug sink: one line per acquisition attempt,
62
+ * exchange outcome, and invalidation. Every other failure path here is
63
+ * silent by design (the relay never throws), which makes a mis-named cookie
64
+ * or an unreachable upstream indistinguishable from "the base has no
65
+ * browser sessions" — wire `ctx.logger` to make that visible.
66
+ */
67
+ log?: (message: string) => void
60
68
  }
61
69
 
62
70
  /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
@@ -65,6 +73,19 @@ function nameValueOnly(setCookie: string): string {
65
73
  return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
66
74
  }
67
75
 
76
+ /**
77
+ * The pathname of a URL, for logging. Never the whole URL: the authenticated
78
+ * URL carries the launch token as a query parameter, and that token is a
79
+ * bearer credential for the upstream harness.
80
+ */
81
+ function pathOf(url: string): string {
82
+ try {
83
+ return new URL(url).pathname
84
+ } catch {
85
+ return '<unparseable>'
86
+ }
87
+ }
88
+
68
89
  /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
69
90
  function cookieNameOf(setCookie: string): string {
70
91
  const eq = setCookie.indexOf('=')
@@ -106,12 +127,14 @@ function exchange(
106
127
  url: string,
107
128
  authority: string,
108
129
  port: number,
130
+ log: (message: string) => void,
109
131
  ): Promise<ExchangeResult | undefined> {
110
132
  return new Promise((resolve) => {
111
133
  let target: URL
112
134
  try {
113
135
  target = new URL(url)
114
136
  } catch {
137
+ log('exchange: authenticatedUrl is not parseable')
115
138
  resolve(undefined)
116
139
  return
117
140
  }
@@ -125,21 +148,33 @@ function exchange(
125
148
  const setCookies = response.headers['set-cookie']
126
149
  response.resume() // drain so the socket can be reused
127
150
  if (setCookies === undefined) {
151
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`)
128
152
  resolve(undefined)
129
153
  return
130
154
  }
131
- const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
132
- .find(isUpstreamSessionCookie)
155
+ const all = Array.isArray(setCookies) ? setCookies : [setCookies]
156
+ const raw = all.find(isUpstreamSessionCookie)
133
157
  if (raw === undefined) {
158
+ // Name what did come back: a missing session cookie is otherwise
159
+ // indistinguishable from an upstream that mints a differently-named one.
160
+ const names = all.map(cookieNameOf).filter((name) => name !== '')
161
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(', ') || 'none'})`)
134
162
  resolve(undefined)
135
163
  return
136
164
  }
137
165
  const header = nameValueOnly(raw)
138
166
  const maxAge = maxAgeSeconds(raw)
167
+ log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? 'n/a'})`)
139
168
  resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
140
169
  })
141
- request.on('error', () => resolve(undefined))
142
- request.setTimeout(5000, () => request.destroy(new Error('upstream-session exchange timeout')))
170
+ request.on('error', (error: Error) => {
171
+ log(`exchange error: ${error.message}`)
172
+ resolve(undefined)
173
+ })
174
+ request.setTimeout(5000, () => {
175
+ log('exchange timeout (5s)')
176
+ request.destroy(new Error('upstream-session exchange timeout'))
177
+ })
143
178
  request.end()
144
179
  })
145
180
  }
@@ -153,6 +188,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
153
188
  private readonly port: number
154
189
  private readonly authority: string
155
190
  private readonly authenticatedUrl: () => string | undefined
191
+ private readonly log: (message: string) => void
156
192
  private held: HeldCookie | undefined
157
193
  private inflight: Promise<string | undefined> | undefined
158
194
 
@@ -160,6 +196,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
160
196
  this.port = options.port
161
197
  this.authority = options.authority ?? `127.0.0.1:${options.port}`
162
198
  this.authenticatedUrl = options.authenticatedUrl
199
+ this.log = options.log ?? (() => { /* no debug sink configured */ })
163
200
  }
164
201
 
165
202
  /** Whether the held session is still comfortably inside its lifetime. */
@@ -176,6 +213,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
176
213
  }
177
214
 
178
215
  invalidate(): void {
216
+ if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
179
217
  this.held = undefined
180
218
  }
181
219
 
@@ -195,9 +233,24 @@ export class UpstreamSessionRelay implements UpstreamSession {
195
233
 
196
234
  private async doExchange(): Promise<string | undefined> {
197
235
  const url = this.authenticatedUrl()
198
- if (url === undefined) return undefined
199
- const result = await exchange(url, this.authority, this.port)
200
- if (result !== undefined) this.held = result
236
+ if (url === undefined) {
237
+ // The launch URL is transiently unavailable (e.g. the connection service
238
+ // is mid-restart). Keep whatever is held: returning undefined here would
239
+ // forward with no cookie at all and draw a 401 upstream, which is worse
240
+ // than riding a session that is probably still valid.
241
+ this.log('authenticatedUrl() returned undefined; keeping current session')
242
+ return this.held?.header
243
+ }
244
+ // Log only the path: the URL carries the launch token in its query string,
245
+ // and that token is a bearer credential for the upstream harness.
246
+ this.log(`acquiring session from ${pathOf(url)}`)
247
+ const result = await exchange(url, this.authority, this.port, this.log)
248
+ if (result !== undefined) {
249
+ this.held = result
250
+ this.log('session acquired and cached')
251
+ } else {
252
+ this.log('exchange failed; keeping current session')
253
+ }
201
254
  // On a transient failure keep whatever session is still held rather than
202
255
  // dropping to anonymous.
203
256
  return this.held?.header