二七王:test_leak 门禁强化——统计量重算、豁免按字段路径收窄、覆盖投降/解散

泄露缺陷功能正常、玩家看不出、只有抓包的人知道,所以门禁的【盲区】比一次具体泄露更值得投入。
本轮补掉四处盲区:

① 统计量层(新增):原来只扫牌 id,seatlist 的计数、playproc.maxcard 的牌编码、curmultiple
   一个都扫不到。新增「公开账本独立重算」——只喂三家都看得见的信息(已广播的 chupai*.cards、
   chupai1 的 count/flower/cardtype、庄家/叫分/主花色,账本里没有任何人的手牌),
   算出的 maxseat/maxcard/缺门无对表/curmultiple/捡分 必须与包内值逐项相等;
   再钉死 seatlist 恒 5 项、playproc 恒 10 个键,防止有人新加一项统计而门禁照样全绿。
   报副格(design §9 的有意公开)单独做门控断言:没人报无主之前必须还是 [-1,-1]。
   反向验证 5 组(塞第 6 项统计 / 提前填报副格 / 按真实手牌点亮已有缺门格 /
   maxcard 混入余主数 / curmultiple 混入余主数)全部转红。

② 埋牌底牌豁免:从「按 rpc 整包放行」收窄为「按字段路径 bottom.cards + step===6 + result!==3」。
   为此 collectCards 改为同时记录牌 id 的字段路径。对照实证:构造「给解散路径也带上 bottom」,
   新规则转红、旧规则静默放行。

③ 投降 / 解散两条结算路径此前零覆盖(9 局全是正常打完),各补 2 局并加覆盖下限断言。
   审计未发现新泄露;唯一需放行的是「算奖明细 aset.seatlist[].cards」——投降/解散时牌一张没打,
   他家的王/冲关牌会随算奖明细发给三家。这是既有行为(算奖需向三家自证、局已终止),
   写成带理由、带命中计数(awardExempt>=1)的显式豁免,不让它落在扫不到的盲区里。

④ 措辞更正:上一轮把「给重连包补上豁免」记成"收紧",实际净效果是放宽(旧放行面 ⊂ 新放行面),
   注释已改成准确说法。

