Skip to content

realcodestudio/QuakeFeel

Repository files navigation

震感共证 QuakeFeel

QuakeFeel 是一个面向比赛演示的地震震感与安否确认原型。它计划将众包报告的设备签名、可解释异常分析、H3 区域聚合、Merkle 批量存证和多中继复制组合到一条可验证的数据链中。

重要声明:本项目不是官方地震观测系统,也不是紧急救援服务。 数据可能延迟、缺失或错误;发生地震时请以当地政府、地震主管机关和应急部门发布的信息为准,紧急情况请直接联系当地救援机构。

当前可运行范围

当前版本在既有地震同步与列表 MVP 上完成了匿名设备签名报告闭环、Merkle/IPFS 公开验证和可选的本地 Anvil 锚定:

  • 浏览器通过 Web Crypto 创建不可导出的 ECDSA P-256 私钥,并以 Dexie 写入 IndexedDB;
  • 服务端仅注册公开 JWK,同一公钥重复注册幂等;
  • Python 与 TypeScript 使用冻结的 ShakingReport Schema v1、相同 canonical JSON 和 SHA-256;
  • 浏览器本地签名,服务端重新规范化、比对哈希、验签、检查事件与 revision 链,再写入不可变报告;
  • 服务端根据签名载荷中的整数经纬度重算 H3 cell,公共报告响应删除精确经纬度;
  • Vue 提供最小震感表单,支持有感/无感、等级、浏览器定位、签名提交与 revision;
  • 按同一事件、同一凭据的最新 revision 生成原始统计和加权可信统计;
  • 以位置、时间、内部一致性、H3 邻域和实验性预计震感生成版本化评估;
  • 稀疏 H3 单元沿父级合并,公开区域始终至少包含 3 个独立凭据;
  • 报告成功提交后通过事件级 SSE 发送最小 report_received 通知,详情页防抖重取权威统计与区域数据;
  • 已签名报告在任何网络请求前写入同一 quakefeel IndexedDB 的持久化队列,断线或重启后保持原 envelope 自动重试;
  • 首次离线使用可先生成不可导出设备密钥,恢复网络后幂等注册凭据,再按 revision 依赖顺序提交报告;
  • 按事件显式创建不可变 sealed Merkle 批次,为每份历史或当前报告按需生成 inclusion proof;
  • 服务端与浏览器分别按冻结的 Merkle Batch Protocol v1 验证 proof,并明确区分“已入批次”和“已上链”;
  • sealed batch 可按冻结的 IPFS Batch Publication Protocol v1 导出确定性白名单快照,显式发布、pin 并重新下载验证;
  • 验证页将 Merkle proof 与 IPFS 快照分开验证,浏览器会重新计算文件摘要、dataset digest 和 Merkle root;
  • 不可升级 Solidity registry 只保存非敏感批次承诺,后端显式交易命令支持提交、确认、恢复与幂等;
  • 浏览器通过可信构建配置中的只读 RPC 独立读取合约,不连接钱包,也不请求账户权限;
  • 管理员配置的可信 relay 可通过认证、显式 pull 命令同步完整 signed envelope,接收端独立重算事件身份、credential ID、canonical bytes、report hash、P1363 签名、revision、H3 和 assessment;
  • 通过正式凭据和报告 API 提交真实 P-256 签名的确定性演示数据。

本阶段不包含安否功能、公共 peer discovery、自动 relay 调度、公共网络部署或用户钱包。Relay Sync、IPFS 与 Anchor 均默认关闭,只有管理员显式命令会拉取、发布或锚定;可选的 automation profile 可显式启动自动批处理 worker(默认关闭,见 docs/AUTO_BATCH_WORKER.md),它只按聚合窗口调用既有 seal/publish/anchor service。

架构

flowchart LR
    Wolfx[Wolfx CENC API] -->|定时同步 / 手动同步| API[FastAPI]
    Wolfx -->|Event Identity v1 / UUIDv8| EventID[确定性事件身份]
    EventID --> API
    API -->|SQLAlchemy async| PG[(PostgreSQL 16)]
    Browser[Vue 3 浏览器客户端] -->|REST /api/v1| API
    API -->|SSE 事件级变更通知| Browser
    Browser -->|Web Crypto P-256| Key[(IndexedDB 私钥)]
    Browser -->|已签名 envelope / 有限重试队列| Queue[(IndexedDB pending_reports)]
    Browser -->|地震列表 / 签名报告| User[用户]
    API -->|公开 JWK / 不可变报告| PG
    PG --> Analysis[版本化异常分析]
    Analysis --> Aggregate[原始 / 可信 H3 聚合]
    Aggregate -->|脱敏统计| API
    PG --> Batch[sealed Merkle Batch]
    Batch -->|按需 inclusion proof| API
    Batch -->|确定性公开快照 / 显式发布| IPFS[(Kubo IPFS)]
    IPFS -->|重新下载并完整验证| API
    IPFS -->|公开快照独立验证| Browser
    IPFS -->|显式锚定命令| Chain[不可升级 AnchorRegistry / Anvil]
    Chain -->|只读独立验证| Browser
    API <-->|认证 pull / signed envelope| Relay[可信第二中继]
    Relay -->|独立验签 / 本地分析与存证| RelayPG[(独立 PostgreSQL)]
Loading

默认 Compose 数据流为 Wolfx → backend → PostgreSQL → frontend,报告方向则为 浏览器密钥 → canonical payload → 本地签名 → backend 重算并验签 → PostgreSQL。外部字段在进入数据库前会进行显式类型转换、范围校验与规范化。

快速启动

需要 Docker Engine 与 Docker Compose v2。

cp .env.example .env
make dev

首次构建时,后端容器会在启动 API 前自动执行 alembic upgrade head,因此全新数据卷无需先手动迁移。构建完成后:

另一个终端可执行:

make migrate
make seed

make seed 以稳定外部 ID 写入可重复 upsert 的演示地震,在外部 API 暂时不可用时也能演示列表。后台同步默认每 60 秒执行;也可用 make sync 触发一次同步。停止服务使用 make down

常用开发命令

make dev              构建并启动 MVP
make test             运行后端与前端测试
make lint             运行 Ruff、mypy、ESLint、Prettier 与 TypeScript 检查
make migrate          执行 Alembic 迁移
make seed             写入演示地震
make sync             单次执行 Wolfx 同步
make simulate-reports 通过正式 API 提交确定性签名演示报告
make merkle-batch     为 EVENT_ID 创建一个 sealed Merkle 批次
make publish-ipfs     显式发布指定事件和批次到 Kubo
make test-merkle-postgres 运行可选 PostgreSQL advisory-lock 集成测试
make test-ipfs-kubo   运行可选本地 Kubo add/pin/refetch 集成测试
make forge-test       运行 Solidity 单元和 fuzz 测试
make anvil-up         启动仅绑定本机的可选 Anvil
make anchor-deploy    部署不可升级 Anchor Registry
make anchor-batch     显式锚定已发布批次
make test-anchor-anvil 运行可选真实 Anvil 集成测试
make anchor-check-rpc 只读探测 RPC 的 chainId/blockNumber/余额/bytecode
make anchor-deploy-injective-testnet 部署 registry 到 Injective EVM Testnet
make anchor-verify-injective-testnet 在 Injective Blockscout 验证合约源码
make anchor-smoke-injective-testnet 显式运行公共测试网 smoke(默认门禁之外)
make auto-batch-once  运行一轮自动批处理/发布/锚定后退出
make auto-batch-dry-run 只输出自动化将执行的动作,不写库不发交易
make test-auto-batch  运行自动化 worker 单元测试
make test-auto-batch-postgres 运行双 worker 并发 PostgreSQL 集成测试
make test-event-identity-postgres 运行两个独立 PostgreSQL 的事件身份测试
make audit-event-identities 输出 legacy / deterministic 安全摘要
make relay-sync       从管理员配置的 PEER 显式拉取 signed reports
make relay-status     输出不含 token/payload 的 relay 状态摘要
make test-relay-postgres 运行两个独立 PostgreSQL/FastAPI relay 集成测试
make config           校验 Compose 文件
make dev-ipfs         额外启动可选 Kubo
make dev-chain        额外启动可选 Anvil
make dev-federation   额外启动隔离的第二后端/数据库

make simulatemake simulate-reports 的别名。Anvil 状态默认是临时的,容器重建或状态丢失后必须重新部署合约并更新两个可信合约地址配置。

