106 lines
8.0 KiB
JavaScript
106 lines
8.0 KiB
JavaScript
/** Explicit offline operator init. No database, application imports or activation. */
|
|
import assert from 'node:assert/strict'
|
|
import {createHash, generateKeyPairSync} from 'node:crypto'
|
|
import {constants} from 'node:fs'
|
|
import {lstat, realpath, open, mkdir, link, unlink} from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import {pathToFileURL} from 'node:url'
|
|
|
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
|
const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value)
|
|
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
|
|
const roles = ['runtime', 'operator', 'capture', 'publisher', 'control']
|
|
export function deploymentRequest(value) {
|
|
assert(exact(value, ['format', 'installationId', 'sourceHeadSha256', 'source', 'roles', 'tls']))
|
|
assert(value.format === 'application-writer-bootstrap-request-v1' && uuid(value.installationId) && hex(value.sourceHeadSha256))
|
|
assert(exact(value.source, ['systemIdentifier', 'databaseOid', 'databaseName', 'serverVersionNum', 'inRecovery']))
|
|
assert(typeof value.source.systemIdentifier === 'string' && /^[1-9][0-9]{0,19}$/.test(value.source.systemIdentifier) && BigInt(value.source.systemIdentifier) <= 18446744073709551615n)
|
|
assert(typeof value.source.databaseOid === 'string' && /^[1-9][0-9]{0,9}$/.test(value.source.databaseOid) && BigInt(value.source.databaseOid) <= 4294967295n)
|
|
assert(typeof value.source.databaseName === 'string' && /^[^\x00-\x1f\x7f]{1,63}$/.test(value.source.databaseName))
|
|
assert(Number.isInteger(value.source.serverVersionNum) && value.source.serverVersionNum >= 180000 && value.source.serverVersionNum <= 189999 && value.source.inRecovery === false)
|
|
assert(exact(value.roles, roles))
|
|
for (const role of roles) {
|
|
const pair = value.roles[role]
|
|
assert(exact(pair, ['name', 'signedOid']) && typeof pair.name === 'string' && /^[a-z_][a-z0-9_]{0,62}$/.test(pair.name))
|
|
assert(typeof pair.signedOid === 'string' && /^[1-9][0-9]{0,9}$/.test(pair.signedOid) && BigInt(pair.signedOid) <= 4294967295n)
|
|
}
|
|
assert(new Set(roles.map(role => value.roles[role].name)).size === 5 && new Set(roles.map(role => value.roles[role].signedOid)).size === 5)
|
|
assert(exact(value.tls, ['serverName', 'caSha256', 'spkiSha256']) && typeof value.tls.serverName === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName) && hex(value.tls.caSha256) && hex(value.tls.spkiSha256))
|
|
return structuredClone(value)
|
|
}
|
|
export function createBootstrapMaterial(raw, requestSha256) {
|
|
const request = deploymentRequest(raw)
|
|
assert(hex(requestSha256))
|
|
const {privateKey, publicKey} = generateKeyPairSync('ed25519')
|
|
const publicKeyPem = publicKey.export({format: 'pem', type: 'spki'}).toString()
|
|
const publicKeySpkiSha256 = digest(publicKey.export({format: 'der', type: 'spki'}))
|
|
return {
|
|
privateKeyPem: privateKey.export({format: 'pem', type: 'pkcs8'}).toString(), publicKeyPem,
|
|
bootstrap: {format: 'application-writer-bootstrap-v1', verificationSubject: {installationId: request.installationId, publicKeyPem, publicKeySpkiSha256},
|
|
deploymentRequest: request, deploymentRequestSha256: requestSha256, keyOrigin: 'explicit-offline-operator-init',
|
|
databaseObserved: false, rolesAuthenticated: false, sourceAuthenticated: false, approvedDefinitionSha256: null,
|
|
publicationAuthorized: false, generationAuthenticated: false, readyAuthorized: false},
|
|
}
|
|
}
|
|
async function protectedDirectory(directory) {
|
|
assert(process.platform !== 'win32' && typeof process.getuid === 'function', 'Protected POSIX ownership required; Windows ACL backend unavailable')
|
|
assert(path.isAbsolute(directory) && await realpath(directory) === directory)
|
|
const uid = process.getuid(), stat = await lstat(directory)
|
|
assert(stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o077) === 0)
|
|
for (let ancestor = path.dirname(directory);; ancestor = path.dirname(ancestor)) {
|
|
const metadata = await lstat(ancestor)
|
|
assert(metadata.isDirectory() && !metadata.isSymbolicLink() && (metadata.uid === uid || metadata.uid === 0))
|
|
assert((metadata.mode & 0o022) === 0 || metadata.uid === 0 && (metadata.mode & 0o1000) !== 0)
|
|
if (ancestor === path.dirname(ancestor)) break
|
|
}
|
|
return stat
|
|
}
|
|
async function publish(directory, name, bytes) {
|
|
await protectedDirectory(directory)
|
|
const temporary = path.join(directory, '.' + name + '.pending'), destination = path.join(directory, name)
|
|
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
|
|
await link(temporary, destination) // Atomic no-replace publication, never rename-overwrite.
|
|
await unlink(temporary)
|
|
const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
try { await parent.sync() } finally { await parent.close() }
|
|
}
|
|
export async function initializeBootstrap({outputDirectory, deploymentFile, deploymentSha256}) {
|
|
assert(path.isAbsolute(outputDirectory) && path.resolve(outputDirectory) === outputDirectory && hex(deploymentSha256))
|
|
await protectedDirectory(path.dirname(outputDirectory))
|
|
assert(path.isAbsolute(deploymentFile) && await realpath(deploymentFile) === deploymentFile)
|
|
const requestHandle = await open(deploymentFile, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
let bytes
|
|
try {
|
|
const before = await requestHandle.stat()
|
|
assert(before.isFile() && before.uid === process.getuid() && (before.mode & 0o077) === 0 && before.size > 0 && before.size <= 16384)
|
|
bytes = Buffer.alloc(before.size + 1)
|
|
let size = 0
|
|
while (size < bytes.length) { const read = await requestHandle.read(bytes, size, bytes.length - size, size); if (!read.bytesRead) break; size += read.bytesRead }
|
|
const after = await requestHandle.stat()
|
|
assert(size === before.size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs)
|
|
bytes = bytes.subarray(0, size)
|
|
} finally { await requestHandle.close() }
|
|
assert(digest(bytes) === deploymentSha256, 'Explicit independently approved deployment digest required')
|
|
const request = deploymentRequest(JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)))
|
|
await mkdir(outputDirectory, {mode: 0o700}) // EEXIST refuses; never adopt/overwrite.
|
|
const material = createBootstrapMaterial(request, deploymentSha256)
|
|
await publish(outputDirectory, 'signing-private.pem', material.privateKeyPem)
|
|
await publish(outputDirectory, 'operator-public.pem', material.publicKeyPem)
|
|
await publish(outputDirectory, 'bootstrap.json', JSON.stringify(material.bootstrap, null, 2) + '\n') // Completion marker last.
|
|
return {event: 'application-writer-bootstrap-initialized', installationId: request.installationId,
|
|
publicKeySpkiSha256: material.bootstrap.verificationSubject.publicKeySpkiSha256,
|
|
deploymentRequestSha256: deploymentSha256, databaseObserved: false, publicationAuthorized: false, readyAuthorized: false}
|
|
}
|
|
export async function main(args) {
|
|
assert(args.length === 7 && args[0] === 'init')
|
|
const values = new Map()
|
|
for (let i = 1; i < args.length; i += 2) { assert(['--output', '--deployment', '--deployment-sha256'].includes(args[i]) && !values.has(args[i])); values.set(args[i], args[i + 1]) }
|
|
return initializeBootstrap({outputDirectory: values.get('--output'), deploymentFile: values.get('--deployment'), deploymentSha256: values.get('--deployment-sha256')})
|
|
}
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
try { console.log(JSON.stringify(await main(process.argv.slice(2)))) }
|
|
catch { console.error(JSON.stringify({event: 'application-writer-bootstrap-refused', publicationAuthorized: false, readyAuthorized: false})); process.exitCode = 1 }
|
|
}
|