"""发布证据的新鲜度与单次使用绑定(集成 v2,代码层)。 首轮缺口(全自动 9.5 扣 0.5):发布门“重算”保证的是“不用 旧结论”,不是“证据一定新鲜”——证据体里没有时间戳/nonce 绑定,调用方把合并时同一份证据体在发布时原样重交,同样 能通过重算。本模块补这一层,但诚实边界先写清: - 本绑定防的是**原样重放**(同一 nonce 二次提交)与**陈旧 证据**(时间戳超出窗口),不防**重盖戳**:没有签名的证据 体,调用方完全可以给旧内容盖一个新时间戳/新 nonce 再交, 本模块无法识破。防篡改/防重盖戳需要签名证据或第 5 项的 只读求证(向 GitHub 实时核对),那是另一层控制,不在本 模块冒充。 - 绑定是**调用方自愿出示**的:请求体里同时带 evidence_ts 与 evidence_nonce 才进入绑定校验;两者都不带时走旧路径 (与 #259 契约兼容,响应里标注 evidence_binding=absent, 绝不谎称已验证新鲜)。只带其一是格式错误(400),不是 “半绑定通过”。把绑定改成强制,是契约决定,须协调员/ 用户在合轨时定,不在本轨悄悄改 #259。 - 单次使用语义:nonce 在**首次通过校验的提交**即被消费, 无论该次发布最终是 200 还是被其它门(未 merged、推回 阻塞)挡下——被挡的调用方下次必须取新证据重盖戳。这 是故意的:每次发布尝试都要求当次新鲜证据,而不是“一份 证据可反复试到放行”。 - nonce 存储与审计链无关:evidence_nonces 表只记 nonce、 任务 id 与时间戳,不写 audit_log、不移动 #241 链头;409 不写审计的规则不受影响。表在写路径(首次消费)才建, 读路径(校验已存在)不建表——查不到表时视为无消费记 录,不是错误。 零第三方依赖,只用标准库。时间一律用 Unix 秒(time.time), 与 full_auto.audit_record 的 ts 同口径。 """ import os import time import uuid # 新鲜度窗口:证据时间戳距提交时刻不得超过 1 小时;未来 # 容忍 5 分钟时钟偏差,超出即视为异常(不是“更新鲜所以更好”)。 MAX_AGE_SECONDS = 3600 CLOCK_SKEW_SECONDS = 300 NONCE_TABLE_DDL = ( "CREATE TABLE IF NOT EXISTS evidence_nonces(" "nonce TEXT NOT NULL PRIMARY KEY, " "task_id TEXT NOT NULL, " "evidence_ts DOUBLE PRECISION NOT NULL, " "consumed_at DOUBLE PRECISION NOT NULL)" ) def bind_evidence(evidence, now=None, nonce=None): """给一份证据体盖戳:返回原证据的浅拷贝 + 时间戳/nonce。 调用方(协调员侧)在**取到证据的那一刻**调用本函数, 不要在合并时盖好、发布时原样重交——那样时间戳本身 就是旧的,会被窗口校验挡下(这正是本绑定的意义)。 nonce 缺省为 uuid4 hex;调用方自带 nonce 时须保证 全局唯一(重复 nonce 即重放)。 """ if not isinstance(evidence, dict): raise ValueError("evidence must be a dict") stamped = dict(evidence) stamped["evidence_ts"] = time.time() if now is None else now stamped["evidence_nonce"] = nonce or uuid.uuid4().hex return stamped def validate_bound_evidence(evidence, now=None): """纯函数校验绑定证据的形状与新鲜度(不查 nonce 库)。 返回 (ok, reason):ok 为 False 时 reason 为 evidence_binding_missing / evidence_binding_incomplete / evidence_ts_not_a_number / evidence_nonce_empty / evidence_too_old / evidence_from_future。nonce 重放由 EvidenceNonceStore.consume 判定,不在本函数(本函数 无状态,同一份证据调两次结果相同,那不是重放判定)。 """ if not isinstance(evidence, dict): return False, "evidence_binding_missing" has_ts = evidence.get("evidence_ts") is not None has_nonce = evidence.get("evidence_nonce") is not None if not has_ts and not has_nonce: return False, "evidence_binding_missing" if not (has_ts and has_nonce): return False, "evidence_binding_incomplete" ts = evidence.get("evidence_ts") if isinstance(ts, bool) or not isinstance(ts, (int, float)): return False, "evidence_ts_not_a_number" nonce = evidence.get("evidence_nonce") if not isinstance(nonce, str) or not nonce.strip(): return False, "evidence_nonce_empty" moment = time.time() if now is None else now if ts > moment + CLOCK_SKEW_SECONDS: return False, "evidence_from_future" if moment - ts > MAX_AGE_SECONDS: return False, "evidence_too_old" return True, None def binding_presence(evidence): """绑定出示状态:bound(成对出示)/ partial(只带其一)/ absent(两者皆无)。服务端据此决定走绑定校验、报 400 还是走旧路径(并标注 absent,绝不谎称已验证)。""" if not isinstance(evidence, dict): return "absent" has_ts = evidence.get("evidence_ts") is not None has_nonce = evidence.get("evidence_nonce") is not None if has_ts and has_nonce: return "bound" if has_ts or has_nonce: return "partial" return "absent" class EvidenceNonceStore: """nonce 单次使用存储(DB 连接由调用方传入并负责提交)。 consume 成功返回 True(首次见此 nonce);nonce 已存在 返回 False(重放)。表不存在时 consume 先建表(写路径 才建);has() 在表不存在时返回 False,不建表。 """ def __init__(self, conn): self.conn = conn def ensure_table(self): self.conn.execute(NONCE_TABLE_DDL) def has(self, nonce): try: row = self.conn.execute( "SELECT nonce FROM evidence_nonces WHERE nonce = ?", (nonce,)).fetchone() except Exception: return False return row is not None def consume(self, task_id, nonce, evidence_ts, consumed_at=None): """消费一个 nonce:首次 True;已存在 False(不覆盖原行)。 调用方须在 consume 后提交(本函数不 commit:服务端 在自己的连接生命周期里提交,与其余写操作保持一致)。 """ self.ensure_table() if self.has(nonce): return False self.conn.execute( "INSERT INTO evidence_nonces(nonce, task_id, evidence_ts," " consumed_at) VALUES(?, ?, ?, ?)", (nonce, task_id, evidence_ts, time.time() if consumed_at is None else consumed_at), ) return True