覆盖量:3141 个下发面 / 38619 次可见性判定 / 103175 次统计量重算比对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:07:15 +08:00
co-authored by Claude Opus 5
parent 8e0c8fdc90
commit 08c387e609
+403 -41
View File
@@ -1,9 +1,17 @@
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11) // 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
// 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id, // 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描:
// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、 // ① 牌 id 层:取出包里出现的所有牌 id,逐个判定「此刻该座位是否有权知道这张牌」。
// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。 // 依据 design §4(底牌只有庄家可见 / 70分亮3秒)、§9(查牌模式)、§11(结束亮埋牌底牌)。
// ② 统计量层:牌 id 之外,包里还有【由手牌算出来的数】——seatlist 的缺门/无对表、
// playproc 的 maxseat/maxcard、curmultiple、捡分。它们不含牌 id,①层一个都扫不到,
// 却同样能泄露("某家某花色还剩几张"这种统计,功能正常、玩家看不出、只有抓包的人知道)。
// 故本层用【公开账本】独立重算这些数:只喂三家都看得见的信息(已广播的 chupai*.cards、
// chupai1 的 count/flower/cardtype、庄家/叫分/主花色),算出来必须与包内值逐项相等。
// 能被公开信息算出来的数 = 不含私密信息;算不出来 = 它一定读了别人的手牌。
// 同时钉死这些结构的【字段集/项数】,防止有人往里加一项新统计而门禁照样全绿。
const R = require('./_rpc.js'); const mod = R.mod; const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith; const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const CFG = global.cls_youle_erqiwang_config;
const D = require('../class.desk.js'), EX = require('../class.export.js'); const D = require('../class.desk.js'), EX = require('../class.export.js');
const t = require('./_assert')(); const t = require('./_assert')();
let seed = 0xBEEF01; let seed = 0xBEEF01;
@@ -11,16 +19,33 @@ const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; s
global.min_ontimeout = fn => fn(); global.min_ontimeout = fn => fn();
global.min_random = (a, b) => a + rnd(b - a + 1); global.min_random = (a, b) => a + rnd(b - a + 1);
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) }); const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
const JS = x => JSON.stringify(x);
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。 // ==========================================================================
// ①层:牌 id 的可见性
// ==========================================================================
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107),并记下它【出现在哪个字段路径上】。
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。 // 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
// 【为什么要记路径】豁免必须按「哪一个字段」给,而不是按「哪一个 rpc」给:
// 同一个包里既有该公开的分组(结算面板的抠底明细/算奖明细),也有绝不该公开的分组,
// 按 rpc 整包放行会把后者一并放掉。路径只由对象键组成、忽略数组下标(如
// aset.seatlist[1].cards → "aset.seatlist.cards")。
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard']; const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
function collectCards(node, key, out) { function collectCards(node, path, key, out) {
if (node === null || node === undefined) return; if (node === null || node === undefined) return;
if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; } if (Array.isArray(node)) { node.forEach(x => collectCards(x, path, key, out)); return; }
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; } if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node + '@' + path); return; }
if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); } if (typeof node === 'object') {
for (const k in node) {
collectCards(node[k], path ? path + '.' + k : k, CARD_FIELDS.indexOf(k) >= 0 ? k : key, out);
}
}
} }
const cidOf = e => parseInt(e.slice(0, e.indexOf('@')), 10);
const pathOf = e => e.slice(e.indexOf('@') + 1);
// 路径尾匹配:deskinfo 会多一层分组前缀(Balance.aset.seatlist.cards vs aset.seatlist.cards)
const pathIs = (path, suffix) => path === suffix || path.endsWith('.' + suffix);
// design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。 // design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai, // 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
@@ -43,30 +68,294 @@ let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定
let curNoCheck = false; // 本局是否「不查牌」 let curNoCheck = false; // 本局是否「不查牌」
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过) let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发) let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
let surrenderRuns = 0; // 走过投降结算的局数
let disbandRuns = 0; // 走过中途解散结算的局数
let awardExempt = 0; // 「算奖明细」这条显式豁免被真正用到的次数
const leaks = []; const leaks = [];
let pkts = 0, scanned = 0; let pkts = 0, scanned = 0;
let ctxRef = null; // 判定:seat 此刻是否有权知道 cid(path 见 collectCards 的说明)
// 判定:seat 此刻是否有权知道 cid function mayKnow(pj, seat, cid, rpc, path) {
function mayKnow(pj, seat, cid, rpc) {
const c = pj.cards[cid]; const c = pj.cards[cid];
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现 if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
if (c.playround > 0) return true; // 已打出,全场可见 if (c.playround > 0) return true; // 已打出,全场可见
if (c.dealowner === seat + 1) return true; // 自己的牌 if (c.dealowner === seat + 1) return true; // 自己的牌
if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌 if (seat === pj.banker && c.dealowner === 0) return true; // 庄家可见底牌(含摸起后又埋下的)
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒 // §4 70分坐庄:上庄推送把 8 张底牌向三家亮 3 秒——只放行 shangzhuang 的 bottomcards 这一处
// §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就 if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0 && pathIs(path, 'bottomcards')) return true;
// 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom
// 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」 // §11 结束亮埋牌底牌:本局【正常结算】后,埋牌底牌随抠底明细对全场公开。
// 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0 // jiesuan 广播(bottom.cards,sendpack_toother(msg,-1) 三家都发)与结算面板还开着时的
if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true; // 重连包(Balance.bottom.cards)是【同一份数据的两条投递路径】,可见性必须同判。
// 门控写成「step===6 且 result!==3」而不是「rpc 是 jiesuan/deskinfo」:
// · 只认字段路径 bottom.cards,不整包放行(同一个包里的其他分组仍严格判定);
// · result 3 = 中途解散(class.paiju.js get_paiju_account 的 case 2,o_paiju.result = 3)。解散是【没打完】,
// 埋牌底牌不该亮。当前解散路径的 tmp_jiesuan_bottom 恒为 null、包里本就没有 bottom,
// 所以这一条现在不改变任何判定;它挡的是【将来】有人给解散路径也带上 bottom——
// 那时门禁必须转红,而不是因为「step 也是 6」被放行。
// 【措辞更正】上一轮把「给重连包补上这条豁免」记成了"收紧"——其实那一步的净效果是
// 【放宽】(在原豁免上加了一个 OR 分支,旧放行面 ⊂ 新放行面),只是放行面被限定在
// step===6 的同一份公开数据上。真正的收紧是这一轮:从「按 rpc 整包放行」改成
// 「按字段路径 bottom.cards + step6 + result!==3 放行」。
if (c.playround === 0 && pathIs(path, 'bottom.cards') && pj.step === 6 && pj.result !== 3) return true;
// 【显式豁免 · 结算算奖明细】aset.seatlist[i].cards(get_paiju_account 的 _cglist[i].cards)
// 是 design §8.1 冲关 / §8.3 傍王的算奖依据牌,三种结算来源都带、且三家都收到。
// · 正常打完:这些牌早已打出(playround>0),本来就公开,本豁免对它无影响。
// · 投降(step2 直接结算)与中途解散:牌【一张没打】(playround===-1),于是他家的
// 王/冲关牌会随算奖明细发给三家。这是既有行为——算奖要向三家自证,牌局此刻已终止、
// 不再有后续出牌,公开这几张牌不影响任何未决的对局信息。
// 写成一条【带路径限定的显式豁免】而不是让它落在扫不到的盲区里:
// 若哪天有人把「未打出的手牌」塞到别的字段上,它不在这条路径下,照样报泄露。
if (pathIs(path, 'aset.seatlist.cards') && pj.step === 6) { awardExempt++; return true; }
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验 if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发) // §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true; if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true;
return false; return false;
} }
function run(roomtype, call) { // ==========================================================================
// ②层:统计量必须能由「公开账本」独立重算出来
// ==========================================================================
// 账本里只有【三家都看得见】的东西:
// 庄家 / 庄家叫分(shangzhuang 广播)、主花色(xuanzhu 广播)、
// 每一手已摊在桌上的牌(chupai1/2/3 的 data.cards,三家同收)、
// 首家这一手的 张数/花色/牌型(chupai1 的 count/flower/cardtype —— 它就是那几张明牌的描述)。
// 账本【没有】任何人的手牌。凡是能从账本算出来的数,就不可能夹带手牌信息。
let LG = null;
let statChecks = 0;
const statBad = [];
const flowerOf = (fl, id) => { const c = A.id_to_code(fl, id); return c > 1000 ? fl : Math.floor(c / 100); };
// 牌面分值:5→5、10/K→10,其余 0。只由牌 id 推出(牌已摊在桌上,牌面是公开的)
const scoreOf = id => { const n = A.id_to_number(id); return n === 5 ? 5 : ((n === 10 || n === 13) ? 10 : 0); };
// 首家这一手的牌型值 = 这一手【最小一张】的牌编码。
// 【独立推导,不复用 can_playcard】:那段逐张状态机无论走 单张/对子/拖拉机/甩牌 哪个分支,
// cardvalue 最终都停在「最后处理到的那张牌」上;牌已按编码降序排列,最后一张即最小一张。
// 换个式子算出同一个数,才谈得上"独立重算"。
function leadValue(fl, cards) {
const s = A.order_cards(fl, cards.concat());
return A.id_to_code(fl, s[s.length - 1]);
}
// 跟家这一手的牌型值:照 design 的规则从【摊在桌上的牌】重算(can_followcard 里这段同样
// 只吃 _sortfollow / startflower / startcardtype,不碰手牌——本函数是它的独立复述)。
function followValue(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
const top = A.id_to_code(fl, s[0]);
const flTop = top > 1000 ? fl : Math.floor(top / 100);
const sameLine = (flTop === startflower || flTop === fl);
// 副7 / 副2 同级归一(四门的 7 一样大、四门的 2 一样大)
const norm = c => (c > 7000 && c < 8000) ? 7000 : ((c > 2000 && c < 3000) ? 2000 : c);
if (starttype === 101) { return sameLine ? norm(top) : 0; }
if (starttype > 101 && starttype < 200) { return 0; } // 首家甩单张:跟牌一律压不过
if (starttype === 201) {
if (A.get_pairlist(fl, s).length !== 1) return 0;
return sameLine ? norm(top) : 0;
}
if (starttype > 201 && starttype < 300) { return 0; } // 首家甩多对:同上
if (starttype > 300 && starttype < 400) {
if (A.get_tuolaji_list(fl, A.get_pairlist(fl, s), starttype).length !== 1) return 0;
return sameLine ? top : 0;
}
return 0;
}
// 跟家这一手暴露出的「缺门 / 无对」:完全由摊在桌上的牌与首家牌型推出(design §9 的牌况表)。
// 最大或最小的一张不是首家花色 → 这门没牌了(顺带也没对子)
// 首家出的是对子/拖拉机、跟牌没凑够同样多的对子 → 这门没对子了
function followFlags(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
let noflower = false, nopair = false;
if (flowerOf(fl, s[0]) !== startflower) { noflower = true; nopair = true; }
if (flowerOf(fl, s[s.length - 1]) !== startflower) { noflower = true; nopair = true; }
if (starttype > 200 && A.get_pairlist(fl, s).length !== starttype % 100) { nopair = true; }
return { noflower, nopair };
}
// 首家甩牌的分量需求:把桌面上那一手直接分解即可(decompose_trump 只吃牌 id),非甩牌为 null
function expectShuaiDemand() {
if (LG.start < 0 || !LG.cards[LG.start]) return null;
const comps = A.decompose_trump(LG.flower, LG.cards[LG.start].concat());
if (comps.length <= 1) return null;
const re = { tractors: [], pairs: 0, singles: 0 };
for (const c of comps) {
if (c.type === 'tractor') { re.tractors.push(c.len); }
else if (c.type === 'pair') { re.pairs++; }
else { re.singles++; }
}
return re;
}
function ledgerNewRound(round, start) {
LG.round = round; LG.start = start; LG.currseat = start;
LG.startcount = -1; LG.startflower = -1; LG.starttype = -1;
LG.maxseat = -1; LG.maxcard = -1;
LG.cards = [null, null, null];
}
function ledgerApplyFollow(seat, cards) {
const fl = LG.flower;
const s = A.order_cards(fl, cards.concat());
LG.cards[seat] = s;
const v = followValue(fl, s, LG.startflower, LG.starttype);
if (v > LG.maxcard) { LG.maxseat = seat; LG.maxcard = v; }
const fg = followFlags(fl, s, LG.startflower, LG.starttype);
const cell = LG.table[seat][LG.startflower - 1];
if (fg.nopair) { cell[1] = 1; }
if (fg.noflower) { cell[0] = 1; cell[1] = 1; }
}
function ledgerEndRound() {
// 本墩被闲家收走 → 墩里的分归闲家(庄家收走则不计入闲家捡分)
let g = 0;
if (LG.maxseat !== LG.banker) {
for (let i = 0; i < 3; i++) { for (const id of (LG.cards[i] || [])) { g += scoreOf(id); } }
}
LG.grade += g;
LG.lastRoundGrade = g;
ledgerNewRound(LG.round + 1, LG.maxseat);
}
// 当前抓分倍数:算法本身只吃 (叫分, 闲家捡分, 级距) 三个数——叫分与捡分都在账本里
function ledgerCurmultiple() {
if (!LG.call || LG.call <= 0) return 0;
return A.get_upgrade(LG.call, LG.grade, A.get_qvalue(LG.call, LG.climb));
}
// 把一个【逻辑包】喂进公开账本(同一个包会逐座位发 3 份,只喂第一份)
function ledgerFeed(rpc, d) {
if (!LG) return;
switch (rpc) {
case 'shangzhuang': LG.banker = d.banker; LG.call = d.grade; break;
case 'xuanzhu': LG.flower = d.flower; break;
case 'maipai': ledgerNewRound(1, d.seat); break;
case 'chupai1': {
const s = A.order_cards(LG.flower, d.cards.concat());
LG.startcount = s.length;
LG.startflower = flowerOf(LG.flower, s[0]);
LG.starttype = d.cardtype;
LG.maxseat = d.seat;
LG.maxcard = leadValue(LG.flower, s);
LG.cards[d.seat] = s;
LG.currseat = (LG.start + 1) % 3;
// 首家自报的 张数/花色 必须与桌面上那几张牌一致(同一份公开数据两处口径)
statChecks++;
if (d.count !== s.length || d.flower !== LG.startflower) {
statBad.push(`chupai1 自报 count/flower=(${d.count},${d.flower}) 与桌面牌重算=(${s.length},${LG.startflower}) 不一致`);
}
// 账本自检:首出者必须就是本轮首家(账本若跟丢了轮次,下面所有比对都不再可信)
statChecks++;
if (d.seat !== LG.start) { statBad.push(`chupai1 首出者=${d.seat} 公开账本认为本轮首家=${LG.start}`); }
break;
}
case 'chupai2': ledgerApplyFollow(d.seat, d.cards); LG.currseat = (LG.start + 2) % 3; break;
case 'chupai3': ledgerApplyFollow(d.seat, d.cards); ledgerEndRound(); break;
case 'jiesuan':
// 收尾墩不发 chupai3,整包换成 jiesuan、把这一手放进 data.chupai(协议 §13/§14)
if (d.chupai && d.chupai.cards) { ledgerApplyFollow(d.chupai.seat, d.chupai.cards); ledgerEndRound(); }
break;
}
}
// playproc 的字段集是【契约】:多一个键就是多一份下发信息,必须显式暴露出来
const PLAYPROC_KEYS = ['cards', 'currseat', 'maxcard', 'maxseat', 'round', 'shuai_demand', 'start', 'startcount', 'startflower', 'starttype'];
function checkPlayproc(tag, pp) {
statChecks++;
const keys = Object.keys(pp).sort();
if (JS(keys) !== JS(PLAYPROC_KEYS)) { statBad.push(`${tag} playproc 字段集变了:${JS(keys)}`); }
const exp = {
round: LG.round, start: LG.start, currseat: LG.currseat,
startcount: LG.startcount, startflower: LG.startflower, starttype: LG.starttype,
maxseat: LG.maxseat, maxcard: LG.maxcard
};
for (const k in exp) {
statChecks++;
if (pp[k] !== exp[k]) { statBad.push(`${tag} playproc.${k}=${pp[k]} 公开账本重算=${exp[k]}`); }
}
for (let s = 0; s < 3; s++) {
statChecks++;
const got = (pp.cards[s] === undefined || pp.cards[s] === null) ? null : pp.cards[s];
if (JS(got) !== JS(LG.cards[s])) { statBad.push(`${tag} playproc.cards[${s}]=${JS(got)} 公开账本重算=${JS(LG.cards[s])}`); }
}
statChecks++;
if (JS(pp.shuai_demand) !== JS(expectShuaiDemand())) {
statBad.push(`${tag} playproc.shuai_demand=${JS(pp.shuai_demand)} 桌面牌重算=${JS(expectShuaiDemand())}`);
}
}
function checkSeatlist(tag, sl) {
statChecks++;
if (!Array.isArray(sl) || sl.length !== 3) { statBad.push(`${tag} seatlist 外层不是定长 3:${JS(sl)}`); return; }
// 报副格(第 5 项)是 design §9 的【有意公开】:一旦有人把主牌打空(报无主),
// 系统就向全体三人公示三家各自的余主数量与余主对数。反过来说——【没人报无主之前
// 一格都不许透露】,必须还是初值 [-1,-1]。这条门控若被放宽,等于提前泄露三家主牌结构。
const anyEmpty = sl.some(one => Array.isArray(one[4]) && one[4][0] === 0);
for (let s = 0; s < 3; s++) {
const one = sl[s];
statChecks++;
if (!Array.isArray(one) || one.length !== 5) {
// 约定恒 5 项:花色 1~4 的 [缺门, 无对] + 报副 [余主数, 余主对数]。
// 多出来的一项必定是一份【新的统计】——而新统计几乎注定是从真实手牌算出来的,
// 公开账本重算不出来。这里直接判死,不给它蒙混过关的机会。
statBad.push(`${tag} seat${s} seatlist 项数不是 5(新增统计?):${JS(one)}`);
continue;
}
for (let f = 0; f < 5; f++) {
statChecks++;
const cell = one[f];
if (!Array.isArray(cell) || cell.length !== 2 || typeof cell[0] !== 'number' || typeof cell[1] !== 'number') {
statBad.push(`${tag} seat${s}[${f}] 形状不是二元数值组:${JS(cell)}`);
}
}
// 前 4 格:只能是公开账本推得出的缺门/无对
for (let f = 0; f < 4; f++) {
const exp = [LG.table[s][f][0], LG.table[s][f][1]];
// 主花色那一格另有一条来源:报无主公示之后,「余主数=0」等价于主花色缺门+无对、
// 「余主对数=0」等价于主花色无对(class.paiju.js do_playcard 的报副刷新)。
// 它推导自【本包自己给出的】报副格,仍属公开信息,不是额外泄露。
if (LG.flower > 0 && f === LG.flower - 1 && Array.isArray(one[4])) {
if (one[4][0] === 0) { exp[0] = 1; exp[1] = 1; }
else if (one[4][0] > 0 && one[4][1] === 0) { exp[1] = 1; }
}
statChecks++;
if (one[f][0] !== exp[0] || one[f][1] !== exp[1]) {
statBad.push(`${tag} seatlist[${s}][${f}]=${JS(one[f])} 公开账本重算=${JS(exp)}`);
}
}
statChecks++;
if (!anyEmpty) {
if (JS(one[4]) !== JS([-1, -1])) { statBad.push(`${tag} 无人报无主却下发了 seat${s} 的余主统计:${JS(one[4])}`); }
} else if (one[4][0] < 0 || one[4][1] < 0) {
statBad.push(`${tag} 已报无主但 seat${s} 的余主统计仍是初值:${JS(one[4])}`);
}
}
}
// 对一个「分组对象」(包的 data,或 deskinfo 的 ChooseMain / BuryCards / PushCards)做统计量审计
function statAuditGroup(tag, g, opts) {
if (!g || typeof g !== 'object' || !LG) return;
opts = opts || {};
if (g.hasOwnProperty('curmultiple')) {
statChecks++;
const exp = ledgerCurmultiple();
if (g.curmultiple !== exp) { statBad.push(`${tag} curmultiple=${g.curmultiple} 公开账本重算=${exp}`); }
}
if (opts.cumulativeGrade && g.hasOwnProperty('grade')) {
statChecks++;
if (g.grade !== LG.grade) { statBad.push(`${tag} grade=${g.grade} 公开账本重算=${LG.grade}`); }
}
// 分组这一层的 seatlist 恒指 design §9 的【牌况表】(maipai / chupai1/2/3 / PushCards);
// 结算包里的 aset.seatlist 是另一回事,嵌在 aset 下、不会走到这里。
// 【不加形状守卫】形状变了就该由 checkSeatlist 判红,而不是被守卫悄悄跳过
if (g.hasOwnProperty('seatlist')) { checkSeatlist(tag, g.seatlist); }
if (g.playproc) { checkPlayproc(tag, g.playproc); }
}
// ==========================================================================
function run(roomtype, call, opts) {
opts = opts || {};
const sent = []; const sent = [];
const o_room = { const o_room = {
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T', roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
@@ -75,49 +364,100 @@ function run(roomtype, call) {
}; };
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌 curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
curLiangpai = new Set(); curLiangpai = new Set();
LG = {
climb: CFG.parse(roomtype).climb, banker: -1, call: -1, flower: -1,
round: 0, start: -1, currseat: -1, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], grade: 0, lastRoundGrade: 0,
table: [0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0]])
};
const desk = D.new(o_room); o_room.o_desk = desk; const desk = D.new(o_room); o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) }; global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } }; global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app; mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
D.do_new_paiju(desk, 0); D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju(); const pj = desk.method.curr_paiju();
ctxRef = { pj, sent };
const audit = () => { const audit = () => {
while (sent.length) { while (sent.length) {
const { to, m } = sent.shift(); const { to, m } = sent.shift();
pkts++; pkts++;
// 同一个逻辑包逐座位发 3 份(i=0,1,2),只用第一份推进公开账本;
// 但【每一份都要审计】,这样三家收到的统计量若有差异也会被抓住
if (to === 0 || to === 'ALL') { ledgerFeed(m.rpc, m.data); }
const targets = to === 'ALL' ? [0, 1, 2] : [to]; const targets = to === 'ALL' ? [0, 1, 2] : [to];
statAuditGroup(`rpc=${m.rpc}→seat${to}`, m.data);
// chupai3 的 data.grade 是【这一墩】闲家收到的分(>0 才带),与账本的墩内分值同源
if (m.rpc === 'chupai3') {
statChecks++;
const got = m.data.hasOwnProperty('grade') ? m.data.grade : 0;
if (got !== LG.lastRoundGrade) { statBad.push(`chupai3 本墩 grade=${got} 公开账本重算=${LG.lastRoundGrade}`); }
}
// 结算包的闲家总捡分 = 账本累计捡分 + 抠底(扣底只在最后一墩产生)
if (m.rpc === 'jiesuan' && m.data.aset) {
statChecks++;
const bg = (m.data.bottom && m.data.bottom.grade2) ? m.data.bottom.grade2 : 0;
if (m.data.aset.grade !== LG.grade + bg) {
statBad.push(`jiesuan aset.grade=${m.data.aset.grade} 公开账本重算=${LG.grade}+抠底${bg}`);
}
}
const out = new Set(); const out = new Set();
collectCards(m.data, null, out); collectCards(m.data, '', null, out);
for (const seat of targets) for (const cid of out) { for (const seat of targets) for (const e of out) {
scanned++; scanned++;
if (!mayKnow(pj, seat, cid, m.rpc)) { const cid = cidOf(e);
leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); if (!mayKnow(pj, seat, cid, m.rpc, pathOf(e))) {
leaks.push(`rpc=${m.rpc} @${pathOf(e)} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
} }
} }
} }
}; };
// 重连快照的审计(每个座位各取一次)。阶段取【实际的】pj.step:最后一手打完后 step 已转 6,
// 这里拿到的是结算快照,写死 "step5" 会让报错信息指错阶段(曾据此误判过)
const auditDeskinfo = () => {
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
pkts++;
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.ChooseMain`, di.ChooseMain);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.BuryCards`, di.BuryCards);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.PushCards`, di.PushCards, { cumulativeGrade: true });
const out = new Set(); collectCards(di, '', null, out);
for (const e of out) {
scanned++;
const cid = cidOf(e);
if (!mayKnow(pj, s, cid, 'deskinfo', pathOf(e))) {
leaks.push(`deskinfo(step${pj.step}) @${pathOf(e)} → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
audit(); audit();
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit(); mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit();
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); } if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); } if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
const b = pj.banker; const b = pj.banker;
// ---- 投降结算路径(design §4:70 分坐庄可在选主阶段直接投降)----
// 这条路径此前从未被审计过:9 局全部「正常打完」,mod.touxiang 一次都没走到。
if (opts.surrender) {
if (pj.call !== 70) { leaks.push(`投降用例前置不成立:庄家叫分=${pj.call},非 70 分坐庄`); return pj; }
mod.touxiang(pk(b)); audit();
if (pj.step !== 6 || pj.result !== 2) { leaks.push(`投降用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
surrenderRuns++;
return pj;
}
mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit(); mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit();
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机) // 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) })); mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
curLiangpai = bankerLiangpaiCards(pj); curLiangpai = bankerLiangpaiCards(pj);
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } } if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
audit(); audit();
// 重连快照也要审计(每个座位各取一次) auditDeskinfo();
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
const out = new Set(); collectCards(di, null, out);
pkts++;
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
}
let guard = 0; let guard = 0;
while (pj.step === 5 && ++guard < 400) { while (pj.step === 5 && ++guard < 400) {
if (opts.disbandAfter && guard > opts.disbandAfter) { break; }
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat); const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null; let pick = null;
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]]; if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
@@ -125,13 +465,7 @@ function run(roomtype, call) {
if (!pick) break; if (!pick) break;
mod.chupai(pk(seat, { cards: pick })); audit(); mod.chupai(pk(seat, { cards: pick })); audit();
if (guard % 7 === 0) { // 中途穿插重连与明牌 if (guard % 7 === 0) { // 中途穿插重连与明牌
for (let s = 0; s < 3; s++) { auditDeskinfo();
const di = EX.new().get_deskinfo(o_room, s);
const out = new Set(); collectCards(di, null, out); pkts++;
// 阶段取【实际的】pj.step:最后一手打完后 step 已转 6,这里拿到的是结算快照,
// 写死 "step5" 会让报错信息指错阶段(曾据此误判过)
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
}
if (pj.method.have_baofu()) { if (pj.method.have_baofu()) {
sent.length = 0; sent.length = 0;
mod.mingpai(pk(0)); mod.mingpai(pk(0));
@@ -154,6 +488,19 @@ function run(roomtype, call) {
} }
} }
audit(); audit();
// ---- 中途解散结算路径(get_disbandRoom)----
// 同样此前零覆盖。平台在解散时取这份 deskfree 下发给三家(前端 Game_Modify.Free),
// 故按「广播给三家」审计;随后的 step6 重连快照(result===3)也一并过一遍。
if (opts.disbandAfter) {
const msg = EX.new().get_disbandRoom(o_room);
if (!msg) { leaks.push('解散用例前置不成立:get_disbandRoom 返回 null'); return pj; }
sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(msg)) });
audit();
if (pj.step !== 6 || pj.result !== 3) { leaks.push(`解散用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
disbandRuns++;
}
return pj; return pj;
} }
@@ -166,13 +513,28 @@ for (const call of [65, 5]) run('00000', call);
seed = 0x777001; seed = 0x777001;
run('00000', 65); run('00000', 65);
run('00001', 65); run('00001', 65);
// 投降与解散:两条结算路径此前从未进过本门禁(9 局全是「正常打完」)
seed = 0x24680;
run('00000', 70, { surrender: true });
run('00001', 70, { surrender: true });
run('00000', 65, { disbandAfter: 11 });
run('00001', 60, { disbandAfter: 5 });
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []); t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
t.eq('统计量均可由公开账本重算', [...new Set(statBad)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量 // 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true); t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true); t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
t.eq('审计覆盖 统计量重算 ≥ 10000 次', statChecks >= 10000, true);
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发, // 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。 // 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true); t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true); t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`); t.eq('审计覆盖 投降结算路径 至少 1 局', surrenderRuns >= 1, true);
t.eq('审计覆盖 中途解散结算路径 至少 1 局', disbandRuns >= 1, true);
// 「算奖明细」那条显式豁免必须真的被用到,否则它是一条无人走过的死规则,
// 早晚会在无人察觉时开始放行别的东西
t.eq('审计覆盖 算奖明细豁免确实被走到', awardExempt >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / ${statChecks} 次统计量重算比对`);
console.log(` [统计] 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局 / 投降${surrenderRuns}局 / 解散${disbandRuns}局 / 算奖明细豁免命中 ${awardExempt} 次`);
process.exit(t.done('leak') ? 0 : 1); process.exit(t.done('leak') ? 0 : 1);