可选 PostgreSQL 集成测试绝不使用默认 quakefeel 应用库。make test-merkle-postgres 会创建 quakefeel_test(或显式的 *_test 名称)、执行 0001→head、运行独立连接的 advisory-lock/trigger 测试,并通过 shell trap 在成功、失败或中断时删除整个测试库。直接运行测试时也必须提供 TEST_DATABASE_URL;安全守卫发现应用库名、非 PostgreSQL URL 或不带 _test 的名称会立即返回 TEST_DATABASE_SAFETY_ERROR

如果旧版本集成测试已经向本地开发卷写入伪 credential/report/publication/anchor,迁移不会猜测或删除它们。只有在人工确认该卷没有任何真实或需保留数据后,才可执行以下破坏性重建:

docker compose down
docker volume ls | grep quakefeel.*postgres_data
# 再次确认卷内无真实数据;下行会永久删除整个开发数据库。
docker volume rm quakefeel_postgres_data
docker compose up -d postgres
make migrate

Compose project name 被自定义时,卷名也会改变;必须使用上一条查询的实际名称,不能让测试脚本自动删除开发卷。

环境变量

.env.example 为准。主要变量如下:

变量 默认值 说明
DATABASE_URL 本机 asyncpg URL 非 Compose 运行后端时使用的数据库连接
TEST_DATABASE_URL .../quakefeel_test 仅用于 PostgreSQL 集成测试;名称必须以 _test 结尾且不能等于应用库
TEST_POSTGRES_DB quakefeel_test Make 创建并在测试退出时销毁的专用数据库
POSTGRES_* quakefeel Compose 数据库名称、用户和密码
HOST_BIND_ADDRESS 127.0.0.1 Compose 端口绑定地址;仅在明确需要局域网访问时修改
WOLFX_API_URL Wolfx CENC URL 地震列表上游地址
EARTHQUAKE_SYNC_ENABLED true 是否启动后台同步任务
EARTHQUAKE_SYNC_ON_STARTUP true 服务启动后是否立即执行一次同步
EARTHQUAKE_SYNC_INTERVAL_SECONDS 60 同步间隔秒数
CORS_ORIGINS http://localhost:5173 允许的前端来源;生产环境禁止设为 *
VITE_API_BASE_URL /api/v1 浏览器同源调用的 API 根路径;Compose 前端代理至后端
RELAY_ID relay-local 当前中继的稳定标识
RELAY_SYNC_ENABLED false 是否启用私有 relay endpoint 与显式拉取
RELAY_PEERS_JSON [] 管理员配置的 peer URL、独立双向 token 和 TLS 策略;作为 secret 处理
RELAY_PAGE_SIZE 100 每页报告数,范围 1–500
RELAY_MAX_PAGES_PER_RUN 100 单次显式同步的页面上限
RELAY_REQUEST_TIMEOUT_SECONDS 30 peer HTTP 请求超时
RELAY_MAX_RESPONSE_BYTES 10485760 单个 peer 响应体硬上限
RELAY_PEER_LOCK_TIMEOUT_SECONDS 30 同 peer session advisory lock 等待上限
REPORT_H3_RESOLUTION 7 服务端报告位置 H3 分辨率
REPORT_ANALYSIS_ALGORITHM_VERSION quakefeel-explainable-v1 当前统计使用的评估算法版本
REGION_PRIVACY_MIN_CREDENTIALS 3 公开 H3 分组所需独立凭据下限,不允许低于 3
SSE_HEARTBEAT_SECONDS 15 SSE comment heartbeat 间隔
SSE_RETRY_MILLISECONDS 3000 建议原生 EventSource 断线重试间隔
SSE_SUBSCRIBER_QUEUE_CAPACITY 32 单个在线订阅者的有限通知队列容量
IPFS_ENABLED false 是否允许显式发布命令访问 Kubo
IPFS_API_URL http://ipfs:5001 仅服务端使用的 Kubo API;公共 API 不返回
IPFS_PUBLIC_GATEWAY_BASE_URL http://localhost:8080/ipfs 服务端用于构造只读 CID gateway URL
IPFS_REQUEST_TIMEOUT_SECONDS 30 Kubo add/pin/cat 请求超时
IPFS_SNAPSHOT_DIR /data/ipfs-snapshots 后端本地确定性快照目录;不通过公共 API 返回
ANCHOR_ENABLED false 是否允许显式锚定命令访问配置链
ANCHOR_RPC_URL http://anvil:8545 仅服务端使用的固定 RPC;API 不允许用户覆盖
ANCHOR_CHAIN_ID 31337 预期链 ID,RPC 返回值必须一致
ANCHOR_CONTRACT_ADDRESS 已部署的不可升级 registry 地址
ANCHOR_SIGNER_PRIVATE_KEY 仅服务端 secret;永不进入 API、日志、数据库或前端
ANCHOR_CONFIRMATIONS 1 达到后才允许返回 anchored=true
ANCHOR_SIGNER_LOCK_TIMEOUT_SECONDS 30 同 chain/signer session advisory lock 等待上限
ANCHOR_GAS_PRICE_WEI 可选固定 legacy gas price;留空使用动态 eth_gasPrice,两者都受 ANCHOR_MAX_GAS_PRICE_WEI 上限约束
ANCHOR_EXPLORER_BASE_URL 仅展示/脚本输出用的区块浏览器根地址,不参与签名或承诺
AUTO_BATCH_ENABLED false 是否启用自动批处理 worker;仅 automation profile 进程使用
AUTO_BATCH_* .env.example 聚合窗口阈值、轮询间隔与单轮上限;启用发布/锚定阶段要求对应 IPFS_ENABLED/ANCHOR_ENABLED 已开启
VITE_ANCHOR_* disabled / local Anvil 浏览器可信只读 RPC、chain ID、合约地址、网络显示名与可选 explorer 根地址,不含私钥

不要提交 .env,也不要把数据库密码、HMAC token 或链上私钥写入日志或版本库。

数据流程与地震同步 API

同步器读取 Wolfx CENC 地震列表 根对象中所有 No1No2 等记录,而不假定固定数量,并忽略根级 md5 数据字段。EventID 是首选外部标识;缺失时根据规范化事件字段生成稳定摘要。经纬度以百万分之一度整数保存,原始响应另存 JSONB 便于审计。

新同步事件使用 Event Identity Protocol v1,协议标识为 quakefeel-event-identity-v1。Wolfx/CENC 的固定内部 namespace 是 cenc;服务端先对 external event ID 执行冻结的 NFC、边界空白/控制字符拒绝和仅 ASCII 小写转大写规则,再计算:

identity_input =
  UTF8("quakefeel-event-id-v1\0")
  || UTF8(source_namespace)
  || 0x00
  || UTF8(canonical_external_event_id)

digest = SHA256(identity_input)

取 digest 前 16 bytes,设置 RFC 4122 variant 与 version 8 bits 后格式化为小写 UUID。同步首次插入会显式提供该 UUID,并以 (source_namespace, external_event_id) 作为上游唯一身份;不再依赖 uuid4、数据库已有行数或插入顺序。Python 与 TypeScript 共用 固定向量

缺失 CENC EventID 时,现有 fallback 只使用 origin time、规范化地点、震级、深度和整数 e6 坐标构造紧凑确定性 JSON,再以 SHA-256 得到 external ID;不含当前时间、随机数、数据库 ID、ReportTime、review type、intensity 或插入顺序。

20260718_0010 不重写任何既有事件主键。所有迁移前事件被显式标记为 legacy-random-v0deterministic_identity=falserelay_eligible=false;它们继续支持本地报告、revision、统计、Merkle、IPFS、Anchor 和 proof 查询,但不会被未来 relay export。新 v1 行必须重算得到相同 UUID,应用校验和 PostgreSQL trigger 都会以 EVENT_IDENTITY_INTEGRITY_ERROR 拒绝错配。可执行 make audit-event-identities 查看不含报告内容的 legacy/eligible 计数。

这一设计的原因与不可迁移边界见 ADR 0001:既有 ShakingReport v1 已把随机 UUID 写入签名 payload,替换它会改变 canonical bytes、report hash、签名以及后续证明链,所以禁止自动映射、重签或主键重写。

同步使用有限连接/读取超时与指数退避。测试通过 mock transport/fixture 提供响应,不访问真实网络。上游不可用不会使已经缓存的地震列表不可读。

