decrypt-envelope.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. import fs from 'node:fs';
  2. import crypto from 'node:crypto';
  3. import os from 'node:os';
  4. import path from 'node:path';
  5. import { fileURLToPath } from 'node:url';
  6. import { spawnSync } from 'node:child_process';
  7. const PURPOSE = 'KSF-credential-receive-20261031';
  8. const ENDPOINT = 'https://kangshifu.fmode.cn/api/functions';
  9. const APP_ID = 'FKieRbIoGt_main';
  10. const ENTRY = '/opt/data/skills/ksf-workshop-runtime/scripts/ksf.mjs';
  11. const ENTRY_SHA256 = '7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477';
  12. const sha256 = value => crypto.createHash('sha256').update(value).digest('hex');
  13. const fail = code => { throw Object.assign(new Error(code), { code }); };
  14. const assert = (condition, code) => { if (!condition) fail(code); };
  15. export function decryptEnvelope(envelope, privateKey, { host, directory, publicKeySha256, now = Date.now() }) {
  16. const h = envelope?.header;
  17. assert(h?.version === 'ksf.credential-envelope.v1' && h.purpose === PURPOSE
  18. && h.keyAlgorithm === 'RSA-OAEP-SHA256' && h.contentAlgorithm === 'AES-256-GCM', 'BAD_ENVELOPE');
  19. assert(h.recipientHost === host && h.recipientDirectory === directory, 'WRONG_RECIPIENT');
  20. assert(privateKey.asymmetricKeyType === 'rsa' && privateKey.asymmetricKeyDetails.modulusLength >= 3072, 'BAD_PRIVATE_KEY');
  21. const publicDer = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
  22. assert(sha256(publicDer) === publicKeySha256 && h.publicKeySha256 === publicKeySha256, 'KEY_FINGERPRINT_MISMATCH');
  23. const aad = Buffer.from(envelope.aadBase64, 'base64');
  24. assert(aad.equals(Buffer.from(JSON.stringify(h))), 'HEADER_INCONSISTENT');
  25. const iv = Buffer.from(envelope.ivBase64, 'base64'), tag = Buffer.from(envelope.tagBase64, 'base64');
  26. assert(iv.length === 12 && tag.length === 16, 'BAD_CIPHER_PARAMETERS');
  27. let key, plaintext;
  28. try {
  29. key = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
  30. oaepHash: 'sha256', oaepLabel: Buffer.from(PURPOSE) }, Buffer.from(envelope.wrappedKeyBase64, 'base64'));
  31. assert(key.length === 32, 'BAD_CONTENT_KEY');
  32. const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  33. decipher.setAAD(aad); decipher.setAuthTag(tag);
  34. plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertextBase64, 'base64')), decipher.final()]);
  35. const payload = JSON.parse(plaintext.toString('utf8'));
  36. assert(payload.purpose === PURPOSE && payload.expiresAt === h.expiresAt
  37. && Number.isFinite(Date.parse(payload.expiresAt)) && Date.parse(payload.expiresAt) > now, 'CREDENTIAL_EXPIRED_OR_MISMATCH');
  38. assert(payload.env && Object.keys(payload.env).sort().join(',') === 'KSF_FUNCTIONS_URL,KSF_PARSE_APP_ID,KSF_SERVICE_TOKEN', 'ENV_KEYS_NOT_ALLOWED');
  39. assert(payload.env.KSF_FUNCTIONS_URL === ENDPOINT && payload.env.KSF_PARSE_APP_ID === APP_ID
  40. && /^[A-Za-z0-9_-]{43}$/.test(payload.env.KSF_SERVICE_TOKEN), 'BAD_SERVICE_CONFIG');
  41. assert(typeof payload.credentialId === 'string' && payload.credentialId.length > 0, 'MISSING_CREDENTIAL_ID');
  42. return payload;
  43. } finally { key?.fill(0); plaintext?.fill(0); }
  44. }
  45. function argsOf(values) {
  46. const args = {};
  47. for (let i = 0; i < values.length; i += 2) {
  48. const name = values[i], value = values[i + 1];
  49. assert(['--envelope', '--sha256', '--request-id'].includes(name) && value && !args[name], 'USAGE');
  50. args[name] = value;
  51. }
  52. assert(args['--envelope'] && /^[a-f0-9]{64}$/.test(args['--sha256'] || '')
  53. && /^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id'] || ''), 'USAGE');
  54. return args;
  55. }
  56. function privateKeyAt(directory) {
  57. assert(/^\/opt\/data\/ksf-key-receive-20261031-[A-Za-z0-9]+$/.test(directory || ''), 'BAD_KEY_DIRECTORY');
  58. assert(fs.realpathSync(directory) === directory, 'SYMLINK_KEY_DIRECTORY');
  59. const dir = fs.lstatSync(directory), file = path.join(directory, 'recipient-private.pem'), stat = fs.lstatSync(file);
  60. const uid = process.geteuid();
  61. assert(dir.isDirectory() && dir.uid === uid && (dir.mode & 0o777) === 0o700, 'BAD_DIRECTORY_PERMISSIONS');
  62. assert(stat.isFile() && !stat.isSymbolicLink() && stat.uid === uid && (stat.mode & 0o777) === 0o600, 'BAD_KEY_PERMISSIONS');
  63. const bytes = fs.readFileSync(file);
  64. try { return crypto.createPrivateKey(bytes); } finally { bytes.fill(0); }
  65. }
  66. export async function runReadControls(env, requestId, fetchImpl = fetch) {
  67. const probe = { className: 'KfSource', objectId: 'auth-probe-' + crypto.randomUUID() };
  68. const controls = [];
  69. for (const [identity, token] of [
  70. ['anonymous', null], ['invalid-control', 'invalid-control-' + crypto.randomBytes(24).toString('base64url')],
  71. ['configured-restricted-service', env.KSF_SERVICE_TOKEN],
  72. ]) {
  73. const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID };
  74. if (token) headers['X-Ksf-Service-Token'] = token;
  75. const response = await fetchImpl(ENDPOINT, { method: 'POST', headers,
  76. body: JSON.stringify({ path: '/ksfGetRecord', params: { ...probe, requestId } }),
  77. redirect: 'error', signal: AbortSignal.timeout(45000) });
  78. const result = (await response.json())?.result;
  79. assert(result?.ok === false && Array.isArray(result.errors), 'UNEXPECTED_CONTROL_RESPONSE');
  80. const codes = result.errors.map(x => x.code);
  81. controls.push({ identity, operation: 'ksfGetRecord', http: response.status, businessOk: false, errorCodes: codes });
  82. assert(identity === 'configured-restricted-service'
  83. ? response.status === 400 && codes.includes('REFERENCE_NOT_FOUND')
  84. : response.status === 401 && codes.includes('AUTH_REQUIRED'), 'AUTH_CONTROL_FAILED');
  85. }
  86. return controls;
  87. }
  88. export function validateEntryResult(child, requestId) {
  89. assert(!child.error && [0, 1].includes(child.status), 'ENTRY_PROCESS_FAILED');
  90. const output = child.status === 0 ? child.stdout : child.stderr;
  91. const result = JSON.parse(output);
  92. assert(result.entry === ENTRY && result.entrySha256 === ENTRY_SHA256 && result.command === 'connection'
  93. && result.requestId === requestId && typeof result.invocationId === 'string'
  94. && /^[a-f0-9-]{36}$/.test(result.invocationId), 'ENTRY_OUTPUT_MISMATCH');
  95. assert(child.status === 0 && result.ok === true && result.readOnly === true
  96. && Number.isInteger(result.allowedClassCount) && result.allowedClassCount > 0, 'ENTRY_CONNECTION_FAILED');
  97. return result;
  98. }
  99. async function main() {
  100. assert(process.platform === 'linux', 'LINUX_REQUIRED');
  101. const args = argsOf(process.argv.slice(2));
  102. const bytes = fs.readFileSync(args['--envelope']);
  103. assert(bytes.length < 32768 && sha256(bytes) === args['--sha256'], 'ENVELOPE_SHA256_MISMATCH');
  104. const envelope = JSON.parse(bytes);
  105. const privateKey = privateKeyAt(envelope?.header?.recipientDirectory);
  106. const payload = decryptEnvelope(envelope, privateKey, { host: os.hostname(), directory: envelope.header.recipientDirectory,
  107. publicKeySha256: envelope.header.publicKeySha256 });
  108. assert(sha256(fs.readFileSync(ENTRY)) === ENTRY_SHA256, 'ENTRY_HASH_MISMATCH');
  109. const env = { ...process.env };
  110. for (const name of ['KSF_SERVICE_TOKEN', 'KSF_PARSE_SESSION_TOKEN', 'KSF_PARSE_MASTER_KEY', 'KSF_FUNCTIONS_URL', 'KSF_PARSE_APP_ID']) delete env[name];
  111. Object.assign(env, payload.env, { KSF_ADAPT_DIR: '/opt/data/ksf-adapt-training-20261031',
  112. KSF_R7_SKILL_DIR: '/opt/data/ksf-baseline/r7/unpacked/skill' });
  113. try {
  114. const child = spawnSync(process.execPath, [ENTRY, 'connection', '--request-id', args['--request-id']], {
  115. env, encoding: 'utf8', timeout: 60000, maxBuffer: 1024 * 1024, windowsHide: true,
  116. });
  117. assert(!String(child.stdout).includes(env.KSF_SERVICE_TOKEN) && !String(child.stderr).includes(env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
  118. const result = validateEntryResult(child, args['--request-id']);
  119. const controls = await runReadControls(payload.env, args['--request-id']);
  120. const output = JSON.stringify({ ok: true, operation: 'remote-memory-only-connection-check',
  121. at: new Date().toISOString(), host: os.hostname(), requestId: args['--request-id'],
  122. envelopeSha256: sha256(bytes), publicKeySha256: envelope.header.publicKeySha256,
  123. credentialExpiresAt: payload.expiresAt, entryExitCode: child.status, entryResult: result,
  124. authenticationVerified: true, authenticationControls: controls,
  125. controlRequestCount: controls.length, entryContractCallSucceeded: true,
  126. credentialWrittenToDisk: false, permanentEnvironmentConfigured: false,
  127. gatewayRestarted: false, databaseWrites: 0, published: false }, null, 2);
  128. assert(!output.includes(env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
  129. console.log(output);
  130. } finally { delete env.KSF_SERVICE_TOKEN; delete payload.env.KSF_SERVICE_TOKEN; }
  131. }
  132. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  133. main().catch(error => {
  134. // Do not echo crypto errors, paths, server bodies, or child stdout/stderr on failure.
  135. console.error(JSON.stringify({ ok: false, error: {
  136. code: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'SECURE_CONNECTION_CHECK_FAILED',
  137. message: '安全连接检查中止;未输出私钥、令牌、明文配置或原始服务器报文。',
  138. } }));
  139. process.exitCode = 1;
  140. });
  141. }