| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- 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;
- });
- }
|