import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { spawnSync } from 'node:child_process'; const SELF = fileURLToPath(import.meta.url); const PACKAGE = path.dirname(SELF); const sha = value => crypto.createHash('sha256').update(value).digest('hex'); const must = (value, code) => { if (!value) throw Object.assign(new Error(code), { code }); }; const json = file => JSON.parse(fs.readFileSync(file, 'utf8')); export function bindingUpdate(beforeBytes, release, actualHost) { const before = JSON.parse(beforeBytes.toString('utf8')); must(actualHost === release.host && before.host === actualHost, 'WRONG_CONTAINER'); must(before.version === 'ksf.encrypted-entry.v1' && before.purpose === 'KSF-credential-receive-20261031', 'BAD_BINDING'); for (const field of ['keyDirectory', 'publicKeySha256', 'decryptModule', 'decryptModuleSha256']) { must(before[field] === release[field], 'RECIPIENT_BINDING_CHANGED'); } const alreadyApplied = before.envelopeFile === release.installEnvelope && before.envelopeSha256 === release.envelopeSha256; if (!alreadyApplied) { must(sha(beforeBytes) === release.expectedBindingSha256, 'UNKNOWN_BINDING_VERSION'); must(before.envelopeFile === release.oldEnvelopeFile && before.envelopeSha256 === release.oldEnvelopeSha256, 'OLD_ENVELOPE_CHANGED'); } const after = { ...before, envelopeFile: release.installEnvelope, envelopeSha256: release.envelopeSha256 }; return { before, after, afterBytes: alreadyApplied ? beforeBytes : Buffer.from(JSON.stringify(after, null, 2) + '\n'), alreadyApplied }; } function privateFile(file, uid, mode = 0o600) { const stat = fs.lstatSync(file); must(stat.isFile() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o777) === mode, 'PRIVATE_FILE_PERMISSIONS'); return fs.readFileSync(file); } function privateDirectory(directory, uid) { must(fs.realpathSync(directory) === directory, 'SYMLINK_DIRECTORY'); const stat = fs.lstatSync(directory); must(stat.isDirectory() && stat.uid === uid && (stat.mode & 0o777) === 0o700, 'PRIVATE_DIRECTORY_PERMISSIONS'); } function writeNew(file, bytes) { const fd = fs.openSync(file, 'wx', 0o600); try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } function atomicBytes(file, bytes, expectedSha) { if (expectedSha) must(sha(fs.readFileSync(file)) === expectedSha, 'CONCURRENT_FILE_CHANGE'); const temporary = file + '.tmp-' + crypto.randomUUID(); writeNew(temporary, bytes); if (expectedSha) must(sha(fs.readFileSync(file)) === expectedSha, 'CONCURRENT_FILE_CHANGE'); fs.renameSync(temporary, file); } function atomicJson(file, value, expectedSha) { atomicBytes(file, Buffer.from(JSON.stringify(value, null, 2) + '\n'), expectedSha); } function verifyManifest() { for (const line of fs.readFileSync(path.join(PACKAGE, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/)) { const match = line.match(/^([a-f0-9]{64}) ([A-Za-z0-9_.\/-]+)$/); must(match && !match[2].split('/').includes('..'), 'BAD_PACKAGE_MANIFEST'); const target = path.resolve(PACKAGE, match[2]); must(target.startsWith(PACKAGE + path.sep) && sha(fs.readFileSync(target)) === match[1], 'PACKAGE_HASH_MISMATCH'); } } function cleanProbe(secureEntry, command, inputFile, requestId) { const child = spawnSync(process.execPath, [secureEntry, command, '--input', inputFile, '--request-id', requestId], { encoding: 'utf8', timeout: 90000, maxBuffer: 4 * 1024 * 1024, windowsHide: true, }); let raw; try { raw = JSON.parse(child.status === 0 ? child.stdout : child.stderr); } catch { return { ok: false, code: 'ENTRY_RESULT_UNREADABLE', exitCode: child.status }; } const result = raw.entryResult?.result, code = raw.error?.code || raw.entryResult?.error?.code; return { ok: child.status === 0 && raw.ok === true && raw.entryResult?.ok === true, exitCode: child.status, ...(code && /^[A-Z][A-Z0-9_]+$/.test(code) ? { code } : {}), invocationId: raw.invocationId, entryInvocationId: raw.entryResult?.invocationId, readOnly: raw.entryResult?.readOnly === true, credentialExpiresAt: raw.credentialExpiresAt, credentialWrittenToDisk: raw.credentialWrittenToDisk, gatewayRestarted: raw.gatewayRestarted, ...(command === 'workshop-contract' && result ? { allowedClassCount: result.allowedClasses?.length, schemaVersion: result.schemaVersion, contractRevision: result.contractRevision, workshopVersion: result.workshop?.version, formalReportConfirmationUnchanged: result.workshop?.formalReportConfirmationUnchanged } : {}), ...(command === 'workshop-schema' && result ? { tableCount: result.tableCount, fieldCount: result.fieldCount, databaseWrites: result.databaseWrites, rowValuesReturned: result.rowValuesReturned } : {}), }; } async function main() { const mode = process.argv[2] || '--dry'; must(process.argv.length <= 3 && ['--dry', '--apply', '--verify', '--rollback'].includes(mode), 'USAGE'); must(process.platform === 'linux' && process.geteuid() === 10000, 'RUN_AS_HERMES_ON_LINUX'); verifyManifest(); const release = json(path.join(PACKAGE, 'RELEASE.json')); must(release.schema === 'ksf.workshop.encrypted-authorization-release.v1' && os.hostname() === release.host, 'WRONG_RECIPIENT'); const uid = process.geteuid(), bindingPath = path.join(release.skillDirectory, 'credential-binding.json'); const originalBytes = privateFile(bindingPath, uid); const update = bindingUpdate(originalBytes, release, os.hostname()); const secureEntry = path.join(release.skillDirectory, 'scripts/ksf-secure.mjs'); must(sha(fs.readFileSync(secureEntry)) === release.wrapperSha256, 'WRAPPER_CHANGED'); const managed = fs.readFileSync(path.join(release.skillDirectory, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/); const beforeSkill = {}; for (const line of managed) { const m = line.match(/^([a-f0-9]{64})\s+\*?(.+)$/); must(m && !path.isAbsolute(m[2]) && !m[2].split(/[\\/]/).includes('..'), 'BAD_SKILL_MANIFEST'); const file = path.join(release.skillDirectory, m[2]); must(sha(fs.readFileSync(file)) === m[1], 'SKILL_MANIFEST_MISMATCH'); beforeSkill[m[2]] = m[1]; } const directory = release.installDirectory; must(['20261108-r2', '20261108-r3-longterm'].includes(release.release) && directory === '/opt/data/ksf-workshop-auth-envelope-' + release.release && release.installEnvelope === path.join(directory, 'credential-envelope.json'), 'INVALID_INSTALL_DIRECTORY'); const receiptFileName = release.release === '20261108-r3-longterm' ? 'agent-auth-receipt-r3-longterm.json' : 'agent-auth-receipt-r2.json'; if (fs.existsSync(directory)) privateDirectory(directory, uid); const receipt = { schema: 'ksf.workshop.encrypted-authorization-install-receipt.v1', at: new Date().toISOString(), mode, host: os.hostname(), uid, serverAuthorizationRestored: true, agentEncryptedEntryVerified: false, actualBusinessWriteVerified: false, formalReportSubmitted: false, businessRecordWrites: 0, serverAuthorizationRecordWrites: 0, paidCalls: 0, skillCodeModified: false, serviceRestarted: false, bindingModified: false, encryptedEnvelopeInstalled: false, credentialPlaintextWrittenToDisk: false, expectedExpiresAt: release.expiresAt, previousBindingSha256: sha(originalBytes), alreadyApplied: update.alreadyApplied, }; const journalPath = path.join(directory, 'install-journal.json'); const backupPath = path.join(directory, 'credential-binding.before.json'); if (mode === '--rollback') { must(update.alreadyApplied && fs.existsSync(journalPath), 'NO_INSTALLED_RELEASE_TO_ROLL_BACK'); must(json(journalPath).afterSha256 === sha(originalBytes), 'BINDING_CHANGED_AFTER_INSTALL'); const backup = privateFile(backupPath, uid); must(sha(backup) === release.expectedBindingSha256, 'BACKUP_HASH_MISMATCH'); atomicBytes(bindingPath, backup, sha(originalBytes)); must(sha(fs.readFileSync(bindingPath)) === release.expectedBindingSha256, 'ROLLBACK_READBACK_FAILED'); receipt.ok = true; receipt.rolledBack = true; receipt.bindingModified = true; receipt.bindingSha256 = sha(fs.readFileSync(bindingPath)); atomicJson(path.join(directory, 'rollback-receipt.json'), receipt); console.log(JSON.stringify(receipt, null, 2)); return; } const envelopeBytes = fs.readFileSync(path.join(PACKAGE, 'credential-envelope.json')); must(sha(fs.readFileSync(release.oldEnvelopeFile)) === release.oldEnvelopeSha256, 'OLD_ENVELOPE_CHANGED'); must(envelopeBytes.length < 32768 && sha(envelopeBytes) === release.envelopeSha256, 'ENVELOPE_HASH_MISMATCH'); const envelope = JSON.parse(envelopeBytes); must(envelope.header?.recipientHost === release.host && envelope.header?.recipientDirectory === release.keyDirectory && envelope.header?.publicKeySha256 === release.publicKeySha256 && envelope.header?.expiresAt === release.expiresAt, 'ENVELOPE_BINDING_MISMATCH'); privateDirectory(release.keyDirectory, uid); const privateBytes = privateFile(path.join(release.keyDirectory, 'recipient-private.pem'), uid); let privateKey; try { privateKey = crypto.createPrivateKey(privateBytes); } finally { privateBytes.fill(0); } must(sha(crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })) === release.publicKeySha256, 'KEY_FINGERPRINT_MISMATCH'); must(fs.realpathSync(release.decryptModule) === release.decryptModule && sha(fs.readFileSync(release.decryptModule)) === release.decryptModuleSha256, 'DECRYPT_MODULE_CHANGED'); const { decryptEnvelope } = await import(pathToFileURL(release.decryptModule).href); const payload = decryptEnvelope(envelope, privateKey, { host: os.hostname(), directory: release.keyDirectory, publicKeySha256: release.publicKeySha256 }); try { must(payload.credentialId === release.credentialId && payload.expiresAt === release.expiresAt, 'DECRYPTED_IDENTITY_MISMATCH'); receipt.newEnvelopeDecryptVerified = true; if (mode === '--dry') { receipt.ok = true; receipt.readOnly = true; receipt.planOnly = true; receipt.proposedBindingSha256 = sha(update.afterBytes); receipt.filesToInstall = [release.installEnvelope]; receipt.bindingFieldsToChange = ['envelopeFile', 'envelopeSha256']; console.log(JSON.stringify(receipt, null, 2)); return; } if (mode === '--verify') must(update.alreadyApplied, 'RELEASE_NOT_INSTALLED'); if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 }); privateDirectory(directory, uid); if (fs.existsSync(release.installEnvelope)) must(sha(privateFile(release.installEnvelope, uid)) === release.envelopeSha256, 'INSTALL_ENVELOPE_CONFLICT'); else writeNew(release.installEnvelope, envelopeBytes); receipt.encryptedEnvelopeInstalled = true; if (!update.alreadyApplied) { must(mode === '--apply', 'RELEASE_NOT_INSTALLED'); must(sha(fs.readFileSync(bindingPath)) === release.expectedBindingSha256, 'CONCURRENT_BINDING_CHANGE'); if (fs.existsSync(backupPath)) must(sha(privateFile(backupPath, uid)) === sha(originalBytes), 'BACKUP_CONFLICT'); else writeNew(backupPath, originalBytes); atomicJson(journalPath, { schema: 'ksf.workshop.auth-binding-journal.v1', preparedAt: new Date().toISOString(), beforeSha256: sha(originalBytes), afterSha256: sha(update.afterBytes), envelopeSha256: release.envelopeSha256 }); atomicJson(bindingPath, update.after, sha(originalBytes)); receipt.bindingModified = true; } must(bindingUpdate(fs.readFileSync(bindingPath), release, os.hostname()).alreadyApplied, 'BINDING_READBACK_FAILED'); receipt.bindingSha256 = sha(fs.readFileSync(bindingPath)); const inputs = { 'workshop-contract': {}, 'workshop-schema': { summary: true } }; for (const [command, input] of Object.entries(inputs)) { const inputFile = path.join(directory, command + '-input.json'); const inputBytes = Buffer.from(JSON.stringify(input) + '\n'); if (fs.existsSync(inputFile)) must(sha(privateFile(inputFile, uid)) === sha(inputBytes), 'INPUT_CONFLICT'); else writeNew(inputFile, inputBytes); const probe = cleanProbe(secureEntry, command, inputFile, 'auth-envelope-' + release.release + '-' + command); receipt[command === 'workshop-contract' ? 'contract' : 'schemaProbe'] = probe; if (!probe.ok) break; } receipt.skillFilesUnchanged = Object.entries(beforeSkill).every(([file, hash]) => sha(fs.readFileSync(path.join(release.skillDirectory, file))) === hash); receipt.decryptModuleUnchanged = sha(fs.readFileSync(release.decryptModule)) === release.decryptModuleSha256; receipt.agentEncryptedEntryVerified = receipt.contract?.ok === true && receipt.contract.readOnly === true && receipt.contract.allowedClassCount > 0 && receipt.contract.credentialExpiresAt === release.expiresAt && receipt.schemaProbe?.ok === true && receipt.schemaProbe.readOnly === true && receipt.schemaProbe.databaseWrites === 0 && receipt.schemaProbe.rowValuesReturned === false && receipt.skillFilesUnchanged && receipt.decryptModuleUnchanged; receipt.ok = receipt.agentEncryptedEntryVerified; receipt.finishedAt = new Date().toISOString(); atomicJson(path.join(directory, receiptFileName), receipt); console.log(JSON.stringify(receipt, null, 2)); if (!receipt.ok) process.exitCode = 1; } finally { if (payload?.env) delete payload.env.KSF_SERVICE_TOKEN; } } if (process.argv[1] && path.resolve(process.argv[1]) === SELF) { main().catch(error => { console.error(JSON.stringify({ ok: false, error: { code: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'ENCRYPTED_AUTH_INSTALL_STOPPED', message: '安装或复验停止;保留原包和现有状态,不输出凭据、私钥、原始异常或业务数据。' } })); process.exitCode = 1; }); }