import * as plugins from '../ts/plugins.js'; import { ControllerIssuerIdentityModel, controllerIssuerAnchorBytes, controllerIssuerAnchorHash, controllerIssuerAnchorVersion, controllerIssuerIdentityDocumentId, isInitializingControllerIssuerIdentityDocument, parseControllerIssuerAnchor, type IControllerIssuerIdentityDocument, type IInitializingControllerIssuerIdentityDocument, } from '../ts/classes.issueridentitymodels.js'; import { AuthError } from '../ts/interfaces.auth.js'; import type { IControllerIssuerIdentity } from '../ts/interfaces.identity.js'; const identityDirectoryName = 'identity'; const anchorFileName = 'issuer-anchor-v1.json'; const temporaryAnchorPrefix = '.issuer-anchor-v1.'; const temporaryAnchorPattern = /^\.issuer-anchor-v1\.[1-9][0-9]{0,19}\.[a-f0-9]{32}\.tmp$/; const maximumAnchorBytes = 512; const maximumIdentityDirectoryEntries = 64; const maximumTemporaryAnchors = 32; const publicationAttempts = 16; const activationAttempts = 16; type TStoredIssuerIdentity = NonNullable>>; const isErrorCode = (errorArg: unknown, codeArg: string): boolean => ( (errorArg as NodeJS.ErrnoException).code === codeArg ); const isAmbiguousWrite = (errorArg: unknown): boolean => ( errorArg instanceof plugins.smartdata.SmartdataExactPersistenceError && errorArg.code === 'ambiguous_write' ); const currentUid = (): number => { if (typeof process.getuid !== 'function') { throw new Error('Controller issuer identity requires a POSIX user identity.'); } return process.getuid(); }; const lstatIfPresent = async (pathArg: string): Promise => { try { return await plugins.fs.promises.lstat(pathArg, { bigint: true }); } catch (errorArg) { if (isErrorCode(errorArg, 'ENOENT')) return undefined; throw errorArg; } }; const assertPrivateDirectory = ( pathArg: string, statsArg: plugins.fs.BigIntStats, ): void => { if ( statsArg.isSymbolicLink() || !statsArg.isDirectory() || statsArg.uid !== BigInt(currentUid()) || Number(statsArg.mode & 0o777n) !== 0o700 ) { throw new Error(`Controller issuer identity directory is unsafe: ${pathArg}`); } }; const syncDirectory = async (pathArg: string): Promise => { const handle = await plugins.fs.promises.open( pathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_DIRECTORY | plugins.fs.constants.O_NOFOLLOW, ); try { await handle.sync(); } finally { await handle.close(); } }; const validateDataRoot = async (dataRootDirectoryArg: string): Promise => { if ( typeof dataRootDirectoryArg !== 'string' || !plugins.path.isAbsolute(dataRootDirectoryArg) || plugins.path.normalize(dataRootDirectoryArg) !== dataRootDirectoryArg || plugins.path.parse(dataRootDirectoryArg).root === dataRootDirectoryArg || dataRootDirectoryArg.includes('\0') || Buffer.byteLength(dataRootDirectoryArg, 'utf8') > 4096 ) { throw new Error('Controller issuer data root must be an absolute normalized non-root path.'); } const stats = await plugins.fs.promises.lstat(dataRootDirectoryArg, { bigint: true }); assertPrivateDirectory(dataRootDirectoryArg, stats); return dataRootDirectoryArg; }; const inspectIdentityDirectory = async ( dataRootDirectoryArg: string, ): Promise => { const identityDirectory = plugins.path.join(dataRootDirectoryArg, identityDirectoryName); const stats = await lstatIfPresent(identityDirectory); if (!stats) return undefined; assertPrivateDirectory(identityDirectory, stats); return identityDirectory; }; const ensureIdentityDirectory = async (dataRootDirectoryArg: string): Promise => { const existing = await inspectIdentityDirectory(dataRootDirectoryArg); if (existing) { await syncDirectory(dataRootDirectoryArg); await syncDirectory(existing); return existing; } const identityDirectory = plugins.path.join(dataRootDirectoryArg, identityDirectoryName); try { await plugins.fs.promises.mkdir(identityDirectory, { mode: 0o700 }); } catch (errorArg) { if (!isErrorCode(errorArg, 'EEXIST')) throw errorArg; } const stats = await plugins.fs.promises.lstat(identityDirectory, { bigint: true }); assertPrivateDirectory(identityDirectory, stats); await syncDirectory(dataRootDirectoryArg); await syncDirectory(identityDirectory); return identityDirectory; }; const readBoundedHandle = async (handleArg: plugins.fs.promises.FileHandle): Promise => { const buffer = Buffer.alloc(maximumAnchorBytes + 1); let offset = 0; while (offset < buffer.byteLength) { const result = await handleArg.read(buffer, offset, buffer.byteLength - offset, offset); if (result.bytesRead === 0) break; offset += result.bytesRead; } if (offset > maximumAnchorBytes) { throw new Error('The controller issuer anchor exceeds its size limit.'); } return buffer.subarray(0, offset); }; const readSafeAnchor = async (anchorPathArg: string): Promise => { const handle = await plugins.fs.promises.open( anchorPathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); try { const before = await handle.stat({ bigint: true }); if ( !before.isFile() || before.uid !== BigInt(currentUid()) || Number(before.mode & 0o777n) !== 0o600 || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximumAnchorBytes) ) { throw new Error('The controller issuer anchor file is unsafe.'); } const bytes = await readBoundedHandle(handle); const after = await handle.stat({ bigint: true }); if ( after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs || bytes.byteLength !== Number(before.size) ) { throw new Error('The controller issuer anchor changed while it was read.'); } return bytes; } finally { await handle.close(); } }; const assertAnchorMatches = async ( anchorPathArg: string, documentArg: IControllerIssuerIdentityDocument, ): Promise => { const bytes = await readSafeAnchor(anchorPathArg); const anchor = parseControllerIssuerAnchor(bytes); if ( anchor.issuerId !== documentArg.issuerId || anchor.createdAt !== documentArg.createdAt.getTime() || controllerIssuerAnchorHash(bytes) !== documentArg.anchorHash || ( documentArg.state === 'initializing' && anchor.anchor !== documentArg.pendingAnchor ) ) { throw new AuthError( 'config_mismatch', 'The controller issuer database record does not match its data-root anchor.', ); } }; const cleanupTemporaryAnchors = async (identityDirectoryArg: string): Promise => { const directoryEntries = await plugins.fs.promises.readdir(identityDirectoryArg); if (directoryEntries.length > maximumIdentityDirectoryEntries) { throw new Error('The controller issuer identity directory contains too many entries.'); } const names = directoryEntries.filter((name) => temporaryAnchorPattern.test(name)); if (names.length > maximumTemporaryAnchors) { throw new Error('The controller issuer identity directory contains too many temporary anchors.'); } let removed = false; for (const name of names) { const path = plugins.path.join(identityDirectoryArg, name); const stats = await lstatIfPresent(path); if (!stats) continue; if ( stats.isSymbolicLink() || !stats.isFile() || stats.uid !== BigInt(currentUid()) || Number(stats.mode & 0o777n) !== 0o600 ) { throw new Error(`Controller issuer temporary anchor is unsafe: ${path}`); } try { await plugins.fs.promises.unlink(path); removed = true; } catch (errorArg) { if (!isErrorCode(errorArg, 'ENOENT')) throw errorArg; } } if (removed) await syncDirectory(identityDirectoryArg); }; const delayPublicationRetry = async (): Promise => await new Promise((resolve) => { setTimeout(resolve, 1 + plugins.crypto.randomInt(10)); }); const publishInitializingAnchor = async ( identityDirectoryArg: string, documentArg: IInitializingControllerIssuerIdentityDocument, ): Promise => { const anchorPath = plugins.path.join(identityDirectoryArg, anchorFileName); const expectedBytes = controllerIssuerAnchorBytes({ version: controllerIssuerAnchorVersion, issuerId: documentArg.issuerId, createdAt: documentArg.createdAt.getTime(), anchor: documentArg.pendingAnchor, }); if (controllerIssuerAnchorHash(expectedBytes) !== documentArg.anchorHash) { throw new Error('The initializing controller issuer anchor hash is invalid.'); } for (let attempt = 0; attempt < publicationAttempts; attempt += 1) { await cleanupTemporaryAnchors(identityDirectoryArg); const existing = await lstatIfPresent(anchorPath); if (existing) { await assertAnchorMatches(anchorPath, documentArg); return; } const temporaryPath = plugins.path.join( identityDirectoryArg, `${temporaryAnchorPrefix}${process.pid}.${plugins.crypto.randomBytes(16).toString('hex')}.tmp`, ); let handle: plugins.fs.promises.FileHandle | undefined; let retry = false; try { handle = await plugins.fs.promises.open( temporaryPath, plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | plugins.fs.constants.O_NOFOLLOW, 0o600, ); await handle.chmod(0o600); await handle.writeFile(expectedBytes); await handle.sync(); await handle.close(); handle = undefined; try { await plugins.fs.promises.link(temporaryPath, anchorPath); } catch (errorArg) { if (isErrorCode(errorArg, 'ENOENT')) { retry = true; } else if (!isErrorCode(errorArg, 'EEXIST')) { throw errorArg; } } } finally { const cleanupErrors: unknown[] = []; if (handle) { try { await handle.close(); } catch (errorArg) { cleanupErrors.push(errorArg); } } try { await plugins.fs.promises.unlink(temporaryPath); } catch (errorArg) { if (!isErrorCode(errorArg, 'ENOENT')) cleanupErrors.push(errorArg); } if (cleanupErrors.length === 1) throw cleanupErrors[0]; if (cleanupErrors.length > 1) { throw new AggregateError( cleanupErrors, 'Controller issuer temporary anchor cleanup was incomplete.', ); } } await cleanupTemporaryAnchors(identityDirectoryArg); await syncDirectory(identityDirectoryArg); if (retry) { await delayPublicationRetry(); continue; } try { await assertAnchorMatches(anchorPath, documentArg); return; } catch (errorArg) { if (isErrorCode(errorArg, 'ENOENT')) { await delayPublicationRetry(); continue; } throw errorArg; } } throw new AuthError( 'concurrent_change', 'The controller issuer anchor could not be published after concurrent changes.', ); }; const sameIssuerRecord = ( leftArg: IControllerIssuerIdentityDocument, rightArg: IControllerIssuerIdentityDocument, ): boolean => ( leftArg.id === rightArg.id && leftArg.issuerId === rightArg.issuerId && leftArg.anchorHash === rightArg.anchorHash && leftArg.createdAt.getTime() === rightArg.createdAt.getTime() ); const sameInitializingRecord = ( leftArg: IControllerIssuerIdentityDocument, rightArg: IInitializingControllerIssuerIdentityDocument, ): leftArg is IInitializingControllerIssuerIdentityDocument => ( leftArg.state === 'initializing' && sameIssuerRecord(leftArg, rightArg) && leftArg.pendingAnchor === rightArg.pendingAnchor ); export const controllerIssuerAnchorPath = (dataRootDirectoryArg: string): string => plugins.path.join( dataRootDirectoryArg, identityDirectoryName, anchorFileName, ); export class ControllerIssuerIdentityV25Migration { constructor(private readonly database: plugins.smartdata.SmartdataDb | undefined) {} private async readStored(): Promise { return (await ControllerIssuerIdentityModel.exact.findStoredOne({ id: controllerIssuerIdentityDocumentId, })) ?? undefined; } private async insertInitializing(): Promise { const createdAt = new Date(); const pendingAnchor = plugins.crypto.randomBytes(32).toString('base64url'); const issuerId = plugins.crypto.randomBytes(32).toString('base64url'); const anchorHash = controllerIssuerAnchorHash(controllerIssuerAnchorBytes({ version: controllerIssuerAnchorVersion, issuerId, createdAt: createdAt.getTime(), anchor: pendingAnchor, })); const candidate: IInitializingControllerIssuerIdentityDocument = { id: controllerIssuerIdentityDocumentId, issuerId, anchorHash, createdAt, state: 'initializing', pendingAnchor, }; try { const inserted = await ControllerIssuerIdentityModel.exact.insert(candidate); return inserted.document; } catch (errorArg) { if (!isAmbiguousWrite(errorArg)) throw errorArg; const reconciled = await this.readStored(); if (reconciled) return reconciled; throw new AuthError( 'ambiguous_write', 'Controller issuer identity initialization has an ambiguous outcome.', { cause: errorArg }, ); } } private async rereadActivation( expectedArg: IInitializingControllerIssuerIdentityDocument, causeArg?: unknown, ): Promise { const reconciled = await this.readStored(); if (!reconciled) { throw new AuthError( 'ambiguous_write', 'Controller issuer identity activation has an ambiguous outcome.', causeArg === undefined ? undefined : { cause: causeArg }, ); } const body = ControllerIssuerIdentityModel.exact.toPersisted(reconciled); if ( (body.state === 'active' && sameIssuerRecord(body, expectedArg)) || sameInitializingRecord(body, expectedArg) ) return reconciled; throw new AuthError( 'concurrent_change', 'Controller issuer identity changed during activation.', causeArg === undefined ? undefined : { cause: causeArg }, ); } public async run(dataRootDirectoryArg: string): Promise { if (!this.database) { throw new AuthError('not_initialized', 'The controller issuer database is unavailable.'); } const dataRootDirectory = await validateDataRoot(dataRootDirectoryArg); let identityDirectory = await inspectIdentityDirectory(dataRootDirectory); let stored = await this.readStored(); if (!stored) { const anchorPath = controllerIssuerAnchorPath(dataRootDirectory); if (identityDirectory && await lstatIfPresent(anchorPath)) { throw new AuthError( 'config_mismatch', 'A controller issuer anchor exists without its database identity record.', ); } stored = await this.insertInitializing(); } for (let attempt = 0; attempt < activationAttempts; attempt += 1) { const document = ControllerIssuerIdentityModel.exact.toPersisted(stored); if (document.state === 'active') { identityDirectory ??= await inspectIdentityDirectory(dataRootDirectory); if (!identityDirectory) { throw new AuthError( 'config_mismatch', 'The active controller issuer identity directory is missing.', ); } await cleanupTemporaryAnchors(identityDirectory); await assertAnchorMatches( plugins.path.join(identityDirectory, anchorFileName), document, ); return { issuerId: document.issuerId, createdAt: document.createdAt.getTime(), }; } if (!isInitializingControllerIssuerIdentityDocument(document)) { throw new Error('The controller issuer identity state is invalid.'); } identityDirectory ??= await ensureIdentityDirectory(dataRootDirectory); await publishInitializingAnchor(identityDirectory, document); try { const transition = await ControllerIssuerIdentityModel.exact.transition({ current: stored, change: (model) => { model.state = 'active'; delete model.pendingAnchor; }, }); stored = transition.status === 'transitioned' ? transition.document : await this.rereadActivation(document); } catch (errorArg) { if (!isAmbiguousWrite(errorArg)) throw errorArg; stored = await this.rereadActivation(document, errorArg); } } throw new AuthError( 'concurrent_change', 'Controller issuer identity activation did not converge.', ); } }