restore-workshop-credential-binding.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import os from 'node:os';
  4. import crypto from 'node:crypto';
  5. import { fileURLToPath, pathToFileURL } from 'node:url';
  6. import { spawnSync } from 'node:child_process';
  7. const SELF = fileURLToPath(import.meta.url);
  8. const PACKAGE = path.dirname(SELF);
  9. const sha = value => crypto.createHash('sha256').update(value).digest('hex');
  10. const must = (value, code) => { if (!value) throw Object.assign(new Error(code), { code }); };
  11. const json = file => JSON.parse(fs.readFileSync(file, 'utf8'));
  12. export function bindingUpdate(beforeBytes, release, actualHost) {
  13. const before = JSON.parse(beforeBytes.toString('utf8'));
  14. must(actualHost === release.host && before.host === actualHost, 'WRONG_CONTAINER');
  15. must(before.version === 'ksf.encrypted-entry.v1' && before.purpose === 'KSF-credential-receive-20261031', 'BAD_BINDING');
  16. for (const field of ['keyDirectory', 'publicKeySha256', 'decryptModule', 'decryptModuleSha256']) {
  17. must(before[field] === release[field], 'RECIPIENT_BINDING_CHANGED');
  18. }
  19. const alreadyApplied = before.envelopeFile === release.installEnvelope && before.envelopeSha256 === release.envelopeSha256;
  20. if (!alreadyApplied) {
  21. must(sha(beforeBytes) === release.expectedBindingSha256, 'UNKNOWN_BINDING_VERSION');
  22. must(before.envelopeFile === release.oldEnvelopeFile && before.envelopeSha256 === release.oldEnvelopeSha256, 'OLD_ENVELOPE_CHANGED');
  23. }
  24. const after = { ...before, envelopeFile: release.installEnvelope, envelopeSha256: release.envelopeSha256 };
  25. return { before, after, afterBytes: alreadyApplied ? beforeBytes : Buffer.from(JSON.stringify(after, null, 2) + '\n'), alreadyApplied };
  26. }
  27. function privateFile(file, uid, mode = 0o600) {
  28. const stat = fs.lstatSync(file);
  29. must(stat.isFile() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o777) === mode, 'PRIVATE_FILE_PERMISSIONS');
  30. return fs.readFileSync(file);
  31. }
  32. function privateDirectory(directory, uid) {
  33. must(fs.realpathSync(directory) === directory, 'SYMLINK_DIRECTORY');
  34. const stat = fs.lstatSync(directory);
  35. must(stat.isDirectory() && stat.uid === uid && (stat.mode & 0o777) === 0o700, 'PRIVATE_DIRECTORY_PERMISSIONS');
  36. }
  37. function writeNew(file, bytes) {
  38. const fd = fs.openSync(file, 'wx', 0o600);
  39. try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
  40. }
  41. function atomicBytes(file, bytes, expectedSha) {
  42. if (expectedSha) must(sha(fs.readFileSync(file)) === expectedSha, 'CONCURRENT_FILE_CHANGE');
  43. const temporary = file + '.tmp-' + crypto.randomUUID();
  44. writeNew(temporary, bytes);
  45. if (expectedSha) must(sha(fs.readFileSync(file)) === expectedSha, 'CONCURRENT_FILE_CHANGE');
  46. fs.renameSync(temporary, file);
  47. }
  48. function atomicJson(file, value, expectedSha) {
  49. atomicBytes(file, Buffer.from(JSON.stringify(value, null, 2) + '\n'), expectedSha);
  50. }
  51. function verifyManifest() {
  52. for (const line of fs.readFileSync(path.join(PACKAGE, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/)) {
  53. const match = line.match(/^([a-f0-9]{64}) ([A-Za-z0-9_.\/-]+)$/);
  54. must(match && !match[2].split('/').includes('..'), 'BAD_PACKAGE_MANIFEST');
  55. const target = path.resolve(PACKAGE, match[2]);
  56. must(target.startsWith(PACKAGE + path.sep) && sha(fs.readFileSync(target)) === match[1], 'PACKAGE_HASH_MISMATCH');
  57. }
  58. }
  59. function cleanProbe(secureEntry, command, inputFile, requestId) {
  60. const child = spawnSync(process.execPath, [secureEntry, command, '--input', inputFile, '--request-id', requestId], {
  61. encoding: 'utf8', timeout: 90000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
  62. });
  63. let raw;
  64. try { raw = JSON.parse(child.status === 0 ? child.stdout : child.stderr); }
  65. catch { return { ok: false, code: 'ENTRY_RESULT_UNREADABLE', exitCode: child.status }; }
  66. const result = raw.entryResult?.result, code = raw.error?.code || raw.entryResult?.error?.code;
  67. return { ok: child.status === 0 && raw.ok === true && raw.entryResult?.ok === true,
  68. exitCode: child.status, ...(code && /^[A-Z][A-Z0-9_]+$/.test(code) ? { code } : {}),
  69. invocationId: raw.invocationId, entryInvocationId: raw.entryResult?.invocationId,
  70. readOnly: raw.entryResult?.readOnly === true, credentialExpiresAt: raw.credentialExpiresAt,
  71. credentialWrittenToDisk: raw.credentialWrittenToDisk, gatewayRestarted: raw.gatewayRestarted,
  72. ...(command === 'workshop-contract' && result ? { allowedClassCount: result.allowedClasses?.length,
  73. schemaVersion: result.schemaVersion, contractRevision: result.contractRevision,
  74. workshopVersion: result.workshop?.version, formalReportConfirmationUnchanged: result.workshop?.formalReportConfirmationUnchanged } : {}),
  75. ...(command === 'workshop-schema' && result ? { tableCount: result.tableCount, fieldCount: result.fieldCount,
  76. databaseWrites: result.databaseWrites, rowValuesReturned: result.rowValuesReturned } : {}),
  77. };
  78. }
  79. async function main() {
  80. const mode = process.argv[2] || '--dry';
  81. must(process.argv.length <= 3 && ['--dry', '--apply', '--verify', '--rollback'].includes(mode), 'USAGE');
  82. must(process.platform === 'linux' && process.geteuid() === 10000, 'RUN_AS_HERMES_ON_LINUX');
  83. verifyManifest();
  84. const release = json(path.join(PACKAGE, 'RELEASE.json'));
  85. must(release.schema === 'ksf.workshop.encrypted-authorization-release.v1' && os.hostname() === release.host, 'WRONG_RECIPIENT');
  86. const uid = process.geteuid(), bindingPath = path.join(release.skillDirectory, 'credential-binding.json');
  87. const originalBytes = privateFile(bindingPath, uid);
  88. const update = bindingUpdate(originalBytes, release, os.hostname());
  89. const secureEntry = path.join(release.skillDirectory, 'scripts/ksf-secure.mjs');
  90. must(sha(fs.readFileSync(secureEntry)) === release.wrapperSha256, 'WRAPPER_CHANGED');
  91. const managed = fs.readFileSync(path.join(release.skillDirectory, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/);
  92. const beforeSkill = {};
  93. for (const line of managed) {
  94. const m = line.match(/^([a-f0-9]{64})\s+\*?(.+)$/);
  95. must(m && !path.isAbsolute(m[2]) && !m[2].split(/[\\/]/).includes('..'), 'BAD_SKILL_MANIFEST');
  96. const file = path.join(release.skillDirectory, m[2]);
  97. must(sha(fs.readFileSync(file)) === m[1], 'SKILL_MANIFEST_MISMATCH');
  98. beforeSkill[m[2]] = m[1];
  99. }
  100. const directory = release.installDirectory;
  101. must(['20261108-r2', '20261108-r3-longterm'].includes(release.release)
  102. && directory === '/opt/data/ksf-workshop-auth-envelope-' + release.release
  103. && release.installEnvelope === path.join(directory, 'credential-envelope.json'), 'INVALID_INSTALL_DIRECTORY');
  104. const receiptFileName = release.release === '20261108-r3-longterm' ? 'agent-auth-receipt-r3-longterm.json' : 'agent-auth-receipt-r2.json';
  105. if (fs.existsSync(directory)) privateDirectory(directory, uid);
  106. const receipt = {
  107. schema: 'ksf.workshop.encrypted-authorization-install-receipt.v1', at: new Date().toISOString(), mode,
  108. host: os.hostname(), uid, serverAuthorizationRestored: true, agentEncryptedEntryVerified: false,
  109. actualBusinessWriteVerified: false, formalReportSubmitted: false,
  110. businessRecordWrites: 0, serverAuthorizationRecordWrites: 0, paidCalls: 0,
  111. skillCodeModified: false, serviceRestarted: false, bindingModified: false,
  112. encryptedEnvelopeInstalled: false, credentialPlaintextWrittenToDisk: false,
  113. expectedExpiresAt: release.expiresAt, previousBindingSha256: sha(originalBytes),
  114. alreadyApplied: update.alreadyApplied,
  115. };
  116. const journalPath = path.join(directory, 'install-journal.json');
  117. const backupPath = path.join(directory, 'credential-binding.before.json');
  118. if (mode === '--rollback') {
  119. must(update.alreadyApplied && fs.existsSync(journalPath), 'NO_INSTALLED_RELEASE_TO_ROLL_BACK');
  120. must(json(journalPath).afterSha256 === sha(originalBytes), 'BINDING_CHANGED_AFTER_INSTALL');
  121. const backup = privateFile(backupPath, uid);
  122. must(sha(backup) === release.expectedBindingSha256, 'BACKUP_HASH_MISMATCH');
  123. atomicBytes(bindingPath, backup, sha(originalBytes));
  124. must(sha(fs.readFileSync(bindingPath)) === release.expectedBindingSha256, 'ROLLBACK_READBACK_FAILED');
  125. receipt.ok = true; receipt.rolledBack = true; receipt.bindingModified = true;
  126. receipt.bindingSha256 = sha(fs.readFileSync(bindingPath));
  127. atomicJson(path.join(directory, 'rollback-receipt.json'), receipt);
  128. console.log(JSON.stringify(receipt, null, 2));
  129. return;
  130. }
  131. const envelopeBytes = fs.readFileSync(path.join(PACKAGE, 'credential-envelope.json'));
  132. must(sha(fs.readFileSync(release.oldEnvelopeFile)) === release.oldEnvelopeSha256, 'OLD_ENVELOPE_CHANGED');
  133. must(envelopeBytes.length < 32768 && sha(envelopeBytes) === release.envelopeSha256, 'ENVELOPE_HASH_MISMATCH');
  134. const envelope = JSON.parse(envelopeBytes);
  135. must(envelope.header?.recipientHost === release.host && envelope.header?.recipientDirectory === release.keyDirectory
  136. && envelope.header?.publicKeySha256 === release.publicKeySha256 && envelope.header?.expiresAt === release.expiresAt, 'ENVELOPE_BINDING_MISMATCH');
  137. privateDirectory(release.keyDirectory, uid);
  138. const privateBytes = privateFile(path.join(release.keyDirectory, 'recipient-private.pem'), uid);
  139. let privateKey;
  140. try { privateKey = crypto.createPrivateKey(privateBytes); } finally { privateBytes.fill(0); }
  141. must(sha(crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })) === release.publicKeySha256, 'KEY_FINGERPRINT_MISMATCH');
  142. must(fs.realpathSync(release.decryptModule) === release.decryptModule
  143. && sha(fs.readFileSync(release.decryptModule)) === release.decryptModuleSha256, 'DECRYPT_MODULE_CHANGED');
  144. const { decryptEnvelope } = await import(pathToFileURL(release.decryptModule).href);
  145. const payload = decryptEnvelope(envelope, privateKey, { host: os.hostname(), directory: release.keyDirectory, publicKeySha256: release.publicKeySha256 });
  146. try {
  147. must(payload.credentialId === release.credentialId && payload.expiresAt === release.expiresAt, 'DECRYPTED_IDENTITY_MISMATCH');
  148. receipt.newEnvelopeDecryptVerified = true;
  149. if (mode === '--dry') {
  150. receipt.ok = true; receipt.readOnly = true; receipt.planOnly = true;
  151. receipt.proposedBindingSha256 = sha(update.afterBytes);
  152. receipt.filesToInstall = [release.installEnvelope]; receipt.bindingFieldsToChange = ['envelopeFile', 'envelopeSha256'];
  153. console.log(JSON.stringify(receipt, null, 2)); return;
  154. }
  155. if (mode === '--verify') must(update.alreadyApplied, 'RELEASE_NOT_INSTALLED');
  156. if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
  157. privateDirectory(directory, uid);
  158. if (fs.existsSync(release.installEnvelope)) must(sha(privateFile(release.installEnvelope, uid)) === release.envelopeSha256, 'INSTALL_ENVELOPE_CONFLICT');
  159. else writeNew(release.installEnvelope, envelopeBytes);
  160. receipt.encryptedEnvelopeInstalled = true;
  161. if (!update.alreadyApplied) {
  162. must(mode === '--apply', 'RELEASE_NOT_INSTALLED');
  163. must(sha(fs.readFileSync(bindingPath)) === release.expectedBindingSha256, 'CONCURRENT_BINDING_CHANGE');
  164. if (fs.existsSync(backupPath)) must(sha(privateFile(backupPath, uid)) === sha(originalBytes), 'BACKUP_CONFLICT');
  165. else writeNew(backupPath, originalBytes);
  166. atomicJson(journalPath, { schema: 'ksf.workshop.auth-binding-journal.v1', preparedAt: new Date().toISOString(),
  167. beforeSha256: sha(originalBytes), afterSha256: sha(update.afterBytes), envelopeSha256: release.envelopeSha256 });
  168. atomicJson(bindingPath, update.after, sha(originalBytes));
  169. receipt.bindingModified = true;
  170. }
  171. must(bindingUpdate(fs.readFileSync(bindingPath), release, os.hostname()).alreadyApplied, 'BINDING_READBACK_FAILED');
  172. receipt.bindingSha256 = sha(fs.readFileSync(bindingPath));
  173. const inputs = { 'workshop-contract': {}, 'workshop-schema': { summary: true } };
  174. for (const [command, input] of Object.entries(inputs)) {
  175. const inputFile = path.join(directory, command + '-input.json');
  176. const inputBytes = Buffer.from(JSON.stringify(input) + '\n');
  177. if (fs.existsSync(inputFile)) must(sha(privateFile(inputFile, uid)) === sha(inputBytes), 'INPUT_CONFLICT');
  178. else writeNew(inputFile, inputBytes);
  179. const probe = cleanProbe(secureEntry, command, inputFile, 'auth-envelope-' + release.release + '-' + command);
  180. receipt[command === 'workshop-contract' ? 'contract' : 'schemaProbe'] = probe;
  181. if (!probe.ok) break;
  182. }
  183. receipt.skillFilesUnchanged = Object.entries(beforeSkill).every(([file, hash]) => sha(fs.readFileSync(path.join(release.skillDirectory, file))) === hash);
  184. receipt.decryptModuleUnchanged = sha(fs.readFileSync(release.decryptModule)) === release.decryptModuleSha256;
  185. receipt.agentEncryptedEntryVerified = receipt.contract?.ok === true && receipt.contract.readOnly === true
  186. && receipt.contract.allowedClassCount > 0 && receipt.contract.credentialExpiresAt === release.expiresAt
  187. && receipt.schemaProbe?.ok === true && receipt.schemaProbe.readOnly === true
  188. && receipt.schemaProbe.databaseWrites === 0 && receipt.schemaProbe.rowValuesReturned === false
  189. && receipt.skillFilesUnchanged && receipt.decryptModuleUnchanged;
  190. receipt.ok = receipt.agentEncryptedEntryVerified;
  191. receipt.finishedAt = new Date().toISOString();
  192. atomicJson(path.join(directory, receiptFileName), receipt);
  193. console.log(JSON.stringify(receipt, null, 2));
  194. if (!receipt.ok) process.exitCode = 1;
  195. } finally { if (payload?.env) delete payload.env.KSF_SERVICE_TOKEN; }
  196. }
  197. if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
  198. main().catch(error => {
  199. console.error(JSON.stringify({ ok: false, error: { code: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'ENCRYPTED_AUTH_INSTALL_STOPPED',
  200. message: '安装或复验停止;保留原包和现有状态,不输出凭据、私钥、原始异常或业务数据。' } }));
  201. process.exitCode = 1;
  202. });
  203. }