当前公开接口:

  • GET /health:容器健康状态;
  • GET /api/v1/events:分页地震列表;
  • POST /api/v1/credentials/device:注册或幂等获取匿名设备公开凭据;
  • POST /api/v1/reports:提交 canonical payload、哈希与签名 envelope;
  • GET /api/v1/reports/{report_hash}:只读取 report hash、存在/验签状态、事件、revision 和 proof 入口;
  • GET /api/v1/reports/{report_hash}/proof:读取非敏感批次元数据与 inclusion proof;
  • GET /api/v1/events/{event_id}/batches:读取 sealed 批次及公开发布状态;
  • GET /api/v1/events/{event_id}/batches/{batch_number}:读取单个批次元数据;
  • GET /api/v1/events/{event_id}/batches/{batch_number}/publication:读取 IPFS 发布状态;
  • GET /api/v1/events/{event_id}/batches/{batch_number}/anchor:读取安全的链上锚定状态;
  • GET /api/v1/events/{event_id}/summary:事件原始、可信直方图和评估状态计数;
  • GET /api/v1/events/{event_id}/regions:经过凭据阈值合并的 H3 区域统计。
  • GET /api/v1/events/{event_id}/stream:事件级 SSE 更新通知,不传输统计或报告原文。

具体参数和响应结构以运行时 /docs 为准。IPFS items.jsonl 会公开批次内所有 report hash,因此 hash 不是访问令牌;公共报告接口不会返回 credential、H3 cell、评论、nonce、环境、楼层、物体现象、定位精度、签名载荷、canonical bytes、签名或公开 JWK。用户自己的完整报告只从本机 IndexedDB 保存的 signed envelope 展示。

隐私设计与当前安全边界

  • 所有上游字段均视为不可信输入并在持久化前校验;
  • CORS 仅允许显式配置的来源;
  • API 不直接暴露 raw_payload
  • 数据库异常由统一错误边界处理,不应向客户端返回内部堆栈;
  • 设备私钥由 Web Crypto 创建为不可导出密钥,只保存在 IndexedDB,不使用 localStorage
  • 服务端仅保存规范化公开 JWK,不接收、保存或记录私钥;
  • 每次报告提交都会重新计算 canonical bytes 与 SHA-256,并使用 cryptography 验证 ECDSA;
  • 原报告没有普通更新或删除端点;20260716_0008 的 PostgreSQL trigger 还会拒绝任何 shaking_reports UPDATE/DELETE,修订必须新增记录并链接同一事件、同一凭据的前序哈希;
  • 读取报告语义或将报告纳入 Merkle 前,服务会从 payload_json 按冻结 v1 规则重新 canonicalize,逐字节对照 canonical_payload,重算 hash、重新验签,并绑定数据库 event/credential/revision/previous hash;失败只返回 REPORT_INTEGRITY_ERROR,不展示不一致内容;
  • 公共读取接口不返回签名载荷中的精确坐标或任何可关联的行为字段;
  • 当前统计只使用每个凭据的最高 revision;历史版本仍保存在不可变数据库和 Merkle 集合中,但公共 hash 查询只返回最小验证状态;
  • 任何少于 3 个独立凭据的 H3 分组都会与同一父分支的兄弟分组合并并继续上卷;整个事件仍不足阈值时不公开区域单元。

签名协议与数据流程

  1. 浏览器通过 Web Crypto 生成匿名设备密钥,私钥仅写入 IndexedDB,服务端只注册公钥。
  2. 公开 JWK 仅允许 kty=ECcrv=P-256 和合法的 x/ycredential_id 是固定字段顺序公开 JWK bytes 的 SHA-256。
  3. 客户端与服务端执行同一套 canonicalization:固定字段顺序、UTF-8、Unicode NFC、整数毫秒、整数 e6 经纬度、明确的 null/数组规则,并拒绝未知字段、NaN/Infinity 和不合法 Unicode。
  4. 客户端计算 report_hash=SHA-256(canonical_payload),再以 ECDSA P-256 + SHA-256 签名。签名 envelope 使用固定 64 字节 IEEE P1363 r || s、无填充 base64url 表示。
  5. 服务端查询凭据,重新规范化、重算哈希、常量时间比较、验签、确认事件存在、重算 H3 cell,最后写入报告。
  6. 相同 report_hash 重试是幂等的。修改报告不会覆盖原记录,而是新增 revision,并用 previous_report_hash 链接同一事件和凭据的前一版本。

完整字段、签名字段/非签名字段边界、数组顺序与编码规则见 ShakingReport Schema v1共享测试向量 是 Python 与 TypeScript 的共同兼容性基准;变更 v1 规则必须先增加新 schema version,不能原地改变已签名 bytes。

异常分析与可信统计

报告不会因为震级或震中距被删除。每次读取 summary 或 regions 时,服务会为当前最新 revision 刷新配置版本的 ReportAssessment。评估保存算法版本、权重、状态、原因码、区域中位数以及实验性预计震感区间、中心和不确定性;传入新算法版本会新增一套评估,不修改原始报告。

最终权重由位置、时间、内部一致性、区域一致性、实验性预计震感和可替换信誉因子相乘。定位精度权重依次为 1.0 / 0.85 / 0.6 / 0.35;发震前和长期补报、现象矛盾、非法环境/楼层组合会产生明确原因码。除无法解析的 invalid 数据外,最终权重最低为 0.05,任何单一模型都不能把报告归零或判 invalid。

区域分析使用当前报告 H3 cell 及其邻接 cell 的加权中位数与加权 MAD:少于 5 条不判离群,5–14 条最多轻度降权,15 条以上才允许标记 outlierraw_level_histogram 对最新报告逐份计数,包含低权重和离群报告;trusted_level_histogram 则按持久化权重累加。区域主要统计使用原始或加权中位数,不以算术平均值代替。

预计震感模型是明确标记为实验性的弱先验,使用震级、深度和震中距给出 min/max/center/uncertainty。它不是官方烈度模型,也不能单独否决报告。包括离群报告在内的全部签名原文仍保留,并预留进入后续存证集合。

SSE 实时更新

详情页首次加载始终通过普通 HTTP 请求事件、summaryregions。浏览器同时为当前事件建立原生 EventSource;连接成功或收到 report_received 后,在 400 毫秒窗口内合并通知,并重新请求现有 summary 与 regions 接口。SSE 不维护第二套统计状态,也不传输经纬度、H3 cell、公钥、签名或 canonical payload。

报告通知只在数据库 commit 成功后发布;相同 report_hash 的幂等重试不会再次发布,revision 则按新报告正常通知。每个订阅者拥有独立有限 asyncio.Queue,发布过程只使用 put_nowait;队列满时丢弃最旧通知后写入最新通知,因此慢客户端不会阻塞报告事务、提交响应或其他订阅者。连接关闭或流生成器取消时会在 finally 中移除订阅者。

当前 broker 只存在于单个后端进程内。使用多个 Uvicorn worker 或部署多个后端实例时,一个进程收到的报告不会通知连接在其他进程的浏览器;比赛 MVP 应保持单 worker。跨实例事件传播将在后续多中继阶段设计,本阶段刻意不引入 Redis、Kafka 或 Celery。

开发和 preview 代理对 /api 禁用上游超时,不会主动结束 SSE 长连接。若在现有部署外另加 Nginx,SSE location 至少应配置较长的 proxy_read_timeout,并设置 proxy_buffering off; proxy_cache off;;后端已返回 Cache-Control: no-cache, no-transformX-Accel-Buffering: no

离线签名与持久化队列

浏览器数据库仍使用原有名称 quakefeel,从 schema v1 原位升级为 v2。原 deviceCredentials store 和其中不可导出的 CryptoKey 不会被删除或重建;升级事务只补充凭据注册状态,并新增:

  • pendingReports:以 reportHash 为唯一主键保存不可变 signed envelope、依赖、重试和安全错误摘要;
  • queueLeases:保存带过期时间的多标签页处理 lease;
  • cachedEvents:保存最近访问事件的非敏感基本信息,供离线上报页展示。

精确坐标属于冻结签名 payload,因此会保存在本机 pendingReports 中,但不会出现在队列 UI、日志或 BroadcastChannel。私钥仍只存在 deviceCredentials,不会复制到报告队列、导出或上传备份。

提交顺序固定为:构造 v1 payload → 本地签名 → 原子写入 IndexedDB → 幂等注册 credential → 提交原始 envelope。失败重试不会重新生成时间、nonce、hash 或签名。服务端 created=false 的幂等响应同样视为成功。

队列状态包括 pendinguploadingretry_waitdependency_waitblockedpermanently_failedsubmitted。同一事件与凭据的 revision 按版本升序处理;本地前置报告未成功时,后续版本不会上传。前置版本永久失败会阻断后续版本,且不能通过修改 previous_report_hash 绕过。

