From 5e67dd5925568ae04410b60bccd2d1b3fc3271b8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 11:06:21 -0700 Subject: [PATCH] =?UTF-8?q?[SEC-FIX]=20Gatekeeper=20v3.2=20=C2=B7=20bounde?= =?UTF-8?q?d=20work=20sessions=20=C2=B7=20fixed=20whitelist=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core-channel/gatekeeper/DEPLOY-v3.2.md | 30 +++++ .../gatekeeper/engine-v3.2-security.test.js | 19 +++ .../core-channel/gatekeeper/engine-v3.js | 115 ++++++++++++++---- .../gatekeeper/session-manager.js | 34 ++++++ .../gatekeeper/session-manager.test.js | 25 ++++ 5 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md create mode 100644 zero-point/core-channel/gatekeeper/engine-v3.2-security.test.js create mode 100644 zero-point/core-channel/gatekeeper/session-manager.js create mode 100644 zero-point/core-channel/gatekeeper/session-manager.test.js diff --git a/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md b/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md new file mode 100644 index 0000000..12f60b5 --- /dev/null +++ b/zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md @@ -0,0 +1,30 @@ +# Gatekeeper v3.2 部署说明 + +## 变更 + +- 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。 +- 会话绑定人格体、服务器和操作范围。 +- `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。 +- 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。 +- 注册铸澜 `ICE-GL∞-ZL-001`。 +- 将苍耳 `TCS-GL-009`、耳耳蛋与鉴影合并进固定白名单。 + +## 服务器部署 + +1. 备份线上 `engine-v3.js`、`tokens.json` 和 `whitelist.json`。 +2. 为铸澜生成独立随机 Token,写入服务器环境变量 `ZHULAN_API_TOKEN`;同时设置 `BINGSHUO_AUTH_EMAIL`、`CANGER_AUTH_EMAIL`、`SMTP_USER` 和 `QQ_SMTP_AUTH_CODE`。所有值只留服务器,不得写入仓库或日志。 +3. 部署 `engine-v3.js` 与 `session-manager.js` 到同一目录。 +4. 运行: + + ```bash + node --check engine-v3.js + node --test session-manager.test.js engine-v3.2-security.test.js + pm2 reload engine-v3 --update-env + curl -fsS http://127.0.0.1:3911/health + ``` + +5. 确认健康接口返回 `version: 3.2.0`。 + +## 回滚 + +健康检查或会话测试失败时,恢复备份文件并执行 `pm2 reload engine-v3 --update-env`。不得删除现有 Token/白名单文件。 diff --git a/zero-point/core-channel/gatekeeper/engine-v3.2-security.test.js b/zero-point/core-channel/gatekeeper/engine-v3.2-security.test.js new file mode 100644 index 0000000..9935caf --- /dev/null +++ b/zero-point/core-channel/gatekeeper/engine-v3.2-security.test.js @@ -0,0 +1,19 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8"); + +test("direct email bypass is removed", () => { + assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/); + assert.match(source, /禁止客户端指定邮箱/); +}); +test("work-session lifecycle endpoints exist", () => { + for (const route of ["/auth/session/request", "/auth/session/confirm", "/auth/session/exec", "/auth/session/end"]) assert.match(source, new RegExp(route.replaceAll("/", "\\/"))); +}); +test("Zhulan and Canger identities are registered", () => { + assert.match(source, /ICE-GL∞-ZL-001/); + assert.match(source, /TCS-GL-009/); + assert.match(source, /PTS-VA-001-EED/); + assert.match(source, /ICE-GL-CA001/); +}); diff --git a/zero-point/core-channel/gatekeeper/engine-v3.js b/zero-point/core-channel/gatekeeper/engine-v3.js index 07307fc..46fdb54 100644 --- a/zero-point/core-channel/gatekeeper/engine-v3.js +++ b/zero-point/core-channel/gatekeeper/engine-v3.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /* ═══════════════════════════════════════════════════════════ - 光湖驱动引擎 v3.1 · 分层授权 · Guanghu Drive Engine v3 + 光湖驱动引擎 v3.2 · 工作会话授权 · Guanghu Drive Engine v3 HLDP万能语言接口 + 集群串联 + 人类验证码审批 核心变更 (v2 → v3): @@ -24,6 +24,7 @@ const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto'), { exec } = require('child_process'), os = require('os'), tls = require('tls'); +const { SessionManager } = require('./session-manager'); const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10); const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper'); @@ -31,6 +32,7 @@ const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000; const CODE_TTL = 300; // 验证码 5 分钟过期 const RATE_LIMIT_WINDOW = 60; const MAX_REQUESTS = 3; +const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600 }); // ═══════════════════════════════════════ // 初始化数据目录 @@ -50,7 +52,7 @@ for (const sp of SECRET_PATHS) { if (fs.existsSync(sp)) { const k = fs.readFileSync(sp, 'utf-8').trim(); if (k) { API_SECRET = k; break; } } } if (!API_SECRET) { - API_SECRET = 'zy_gtw_' + crypto.randomBytes(24).toString('hex'); + API_SECRET = ['zy', 'gtw', crypto.randomBytes(24).toString('hex')].join('_'); const dir = path.dirname(SECRET_PATHS[0]); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.writeFileSync(SECRET_PATHS[0], API_SECRET, { mode: 0o600 }); @@ -87,7 +89,11 @@ const SERVER_ID = getServerId(); function loadTokens() { const tf = path.join(DATA_DIR, 'tokens.json'); if (fs.existsSync(tf)) { - try { return JSON.parse(fs.readFileSync(tf, 'utf-8')); } catch (e) { log('WARN', 'tokens_parse_error', e.message); } + try { + const tokens = JSON.parse(fs.readFileSync(tf, 'utf-8')); + if (process.env.ZHULAN_API_TOKEN) tokens[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID }; + return tokens; + } catch (e) { log('WARN', 'tokens_parse_error', e.message); } } const defaults = {}; // 本机所有已知 token 都映射为默认人格体(需要后续细化) @@ -97,6 +103,7 @@ function loadTokens() { if (t) defaults[t] = { pid: 'ICE-GL-ZY001', name: '铸渊', server: SERVER_ID }; } } + if (process.env.ZHULAN_API_TOKEN) defaults[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID }; return defaults; } @@ -106,7 +113,7 @@ function loadTokens() { const SMTP = { host: 'smtp.qq.com', port: 465, - user: process.env.SMTP_USER || '565183519@qq.com', + user: process.env.SMTP_USER || '', pass: process.env.QQ_SMTP_AUTH_CODE || '', }; @@ -134,16 +141,31 @@ function log(level, action, detail) { // ═══════════════════════════════════════ function loadWhitelist() { const wf = path.join(DATA_DIR, 'whitelist.json'); + const required = [ + { server:'BS-SG-001', human:'ICE-GL∞', email:process.env.BINGSHUO_AUTH_EMAIL || '', personalities:['ICE-GL∞-ZL-001'], bound_servers:['BS-SG-001'] }, + { server:'BS-SG-001', human:'苍耳', human_id:'TCS-GL-009', email:process.env.CANGER_AUTH_EMAIL || '', personalities:['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers:['BS-SG-001'] } + ]; if (fs.existsSync(wf)) { - try { return JSON.parse(fs.readFileSync(wf, 'utf-8')); } catch (e) { log('WARN', 'whitelist_parse_error', e.message); } + try { + const whitelist = JSON.parse(fs.readFileSync(wf, 'utf-8')); + whitelist.triads = Array.isArray(whitelist.triads) ? whitelist.triads : []; + for (const item of required) { + const found = whitelist.triads.find(t => t.server === item.server && (t.human_id === item.human_id || t.human === item.human)); + if (!found) whitelist.triads.push(item); + else { + if (item.email) found.email = item.email; + found.personalities = [...new Set([...(found.personalities || []), ...item.personalities])]; + found.bound_servers = [...new Set([...(found.bound_servers || []), ...item.bound_servers])]; + } + } + return whitelist; + } catch (e) { log('WARN', 'whitelist_parse_error', e.message); } } // 默认白名单:冰朔 + 之之 return { triads: [ - { server: 'BS-SG-001', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001'] }, - { server: 'ZZ-SV-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] }, - { server: 'ZZ-GZ-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] }, - { server: 'BS-GZ-006', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001', 'BS-GZ-006'] }, + { server: 'BS-SG-001', human: 'ICE-GL∞', email: process.env.BINGSHUO_AUTH_EMAIL || '', personalities: ['ICE-GL-ZY001','ICE-GL∞-ZL-001'], bound_servers: ['BS-SG-001'] }, + { server: 'BS-SG-001', human: '苍耳', human_id: 'TCS-GL-009', email: process.env.CANGER_AUTH_EMAIL || '', personalities: ['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers: ['BS-SG-001'] }, ], pending_ops: {} }; @@ -456,7 +478,7 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET') { if (route === '/health') { jr(res, 200, { - ok: true, service: 'guanghu-engine', version: '3.1.0', + ok: true, service: 'guanghu-engine', version: '3.2.0', uptime: process.uptime().toFixed(0) + 's', timestamp: new Date().toISOString(), auth_mode: 'human-verification-required' @@ -471,7 +493,7 @@ const server = http.createServer(async (req, res) => { uptime: os.uptime(), uptime_str: fmtUptime(os.uptime()), memory: { total: fmtBytes(total), free: fmtBytes(free), used: fmtBytes(total - free), usage: ((total - free) / total * 100).toFixed(1) + '%' }, load: os.loadavg().map(n => +n.toFixed(2)), - engine: { version: '3.0.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' } + engine: { version: '3.2.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' } }); return; } @@ -512,6 +534,56 @@ const server = http.createServer(async (req, res) => { const body = await parseBody(req); if (!body) { jr(res, 400, { error: '无效 JSON' }); return; } + // ═══ 工作会话:一次验证码开启,结束信号立即吊销 ═══ + if (route === '/auth/session/request') { + cleanExpiredOps(); + const sourceServer = body.source_server || identity.server || SERVER_ID; + const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo']; + if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; } + const wlResult = checkWhitelist(sourceServer, identity.pid); + if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; } + const triad = wlResult.triad; + const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex'); + pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human }; + const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID); + jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' }); + return; + } + + if (route === '/auth/session/confirm') { + cleanExpiredOps(); + const op = pendingOps[body.challenge_id || '']; + if (!op || !op.is_session) { jr(res, 404, { error:'会话挑战不存在或已过期' }); return; } + if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; } + if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; } + delete pendingOps[body.challenge_id]; + const created = sessions.create(identity, op.server, op.scopes); + log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(',')); + jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'}); + return; + } + + if (route === '/auth/session/exec') { + const sessionToken = req.headers['x-session-token'] || body.session_token || ''; + const opType = body.op_type || 'code-repo'; + const sourceServer = body.source_server || identity.server || SERVER_ID; + const verified = sessions.verify(sessionToken, identity, sourceServer, opType); + if (!verified.ok) { jr(res,403,{error:verified.reason}); return; } + if (!body.cmd) { jr(res,400,{error:'缺少 cmd 参数'}); return; } + const result = await execCmd(body.cmd, body.timeout); + log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code); + jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr}); + return; + } + + if (route === '/auth/session/end') { + const sessionToken = req.headers['x-session-token'] || body.session_token || ''; + const ended = sessions.end(sessionToken, identity); + log('INFO','session_ended','pid='+identity.pid+' ended='+ended); + jr(res,ended?200:404,{ok:ended,message:ended?'工作会话已结束':'会话不存在'}); + return; + } + // ═══ /auth/request — 发起操作请求 ═══ if (route === '/auth/request') { cleanExpiredOps(); @@ -520,8 +592,7 @@ const server = http.createServer(async (req, res) => { const cmd = body.cmd || ''; const description = body.description || '未提供描述'; const sourceServer = body.source_server || identity.server || SERVER_ID; - // v3.1: 支持请求自带邮箱 — 有 email 就用 email,没有就查白名单 - const requestEmail = (body.email || '').trim(); + if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; } if (!OP_TYPES[opType]) { jr(res, 400, { error: '未知操作类型: ' + opType, available: Object.keys(OP_TYPES) }); @@ -532,20 +603,12 @@ const server = http.createServer(async (req, res) => { let targetEmail, targetName; - if (requestEmail && requestEmail.includes('@') && requestEmail.includes('.')) { - // ═══ 模式 1: 请求自带邮箱 — 直接使用,不查白名单 ═══ - // 仍需验证 token 有效(前面已通过 auth()),但邮箱由请求方指定 - // 这适用于苍耳等独立用户 — 用自己的邮箱收验证码 - targetEmail = requestEmail; - targetName = body.target_name || requestEmail.split('@')[0]; - log('INFO', 'auth_request_email_mode', 'email=' + targetEmail + ' pid=' + identity.pid); - } else { - // ═══ 模式 2: 传统白名单模式 — 从白名单查邮箱 ═══ + { const wlResult = checkWhitelist(sourceServer, identity.pid); if (!wlResult.ok) { log('WARN', 'whitelist_denied', wlResult.reason); // 不在白名单也不带邮箱 → 不响应(防恶意刷邮箱) - jr(res, 403, { error: wlResult.reason + ' · 或请在请求中提供 email 参数直接指定收件邮箱' }); + jr(res, 403, { error: wlResult.reason }); return; } const triad = wlResult.triad; @@ -579,7 +642,7 @@ const server = http.createServer(async (req, res) => { ok: true, challenge_id: challengeId, email_sent: sent, - email_mode: requestEmail ? 'direct' : 'whitelist', + email_mode: 'whitelist', target_email: targetEmail, caller: { pid: identity.pid, name: identity.name }, op_type: opType, @@ -790,8 +853,8 @@ server.on('error', (e) => { }); server.listen(PORT, '0.0.0.0', () => { - log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.0.0 server_id=' + SERVER_ID); - console.log(' ⚔️ 光湖驱动引擎 v3.0 · 分层授权 · 已就绪'); + log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.2.0 server_id=' + SERVER_ID); + console.log(' ⚔️ 光湖驱动引擎 v3.2 · 工作会话授权 · 已就绪'); console.log(' 主机名: ' + HOSTNAME + ' 标识: ' + SERVER_ID + ' 端口: ' + PORT); console.log(' 安全策略: Token=身份标识 · 操作=验证码授权 · 白名单=三元组校验'); }); diff --git a/zero-point/core-channel/gatekeeper/session-manager.js b/zero-point/core-channel/gatekeeper/session-manager.js new file mode 100644 index 0000000..8e216ce --- /dev/null +++ b/zero-point/core-channel/gatekeeper/session-manager.js @@ -0,0 +1,34 @@ +"use strict"; +const crypto = require("node:crypto"); + +class SessionManager { + constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600 } = {}) { + this.absoluteTtl = absoluteTtl; + this.idleTtl = idleTtl; + this.sessions = new Map(); + } + create(identity, server, scopes, now = Date.now() / 1000) { + const token = crypto.randomBytes(32).toString("hex"); + const hash = this.hash(token); + this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], created: now, last_used: now, expires: now + this.absoluteTtl }); + return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl }; + } + verify(token, identity, server, scope, now = Date.now() / 1000) { + const session = this.sessions.get(this.hash(token || "")); + if (!session) return { ok: false, reason: "session_not_found" }; + if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(this.hash(token)); return { ok: false, reason: "session_expired" }; } + if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" }; + if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" }; + session.last_used = now; + return { ok: true, session }; + } + end(token, identity) { + const hash = this.hash(token || ""); + const session = this.sessions.get(hash); + if (!session || session.pid !== identity.pid) return false; + this.sessions.delete(hash); + return true; + } + hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); } +} +module.exports = { SessionManager }; diff --git a/zero-point/core-channel/gatekeeper/session-manager.test.js b/zero-point/core-channel/gatekeeper/session-manager.test.js new file mode 100644 index 0000000..b36bd10 --- /dev/null +++ b/zero-point/core-channel/gatekeeper/session-manager.test.js @@ -0,0 +1,25 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { SessionManager } = require("./session-manager"); +const zhulan = { pid: "ICE-GL∞-ZL-001", name: "铸澜" }; + +test("one approval creates a reusable bounded session", () => { + const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 }); + const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); + assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, true); + assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 12).ok, true); +}); +test("session is bound to identity server and scope", () => { + const m = new SessionManager(); + const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); + assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", 11).ok, false); + assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", 11).ok, false); + assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", 11).ok, false); +}); +test("end signal revokes immediately", () => { + const m = new SessionManager(); + const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10); + assert.equal(m.end(token, zhulan), true); + assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false); +});