I-3 阶段与控制权改由服务端唯一维护并逐包下发: - fapai / jiaofen / shangzhuang / xuanzhu / maipai / jiesuan 各补 step(取自 o_paiju.step) - shangzhuang / xuanzhu 补 nextseat(控制权 = o_paiju.banker,选主/投降/埋牌均只由庄家做) - deskinfo 的 ChooseMain / BuryCards 各补 seat(控制权,与增量推送 nextseat 同源同值) - 各下发点的真实值先用探针跑真实一局逐个核实;顺带发现 shangzhuang.data.seat 是 最后一个叫分者而非控制权(本局实测 2 vs 庄家 0),故控制权另起名 nextseat - get_paiju_account 统一把 step 置 6:解散结算此前无人推进 step,包里给不出诚实的阶段值 I-2 结算阶段的重连包补发抠底明细与大局结算: - get_paiju_account 末尾一并冻结 tmp_jiesuan_bottom / tmp_jiesuan_account - get_deskinfo case 6 的 Balance 据此补 bottom / account,与 jiesuan 推送同源同值、 门控一致(投降/解散无 bottom、非末局无 account);新局开始时三份快照一并清除 - 修复「结算面板开着时断线重连/硬刷新 → 抠底明细与末局大结算空白」 M-2 seatlist 不再以活引用挂进下发包: - 新增 get_seatlist() 深拷贝快照,maipai / chupai / deskinfo.PushCards 三处改用它 测试:test_rpc 新增 I-3/I-2/M-2 三节(131→166 checks)、test_desk 新增 fapai 阶段与控制权用例(6→10);全部新增守卫已逐条做「退回修复必转红」反向验证(15/15)。 test_leak 的可见性规则同步收紧到「本局结算(step6)后埋牌底牌才全场公开」, 并修掉一处把阶段写死成 step5 的报错文案;整局泄露审计仍全绿。 协议文档同步补充上述字段说明。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
11 KiB
JavaScript
179 lines
11 KiB
JavaScript
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
|
||
// 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id,
|
||
// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、
|
||
// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。
|
||
const R = require('./_rpc.js'); const mod = R.mod;
|
||
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
|
||
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 || {}) });
|
||
|
||
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。
|
||
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
|
||
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
|
||
function collectCards(node, key, out) {
|
||
if (node === null || node === undefined) return;
|
||
if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; }
|
||
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; }
|
||
if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); }
|
||
}
|
||
|
||
// 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; // 不查牌 + 庄家达标的局数(此时一张都不该发)
|
||
|
||
const leaks = [];
|
||
let pkts = 0, scanned = 0;
|
||
let ctxRef = null;
|
||
// 判定:seat 此刻是否有权知道 cid
|
||
function mayKnow(pj, seat, cid, rpc) {
|
||
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 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌
|
||
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒
|
||
// §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就
|
||
// 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom
|
||
// 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」
|
||
// 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0
|
||
if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true;
|
||
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
|
||
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
|
||
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
|
||
return false;
|
||
}
|
||
|
||
function run(roomtype, call) {
|
||
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();
|
||
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();
|
||
ctxRef = { pj, sent };
|
||
|
||
const audit = () => {
|
||
while (sent.length) {
|
||
const { to, m } = sent.shift();
|
||
pkts++;
|
||
const targets = to === 'ALL' ? [0, 1, 2] : [to];
|
||
const out = new Set();
|
||
collectCards(m.data, null, out);
|
||
for (const seat of targets) for (const cid of out) {
|
||
scanned++;
|
||
if (!mayKnow(pj, seat, cid, m.rpc)) {
|
||
leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${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;
|
||
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();
|
||
// 重连快照也要审计(每个座位各取一次)
|
||
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;
|
||
while (pj.step === 5 && ++guard < 400) {
|
||
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) { // 中途穿插重连与明牌
|
||
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++;
|
||
// 阶段取【实际的】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()) {
|
||
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();
|
||
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);
|
||
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
|
||
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
|
||
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
|
||
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
|
||
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
|
||
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
|
||
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
|
||
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
|
||
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`);
|
||
process.exit(t.done('leak') ? 0 : 1);
|