ksf-secure.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import os from 'node:os';
  5. import { fileURLToPath, pathToFileURL } from 'node:url';
  6. import { spawnSync } from 'node:child_process';
  7. const self = fileURLToPath(import.meta.url);
  8. const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
  9. const ENTRY_HASH = '7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477';
  10. const LOCK_HASH = '2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79';
  11. const WORKSHOP_ENTRY_HASH = '7a04eb22d3429cdced23513717f36594f639539738994d45712a8bea738100a1';
  12. const IDENTITY_MODULE_HASH = '9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e';
  13. const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract','workshop-schema','employee-read','employee-name'];
  14. const fail = code => { throw Object.assign(new Error(code), { code }); };
  15. const requireThat = (value, code) => { if (!value) fail(code); };
  16. export function parseArgs(argv) {
  17. const [command, ...rest] = argv, args = { command };
  18. requireThat(['status','demo','connection','ingest','report-preview','report-submit',...WORKSHOP_COMMANDS].includes(command), 'USAGE');
  19. for (let i = 0; i < rest.length; i += 2) {
  20. const key = rest[i], value = rest[i + 1];
  21. requireThat(['--input','--request-id','--agent-profile'].includes(key) && value && !value.startsWith('--') && !args[key], 'USAGE');
  22. args[key] = value;
  23. }
  24. requireThat(['ingest','report-preview','report-submit',...WORKSHOP_COMMANDS].includes(command) === Boolean(args['--input']), 'USAGE');
  25. if(WORKSHOP_COMMANDS.includes(command))requireThat(Boolean(args['--request-id']), 'BAD_REQUEST_ID');
  26. if(args['--agent-profile'])requireThat(WORKSHOP_COMMANDS.includes(command)&&/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(args['--agent-profile']),'INVALID_AGENT_KEY');
  27. if(WORKSHOP_COMMANDS.includes(command)&&!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup','workshop-schema'].includes(command))requireThat(Boolean(args['--agent-profile']),'AGENT_PROFILE_REQUIRED');
  28. if (args['--input']) requireThat(path.isAbsolute(args['--input']), 'INPUT_PATH_MUST_BE_ABSOLUTE');
  29. if (args['--request-id']) requireThat(/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id']), 'BAD_REQUEST_ID');
  30. return args;
  31. }
  32. function readPrivateKey(directory) {
  33. requireThat(/^\/opt\/data\/ksf-key-receive-20261031-[A-Za-z0-9]+$/.test(directory), 'BAD_KEY_DIRECTORY');
  34. requireThat(fs.realpathSync(directory) === directory, 'KEY_DIRECTORY_IS_SYMLINK');
  35. const dir = fs.lstatSync(directory), file = path.join(directory, 'recipient-private.pem'), stat = fs.lstatSync(file);
  36. requireThat(dir.isDirectory() && dir.uid === process.geteuid() && (dir.mode & 0o777) === 0o700, 'BAD_KEY_DIRECTORY_PERMISSIONS');
  37. requireThat(stat.isFile() && !stat.isSymbolicLink() && stat.uid === process.geteuid() && (stat.mode & 0o777) === 0o600, 'BAD_KEY_FILE_PERMISSIONS');
  38. const bytes = fs.readFileSync(file);
  39. try { return crypto.createPrivateKey(bytes); } finally { bytes.fill(0); }
  40. }
  41. // Test dependencies are module arguments only; the executable accepts no override flags.
  42. export async function executeSecure(argv, dependencies = {}) {
  43. const args = parseArgs(argv);
  44. const skillDir = dependencies.skillDir || path.resolve(path.dirname(self), '..');
  45. const readBytes = dependencies.readBytes || (file => fs.readFileSync(file));
  46. const spawn = dependencies.spawn || spawnSync;
  47. const workshop = WORKSHOP_COMMANDS.includes(args.command);
  48. const entry = path.join(skillDir, 'scripts', workshop ? 'ksf-workshop.mjs' : 'ksf.mjs');
  49. const expectedEntryHash = workshop ? WORKSHOP_ENTRY_HASH : ENTRY_HASH;
  50. requireThat(sha256(readBytes(entry)) === expectedEntryHash, 'ENTRY_HASH_MISMATCH');
  51. if(workshop)requireThat(sha256(readBytes(path.join(skillDir,'scripts','ksf-identity.mjs'))) === IDENTITY_MODULE_HASH, 'IDENTITY_MODULE_HASH_MISMATCH');
  52. requireThat(sha256(readBytes(path.join(skillDir, 'runtime-lock.json'))) === LOCK_HASH, 'RUNTIME_LOCK_HASH_MISMATCH');
  53. const inheritedEnv = dependencies.inheritedEnv || process.env;
  54. const env = { ...inheritedEnv };
  55. for (const name of Object.keys(env)) if (name.startsWith('KSF_')) delete env[name];
  56. Object.assign(env, {
  57. KSF_ADAPT_DIR: dependencies.runtimeDir || '/opt/data/ksf-adapt-training-20261031',
  58. KSF_R7_SKILL_DIR: dependencies.r7Dir || '/opt/data/ksf-baseline/r7/unpacked/skill',
  59. });
  60. let payload = null, binding = null;
  61. try {
  62. if (args.command !== 'demo') {
  63. binding = JSON.parse(readBytes(path.join(skillDir, 'credential-binding.json')));
  64. requireThat(binding.version === 'ksf.encrypted-entry.v1' && binding.purpose === 'KSF-credential-receive-20261031', 'BAD_BINDING');
  65. requireThat(binding.host === (dependencies.hostname || os.hostname()), 'WRONG_CONTAINER');
  66. requireThat(/^\/opt\/data\/.+/.test(binding.envelopeFile)
  67. && /^\/opt\/data\/.+/.test(binding.decryptModule), 'BAD_BINDING_PATHS');
  68. requireThat(/^[a-f0-9]{64}$/.test(binding.envelopeSha256)
  69. && /^[a-f0-9]{64}$/.test(binding.decryptModuleSha256)
  70. && /^[a-f0-9]{64}$/.test(binding.publicKeySha256), 'BAD_BINDING_HASHES');
  71. const envelopeBytes = readBytes(binding.envelopeFile);
  72. requireThat(envelopeBytes.length < 32768 && sha256(envelopeBytes) === binding.envelopeSha256, 'ENVELOPE_HASH_MISMATCH');
  73. const envelope = JSON.parse(envelopeBytes);
  74. requireThat(envelope.header?.recipientHost === binding.host
  75. && envelope.header?.recipientDirectory === binding.keyDirectory
  76. && envelope.header?.publicKeySha256 === binding.publicKeySha256, 'ENVELOPE_BINDING_MISMATCH');
  77. requireThat(sha256(readBytes(binding.decryptModule)) === binding.decryptModuleSha256, 'DECRYPT_MODULE_HASH_MISMATCH');
  78. const module = await (dependencies.importDecrypt || (file => import(pathToFileURL(file).href)))(binding.decryptModule);
  79. const key = (dependencies.readPrivateKey || readPrivateKey)(binding.keyDirectory);
  80. payload = module.decryptEnvelope(envelope, key, { host: binding.host, directory: binding.keyDirectory,
  81. publicKeySha256: binding.publicKeySha256 });
  82. Object.assign(env, payload.env);
  83. }
  84. const child = spawn(process.execPath, [entry, ...argv], {
  85. env, encoding: 'utf8', timeout: 600000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
  86. });
  87. requireThat(!child.error && [0,1].includes(child.status), 'ENTRY_PROCESS_FAILED');
  88. if (payload) requireThat(!String(child.stdout).includes(payload.env.KSF_SERVICE_TOKEN)
  89. && !String(child.stderr).includes(payload.env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
  90. const result = JSON.parse(child.status === 0 ? child.stdout : child.stderr);
  91. requireThat(result.skill === 'ksf-workshop-runtime' && result.entry === entry
  92. && result.entrySha256 === expectedEntryHash && result.command === args.command
  93. && typeof result.invocationId === 'string' && /^[a-f0-9-]{36}$/.test(result.invocationId)
  94. && (!args['--request-id'] || result.requestId === args['--request-id']), 'ENTRY_OUTPUT_MISMATCH');
  95. requireThat(typeof result.ok === 'boolean' && (child.status === 0) === result.ok, 'ENTRY_EXIT_STATUS_MISMATCH');
  96. const output = {
  97. skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
  98. wrapper: self, wrapperSha256: sha256(readBytes(self)), command: args.command,
  99. ...(args['--request-id'] ? { requestId: args['--request-id'] } : {}),
  100. ok: result.ok, credentialMode: payload ? 'encrypted-file-decrypted-per-command' : 'not-needed-for-demo',
  101. credentialLoadedForChild: Boolean(payload), credentialWrittenToDisk: false,
  102. gatewayEnvironmentModified: false, gatewayRestarted: false,
  103. ...(payload ? { credentialExpiresAt: payload.expiresAt, envelopeSha256: binding.envelopeSha256 } : {}),
  104. entryExitCode: child.status, entryResult: result,
  105. };
  106. if (payload) requireThat(!JSON.stringify(output).includes(payload.env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
  107. return output;
  108. } finally {
  109. delete env.KSF_SERVICE_TOKEN;
  110. if (payload) delete payload.env.KSF_SERVICE_TOKEN;
  111. }
  112. }
  113. if (process.argv[1] && path.resolve(process.argv[1]) === self) {
  114. const base = { skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
  115. wrapper: self, wrapperSha256: sha256(fs.readFileSync(self)) };
  116. try {
  117. requireThat(process.platform === 'linux', 'LINUX_REQUIRED');
  118. const result = await executeSecure(process.argv.slice(2));
  119. console.log(JSON.stringify(result, null, 2));
  120. if (!result.ok) process.exitCode = 1;
  121. } catch (error) {
  122. console.error(JSON.stringify({ ...base, ok: false, error: {
  123. code: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'SECURE_ENTRY_FAILED',
  124. message: '执行停止;未显示凭据或原始异常。凭据过期需由管理端续期或轮换;请勿改指纹或绕过校验。',
  125. } }, null, 2));
  126. process.exitCode = 1;
  127. }
  128. }