网络错误、超时、HTTP 408/425/429 和 5xx 会按约 2 秒、5 秒、15 秒逐步退避,最高约 5 分钟,并尊重 Retry-After。稳定的 payload、签名、事件或 revision 错误会停止自动重试并保留记录供用户处理。应用启动、online、SSE 恢复、手动操作和低频定时器都会触发处理,真实 API 结果而非 navigator.onLine 决定成功与否。

支持 Web Locks 时使用站点级队列锁;不支持时退化到 IndexedDB 过期 lease。每条上传还持久化 owner 和过期时间,崩溃后长期停在 uploading 的记录会恢复为 retry_wait。BroadcastChannel 只广播队列变化类型和可选 report hash,不承载 payload、位置、签名或密钥,IndexedDB 始终是唯一事实来源。

Service Worker 只缓存应用 shell、导航 fallback 和成功取得的同源静态资源。所有 /api/ 请求、POST、credential 注册、报告提交和 SSE 都被排除,不会由 Service Worker 重放。Background Sync 不是必要条件;只要应用重新打开,普通页面逻辑仍会恢复队列。

手工离线验收

场景 A——普通离线提交:

  1. 打开事件详情和上报页,执行 docker compose stop backend
  2. 填写并提交,确认显示“已安全保存在本设备”,且没有声称服务器已收到。
  3. 刷新页面,确认待提交列表仍存在相同 report hash。
  4. 执行 docker compose start backend,等待自动重试;确认记录变为“已提交”、数据库只有一份报告,详情页随后由 SSE 刷新。

场景 B——首次使用即离线:

  1. 清除站点数据并停止 backend。
  2. 首次填写并提交,确认本地生成设备身份、凭据状态待注册且报告已排队。
  3. 启动 backend,确认先出现 credential 注册请求,再出现 report 提交请求,且身份没有被替换。

场景 C——离线 revision:

  1. 离线创建 v1,复制其队列 report hash 到修订输入并创建 v2。
  2. 恢复网络,确认请求顺序为 v1 后 v2。
  3. 请求 summary,确认当前统计只使用 v2,v1 仍可按哈希查询。

场景 D——多标签页:

  1. 同时打开两个同源标签页,离线创建一份 pending report。
  2. 恢复网络,观察只有获得 Web Lock/lease 的标签页处理该报告。
  3. 两个标签页通过队列变化提示读取同一 IndexedDB,最终均显示已提交;数据库仍只有一份。

Merkle 批次与验证

Merkle Batch Protocol v1 与 ShakingReport Schema v1 独立冻结。report_hash 先解码为原始 32 字节,再计算叶子 SHA-256(0x00 || report_hash_bytes);内部节点为 SHA-256(0x01 || left || right)。域分离避免叶子和内部节点的语义混淆。叶子按原始 report hash bytes 字典序升序排列,每层为奇数时复制最后一个节点;单叶批次的 root 就是该叶子的 domain-separated hash,空批次禁止创建。完整规则见 Merkle Batch Protocol v1v1 兼容性勘误。Python 与 TypeScript 共用 正向固定向量负向固定向量

执行以下命令显式创建批次;启用可选 automation profile 后,自动批处理 worker 也会按聚合窗口调用同一 service(默认关闭,见 docs/AUTO_BATCH_WORKER.md):

make merkle-batch EVENT_ID=<uuid>

同一事件的创建事务通过 PostgreSQL transaction advisory lock 串行化。事务先选择该查询快照可见、且尚未进入本协议 sealed batch 的全部报告,再按原始 hash bytes 排序,创建 building batch 和 items。服务按 leaf_index 重新读取已落库 items,完成全批次不变量与每份 canonical proof 自验证后,才原子切换为 sealed。任一步失败都会回滚,不能留下看似有效的 sealed batch;没有新报告时返回 no_new_reports 且不创建空批次。查询之后提交的新报告不会被阻塞,留到下一个单调递增的 batch number。

不可变批次生命周期只有 buildingsealed。成员资格同时要求 status=sealedsealed_at IS NOT NULL,且 PostgreSQL trigger 禁止普通服务逻辑修改 sealed batch、清空 sealed_at 或增删改其 items。0008 同时检查 item UPDATE 的 OLD 与 NEW batch,因此 item 既不能从 sealed 移出,也不能从 building 移入 sealed。IPFS 发布状态属于独立的 MerkleBatchPublication,链上锚定属于 EventAnchor;它们不会改变成员资格,也不会使旧报告重新变成 unbatched。

完整性校验不会重新排序 items 来掩盖篡改:它要求 item 数量等于 leaf_countleaf_index 严格为 0..n-1、item/batch 协议一致、item 的 (report_id, report_hash) 指向同一报告、leaf hash 正确、按 index 读取的 report hash 已是规范 byte 顺序且无重复,并从该顺序复算 root。数据库另以复合外键 (batch_id, protocol_version)(report_id, report_hash) 保证冗余身份字段一致。proof 查询先验证完整批次,失败返回稳定的 MERKLE_BATCH_INTEGRITY_ERROR,不会返回看似正常或 verified=true 的 proof。

Merkle 集合和区域统计视图刻意不同:统计只使用同一 event/credential 的最新 revision;Merkle 批次则包含所有成功保存且签名有效的不可变报告,包括历史 revision、无感、acceptedlow_weightoutlier。异常分析不能删除原始证据,旧 sealed batch 也不会因新报告出现而改变。

GET /api/v1/reports/{report_hash}/proof 在报告未入批次时返回 batched=false;入批次后返回 batch number、协议版本、leaf index/count、leaf hash、root、sealed time 和带左右方向的 proof。服务端响应前会从有序 items 重建树并验证;/verify/:reportHash 页面检查响应 hash 与路由请求严格绑定,再由浏览器独立重算。proof 不在每行永久重复保存,当前比赛规模按需读取该批次全部叶子并建树,时间和临时内存均为 O(n),proof 本身为 O(log n)。报告尚未入批次或发布/锚定尚未到达终态时,验证页会以约 5 秒间隔自动轮询同一 proof API(页面隐藏暂停、失败指数退避、约 5 分钟后停止),到达已启用阶段的终态即停止;手动刷新按钮保留。

v1 勘误区分两层验证:基础 crypto verifier 只验证哈希路径能否得到 root;canonical verifier 还结合 leaf_indexleaf_count 和层级检查 proof 长度与方向。奇数层复制为 X/X 时,单纯交换方向不会改变 SHA-256(0x01 || X || X),但协议规定复制 sibling 的规范方向必须是 right,因此 canonical verifier 会拒绝标记为 left 的等价路径。proof API 与浏览器页面都使用 canonical verifier。

proof API 只有在对应 EventAnchor.status=confirmed 且确认元数据完整时才返回 anchored=true。返回前还会把 anchor 的 batch ID、由 event UUID 复算的 event hash、proof batch number、root、publication digest、leaf count、commitment、chain ID 和 contract address 逐项绑定;不一致返回 EVIDENCE_CHAIN_BINDING_ERROR。浏览器独立使用 proof batch number 计算 anchor key,并要求 API key、RPC 查询目标和合约存储一致。inclusion 只证明 report hash 被纳入 sealed batch;publication.published=true 只证明隐私安全公开快照已上传并由服务端重新下载验证;两者都不等同于链上确认。完整签名报告从不因 IPFS publication 或 anchor 而公开。

Merkle 完整性加固保持 merkle-batch-v1 标识、全部正向 root、leaf hash、sibling hash 和 canonical proof 输出不变,只让验证器更严格。此前方向不规范但因 X/X 而密码学等价的复制 proof 会被 canonical verifier 拒绝。

Alembic 20260715_0005 会把 sealed_at 非空且旧状态为 anchoring_pending/anchored、同时没有实际发布或锚定元数据的行安全归并为 sealed。若发现 failed、未 sealed 的锚定状态、已填写未来发布字段、状态时间不一致或 item 身份不一致,迁移会明确失败并要求人工分类;不会静默删除或重写 items。

Merkle 手工验收

场景 A——首次批次:

  1. 使用 make simulate-reports EVENT_ID=<uuid> 提交多份签名报告并记录一个 report hash。
  2. 请求 /api/v1/reports/<report_hash>/proof,确认 batched=false
  3. 执行 make merkle-batch EVENT_ID=<uuid>,再次请求 proof。
  4. 打开 /verify/<report_hash>,确认服务端与浏览器本地验证均通过,且显示“尚未上链”。

