泄露缺陷功能正常、玩家看不出、只有抓包的人知道,所以门禁的【盲区】比一次具体泄露更值得投入。 本轮补掉四处盲区: ① 统计量层(新增):原来只扫牌 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>
541 lines
32 KiB
JavaScript
541 lines
32 KiB
JavaScript
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
|
||
// 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描:
|
||
// ① 牌 id 层:取出包里出现的所有牌 id,逐个判定「此刻该座位是否有权知道这张牌」。
|
||
// 依据 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 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 t = require('./_assert')();
|
||
let seed = 0xBEEF01;
|
||
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
|
||
global.min_ontimeout = fn => fn();
|
||
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 JS = x => JSON.stringify(x);
|
||
|
||
// ==========================================================================
|
||
// ①层:牌 id 的可见性
|
||
// ==========================================================================
|
||
|
||
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107),并记下它【出现在哪个字段路径上】。
|
||
// 为避免把 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'];
|
||
function collectCards(node, path, key, out) {
|
||
if (node === null || node === undefined) 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 + '@' + path); return; }
|
||
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 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
|
||
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
|
||
// 下面的集合里不会有它,审计照样报泄露。这是审计的意义所在,不能图省事直接复用被测实现。
|
||
function bankerLiangpaiCards(pj) {
|
||
const out = [];
|
||
let wang = 0, qi = 0, er = 0;
|
||
for (const c of pj.cards) {
|
||
// 与 get_seat_cards_award 同口径:庄家手牌 = 发到自己的 + 摸起的底牌,且排除已埋的
|
||
if (!(c.dealowner === pj.banker + 1 || c.dealowner === 0)) continue;
|
||
if (c.playround === 0) continue;
|
||
if (c.number >= 53) { wang++; out.push(c.id); }
|
||
else if (c.number === 7) { qi++; out.push(c.id); }
|
||
else if (c.number === 2) { er++; out.push(c.id); }
|
||
}
|
||
if (out.length < 10 && wang < 3 && qi < 6 && er < 6) return new Set(); // 不达标:一张都不该下发
|
||
return new Set(out);
|
||
}
|
||
let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定,全程固定)
|
||
let curNoCheck = false; // 本局是否「不查牌」
|
||
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
|
||
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
|
||
let surrenderRuns = 0; // 走过投降结算的局数
|
||
let disbandRuns = 0; // 走过中途解散结算的局数
|
||
let awardExempt = 0; // 「算奖明细」这条显式豁免被真正用到的次数
|
||
|
||
const leaks = [];
|
||
let pkts = 0, scanned = 0;
|
||
// 判定:seat 此刻是否有权知道 cid(path 见 collectCards 的说明)
|
||
function mayKnow(pj, seat, cid, rpc, path) {
|
||
const c = pj.cards[cid];
|
||
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
|
||
if (c.playround > 0) return true; // 已打出,全场可见
|
||
if (c.dealowner === seat + 1) return true; // 自己的牌
|
||
if (seat === pj.banker && c.dealowner === 0) return true; // 庄家可见底牌(含摸起后又埋下的)
|
||
// §4 70分坐庄:上庄推送把 8 张底牌向三家亮 3 秒——只放行 shangzhuang 的 bottomcards 这一处
|
||
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0 && pathIs(path, 'bottomcards')) return true;
|
||
|
||
// §11 结束亮埋牌底牌:本局【正常结算】后,埋牌底牌随抠底明细对全场公开。
|
||
// jiesuan 广播(bottom.cards,sendpack_toother(msg,-1) 三家都发)与结算面板还开着时的
|
||
// 重连包(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 明牌:单独在下面按内容校验
|
||
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发)
|
||
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true;
|
||
return false;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// ②层:统计量必须能由「公开账本」独立重算出来
|
||
// ==========================================================================
|
||
// 账本里只有【三家都看得见】的东西:
|
||
// 庄家 / 庄家叫分(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 o_room = {
|
||
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
|
||
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })),
|
||
method: { sendpack_toother: m => sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(m)) }) }
|
||
};
|
||
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
|
||
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;
|
||
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: () => { } };
|
||
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
|
||
D.do_new_paiju(desk, 0);
|
||
const pj = desk.method.curr_paiju();
|
||
|
||
const audit = () => {
|
||
while (sent.length) {
|
||
const { to, m } = sent.shift();
|
||
pkts++;
|
||
// 同一个逻辑包逐座位发 3 份(i=0,1,2),只用第一份推进公开账本;
|
||
// 但【每一份都要审计】,这样三家收到的统计量若有差异也会被抓住
|
||
if (to === 0 || to === 'ALL') { ledgerFeed(m.rpc, m.data); }
|
||
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();
|
||
collectCards(m.data, '', null, out);
|
||
for (const seat of targets) for (const e of out) {
|
||
scanned++;
|
||
const cid = cidOf(e);
|
||
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();
|
||
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(); }
|
||
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();
|
||
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
|
||
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
|
||
curLiangpai = bankerLiangpaiCards(pj);
|
||
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
|
||
audit();
|
||
auditDeskinfo();
|
||
let guard = 0;
|
||
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);
|
||
let pick = null;
|
||
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
|
||
else for (const c of hand) if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
|
||
if (!pick) break;
|
||
mod.chupai(pk(seat, { cards: pick })); audit();
|
||
if (guard % 7 === 0) { // 中途穿插重连与明牌
|
||
auditDeskinfo();
|
||
if (pj.method.have_baofu()) {
|
||
sent.length = 0;
|
||
mod.mingpai(pk(0));
|
||
// §9 明牌:只允许返回「另外两家未出的主牌」,多一张都算越权
|
||
for (const { m } of sent) {
|
||
if (m.rpc !== 'mingpai' || !m.data.others) continue;
|
||
for (const o of m.data.others) {
|
||
for (const cid of o.zhucards) {
|
||
scanned++;
|
||
const c = pj.cards[cid];
|
||
const isTrumpCard = c.flower === pj.flower || c.flower === 5 || c.number === 2 || c.number === 7;
|
||
if (c.dealowner !== o.seat + 1 && !(o.seat === pj.banker && c.dealowner === 0)) leaks.push(`mingpai 返回了非该座位的牌 ${cid}`);
|
||
if (c.playround !== -1) leaks.push(`mingpai 返回了已出/已埋的牌 ${cid}`);
|
||
if (!isTrumpCard) leaks.push(`mingpai 返回了非主牌 ${cid}`);
|
||
}
|
||
}
|
||
}
|
||
sent.length = 0;
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
for (const rt of ['00000', '00001']) for (const call of [65, 70, 5]) run(rt, call);
|
||
// 上面 6 局按 0xBEEF01 的牌序,庄家一次都没达到亮牌门槛(§8.2)。亮牌是【有意的信息公开】,
|
||
// 必须真的被审计走过,否则「无越权泄露」对这条路径只是碰巧没触发。故换牌序再补几局:
|
||
// 可查牌局验「该发的发了且不越权」,不查牌局验「达标也一张都不发」。
|
||
seed = 0x12345;
|
||
for (const call of [65, 5]) run('00000', call);
|
||
seed = 0x777001;
|
||
run('00000', 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(statBad)].slice(0, 5), []);
|
||
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
|
||
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
|
||
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
|
||
t.eq('审计覆盖 统计量重算 ≥ 10000 次', statChecks >= 10000, true);
|
||
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
|
||
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
|
||
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
|
||
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
|
||
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);
|