155 lines
6.6 KiB
Python
155 lines
6.6 KiB
Python
"""发布证据的新鲜度与单次使用绑定(集成 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 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
|