场景 B——不可变批次:

  1. 记录 batch 1 root,再提交一份新报告。
  2. 确认 batch 1 的 proof/root 不变,随后再次运行批次命令。
  3. 确认新报告只进入 batch 2,旧报告不会重复纳入。

场景 C——revision:

  1. 提交同一凭据的 v1 和链接 v1 的 v2,再创建批次。
  2. 分别查询两个 report hash,确认两者都有 inclusion proof。
  3. 查询 summary,确认统计仍只使用 v2,而 Merkle 集合保留完整历史。

场景 D——异常报告:

  1. 从模拟数据找到一个评估为 outlier 的报告并创建批次。
  2. 查询其 proof,确认异常报告仍被纳入且验证通过。

场景 E——篡改 proof:

  1. 在浏览器开发测试或固定向量测试中修改任一 sibling hash、非复制方向或 root,确认 crypto 与 canonical 验证失败。
  2. 把奇数复制层的 sibling 方向从规范 right 改为 left,确认基础 crypto 验证仍可通过,但 canonical 验证失败。

IPFS 批次公开快照

IPFS Batch Publication Protocol v1 与签名协议、Merkle 协议分离冻结,协议标识为 ipfs-batch-publication-v1。发布只接受已经通过完整不变量验证的 sealed batch,绝不改变 batch、items、root 或成员顺序。MerkleBatchPublication 独立保存 pending → publishing → publishedretry_wait/permanently_failed 状态;一次 Kubo 故障不会影响报告提交、统计、SSE、proof API 或 sealed batch。

公开目录固定包含三个文件:

  • batch.json:批次 ID、事件 ID、批次号、叶子数、Merkle root、协议版本和固定格式 sealed time;
  • items.jsonl:严格按 leaf_indexleaf_index/report_hash/leaf_hash 白名单记录;
  • manifest.json:文件摘要、批次绑定和 dataset digest。

batch.jsonmanifest.json 使用 NFC、UTF-8、紧凑 JSON、固定字段顺序且无末尾换行;items.jsonl 每行使用同样的紧凑 JSON、统一 LF,并有且仅有一个末尾 LF。时间固定为 UTC、六位小数秒和 Z。任何执行时间或本地路径都不参与序列化,因此同一 sealed batch 重复生成三个文件会逐字节一致。

dataset digest 使用原始摘要 bytes,而不是 hex 字符串拼接:

SHA256(
  "quakefeel-ipfs-batch-publication-v1\0"
  || SHA256(batch.json)
  || SHA256(items.jsonl)
  || SHA256(manifest_core)
)

manifest_core 是不含 dataset_digest 的确定性 manifest bytes,避免自引用。Python 与 TypeScript 共同读取 固定向量。验证会重新计算两个文件摘要、manifest core、dataset digest、连续 leaf index、canonical report-hash byte 顺序、每个 leaf hash、leaf count 和 Merkle root;不会通过重新排序掩盖篡改。

公开快照严格按字段白名单构造,不序列化 ORM 报告对象。它不包含精确经纬度、H3 cell、payload/canonical payload、signed envelope、签名、公钥/JWK、credential、comment、IP 地址、安否数据、本地路径或 Kubo API 地址。items.jsonl 公开的是既有 report hash 与 leaf hash,而不是完整 signed envelope。

Kubo 导入参数在 v1 协议中冻结为 CID v1、sha2-256、raw leaves、size-262144 fixed chunker、wrap directory、preserve-mode=falsepreserve-mtime=false,add 时不隐式 pin,成功后再显式 pin。发布不能只相信 add 响应:服务端先用 Kubo ls 确认根 CID 是只含 batch.jsonitems.jsonlmanifest.json 三个普通文件的 wrapped directory,拒绝额外、缺失或重复名称、子目录和非文件 entry;之后才从同一根 CID cat 三个路径,执行完整快照验证并要求远端 bytes 与本地快照一致。目录或内容任一不一致都不会标记 published

显式发布命令如下;默认没有定时任务,仅可选 automation profile 的 worker 会自动发布 sealed 批次:

docker compose --profile ipfs up -d ipfs
# 在 .env 中设置 IPFS_ENABLED=true 后:
make publish-ipfs EVENT_ID=<uuid> BATCH_NUMBER=<number>

同一 batch 的执行者以数据库 advisory lock 和 publication 行锁串行化。首次执行会在临时目录生成并验证文件,再原子移动到命名 volume;已存在快照必须重新验证且逐字节匹配。网络失败保留本地快照并进入 retry_wait,再次执行可恢复。已发布批次再次执行不会重新 add,而会重新下载已有 CID 并校验 digest/root;成功时幂等返回,失败时保留既有已发布记录并报告重新验证失败,不会伪造新成功。

验证页分别展示“Merkle proof 验证”和“IPFS 公开快照验证”。浏览器只访问服务端响应中与 CID 绑定的可信 gateway URL,带超时和路由取消;它使用 Web Crypto 独立计算文件摘要、dataset digest 和 Merkle root,并确认当前 report hash 位于 proof 指定 leaf index。gateway 暂时不可用只会使 IPFS 验证失败,不会推翻已经独立通过的 Merkle proof。页面明确提示“批次已发布到内容寻址存储,但尚未写入区块链”。

IPFS 手工验收

场景 A——正常发布:

  1. 创建 sealed batch,查询某份 proof 并确认 publication.published=false
  2. 启动 Kubo profile,设置 IPFS_ENABLED=true,执行 make publish-ipfs EVENT_ID=<uuid> BATCH_NUMBER=1
  3. 再次查询 proof,确认返回 CID 与 dataset digest;打开 /verify/<report_hash>,确认 Merkle proof 和浏览器 IPFS 快照均通过,同时显示尚未上链。

场景 B——Kubo 离线:

  1. 停止 Kubo 后发布一个新批次,确认 batch 仍为 sealed、publication 进入可重试状态而非 published。
  2. 启动 Kubo 并重新执行相同命令,确认从保留的本地快照恢复成功。

场景 C——确定性:

  1. 对同一 sealed batch 在两个空临时目录调用快照生成器。
  2. 分别使用 cmp 比较 batch.jsonitems.jsonlmanifest.json,确认逐字节一致且 dataset digest 相同。

场景 D——隐私:

  1. 从 gateway 下载整个目录,检查每种文件的字段白名单。
  2. 搜索位置、签名、credential、payload、公钥和安否字段,确认不存在;再仅用公开 items 独立重建 root。

场景 E——篡改:

  1. 修改本地 items.jsonl 的任一 byte 后运行快照验证测试/函数。
  2. 确认文件摘要、dataset digest、leaf 或 Merkle root 校验失败,publication 不会标记成功。

Anchor Protocol v1 与本地 Anvil

Anchor Protocol v1 的标识为 quakefeel-anchor-v1,与 ShakingReport、Merkle Batch 和 IPFS Publication 三个冻结协议分离。链上只保存 event_id_hashuint64 batch_numbermerkle_rootdataset_digestuint64 leaf_count;不保存 CID、batch UUID、report hash 列表、签名、credential、位置、H3、评论或 signed envelope。CID 不上链是为了避免把存储寻址编码与永久承诺耦合;固定 32-byte dataset digest 足以将合约承诺绑定到已验证公开快照。

事件 UUID 先规范化为小写、带连字符的 ASCII,再计算:

event_id_hash = KECCAK256("quakefeel-event-id-v1\0" || canonical_uuid_ascii)
anchor_key = KECCAK256(abi.encode(event_id_hash, uint64(batch_number)))
anchor_commitment = KECCAK256(abi.encode(
  KECCAK256(bytes("quakefeel-anchor-v1")),
  event_id_hash,
  uint64(batch_number),
  merkle_root,
  dataset_digest,
  uint64(leaf_count)
))

这里使用 Ethereum Keccak-256 和 Solidity abi.encode,不是 NIST SHA3-256 或 abi.encodePacked。Python、TypeScript 与 Solidity 继续验证既有 Anchor 固定向量,并额外共用 跨阶段 fixture:它直接绑定冻结 IPFS 向量中的真实 event ID、Merkle root、dataset digest 和 leaf count,再推导 event hash、anchor key 与 commitment。既有冻结向量及其密码学输出没有改变。

QuakeFeelAnchorRegistry 是不可升级合约,没有代理、delegatecall、更新、删除或管理员改写历史承诺的路径。部署者是初始 publisher,可以把发布权安全转移到非零地址;publisher 只能新增承诺,不能绕过 AnchorConflict。同一 anchor key 的完全相同调用幂等返回 created=false 且不重复发出 BatchAnchored;不同 root、digest 或 leaf count 会永久 revert。合约拒收 ETH。生产部署应把单一开发 key 替换为硬件钱包或多签。

