事故:把时钟拨快三十天
《潮汐方舟》客服收到投诉:某账号三天内领了 30 天离线收益。日志显示每次领取的 lastSeenAt 与 claimedAt 都合法,差值恰好 24 小时——玩家把系统时间往前拨,重复制造「离线一天」。另一条工单是金币异常:存档 JSON 里的 coins 被改成 999999,校验和也被重算,客户端读得津津有味。两个案例说明:完整性、时间、权威字段是三件不同的事。
校验和挡损坏,签名挡改写
| 机制 | 计算 | 能挡住 | 挡不住 |
|---|---|---|---|
| 校验和 | 无密钥 FNV-1a | 截断、半写、传输损坏 | 有意改写后重算 |
| 签名 | 带密钥的 MAC | 不知道密钥的改写 | 密钥泄露、重放 |
模型里的签名用 keyed FNV 演示「校验位置与失败路径」,不是生产级密码学。生产环境应使用 HMAC-SHA256,密钥放在平台 keystore,并由服务端下发一次性 nonce,避免同一份签名被重放。签名覆盖信封的固定字段与 payload,校验和覆盖签名后的完整字节;两者顺序不能反。
时间锚点:墙上时钟不可信
| 异常 | 判据 | 动作 |
|---|---|---|
future-timestamp | savedAtMs > lastServerTimeMs + maxClockSkewMs | 拒绝候选 |
time-rollback | 版本更高,但 savedAtMs 早于最近可信存档 | 拒绝候选 |
playtime-regression | 版本更高,但累计 playTimeMs 变小 | 拒绝候选 |
revision-regression | 版本低于服务端已确认值,时间却很新 | 拒绝候选 |
锚点来自最近一次服务端时间与最近一份已确认存档,而不是设备当前时间。允许 maxClockSkewMs 的容差,是为了吸收网络往返与轻微校时;容差不能大到能容纳「拨快一天」。版本低且时间也旧的存档是合法的回滚候选,不应按作弊处理,这两类要在判据上分开。
离线收益:窗口、速率、上限
离线收益不是「按时间发钱」,而是一个可验证的区间:
duration = claimedAtMs - lastSeenAtMs
capMs = min(claim.capHours, policy.maxCapHours) * 3_600_000
expected = floor(duration / 3_600_000 * ratePerHour)
拒绝条件有四类:duration <= 0;领取窗口落在服务端时间之后超过容差;duration > capMs;amount > expected + amountTolerance。上限取客户端声明与服务端策略的较小值,防止客户端把 capHours 改成 999。12 小时是模型参数,不是案例设定;真实上限要由经济系统给定。
服务端权威字段
等级、货币、卡组、赛季分不参与客户端合并,直接以服务端快照为准。reconcileServerFields 做三件事:用服务端值覆盖本地值、记录被替换的字段、把「本地数值明显高于服务端」的字段标为可疑。允许一个小的 optimisticAllowance,是为了容纳请求在途时的本地乐观更新;超过这个额度就不是延迟,而是分歧。
被替换不等于作弊。玩家可能在弱网下完成了服务端尚未收到的一局,下一次同步会补齐。但可疑列表要进日志,和账号维度一起看,而不是单次直接封号。
回滚到上一个可用存档
| 顺序 | 判据 | 说明 |
|---|---|---|
| 1 | 校验和与签名 | 任一失败立即淘汰 |
| 2 | 时间锚点 | 未来时间、回拨、累计时长回退淘汰 |
| 3 | 保留期 | 超过 maxAgeMs 淘汰 |
| 4 | 版本最高 | 在可用候选中选 revision 最大 |
| 5 | 时间与槽位 | 版本相同选 savedAtMs,再按 primary、backup、snapshot 顺序 |
关键点是「先验证、再排序」。不能因为某份文件最新就选它;一份签名失败的「最新档」比一份三天前的可用档危险得多。全部候选都不可用时返回 fresh-start,附带拒绝原因与支持码,让客服知道该走人工恢复,而不是让客户端猜。
可运行模型:migrate.ts
模型把签名、时间锚点、离线收益、服务端字段与回滚选择放在一个文件里。Signer 与 TrustAnchor 都是注入的,测试不需要真实密钥或服务器。
// file: src/save/migrate.ts
// tier: L1
// verify: node --test test/save/migrate.test.ts
import {
fnv1a, parseEnvelope, sealEnvelope, signingBody,
type SaveBody, type SaveEnvelope, type SavePayload, type SaveValue,
} from "./store.ts";
export type IntegrityIssue = "corrupt" | "checksum-mismatch" | "signature-mismatch"
| "future-timestamp" | "time-rollback" | "playtime-regression" | "revision-regression"
| "offline-window-invalid" | "offline-cap-exceeded" | "offline-amount-mismatch"
| "server-divergence" | "stale-save";
export interface Signer { readonly id: string; sign(text: string): string }
export function createKeyedSigner(secret: string): Signer {
if (secret.length < 8) throw new RangeError("signer secret must be at least 8 characters");
return { id: `keyed-fnv:${secret.length}`, sign: (text: string) => fnv1a(`${secret}:${text}`) };
}
export function signBody(body: SaveBody, signer: Signer): SaveEnvelope {
return sealEnvelope(body, signer.sign(signingBody(body)));
}
export function verifySignature(envelope: SaveEnvelope, signer: Signer): boolean {
return envelope.signature !== null && envelope.signature === signer.sign(signingBody(envelope));
}
export type TrustAnchor = Readonly<{
lastServerTimeMs: number; lastServerRevision: number; lastKnownGoodRevision: number;
lastKnownGoodAtMs: number; lastKnownPlayTimeMs: number; maxClockSkewMs: number;
}>;
export function checkTimeAnchor(
envelope: SaveEnvelope, anchor: TrustAnchor,
): readonly IntegrityIssue[] {
const issues: IntegrityIssue[] = [];
if (envelope.savedAtMs > anchor.lastServerTimeMs + anchor.maxClockSkewMs) {
issues.push("future-timestamp");
}
if (envelope.revision < anchor.lastServerRevision
&& envelope.savedAtMs >= anchor.lastKnownGoodAtMs) {
issues.push("revision-regression");
}
if (envelope.revision > anchor.lastKnownGoodRevision
&& envelope.savedAtMs + anchor.maxClockSkewMs < anchor.lastKnownGoodAtMs) {
issues.push("time-rollback");
}
if (envelope.revision > anchor.lastKnownGoodRevision
&& envelope.playTimeMs < anchor.lastKnownPlayTimeMs) {
issues.push("playtime-regression");
}
return issues;
}
export interface OfflineClaim {
readonly lastSeenAtMs: number; readonly claimedAtMs: number;
readonly amount: number; readonly ratePerHour: number; readonly capHours: number;
}
export interface OfflinePolicy {
readonly maxCapHours: number; readonly maxClockSkewMs: number; readonly amountTolerance: number;
}
export const DEFAULT_OFFLINE_POLICY: OfflinePolicy = {
maxCapHours: 12, maxClockSkewMs: 120_000, amountTolerance: 1,
};
export function validateOfflineClaim(
claim: OfflineClaim,
anchor: TrustAnchor,
policy: OfflinePolicy = DEFAULT_OFFLINE_POLICY,
): readonly IntegrityIssue[] {
const issues: IntegrityIssue[] = [];
if (!Number.isFinite(claim.amount) || claim.amount < 0) {
return ["offline-amount-mismatch"];
}
const durationMs = claim.claimedAtMs - claim.lastSeenAtMs;
const allowedHours = Math.min(claim.capHours, policy.maxCapHours);
if (durationMs <= 0
|| claim.lastSeenAtMs > anchor.lastServerTimeMs + policy.maxClockSkewMs
|| claim.claimedAtMs > anchor.lastServerTimeMs + policy.maxClockSkewMs) {
issues.push("offline-window-invalid");
}
if (durationMs > allowedHours * 3_600_000) {
issues.push("offline-cap-exceeded");
}
const expected = Math.floor((Math.max(0, durationMs) / 3_600_000) * claim.ratePerHour);
if (claim.amount > expected + policy.amountTolerance) {
issues.push("offline-amount-mismatch");
}
return issues;
}
export interface ReconcileOutcome {
readonly fields: SavePayload; readonly replaced: readonly string[];
readonly suspicious: readonly string[];
}
export function reconcileServerFields(
local: SavePayload,
server: SavePayload,
serverFields: readonly string[],
optimisticAllowance = 0,
): ReconcileOutcome {
const fields: Record<string, SaveValue> = { ...local };
const replaced: string[] = [];
const suspicious: string[] = [];
for (const name of serverFields) {
const serverValue = server[name];
const localValue = local[name];
if (serverValue === undefined || JSON.stringify(localValue) === JSON.stringify(serverValue)) {
continue;
}
replaced.push(name);
if (typeof localValue === "number" && typeof serverValue === "number"
&& localValue - serverValue > optimisticAllowance) {
suspicious.push(name);
}
fields[name] = serverValue;
}
return { fields, replaced, suspicious };
}
export interface SaveCandidate { readonly label: string; readonly text: string }
export type CandidateVerdict = "usable" | "reject";
export interface CandidateAssessment {
readonly label: string; readonly verdict: CandidateVerdict;
readonly envelope: SaveEnvelope | null; readonly issues: readonly IntegrityIssue[];
readonly reason: string | null;
}
export interface AssessPolicy {
readonly signer: Signer; readonly anchor: TrustAnchor;
readonly nowMs: number; readonly maxAgeMs: number;
}
export function assessCandidate(
candidate: SaveCandidate, policy: AssessPolicy,
): CandidateAssessment {
const parsed = parseEnvelope(candidate.text);
if (!parsed.ok) {
const issue: IntegrityIssue = parsed.error === "checksum-mismatch"
? "checksum-mismatch" : "corrupt";
return {
label: candidate.label, verdict: "reject", envelope: null,
issues: [issue], reason: parsed.reason,
};
}
const envelope = parsed.value;
const issues = new Set<IntegrityIssue>(checkTimeAnchor(envelope, policy.anchor));
if (!verifySignature(envelope, policy.signer)) issues.add("signature-mismatch");
if (policy.nowMs - envelope.savedAtMs > policy.maxAgeMs) issues.add("stale-save");
if (envelope.savedAtMs > policy.nowMs + policy.anchor.maxClockSkewMs) {
issues.add("future-timestamp");
}
const list = [...issues];
return {
label: candidate.label, verdict: list.length === 0 ? "usable" : "reject",
envelope, issues: list, reason: list.length === 0 ? null : list.join(","),
};
}
const LABEL_PRIORITY: readonly string[] = ["primary", "backup", "snapshot-1", "snapshot-2"];
function priorityOf(label: string): number {
const index = LABEL_PRIORITY.indexOf(label);
return index === -1 ? LABEL_PRIORITY.length : index;
}
export interface RollbackDecision {
readonly kind: "restore" | "fresh-start"; readonly chosen: CandidateAssessment | null;
readonly rejected: readonly string[]; readonly reasons: readonly string[];
}
export function chooseRollback(
assessments: readonly CandidateAssessment[],
): RollbackDecision {
const usable = assessments.filter(
(item): item is CandidateAssessment & { readonly envelope: SaveEnvelope } =>
item.verdict === "usable" && item.envelope !== null,
);
const rejected = assessments
.filter((item) => item.verdict !== "usable")
.map((item) => `${item.label}:${item.issues.join(",")}`);
if (usable.length === 0) {
return {
kind: "fresh-start", chosen: null, rejected,
reasons: ["no signature-verified candidate within the retention window"],
};
}
const sorted = [...usable].sort((left, right) => {
const revisionDelta = right.envelope.revision - left.envelope.revision;
if (revisionDelta !== 0) return revisionDelta;
const timeDelta = right.envelope.savedAtMs - left.envelope.savedAtMs;
if (timeDelta !== 0) return timeDelta;
return priorityOf(left.label) - priorityOf(right.label);
});
return { kind: "restore", chosen: sorted[0], rejected, reasons: [] };
}
测试 test/save/migrate.test.ts 覆盖 16 条路径:签名往返、有效档、篡改、错误密钥、无签名、四类时间异常、离线正常/超上限/超速率/负窗口、服务端字段覆盖、乐观额度、回滚跳过损坏主档、全失败新档、过期档、同版本槽位优先级。命令:node --test test/save/migrate.test.ts。
这里是模型,不是安全审计:keyed FNV 可以被离线暴力破解,真实签名、密钥轮换与服务端重放保护要单独设计。
双案例:方舟信服务端,消消守离线
《潮汐方舟》强联网,等级、货币、卡组与赛季分都由服务端结算,客户端只负责展示与设置;它的回滚优先选服务端确认过的快照,时间锚点用最近一次服务端时间。
《潮汐消消》弱联网,离线收益、广告奖励与断网推关是主场景;它必须把离线窗口、速率与上限写死在客户端策略里,同时接受「设备时间不可信」这个前提,不能只靠本地时间判断。
与相邻章节的关系
10.2 解决两份存档怎么合并,本节解决合并前后「哪份能信、坏了退回哪份」。更完整的反作弊校验可以参考反作弊与校验 ,崩溃后的会话恢复见 4.3;两者都依赖同一套「先验证再使用」的顺序。
小结
- 校验和挡损坏,签名挡改写;两者都不能替代服务端权威。
- 时间锚点用服务端时间与最近可信存档,不用设备当前时间。
- 离线收益要同时校验窗口、上限与速率,缺一项就能被重复领取。
- 服务端权威字段由服务端覆盖,本地异常进可疑日志而不是直接封号。
- 回滚先验证再排序;全部失败时明确进入新档流程,不猜「最新的那份」。
阅读导航:上一节:10.2 云存档冲突与合并 · 下一节:11.1 文本、字体与排版 。
常见错误与边界
- 用校验和防作弊。玩家改完 payload 重算 FNV 即可通过;校验和只证明文件完整。
- 用设备时间判断离线时长。拨快时钟是最低成本的作弊;锚点必须来自服务端时间。
- 只限制离线时长不限制金额。客户端可以把速率或 cap 一起改大;金额必须由服务端策略重算。
- 把「本地比服务端高」直接判封号。弱网下的乐观更新会造成同样现象;先记录、再按账号聚合判断。
- 回滚时选最新文件。未验签的最新档可能是伪造或半写;先淘汰失败候选,再按版本排序。
- 所有失败都静默新档。玩家会以为进度凭空消失;
fresh-start必须带原因与支持码。 - 边界:本节不实现生产级密码学与服务端风控。模型只演示校验顺序与失败路径,密钥管理、nonce 与重放保护需要平台与服务端配合。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。