250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
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 })
|
|
}
|
|
}
|