EventAnchor 使用独立业务状态机;每个 anchor 另有唯一的 AnchorTransactionIntent 可靠发送 outbox:

pending -> submitting -> submitted -> confirmed
                  \-> retry_wait -> submitting
                  \-> permanently_failed

intent: prepared -> broadcasting -> broadcast -> confirmed
                     \-> retry_wait -> broadcasting
                     \-> permanently_failed

每个 batch/protocol/chain/contract 只有一行。0009 outbox 在广播前持久保存 nonce、to/value/calldata hash、gas 字段、完整 signed raw transaction 与由 raw bytes 本地推导的 transaction hash;UNIQUE(chain_id, signer_address, nonce) 防止同一 publisher 重复预留 nonce,confirmed intent 的交易身份由 trigger 保护且不可删除。raw transaction 虽不含私钥,但具备可广播能力,因此是敏感后端数据:公共 API、日志、异常和前端均不返回;本地开发允许保存于受控 PostgreSQL,生产环境必须采用数据库加密、严格访问控制或独立签名交易服务。

相同 chain/signer 的发送路径使用独立数据库连接持有 PostgreSQL session-level advisory lock。锁 key 是 SHA-256("quakefeel-anchor-signer-lock-v1\\0" || chain_id_word || normalized_signer_address) 的前 64 bit,拆为两个 signed int32;它稳定隔离不同 chain/signer,但与任何 64-bit advisory namespace 一样存在极低而非数学为零的碰撞概率。锁从未完成 intent 的 nonce 顺序恢复开始,覆盖链上检查、pending nonce、nonce 预留、确定签名、outbox commit、同一 raw transaction 广播、RPC hash 比对和 submitted 状态 commit;所有异常路径释放,超时返回 ANCHOR_SIGNER_LOCK_TIMEOUT。不同 signer 或不同 chain 不共享该锁。

只有 confirmed 且具备 tx hash、block number/hash、transaction index 与 anchored time 时,API 才返回 anchored=true;PostgreSQL trigger 阻止普通服务修改 confirmed 承诺及确认身份,0008 进一步禁止删除 confirmed anchor。链状态异常必须通过健康/审计错误输出,不能改写原确认承诺。20260715_0007 会显式拒绝自动转换任何旧占位 event_anchors 行,避免把语义不明的数据伪装成 Anchor v1;空表会被安全替换为新结构。

锚定前会重新执行 sealed batch 完整性验证、本地快照验证、当前 CID 的远端重新下载验证,并逐项绑定 root、digest、leaf count、event ID 和 batch number。随后验证 RPC chain ID、合约代码和 protocolVersion(),先读取已有 anchor,再决定是否广播。交易使用 EIP-155 chain ID、pending nonce、gas estimation、配置 gas 上限和 gas-price 上限;确定签名和本地 tx hash 先进入 outbox 并成功 commit,之后才发送数据库中原样保存的 raw bytes。RPC 返回 hash 必须等于本地 hash。

prepared 后广播前崩溃会重播同一 raw transaction;RPC 接受后超时或 submitted commit 失败,会以本地 hash 查询 transaction/receipt,必要时仍只重播同一 raw bytes;receipt 后 confirmed commit 失败会重新验证后恢复。低 nonce intent 未确认前不会给后续 batch 分配更高 nonce。链上已存在相同承诺时,服务按 anchor key 查询最早的 BatchAnchored 历史事件,核对 emitter/topics/data、原始 transaction/receipt/block 与 storage;后续 created=false 且无事件的幂等交易不会被伪装成原始锚定交易,找不到原事件返回 ANCHOR_ORIGIN_EVENT_NOT_FOUND

确认前统一绑定四层身份:raw transaction 与本地 hash/calldata/nonce/signer/to/value/gas,RPC transaction 的 sender/target/input/block,receipt 的 status/hash/block/非合约创建属性,registry 发出的唯一 BatchAnchored 事件,以及 getAnchor storage 的全部字段和 anchoredAt。任何错位返回 ANCHOR_TRANSACTION_IDENTITY_MISMATCH,不会设置 confirmed。锚定失败从不修改 sealed batch 或 publication。

本地操作:

# 从本地 Anvil 输出选取一个仅供开发的默认 key,写入未跟踪的 .env。
make anvil-up
make anchor-deploy ANCHOR_SIGNER_PRIVATE_KEY=<local-anvil-key>

# 将部署输出地址同时写入 ANCHOR_CONTRACT_ADDRESS 和
# VITE_ANCHOR_CONTRACT_ADDRESS,并启用后端/浏览器开关后重启相应服务。
make anchor-batch EVENT_ID=<uuid> BATCH_NUMBER=1

Anvil RPC 只绑定宿主 127.0.0.1,backend 通过 Compose 内网访问;Anvil 不是报告提交、统计、SSE 或 IPFS 的依赖。默认 Anvil state 是临时的,重启丢失状态时应重新部署并更新地址。绝不能把 Anvil 默认密钥用于任何公共网络。

锚定链路本身链无关,完全由 ANCHOR_CHAIN_IDANCHOR_RPC_URLANCHOR_CONTRACT_ADDRESS 驱动。同一合约与协议可部署到 Injective EVM Testnet(chain ID 1439):make anchor-deploy-injective-testnet 部署、make anchor-verify-injective-testnet 做 Blockscout 源码验证、make anchor-smoke-injective-testnet 为显式 opt-in 公共 smoke;默认 release gate 不访问公共测试网,操作细节与密钥安全见 docs/INJECTIVE_EVM_TESTNET.md

验证页把 Merkle、IPFS 和 chain 三段独立展示。浏览器从受信任的 Vite 构建配置取得只读 RPC、chain ID 和合约地址,不接受用户输入 URL,不请求钱包账户,也不保存 signer。它独立重算 event ID hash、anchor key 和 commitment,读取 protocolVersion/getAnchor 并对照 proof API 与 IPFS 快照。RPC 不可用只会令链上段显示暂不可用,不会推翻已通过的 Merkle/IPFS 验证。网络显示名由 VITE_ANCHOR_NETWORK 驱动,配置 VITE_ANCHOR_EXPLORER_BASE_URL 后合约/交易/区块会链接到对应区块浏览器;Anvil 模式不配置 explorer,因此不显示链接,且仅显示为本地开发链,不会描述为公共主网。

Anchor 手工验收

场景 A——正常锚定:先创建 sealed batch 并发布 IPFS,启动 Anvil、部署合约、设置 Anchor 环境变量,再运行 make anchor-batch EVENT_ID=<uuid> BATCH_NUMBER=1。proof API 和验证页应分别显示 Merkle、IPFS、链上读取通过,并明确它是本地 Anvil。

场景 B——幂等:记录 tx hash 和 BatchAnchored 日志数,再次运行同一命令;tx/承诺不变且没有第二个事件。

场景 C——恢复:在广播后模拟数据库未确认,再次运行;服务从已有链上承诺和事件恢复为 confirmed,不发送冲突交易。

场景 D——冲突:对同一 event/batch 用不同 root、digest 或 count 调用合约,确认 AnchorConflict,原承诺不变。

场景 E——服务不可用:停止 Anvil 后执行命令,确认 batch/publication 不变且 anchor 为可重试失败;恢复 Anvil 后再次执行成功。

场景 F——隐私:检查合约 storage、事件和 API,确认没有 report hash 列表、签名、credential、位置、完整 payload 或 CID 字符串。

Authenticated Relay Sync Protocol v1

Relay Sync Protocol v1 是管理员配置、server-to-server、pull-only 的私有同步协议。默认 RELAY_SYNC_ENABLED=false,不支持 peer discovery、gossip、远程 push 或后台定时任务。受保护端点为:

  • GET /api/v1/relay-sync/v1/changes
  • GET /api/v1/relay-sync/v1/reports/{report_hash}
  • GET /api/v1/relay-sync/v1/status

调用方同时发送自己的 relay ID 和每 peer 独立 bearer token;比较使用常量时间函数。URL、token、TLS 策略只来自服务端静态配置,API 参数不能指定转发目标。生产配置强制 HTTPS 与证书验证;HTTP 仅允许 loopback 或 Compose 单标签私有主机。成功响应使用 Cache-Control: no-store,未认证响应不会返回 report hash 或 envelope。

