// 下发面泄露审计(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);