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:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user