changes 按 (received_at ASC, report_hash ASC) 排序。cursor 是固定字段顺序的紧凑 JSON,经无 padding base64url 编码,同时包含 UTC 微秒时间和 report hash tie-breaker。每个 peer 独立持久化 cursor;网络/页面失败不推进,imported、exact duplicate 或已安全保存的 pending/permanent item 才允许推进。同 peer 显式任务由 PostgreSQL session advisory lock 串行。

导出端逐份调用 validate_stored_report_integrity()validate_event_identity(),只允许 relay_eligible=true 的 Event Identity v1 事件。Legacy random event 返回 RELAY_EVENT_NOT_ELIGIBLE。transport 使用白名单构造,只含事件身份、公开 JWK 和完整 signed envelope,不含 H3、assessment、summary、IPFS、anchor、IP、User-Agent 或日志。

接收端不信任来源结论:重新规范化事件 external ID、计算 UUIDv8、验证本地事件;重新计算 P-256 credential ID;使用冻结的 ShakingReport v1 canonicalizer 重算 hash 并验证 P1363 签名;最后调用正常本地 report submission service,由本地重算 H3、保存本地 received_at、运行 assessment 并通知 SSE。CLI 导入与 Web 进程之间只通过 PostgreSQL LISTEN/NOTIFY 传递现有非敏感 SSE 四字段,不传 payload。

乱序 revision 原样保存在访问受控的 relay_pending_imports.envelope_json,状态为 dependency_wait,并通过单报告端点尝试恢复 predecessor。成功后 pending envelope 删除;永久冲突保留供管理员检查,不会覆盖本地数据。该列可能包含精确坐标、评论和签名,必须按敏感数据库内容限制访问、备份和保留周期。

report_hash 是全局幂等键。完全一致的本地 envelope 只新增 (report, peer) provenance;不一致返回 RELAY_REPORT_HASH_CONFLICT。双向拉取不会因 provenance 生成新报告,因此循环最终由唯一约束、duplicate 处理和 cursor 推进终止。每个 relay 仍独立生成 assessment、Merkle batch、CID、dataset digest 和 anchor;这些状态绝不从 peer 复制。

显式运行:

make relay-status
make relay-sync PEER=relay-b
make test-relay-postgres

peer 会接收包含精确位置的完整 signed envelope,因此它是受信任的数据处理方。多 relay 会扩大敏感位置数据的信任边界;管理员只能配置受控、可信且具备 TLS 的节点。公共用户不得枚举或配置 relay,也不应获得 peer 列表或 token。

Safety Confirmation v1

Safety 是与震感报告和公开证明链隔离的高敏感数据域。状态只有 safeneed_assistance,不收集位置、H3、IP 推断、自由文本、联系人或医疗信息;过期、撤销、 删除和不存在统一表示无法获得当前状态。need_assistance 不等于已报警,本系统不会联系 紧急服务、派遣救援,也不能替代当地电话或报警渠道。

0012 将空的旧 safety_reports 隔离为 legacy_safety_reports_v0;旧表只要非空,迁移就以 LEGACY_SAFETY_DATA_REQUIRES_MANUAL_REVIEW 原子失败且不读取正文。新表保存不可变签名 revision/revocation/owner command、只增 chain head/generation、只保存 HMAC 的 capability 与 recovery lookup,以及最小 tombstone。普通应用不能更新或删除签名历史;受控清理由 NOLOGIN 维护角色拥有的 security-definer 函数执行。

Owner API 严格调用冻结的 Safety Python 协议实现重新 canonicalize、hash 和 P-256/P1363 验签。sequence 必须逐一递增,previous hash 指向 head;status hash 重试幂等,同 sequence 不同内容冲突。首次 chain 生成 32-byte recovery secret,仅响应一次;服务器只保存独立 HMAC。签名撤销和 recovery revoke 都会原子撤销 capability、关闭公开读取并增加 capability_generation,但 recovery secret 不能伪造任何状态。

所有以 current head 作为授权依据的读取都会调用统一的存储完整性验证:严格重解析数据库 canonical bytes,重算 status hash,使用 chain credential 重新验 P-256/P1363 签名,并逐项比对 冗余列、事件身份、有效期和 tombstone key。高权限数据库篡改因此会以 SAFETY_INTEGRITY_ERROR 失败;公开 exchange 仍统一收敛为 CAPABILITY_UNAVAILABLE,不会形成 完整性 oracle。协议时间先以整数毫秒比较,只有通过窗口检查的值才进行有界 datetime 转换。

Capability token 是 32-byte CSPRNG、无 padding base64url bearer,只返回一次且不保存明文。 同一 create command 响应丢失后返回 CAPABILITY_TOKEN_ALREADY_ISSUED,不会悄悄签发第二条。 收件人可转发 bearer 链接,因此只应分享给可信对象。状态默认 24 小时、最长 72 小时; capability 默认 12 小时、最长 24 小时且不超过状态;新状态窗口为发震后 7 天。

分享 viewer 是 safety-viewer/ 的独立构建与 origin,不加载主 PWA、不注册 Service Worker、 不使用 cookie/Web Storage/IndexedDB/Cache/BroadcastChannel、不加载第三方资源。最早 inline bootstrap 严格解析 fragment,先 replaceState 清除 token,再以 credentials: omit 的同源 POST 兑换。独立 ASGI 只提供 exchange/health,无 OpenAPI/CORS/普通 access log;Nginx 同样 关闭 access log 并设置 no-store、no-referrer、noindex、CSP 和 Permissions-Policy。 bootstrap 同时覆盖初始 fragment 与同文档 hashchange,解码 32 bytes 后重新编码并要求逐字节 规范一致;同一 token 每个页面生命期最多兑换一次,失效 fragment 也会立即清除。启用 Safety 时,PUBLIC_APP_ORIGINSAFETY_VIEWER_ORIGIN 会按 scheme/host/effective-port 规范化比较, 相同 origin 或非 localhost HTTP viewer 会在进程启动时失败,production viewer 强制 HTTPS。

本地进程实现 global、来源、token-HMAC-prefix、credential/安全伪名及 recovery 分层限速, 所有桶并发安全、有界并自动过期;超限在数据库访问前拒绝,原始 IP/token/event/credential 不会成为日志 key。它只用于受控单进程环境:任何公共、多进程部署都必须在独立 Safety origin 的可信代理层增加共享、隐私安全的分布式限速,本轮不批准公共互联网部署。

本地启用需要配置三个互相独立的 32-byte key 与 key ID,然后:

make dev-safety
make test-safety-postgres
make test-safety-viewer
make test-safety-browser
make test-safety-canary
make safety-cleanup

capability/recovery retiring key 必须保留最大 TTL 加 clock skew。cleanup 使用 advisory lock、 小批事务和 tombstone;lookup hash 在撤销/使用/到期后最多再保留 24 小时,正文在 event window 关闭后最多再保留 7 天,备份删除延迟最多 30 天。部署与轮换见 SAFETY_RUNTIME_OPERATIONS.mdSAFETY_PRIVACY_DEPLOYMENT_MATRIX.md。 生产数据库必须另外应用 safety_roles.sql,其角色边界见 SAFETY_DATABASE_ROLES.md;Compose 超级用户仅供本地 开发,不能作为生产最小权限验收。

Safety 不进入 Relay Sync、region summary、SSE、Merkle、IPFS 或 Anchor;owner 提交也不进入 现有 IndexedDB 离线报告队列。

可选服务

Kubo、Anvil 和第二套隔离 relay 拓扑通过 Compose profile 隔离,默认 make dev 不下载或启动它们:

make dev-ipfs
make dev-chain
make dev-federation

Kubo API 只存在于 Compose 内网,不映射宿主端口;只读 gateway 默认仅绑定 127.0.0.1。Kubo repo/pins 和后端快照分别使用命名 volume,backend 不依赖 Kubo healthcheck,IPFS_ENABLED=false 时核心服务照常启动。Anvil RPC 同样默认只绑定 127.0.0.1,backend 不依赖其 healthcheck,ANCHOR_ENABLED=false 时不会广播交易。第二套 relay 使用独立 PostgreSQL volume,且只有管理员提供双方静态 peer JSON 并启用开关后才允许同步。

自动批处理 worker 属于独立的 automation profile(docker compose --profile automation up -d auto-batch-worker),复用 backend 镜像、默认不启动、不进入 release gate;报告提交 API 从不等待 Merkle、IPFS 或链上 receipt,worker 故障也不影响核心上报,见 docs/AUTO_BATCH_WORKER.md

