dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "access-manager",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs", "health"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import { chmod, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { AccessStateStore } from './lib/store.mjs'
import { AccessService, createAccessHttpServer } from './lib/server.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
async function listen(server, path) {
await mkdir(dirname(path), { recursive: true })
await rm(path, { force: true })
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(path, resolve)
})
await chmod(path, 0o600)
}
const classificationToken = process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
delete process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
if (typeof classificationToken !== 'string' || classificationToken.length < 32) {
throw new Error('Access classification token is unavailable')
}
const store = new AccessStateStore({ root: paths.accessStateRoot })
await store.prepare()
const service = new AccessService({
store,
classificationToken,
report: (message, fields) => {
process.send?.({ type: 'diagnostic', message, fields })
},
})
await service.reconcileRuntimePolicy()
const access = createAccessHttpServer({ service })
const recovery = createAccessHttpServer({ service, surface: 'recovery' })
await listen(access, paths.accessSocket)
await listen(recovery, paths.accessRecoverySocket)
process.send?.({ type: 'ready', componentReady: true })
let resolveSignal
const signal = new Promise(resolve => { resolveSignal = resolve })
process.once('SIGINT', () => resolveSignal())
process.once('SIGTERM', () => resolveSignal())
await signal
await Promise.all([
new Promise(resolve => access.close(resolve)),
new Promise(resolve => recovery.close(resolve)),
])
@@ -0,0 +1,64 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(random) { return `dshcap_${random(32).toString('base64url')}` }
export function capabilityTarget(path) {
if (typeof path !== 'string' || path.length === 0 || path.length > 8_192 || !path.startsWith('/')) {
throw new TypeError('capability target is invalid')
}
const value = new URL(path, 'http://capability.internal')
if (value.origin !== 'http://capability.internal' || value.hash !== '') throw new TypeError('capability target is invalid')
return `${value.pathname}${value.search}`
}
export class CapabilityStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 5_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.capabilities = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.capabilities) if (value.expiresAt <= now) this.capabilities.delete(key)
}
issue(session, account, { audience, method, target }) {
if (!['management', 'maintenance', 'plugin'].includes(audience)) throw new TypeError('capability audience is invalid')
if (!/^(?:GET|HEAD|POST|PUT|DELETE)$/.test(method ?? '')) throw new TypeError('capability method is invalid')
this.prune()
const value = token(this.random)
this.capabilities.set(digest(value), {
audience,
method,
target: capabilityTarget(target),
sessionId: session.sessionId,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
expiresAt: this.now() + this.ttlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.ttlMs).toISOString() })
}
consume(value, account, { audience, method, target }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const capability = this.capabilities.get(key)
this.capabilities.delete(key)
const additionalVersion = account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null
if (capability === undefined || capability.accountId !== account.accountId
|| capability.mainCredentialVersion !== account.mainCredential.version
|| capability.managementAdditionalCredentialVersion !== additionalVersion
|| capability.managementAccessVersion !== account.managementAccess.version
|| capability.audience !== audience || capability.method !== method
|| capability.target !== capabilityTarget(target)) return undefined
return Object.freeze({ sessionId: capability.sessionId, audience: capability.audience })
}
}
@@ -0,0 +1,89 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import { AccessError } from './errors.mjs'
const scrypt = promisify(scryptCallback)
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u
const CONTROL_CHARACTERS = /\p{Cc}/u
export const SCRYPT_POLICY = Object.freeze({ N: 32768, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 })
function codePoints(value) { return [...value].length }
export function normalizeUsername(input) {
if (typeof input !== 'string') throw new AccessError('USERNAME_INVALID', 'username is invalid')
const value = input.trim().normalize('NFC')
const bytes = Buffer.byteLength(value, 'utf8')
if (value.length === 0 || codePoints(value) > 64 || bytes > 256
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('USERNAME_INVALID', 'username is invalid')
}
return value
}
export function normalizePassword(input) {
if (typeof input !== 'string') throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
const value = input.normalize('NFC')
const length = codePoints(value)
if (length < 8 || length > 1024 || Buffer.byteLength(value, 'utf8') > 1024
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
}
return value
}
function validParameters(value) {
return value !== null && typeof value === 'object'
&& Number.isInteger(value.N) && value.N >= 2
&& Number.isInteger(value.r) && value.r > 0
&& Number.isInteger(value.p) && value.p > 0
&& Number.isInteger(value.keyLength) && value.keyLength >= 16
&& Number.isInteger(value.maxmem) && value.maxmem >= 1024 * 1024
}
export async function createCredential(password, {
version = 1,
policy = SCRYPT_POLICY,
now = () => new Date(),
random = randomBytes,
derive = scrypt,
} = {}) {
const normalized = normalizePassword(password)
const salt = random(16)
const hash = await derive(normalized, salt, policy.keyLength, {
N: policy.N, r: policy.r, p: policy.p, maxmem: policy.maxmem,
})
return Object.freeze({
version,
algorithm: 'scrypt',
parameters: { ...policy },
salt: Buffer.from(salt).toString('base64url'),
hash: Buffer.from(hash).toString('base64url'),
changedAt: now().toISOString(),
})
}
export function validCredential(value) {
if (value === null || typeof value !== 'object' || value.algorithm !== 'scrypt'
|| !Number.isInteger(value.version) || value.version < 1 || !validParameters(value.parameters)
|| typeof value.salt !== 'string' || typeof value.hash !== 'string'
|| typeof value.changedAt !== 'string') return false
try {
return Buffer.from(value.salt, 'base64url').byteLength >= 16
&& Buffer.from(value.hash, 'base64url').byteLength === value.parameters.keyLength
&& Number.isFinite(Date.parse(value.changedAt))
} catch { return false }
}
export async function verifyCredential(password, credential, { derive = scrypt } = {}) {
if (!validCredential(credential)) return false
let normalized
try { normalized = normalizePassword(password) } catch { return false }
const expected = Buffer.from(credential.hash, 'base64url')
const salt = Buffer.from(credential.salt, 'base64url')
const parameters = credential.parameters
const actual = Buffer.from(await derive(normalized, salt, parameters.keyLength, {
N: parameters.N, r: parameters.r, p: parameters.p, maxmem: parameters.maxmem,
}))
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
@@ -0,0 +1,19 @@
export class AccessError extends Error {
constructor(code, message, statusCode = 400, details = {}) {
super(message)
this.name = 'AccessError'
this.code = code
this.statusCode = statusCode
this.details = details
}
}
export function accessErrorBody(error) {
if (error instanceof AccessError) return {
error: error.message,
code: error.code,
...(Number.isInteger(error.details.retryAfterSeconds)
? { retryAfterSeconds: error.details.retryAfterSeconds } : {}),
}
return { error: 'access operation failed', code: 'ACCESS_INTERNAL_ERROR' }
}
@@ -0,0 +1,97 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
export class ManagementExchangeStore {
constructor({ now = Date.now, random = randomBytes, handoffTtlMs = 30_000, pendingTtlMs = 5 * 60_000, pendingAttempts = 16 } = {}) {
this.now = now
this.random = random
this.handoffTtlMs = handoffTtlMs
this.pendingTtlMs = pendingTtlMs
this.pendingAttempts = pendingAttempts
this.handoffs = new Map()
this.pending = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.handoffs) if (value.expiresAt <= now) this.handoffs.delete(key)
for (const [key, value] of this.pending) if (value.expiresAt <= now) this.pending.delete(key)
}
createHandoff(session, account, targetOrigin) {
this.prune()
const value = token('dshh', this.random)
this.handoffs.set(digest(value), {
accountId: account.accountId,
dshSessionId: session.sessionId,
mainCredentialVersion: account.mainCredential.version,
managementAccessVersion: account.managementAccess.version,
sourceDshOrigin: session.origin,
targetOrigin,
expiresAt: this.now() + this.handoffTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.handoffTtlMs).toISOString() })
}
consumeHandoff(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const handoff = this.handoffs.get(key)
this.handoffs.delete(key)
if (handoff === undefined || handoff.accountId !== account.accountId
|| handoff.mainCredentialVersion !== account.mainCredential.version
|| handoff.managementAccessVersion !== account.managementAccess.version
|| handoff.targetOrigin !== targetOrigin) return undefined
return handoff
}
createPending(account, { targetOrigin, sourceDshOrigin = null, sourceDshSessionId = null }) {
this.prune()
const value = token('dshmp', this.random)
this.pending.set(digest(value), {
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.version,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
attempts: 0,
expiresAt: this.now() + this.pendingTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.pendingTtlMs).toISOString() })
}
peekPendingSource(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const pending = this.pending.get(digest(value))
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
return pending.sourceDshSessionId
}
inspectPending(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const pending = this.pending.get(key)
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
pending.attempts += 1
if (pending.attempts > this.pendingAttempts) {
this.pending.delete(key)
return undefined
}
return { key, value: pending }
}
consumePending(key) { return this.pending.delete(key) }
}
@@ -0,0 +1,241 @@
import { AccessError } from './errors.mjs'
export class AuthenticationLimiter {
constructor({
globalLimit = 20,
windowMs = 60_000,
maxConcurrent = 2,
backoffThreshold = 5,
initialBackoffMs = 30_000,
maxBackoffMs = 15 * 60_000,
consecutiveResetMs = 24 * 60 * 60_000,
sourceWindows,
globalWindows,
clock = () => Date.now(),
} = {}) {
this.globalLimit = globalLimit
this.windowMs = windowMs
this.maxConcurrent = maxConcurrent
this.backoffThreshold = backoffThreshold
this.initialBackoffMs = initialBackoffMs
this.maxBackoffMs = maxBackoffMs
this.consecutiveResetMs = consecutiveResetMs
this.sourceWindows = sourceWindows ?? [
{ limit: 12, windowMs: 60 * 60_000 },
{ limit: 24, windowMs: 24 * 60 * 60_000 },
]
this.globalWindows = globalWindows ?? [
{ limit: globalLimit, windowMs },
{ limit: 60, windowMs: 60 * 60_000 },
{ limit: 120, windowMs: 24 * 60 * 60_000 },
]
this.maxSourceWindowMs = Math.max(...this.sourceWindows.map(value => value.windowMs))
this.maxGlobalWindowMs = Math.max(...this.globalWindows.map(value => value.windowMs))
this.clock = clock
this.global = []
this.sources = new Map()
this.consecutiveFailures = new Map()
this.active = 0
this.lastPruneAt = Number.NEGATIVE_INFINITY
}
prune(values, now, windowMs = this.windowMs) {
while (values.length > 0 && this.timestamp(values[0]) <= now - windowMs) values.shift()
}
timestamp(value) { return typeof value === 'number' ? value : value.at }
retryAfterSeconds(values, windows, now) {
let retryAfterMs = 0
for (const { limit, windowMs } of windows) {
const withinWindow = values.filter(value => this.timestamp(value) > now - windowMs)
if (withinWindow.length < limit) continue
retryAfterMs = Math.max(
retryAfterMs,
this.timestamp(withinWindow[withinWindow.length - limit]) + windowMs - now,
)
}
return Math.max(0, Math.ceil(retryAfterMs / 1_000))
}
retryKey(accountId, sourceId, credential = 'main') { return `${accountId}\u0000${credential}\u0000${sourceId}` }
retryPrefix(accountId, credential = 'all') {
return credential === 'all' ? `${accountId}\u0000` : `${accountId}\u0000${credential}\u0000`
}
pruneExpiredSources(now) {
if (now - this.lastPruneAt < this.windowMs) return
this.lastPruneAt = now
for (const [key, values] of this.sources) {
this.prune(values, now, this.maxSourceWindowMs)
if (values.length === 0) this.sources.delete(key)
}
for (const [key, retry] of this.consecutiveFailures) {
if (retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
}
currentRetry(accountId = 'unknown', sourceId = 'unknown', now = this.clock(), credential = 'main') {
const retryKey = this.retryKey(accountId, sourceId, credential)
const retry = this.consecutiveFailures.get(retryKey)
if (retry !== undefined && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(retryKey)
return undefined
}
return retry
}
checkRetry(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const now = this.clock()
const retry = this.currentRetry(accountId, sourceId, now, credential)
if (retry !== undefined && retry.blockedUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((retry.blockedUntil - now) / 1_000))
throw new AccessError(
'AUTHENTICATION_RETRY_REQUIRED',
`authentication retry is available in ${retryAfterSeconds} seconds`,
429,
{ retryAfterSeconds },
)
}
}
enter(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
this.checkRetry(accountId, sourceId, credential)
const now = this.clock()
this.pruneExpiredSources(now)
const retryKey = this.retryKey(accountId, sourceId, credential)
const source = this.sources.get(retryKey) ?? []
this.prune(source, now, this.maxSourceWindowMs)
this.sources.set(retryKey, source)
this.prune(this.global, now, this.maxGlobalWindowMs)
if (this.active >= this.maxConcurrent) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
'authentication is temporarily unavailable',
429,
{ retryAfterSeconds: 1 },
)
}
const globalRetryAfterSeconds = this.retryAfterSeconds(this.global, this.globalWindows, now)
const sourceRetryAfterSeconds = this.retryAfterSeconds(source, this.sourceWindows, now)
if (sourceRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`browser authentication is available in ${sourceRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: sourceRetryAfterSeconds },
)
}
if (globalRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`administrator authentication is available in ${globalRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: globalRetryAfterSeconds },
)
}
this.active += 1
let released = false
const release = success => {
if (released) return this.sourceStatus(accountId, sourceId, credential)
released = true
this.active -= 1
if (success !== true) {
const failedAt = this.clock()
this.global.push({ at: failedAt, credential })
source.push(failedAt)
const previous = this.consecutiveFailures.get(retryKey)
const failures = (previous !== undefined
&& previous.lastFailureAt > failedAt - this.consecutiveResetMs ? previous.failures : 0) + 1
const exponent = Math.max(0, failures - this.backoffThreshold)
const backoffMs = failures < this.backoffThreshold
? 0
: Math.min(this.maxBackoffMs, this.initialBackoffMs * (2 ** exponent))
this.consecutiveFailures.set(retryKey, {
failures,
blockedUntil: backoffMs === 0 ? 0 : failedAt + backoffMs,
lastFailureAt: failedAt,
})
} else {
this.consecutiveFailures.delete(this.retryKey(accountId, sourceId, credential))
this.sources.delete(this.retryKey(accountId, sourceId, credential))
}
return this.sourceStatus(accountId, sourceId, credential)
}
return { release }
}
sourceStatus(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const retry = this.currentRetry(accountId, sourceId, this.clock(), credential)
if (retry === undefined) return { consecutiveFailures: 0, retryAfterSeconds: 0 }
return {
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}
}
status(accountId = 'unknown', credential = 'all') {
const now = this.clock()
this.pruneExpiredSources(now)
this.prune(this.global, now, this.maxGlobalWindowMs)
const prefix = this.retryPrefix(accountId, credential)
for (const [key, retry] of this.consecutiveFailures) {
if (key.startsWith(prefix) && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
const states = [...this.consecutiveFailures.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, retry]) => ({
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}))
const sourcePrefix = prefix
const sourceRetryAfterSeconds = Math.max(0, ...[...this.sources.entries()]
.filter(([key]) => key.startsWith(sourcePrefix))
.map(([, values]) => this.retryAfterSeconds(values, this.sourceWindows, now)))
const global = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
return {
activeSources: states.length,
consecutiveFailures: Math.max(0, ...states.map(value => value.consecutiveFailures)),
retryAfterSeconds: Math.max(0, ...states.map(value => value.retryAfterSeconds)),
sourceRetryAfterSeconds,
globalFailures: global.length,
globalRetryAfterSeconds: this.retryAfterSeconds(global, this.globalWindows, now),
}
}
clear(accountId = 'unknown', credential = 'all') {
const previous = this.status(accountId, credential)
const prefix = this.retryPrefix(accountId, credential)
for (const key of this.consecutiveFailures.keys()) {
if (key.startsWith(prefix)) this.consecutiveFailures.delete(key)
}
for (const key of this.sources.keys()) {
if (key.startsWith(prefix)) this.sources.delete(key)
}
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return { cleared: previous.activeSources > 0 || previous.globalFailures > 0, ...previous }
}
clearGlobal(credential = 'all') {
const now = this.clock()
this.prune(this.global, now, this.maxGlobalWindowMs)
const matching = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
const globalFailures = matching.length
const globalRetryAfterSeconds = this.retryAfterSeconds(matching, this.globalWindows, now)
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return {
cleared: globalFailures > 0,
globalFailures,
globalRetryAfterSeconds,
}
}
}
@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises'
function groupId(groupFile, name) {
for (const line of groupFile.split('\n')) {
const fields = line.split(':')
if (fields[0] === name && /^\d+$/.test(fields[2] ?? '')) return Number(fields[2])
}
return null
}
function supplementaryGroups(status) {
const line = status.split('\n').find(value => value.startsWith('Groups:'))
if (line === undefined) return []
return line.slice('Groups:'.length).trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger)
}
function mountOption(mounts, mountPoint, prefix) {
for (const line of mounts.split('\n')) {
const fields = line.split(' ')
if (fields[1] !== mountPoint) continue
return fields[3]?.split(',').find(value => value.startsWith(prefix)) ?? null
}
return null
}
async function optionalRead(path) {
try { return await readFile(path, 'utf8') } catch { return '' }
}
export async function detectRuntimeCapabilities({
groupPath = '/etc/group',
initStatusPath = '/proc/1/status',
mountsPath = '/proc/mounts',
ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope',
} = {}) {
const [groupsFile, initStatus, mounts, ptraceScopeValue] = await Promise.all([
optionalRead(groupPath),
optionalRead(initStatusPath),
optionalRead(mountsPath),
optionalRead(ptraceScopePath),
])
const enabledGroup = groupId(groupsFile, 'dsh-sudo-true')
const disabledGroup = groupId(groupsFile, 'dsh-sudo-false')
const initGroups = supplementaryGroups(initStatus)
const dshRootCapabilityEffective = enabledGroup !== null && initGroups.includes(enabledGroup)
const sudoSelection = dshRootCapabilityEffective
? 'enabled'
: (disabledGroup !== null && initGroups.includes(disabledGroup) ? 'disabled' : 'unknown')
const ptraceScope = /^\d+$/.test(ptraceScopeValue.trim()) ? Number(ptraceScopeValue.trim()) : null
const hidepid = mountOption(mounts, '/proc', 'hidepid=')
return {
dshRootCapabilityEffective,
agentIsolationEffective: false,
details: {
sudoSelection,
ptraceScope,
procHidepid: hidepid === null ? null : Number(hidepid.slice('hidepid='.length)),
agentIsolationReason: 'shared-node-process-identity',
},
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,301 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) {
return createHash('sha256').update(value).digest()
}
function identifier(random = randomBytes, bytes = 32) {
return random(bytes).toString('base64url')
}
function sameDigest(value, expected) {
const actual = digest(value ?? '')
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
function sessionClient(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({ ip: null, userAgent: null })
const bounded = (field, maximum) => typeof field === 'string' && field.length > 0 && field.length <= maximum
? field : null
return Object.freeze({
ip: bounded(value.ip, 128),
userAgent: bounded(value.userAgent, 512),
})
}
export class BrowserSessionStore {
constructor({
now = Date.now,
random = randomBytes,
dshAbsoluteMs = 30 * 24 * 60 * 60_000,
dshIdleMs = 7 * 24 * 60 * 60_000,
managementAbsoluteMs = 24 * 60 * 60_000,
managementIdleMs = 2 * 60 * 60_000,
activityReportMs = 5 * 60_000,
onEvent = () => {},
} = {}) {
this.now = now
this.random = random
this.policy = Object.freeze({
dsh: Object.freeze({ absoluteMs: dshAbsoluteMs, idleMs: dshIdleMs }),
management: Object.freeze({ absoluteMs: managementAbsoluteMs, idleMs: managementIdleMs }),
})
this.activityReportMs = activityReportMs
this.onEvent = onEvent
this.sessions = new Map()
}
setEventSink(onEvent) {
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {}
}
emit(message, session, fields = {}) {
this.onEvent(message, {
accountId: session.accountId,
sessionId: session.sessionId,
kind: session.kind,
...fields,
})
}
prune() {
const now = this.now()
for (const [key, session] of this.sessions) {
const reason = session.expiresAt <= now
? 'absolute'
: session.lastSeenAt + session.idleMs <= now ? 'idle' : null
if (reason === null) continue
this.sessions.delete(key)
this.emit('access.session.expired', session, { reason })
}
for (const [key, session] of this.sessions) {
if (session.kind === 'management' && this.activeDshSession(session.sourceDshSessionId, session.accountId) === undefined) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
}
}
}
activeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string') return undefined
for (const session of this.sessions.values()) {
if (session.kind === 'dsh' && session.sessionId === sessionId && session.accountId === accountId) return session
}
return undefined
}
issue(kind, account, {
origin, sourceDshOrigin = null, sourceDshSessionId = null, client = null, authenticationSource = 'unknown',
} = {}) {
const policy = this.policy[kind]
if (policy === undefined) throw new TypeError('browser session kind is invalid')
if (typeof origin !== 'string' || origin.length === 0 || origin.length > 2_048) {
throw new TypeError('browser session origin is invalid')
}
this.prune()
const sourceDshSession = kind === 'management'
? this.activeDshSession(sourceDshSessionId, account.accountId)
: undefined
if (kind === 'management' && (sourceDshSession === undefined || sourceDshSession.origin !== sourceDshOrigin)) {
throw new TypeError('Management session requires an active DSH session')
}
const token = `${kind === 'dsh' ? 'dshs' : 'dshms'}_${identifier(this.random)}`
const csrfToken = `dshc_${identifier(this.random)}`
const sessionId = identifier(this.random, 16)
const createdAt = this.now()
const sourceClient = sourceDshSession === undefined ? sessionClient(client) : sourceDshSession.client
const expiresAt = Math.min(createdAt + policy.absoluteMs, sourceDshSession?.expiresAt ?? Number.POSITIVE_INFINITY)
this.sessions.set(digest(token).toString('hex'), {
sessionId,
kind,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
origin,
sourceDshOrigin,
sourceDshSessionId,
authenticationSource: sourceDshSession?.authenticationSource ?? authenticationSource,
client: sourceClient,
csrfDigest: digest(csrfToken),
createdAt,
lastSeenAt: createdAt,
activityReportedAt: createdAt,
expiresAt,
idleMs: policy.idleMs,
})
return Object.freeze({
token,
csrfToken,
sessionId,
expiresAt: new Date(expiresAt).toISOString(),
})
}
validate(token, kind, account, { origin, csrfToken, requireCsrf = false, touch = true } = {}) {
this.prune()
if (typeof token !== 'string' || token.length > 512 || this.policy[kind] === undefined) return undefined
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (session === undefined || session.kind !== kind || session.accountId !== account.accountId
|| session.origin !== origin || (requireCsrf && !sameDigest(csrfToken, session.csrfDigest))) {
return undefined
}
let invalidationReason = null
if (session.mainCredentialVersion !== account.mainCredential.version) invalidationReason = 'main-credential-version'
else if (session.managementAccessVersion !== account.managementAccess.version) invalidationReason = 'management-access-version'
else if (kind === 'management'
&& session.managementAdditionalCredentialVersion !== (account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null)) invalidationReason = 'management-credential-version'
if (invalidationReason !== null) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: invalidationReason })
return undefined
}
if (kind === 'management') {
const source = this.activeDshSession(session.sourceDshSessionId, account.accountId)
if (source === undefined || source.origin !== session.sourceDshOrigin
|| source.mainCredentialVersion !== account.mainCredential.version
|| source.managementAccessVersion !== account.managementAccess.version) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
return undefined
}
}
if (touch) {
const now = this.now()
session.lastSeenAt = now
if (now - session.activityReportedAt >= this.activityReportMs) {
session.activityReportedAt = now
this.emit('access.session.activity', session)
}
}
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
accountId: session.accountId,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
createdAt: new Date(session.createdAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
})
}
revoke(token) {
if (typeof token !== 'string' || token.length > 512) return false
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (!this.sessions.delete(key)) return false
if (session.kind === 'dsh') this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
revokeKind(kind) {
const dshSessionIds = []
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== kind) continue
this.sessions.delete(key)
revoked += 1
if (kind === 'dsh') dshSessionIds.push(session.sessionId)
}
for (const sessionId of dshSessionIds) this.revokeManagementFromDsh(sessionId, 'source-dsh-session-revoked')
return revoked
}
countKind(kind) {
this.prune()
let count = 0
for (const session of this.sessions.values()) {
if (session.kind === kind) count += 1
}
return count
}
list(account, currentSessionId) {
this.prune()
const currentDshSessionId = this.sourceDshSessionId(currentSessionId) ?? currentSessionId
const managementSources = new Set([...this.sessions.values()]
.filter(session => session.kind === 'management')
.map(session => session.sourceDshSessionId))
return [...this.sessions.values()]
.filter(session => session.accountId === account.accountId
&& session.kind === 'dsh'
&& session.mainCredentialVersion === account.mainCredential.version
&& session.managementAccessVersion === account.managementAccess.version)
.map(session => Object.freeze({
sessionId: session.sessionId,
origin: session.origin,
current: session.sessionId === currentDshSessionId,
managementActive: managementSources.has(session.sessionId),
ip: session.client.ip,
userAgent: session.client.userAgent,
createdAt: new Date(session.createdAt).toISOString(),
lastSeenAt: new Date(session.lastSeenAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
}))
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
}
revokeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string' || typeof accountId !== 'string') return false
for (const [key, session] of this.sessions) {
if (session.kind !== 'dsh' || session.sessionId !== sessionId || session.accountId !== accountId) continue
this.sessions.delete(key)
this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
return false
}
revokeManagementFromDsh(sessionId, reason = 'source-dsh-session') {
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== 'management' || session.sourceDshSessionId !== sessionId) continue
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason })
revoked += 1
}
return revoked
}
sourceDshSessionId(sessionId) {
for (const session of this.sessions.values()) {
if (session.kind === 'management' && session.sessionId === sessionId) return session.sourceDshSessionId
}
return null
}
refreshDshSession(sessionId, account) {
const session = this.activeDshSession(sessionId, account.accountId)
if (session === undefined) return false
session.mainCredentialVersion = account.mainCredential.version
session.managementAccessVersion = account.managementAccess.version
this.emit('access.session.refreshed', session, { reason: 'management-access-change' })
return true
}
details(sessionId) {
this.prune()
for (const session of this.sessions.values()) {
if (session.sessionId !== sessionId) continue
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
authenticationSource: session.authenticationSource,
})
}
return undefined
}
revokeAll() {
const revoked = this.sessions.size
this.sessions.clear()
return revoked
}
}
@@ -0,0 +1,249 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { AccessError } from './errors.mjs'
import { createCredential, normalizeUsername, validCredential } from './credentials.mjs'
import { validTotpSecret } from './totp.mjs'
const STATES = new Set(['never-initialized', 'migration-required', 'initialized', 'recovery-required'])
function identifier(random = randomBytes) { return random(32).toString('base64url') }
function timestamp(now) { return now().toISOString() }
async function syncDirectory(root) {
const handle = await open(root, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function atomicJson(path, value, mode = 0o600) {
const temporary = join(dirname(path), `.${path.split('/').at(-1)}.${randomUUID()}.tmp`)
const handle = await open(temporary, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(temporary, mode)
await rename(temporary, path)
await syncDirectory(dirname(path))
}
async function createExclusiveJson(path, value, mode = 0o600) {
const handle = await open(path, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(path, mode)
await syncDirectory(dirname(path))
}
async function readJson(path) {
try { return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } }
catch (error) {
if (error?.code === 'ENOENT') return { exists: false, value: undefined }
return { exists: true, error }
}
}
export function validInitialization(value) {
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.instanceId === 'string' && Buffer.from(value.instanceId, 'base64url').byteLength === 32
&& STATES.has(value.state) && Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
export function validAccount(value) {
const additional = value?.managementAdditionalCredential
const totp = value?.totp
const access = value?.managementAccess
const validOrigin = origin => {
try {
const parsed = new URL(origin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === '' && origin === parsed.origin
} catch { return false }
}
const additionalValid = additional !== null && typeof additional === 'object'
&& typeof additional.enabled === 'boolean'
&& Number.isInteger(additional.version) && additional.version >= 1
&& Number.isFinite(Date.parse(additional.changedAt))
&& (additional.enabled ? validCredential(additional.verifier) : additional.verifier === null)
const totpValid = totp === undefined || (totp !== null && typeof totp === 'object'
&& typeof totp.enabled === 'boolean'
&& Number.isInteger(totp.version) && totp.version >= 1
&& Number.isFinite(Date.parse(totp.changedAt))
&& (totp.enabled ? validTotpSecret(totp.secret) : totp.secret === null))
const isolatedEntryValid = access?.mode === 'compat'
? access.isolatedEntry === null && access.dshPublicOrigin === null
: access?.mode === 'isolated' && access.isolatedEntry !== null && typeof access.isolatedEntry === 'object'
&& validOrigin(access.dshPublicOrigin)
&& (access.isolatedEntry.kind === 'local-only' && validOrigin(access.isolatedEntry.managementLocalOrigin)
|| (access.isolatedEntry.kind === 'public' && (() => {
try {
const parsed = new URL(access.isolatedEntry.managementPublicOrigin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === ''
&& access.isolatedEntry.managementPublicOrigin === parsed.origin
} catch { return false }
})()))
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.revision === 'string' && Buffer.from(value.revision, 'base64url').byteLength === 32
&& typeof value.accountId === 'string' && Buffer.from(value.accountId, 'base64url').byteLength === 32
&& (() => { try { return normalizeUsername(value.username) === value.username } catch { return false } })()
&& validCredential(value.mainCredential)
&& additionalValid && totpValid && isolatedEntryValid
&& Number.isInteger(access?.version) && access.version >= 1
&& Number.isFinite(Date.parse(access.changedAt))
&& Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
function validEvidence(evidence) {
if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) return false
const entries = Object.entries(evidence)
return entries.length > 0 && entries.every(([key, value]) => (
/^[a-z][A-Za-z0-9]{0,63}$/.test(key) && typeof value === 'boolean'
))
}
export class AccessStateStore {
constructor({ root, now = () => new Date(), random = randomBytes, createVerifier = createCredential } = {}) {
if (typeof root !== 'string') throw new TypeError('Access state root is required')
this.root = root
this.initializationPath = join(root, 'initialization.json')
this.accountPath = join(root, 'account.json')
this.now = now
this.random = random
this.createVerifier = createVerifier
}
async prepare() {
await mkdir(this.root, { recursive: true, mode: 0o700 })
await chmod(this.root, 0o700)
}
async inspect() {
const [initialization, account] = await Promise.all([
readJson(this.initializationPath), readJson(this.accountPath),
])
return {
initialization: initialization.error === undefined && validInitialization(initialization.value)
? initialization.value : undefined,
initializationExists: initialization.exists,
initializationDamaged: initialization.error !== undefined || (initialization.exists && !validInitialization(initialization.value)),
account: account.error === undefined && validAccount(account.value) ? account.value : undefined,
accountExists: account.exists,
accountDamaged: account.error !== undefined || (account.exists && !validAccount(account.value)),
}
}
async transition(initialization, state) {
const next = { ...initialization, state, updatedAt: timestamp(this.now) }
await atomicJson(this.initializationPath, next)
return next
}
async classify(evidence) {
if (!validEvidence(evidence)) throw new AccessError('CLASSIFICATION_EVIDENCE_INVALID', 'classification evidence is invalid')
await this.prepare()
const inspected = await this.inspect()
if (inspected.initialization !== undefined) {
if (inspected.initialization.state === 'never-initialized' && inspected.account !== undefined) {
const initialization = await this.transition(inspected.initialization, 'initialized')
return { initialization, account: inspected.account }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
const initialization = await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
if (inspected.accountDamaged) {
const initialization = inspected.initialization.state === 'recovery-required'
? inspected.initialization : await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
return { initialization: inspected.initialization, account: inspected.account }
}
if (inspected.initializationDamaged || inspected.accountExists) {
const initialization = {
schema: 1, instanceId: identifier(this.random), state: 'recovery-required',
createdAt: timestamp(this.now), updatedAt: timestamp(this.now),
}
await atomicJson(this.initializationPath, initialization)
return { initialization, account: undefined }
}
const persistentEvidence = Object.entries(evidence)
.some(([key, value]) => key !== 'legacyAuthenticationConfigured' && value)
const initialization = {
schema: 1,
instanceId: identifier(this.random),
state: persistentEvidence ? 'migration-required' : 'never-initialized',
createdAt: timestamp(this.now),
updatedAt: timestamp(this.now),
}
try { await createExclusiveJson(this.initializationPath, initialization) }
catch (error) {
if (error?.code === 'EEXIST') return this.classify(evidence)
throw error
}
return { initialization, account: undefined }
}
async state() {
const inspected = await this.inspect()
if (inspected.initialization === undefined) {
return { initialization: undefined, account: undefined, state: 'classification-pending' }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
return { initialization: inspected.initialization, account: undefined, state: 'recovery-required' }
}
return { initialization: inspected.initialization, account: inspected.account, state: inspected.initialization.state }
}
async createAccount({ username, password }, allowedState) {
const current = await this.state()
if (current.state !== allowedState) {
throw new AccessError('ALREADY_INITIALIZED', 'administrator initialization is unavailable', 409)
}
const normalized = normalizeUsername(username)
const now = timestamp(this.now)
const verifier = await this.createVerifier(password, { now: this.now, random: this.random })
const account = {
schema: 1,
revision: identifier(this.random),
accountId: identifier(this.random),
username: normalized,
mainCredential: verifier,
totp: { enabled: false, version: 1, secret: null, changedAt: now },
managementAdditionalCredential: { enabled: false, version: 1, verifier: null, changedAt: now },
managementAccess: {
mode: 'compat', version: 1, isolatedEntry: null, dshPublicOrigin: null, changedAt: now,
},
createdAt: now,
updatedAt: now,
}
await atomicJson(this.accountPath, account)
await this.transition(current.initialization, 'initialized')
return account
}
initialize(value) { return this.createAccount(value, 'never-initialized') }
migrate(value) { return this.createAccount(value, 'migration-required') }
recover(value) { return this.createAccount(value, 'recovery-required') }
async replaceAccount(account, revision) {
const current = await this.state()
if (current.state !== 'initialized' || current.account === undefined) {
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
}
if (current.account.revision !== revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409)
if (!validAccount(account)) throw new AccessError('ACCOUNT_INVALID', 'account record is invalid')
await atomicJson(this.accountPath, account)
return account
}
async clear() {
await rm(this.accountPath, { force: true })
}
}
@@ -0,0 +1,293 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const CODE_PATTERN = /^\d{6}$/
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
function base32Encode(value) {
let bits = 0
let buffer = 0
let output = ''
for (const byte of value) {
buffer = (buffer << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]
return output
}
function base32Decode(value) {
if (typeof value !== 'string' || value.length === 0 || /[^A-Z2-7]/u.test(value)) return undefined
let bits = 0
let buffer = 0
const output = []
for (const character of value) {
buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character)
bits += 5
if (bits >= 8) {
output.push((buffer >>> (bits - 8)) & 255)
bits -= 8
}
}
return Buffer.from(output)
}
function counterBuffer(counter) {
const value = Buffer.alloc(8)
value.writeBigUInt64BE(BigInt(counter))
return value
}
export function generateTotpSecret(random = randomBytes) {
return base32Encode(random(20))
}
export function validTotpSecret(secret) {
return base32Decode(secret)?.byteLength === 20
}
export function totpCode(secret, { now = Date.now(), offset = 0 } = {}) {
const key = base32Decode(secret)
if (key?.byteLength !== 20) throw new TypeError('TOTP secret is invalid')
const counter = Math.floor(now / 30_000) + offset
const hmac = createHmac('sha1', key).update(counterBuffer(counter)).digest()
const position = hmac.at(-1) & 15
const value = (hmac.readUInt32BE(position) & 0x7fffffff) % 1_000_000
return String(value).padStart(6, '0')
}
export function verifyTotpCode(code, secret, { now = Date.now(), window = 1 } = {}) {
if (typeof code !== 'string' || !CODE_PATTERN.test(code)) return false
const actual = Buffer.from(code)
for (let offset = -window; offset <= window; offset += 1) {
const expected = Buffer.from(totpCode(secret, { now, offset }))
if (timingSafeEqual(actual, expected)) return true
}
return false
}
export function totpUri({ secret, username, issuer = 'DSH Docker' }) {
if (!validTotpSecret(secret)) throw new TypeError('TOTP secret is invalid')
const label = `${issuer}:${username}`
const query = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' })
return `otpauth://totp/${encodeURIComponent(label)}?${query}`
}
export class TotpFlowStore {
constructor({ now = Date.now, random = randomBytes, enrollmentTtlMs = 5 * 60_000, loginTtlMs = 5 * 60_000 } = {}) {
this.now = now
this.random = random
this.enrollmentTtlMs = enrollmentTtlMs
this.loginTtlMs = loginTtlMs
this.enrollments = new Map()
this.disableConfirmations = new Map()
this.logins = new Map()
}
prune() {
const current = this.now()
for (const [key, value] of this.enrollments) if (value.expiresAt <= current) this.enrollments.delete(key)
for (const [key, value] of this.disableConfirmations) if (value.expiresAt <= current) this.disableConfirmations.delete(key)
for (const [key, value] of this.logins) if (value.expiresAt <= current) this.logins.delete(key)
}
createEnrollment(account, sessionId) {
this.prune()
for (const [key, value] of this.enrollments) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.enrollments.delete(key)
}
const value = token('dshte', this.random)
const secret = generateTotpSecret(this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.enrollments.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
secret,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, secret, expiresAt: new Date(expiresAt).toISOString() })
}
enrollment(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const enrollment = this.enrollments.get(key)
if (enrollment === undefined || enrollment.accountId !== account.accountId
|| enrollment.accountRevision !== account.revision || enrollment.sessionId !== sessionId) return undefined
return { key, value: enrollment }
}
cancelEnrollment(value, account, sessionId) {
const enrollment = this.enrollment(value, account, sessionId)
return enrollment === undefined ? false : this.enrollments.delete(enrollment.key)
}
confirmEnrollment(key) {
const enrollment = this.enrollments.get(key)
if (enrollment === undefined) return false
enrollment.confirmed = true
return true
}
consumeEnrollment(key) { return this.enrollments.delete(key) }
createDisableConfirmation(account, sessionId) {
this.prune()
for (const [key, value] of this.disableConfirmations) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.disableConfirmations.delete(key)
}
const value = token('dshtd', this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.disableConfirmations.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
disableConfirmation(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined || confirmation.accountId !== account.accountId
|| confirmation.accountRevision !== account.revision || confirmation.sessionId !== sessionId) return undefined
return { key, value: confirmation }
}
cancelDisableConfirmation(value, account, sessionId) {
const confirmation = this.disableConfirmation(value, account, sessionId)
return confirmation === undefined ? false : this.disableConfirmations.delete(confirmation.key)
}
confirmDisable(key) {
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined) return false
confirmation.confirmed = true
return true
}
consumeDisableConfirmation(key) { return this.disableConfirmations.delete(key) }
createLogin(account, details) {
this.prune()
const value = token('dshtl', this.random)
const expiresAt = this.now() + this.loginTtlMs
this.logins.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
...details,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
login(value, account) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const login = this.logins.get(key)
if (login === undefined || login.accountId !== account.accountId
|| login.accountRevision !== account.revision) return undefined
return { key, value: login }
}
consumeLogin(key) { return this.logins.delete(key) }
clear() {
this.enrollments.clear()
this.disableConfirmations.clear()
this.logins.clear()
}
}
export class TotpRetryLimiter {
constructor({
now = Date.now,
threshold = 3,
retryMs = 10_000,
dailyWindowMs = 24 * 60 * 60_000,
sourceDailyLimit = 25,
globalDailyLimit = 50,
} = {}) {
this.now = now
this.threshold = threshold
this.retryMs = retryMs
this.dailyWindowMs = dailyWindowMs
this.sourceDailyLimit = sourceDailyLimit
this.globalDailyLimit = globalDailyLimit
this.entries = new Map()
this.globalFailures = new Map()
}
key(accountId, source) { return `${accountId}\u0000${source}` }
prune(values) {
const cutoff = this.now() - this.dailyWindowMs
while (values.length > 0 && values[0] <= cutoff) values.shift()
}
retry(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
const global = this.globalFailures.get(accountId) ?? []
if (entry !== undefined) this.prune(entry.failuresInWindow)
this.prune(global)
const until = values => Math.max(1, Math.ceil((values[0] + this.dailyWindowMs - this.now()) / 1000))
if (global.length >= this.globalDailyLimit) return { kind: 'rate', retryAfterSeconds: until(global) }
if (entry?.failuresInWindow.length >= this.sourceDailyLimit) {
return { kind: 'rate', retryAfterSeconds: until(entry.failuresInWindow) }
}
if (entry !== undefined && entry.blockedUntil > this.now()) {
return { kind: 'retry', retryAfterSeconds: Math.max(1, Math.ceil((entry.blockedUntil - this.now()) / 1000)) }
}
return { kind: null, retryAfterSeconds: 0 }
}
fail(accountId, source) {
const key = this.key(accountId, source)
const entry = this.entries.get(key) ?? { consecutiveFailures: 0, blockedUntil: 0, failuresInWindow: [] }
const global = this.globalFailures.get(accountId) ?? []
this.prune(entry.failuresInWindow)
this.prune(global)
entry.consecutiveFailures += 1
entry.failuresInWindow.push(this.now())
global.push(this.now())
if (entry.consecutiveFailures >= this.threshold) entry.blockedUntil = this.now() + this.retryMs
this.entries.set(key, entry)
this.globalFailures.set(accountId, global)
return this.retry(accountId, source)
}
succeed(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
if (entry === undefined) return
entry.consecutiveFailures = 0
entry.blockedUntil = 0
}
clearDailyLimits(accountId, { globalOnly = false } = {}) {
let cleared = this.globalFailures.delete(accountId) ? 1 : 0
if (globalOnly) return { cleared }
for (const [key, entry] of this.entries) {
if (!key.startsWith(`${accountId}\u0000`)) continue
if (entry.failuresInWindow.length > 0) {
entry.failuresInWindow = []
cleared += 1
}
}
return { cleared }
}
}
@@ -0,0 +1,120 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest() }
function token(prefix, random, bytes = 32) { return `${prefix}_${random(bytes).toString('base64url')}` }
function sameDigest(value, expected) {
if (typeof value !== 'string' || expected === null) return false
const actual = digest(value)
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
export class ManagementTransitionStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 30_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.transitions = new Map()
this.continuations = new Map()
}
prune() {
const now = this.now()
for (const [id, value] of this.transitions) if (value.expiresAt <= now) this.transitions.delete(id)
for (const [key, value] of this.continuations) if (value.expiresAt <= now) this.continuations.delete(key)
}
create({ account, instanceId, sessionId, sourceOrigin, sourceDshOrigin, sourceDshSessionId, mode, isolatedEntry, candidateOrigin }) {
this.prune()
const transitionId = token('dshmt', this.random, 16)
const nonce = candidateOrigin === null ? null : token('dshmn', this.random)
const expiresAt = this.now() + this.ttlMs
this.transitions.set(transitionId, {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
instanceId,
sessionId,
sourceOrigin,
sourceDshOrigin,
sourceDshSessionId,
mode,
isolatedEntry,
candidateOrigin,
nonceDigest: nonce === null ? null : digest(nonce),
proofDigest: null,
expiresAt,
})
return Object.freeze({
transitionId,
nonce,
candidateOrigin,
expiresAt: new Date(expiresAt).toISOString(),
})
}
probe({ transitionId, nonce, candidateOrigin, sourceOrigin, instanceId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.candidateOrigin !== candidateOrigin
|| transition.sourceOrigin !== sourceOrigin || transition.instanceId !== instanceId
|| !sameDigest(nonce, transition.nonceDigest)) return undefined
transition.nonceDigest = null
const proof = token('dshmpf', this.random)
transition.proofDigest = digest(proof)
return Object.freeze({ proof, expiresAt: new Date(transition.expiresAt).toISOString() })
}
inspect({ transitionId, proof, account, sessionId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.accountId !== account.accountId
|| transition.accountRevision !== account.revision
|| transition.managementAccessVersion !== account.managementAccess.version
|| transition.sessionId !== sessionId
|| (transition.candidateOrigin !== null && !sameDigest(proof, transition.proofDigest))) return undefined
return transition
}
consume(value) {
const transition = this.inspect(value)
if (transition === undefined) return undefined
const { transitionId } = value
this.transitions.delete(transitionId)
return transition
}
createContinuation({ account, targetOrigin, sourceDshOrigin, sourceDshSessionId }) {
if (targetOrigin === null) return null
this.prune()
const continuation = token('dshmc', this.random)
const expiresAt = this.now() + this.ttlMs
this.continuations.set(digest(continuation).toString('hex'), {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
expiresAt,
})
return Object.freeze({ token: continuation, expiresAt: new Date(expiresAt).toISOString() })
}
consumeContinuation({ token: value, account, targetOrigin }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value).toString('hex')
const continuation = this.continuations.get(key)
this.continuations.delete(key)
if (continuation === undefined || continuation.accountId !== account.accountId
|| continuation.accountRevision !== account.revision
|| continuation.managementAccessVersion !== account.managementAccess.version
|| continuation.targetOrigin !== targetOrigin) return undefined
return continuation
}
clear() {
this.transitions.clear()
this.continuations.clear()
}
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
const launch = new LocalApiClient(paths.accessLaunchSocket)
if (process.argv[2] === 'health') {
const status = await launch.request('GET', '/v1/status')
process.exit(status.componentReady === true ? 0 : 1)
}
const token = process.env.DSH_ACCESS_LAUNCH_TOKEN
delete process.env.DSH_ACCESS_LAUNCH_TOKEN
if (typeof token !== 'string' || token.length < 32) throw new Error('Access Manager launch token is unavailable')
await launch.request('POST', '/v1/start', { token })
let stopping = false
let resolveStop
const stopped = new Promise(resolve => { resolveStop = resolve })
const stop = () => {
if (stopping) return
stopping = true
void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const poll = setInterval(() => {
void launch.request('GET', '/v1/status').then(status => {
if (!stopping && status.running !== true) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
}, () => {
if (!stopping) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
})
}, 500)
await stopped
clearInterval(poll)