比赛演示(签名报告、可信区域统计与 Merkle proof)

  1. cp .env.example .env && make dev 启动三项核心服务。
  2. make migrate && make seed 准备可重复生成的演示数据。
  3. 保持 make dev 运行,在另一个终端执行 make simulate-reports。模拟器默认注册 92 个确定性测试凭据,通过正式 API 提交 92 份初始报告和 2 份 revision。
  4. 可使用 make simulate-reports EVENT_ID=<uuid> SEED=42 指定事件和随机种子;重复执行同一组合会按凭据和报告哈希幂等处理。
  5. 请求 /api/v1/events/<event_id>/summary,展示 raw histogram 包含异常报告,而 trusted histogram 按评估权重累加。
  6. 请求 /api/v1/events/<event_id>/regions,展示 H3 加权中位数、confidence 和稀疏单元父级合并。
  7. 使用前端手动提交得到的 report_hash 调用 GET /api/v1/reports/{report_hash},确认只返回最小验证状态;完整 signed envelope 仅从本机 IndexedDB 查看。
  8. 先查询对应 /proof 确认尚未入批次,再执行 make merkle-batch EVENT_ID=<uuid>;打开 /verify/<report_hash> 展示服务端与浏览器独立验证。
  9. 启动 Kubo、启用 IPFS 并执行 make publish-ipfs EVENT_ID=<uuid> BATCH_NUMBER=1;刷新验证页,展示 CID、dataset digest 和浏览器独立快照验证,同时强调尚未上链。
  10. 启动 Anvil、部署 registry 并显式锚定 batch;刷新验证页,分别展示本地 Merkle、IPFS 内容和本地 Anvil 合约读取通过。
  11. 也可在 .env 启用 AUTO_BATCH_ENABLED=true 并加 --profile automation 启动,第 8–10 步将按聚合窗口自动完成,验证页无需手动刷新即可显示完整证明。
  12. 执行 make test && make lint && make forge-test,展示冻结跨语言向量、真实签名模拟器与不可升级合约测试。

已知限制

匿名设备身份只证明同一不可导出私钥对报告签名,不证明自然人身份,也不阻止同一人使用多个浏览器身份。清除站点 IndexedDB 会永久失去该设备私钥;当前没有密钥恢复、撤销 UI 或 WebAuthn。定位仍依赖浏览器与用户授权,H3 重算不等于位置真实性证明。

异常权重是比赛原型的可解释启发式结果,不是地震学结论;预计震感公式尤其不能替代仪器观测。当前 GET 聚合端点会同步刷新当前算法版本的评估,适合原型规模,但大量报告下需要转为任务队列和增量物化统计。

当前版本依赖第三方地震列表,第三方可用性和数据正确性不由本项目保证。IPFS pin 本身不是可信时间戳;本地 Anvil 也不是公共、抗审查或长期不可变的网络,默认 1 个确认只适合开发。未来公共部署需要 secret manager、数据库加密或独立交易签名服务、runtime bytecode hash 绑定、受控 RPC、更高确认数、周期性重验、reorg 处理,以及硬件钱包或多签 signer。批次 proof 与公开快照验证当前均需按需 O(n) 重建树,超大批次未来需要紧凑树节点或分层缓存。尚未实现安否确认或公共链部署。

普通浏览器提交仍使用进程内 SSE broker,不能跨 worker 或跨实例传播;Relay CLI 导入额外使用同 PostgreSQL 内的 LISTEN/NOTIFY 到达 Web 进程。通知不持久化,服务器重启期间仍可能遗漏;EventSource 重连后会重取权威 HTTP 状态。

Relay Sync v1 没有后台调度、公共发现、peer 自动事件创建或 pending envelope 自动清理任务。永久失败 envelope 需要管理员审查并按最短必要期限清理。它只适合管理员控制的可信本地/私有拓扑,不是 Byzantine 共识,也不能阻止一个已获授权 peer 读取用户签名 payload 中的精确位置。Safety 数据明确不进入 Relay。

浏览器可能清理长期未使用站点的 IndexedDB;本阶段没有密钥导出或云端备份,站点数据被清除后无法恢复旧私钥和未提交报告。navigator.onLine 不可靠,因此队列以真实请求结果为准。Web Locks 不可用时的 IndexedDB lease 能降低并发重复,但标签页冻结超过 lease 时间仍可能产生重复 HTTP 请求;服务端的 report hash 幂等约束是最后保障。

后续计划

Safety Confirmation v1 最小运行时完成后必须先进行独立隐私、后端和部署安全审查。任何公共网络部署仍必须先增加 secret manager、peer mTLS/密钥轮换、数据库敏感列加密与保留策略、流量配额和独立安全审计。

v1.0.0-rc1 发布工程与门禁

v1 已进入功能冻结的发布工程阶段,只做可追溯绑定、隔离验收、发布基线与门禁、故障安全清理与文档,不新增业务功能或协议。冻结基线 commit 为 05536f429a03607870aecb71e108b3fc8dbd3dad,唯一 Alembic head 为 20260718_0012。规则见 V1_FEATURE_FREEZE.mdV1_RELEASE_PROCESS.md,工具固定见 TOOLCHAIN_VERSIONS.md

v1 核心功能范围与部署边界

  • 核心范围:Event Identity v1、ShakingReport v1、可信聚合/地图/SSE/离线报告、Merkle Batch v1、IPFS Batch Publication v1、Anchor v1、Authenticated Relay Sync v1、Safety Status/Capability/Confirmation v1。
  • 部署边界:v1.0.0-rc1 只批准受控本地 / 私有部署公共互联网部署尚未批准,也不在本轮范围内。
  • Safety 不联系紧急服务need_assistance 不等于已报警,系统不会派遣救援,不能替代当地报警渠道。
  • 演示 vs 生产make dev 系列使用开发数据库、开发卷、127.0.0.1 绑定、临时 Anvil key,仅供本地演示(见 DEMO_RUNBOOK.md);任何生产化都必须另加 secret manager、mTLS/密钥轮换、数据库敏感列加密与保留策略、独立签名服务、更高确认数与独立安全审计。演示 fixture 只写入开发库,绝不写入生产库。

镜像与 Git commit 的绑定

四个发布镜像(quakefeel-backendquakefeel-frontendquakefeel-safety-backendquakefeel-safety-viewer)通过 build args 注入 OCI 元数据,Dockerfile 不在构建时执行 git,也不复制 Git 凭据或工作区状态:

  • org.opencontainers.image.revision = git rev-parse HEAD
  • org.opencontainers.image.version = 显式 RELEASE_VERSION
  • org.opencontainers.image.created / .source / .title
make build-release-images RELEASE_VERSION=v1.0.0-rc1
make verify-image-revisions RELEASE_VERSION=v1.0.0-rc1

verify-image-revisions 重新读取 label 并核对当前 HEAD:镜像缺失、含 unknown、四镜像 revision/version 不一致或与 HEAD 不符都会失败。

隔离验收与发布总门禁

make acceptance-v1 RELEASE_VERSION=v1.0.0-rc1
make release-gate-v1 EXPECTED_HEAD=$(git rev-parse HEAD) RELEASE_VERSION=v1.0.0-rc1
  • acceptance-v1 使用 docker-compose.acceptance.yml,以独立 project quakefeel-acceptance、独立 acc_* 卷、独立网络、仅 127.0.0.1 高端口与临时密钥启动全新九服务栈;绝不连接或修改开发数据库、开发卷或已运行服务。失败/中断均通过 trap 清理,KEEP_ACCEPTANCE_ENV=1 可保留诊断日志,make acceptance-v1-clean 只删除本 project 资源。
  • 验收报告位置build/acceptance-v1/report.json(机器可读)与 build/acceptance-v1/summary.md(人读摘要,从 report.json 派生)。build/ 已加入 .gitignore,不提交运行结果。
  • release-gate-v1 依次运行发布基线、镜像构建与 revision 核对、Compose config、后端/前端测试与 lint、Safety/Relay PostgreSQL、legacy 迁移、Safety viewer/browser、隐私 canary、Forge fmt/build/test、acceptance-v1git diff --check,任一步失败立即非零退出。
  • 门禁从不自动打 tag、push 镜像或部署公共网络;v1.0.0-rc1 / v1.0.0 的打标与部署是复审通过后由人工执行的独立步骤。

故障恢复

部分失败下的重试与不变量(无重复报告、无第二笔 anchor、无复活 capability、无敏感日志、可恢复)见 FAILURE_RECOVERY_RUNBOOK.md

License

比赛原型暂未指定开源许可证;在复用或分发前请先确认项目许可。