omkarmagadumdev/UPI_Without_Internet

GitHub: omkarmagadumdev/UPI_Without_Internet

基于 Node.js 的离线 UPI Mesh 支付模拟系统,演示加密支付指令如何通过不可信的蓝牙 Mesh 中间节点安全路由至后端,并实现幂等结算与抗重放防护的完整流程。

Stars: 1 | Forks: 0

# UPI 离线 Mesh 网络 — 演示项目 一个基于 Node.js + Express 的后端项目,演示了**通过蓝牙风格的 Mesh 网络进行离线 UPI 支付**。假设你在完全没有网络连接的地下室,你要给朋友转 ₹500。你的手机会对支付指令进行加密,将其广播到附近的手机,该数据包会在设备之间不断跳跃,直到*某部*手机走到室外连上 4G 网络,并默默将其上传到本后端。后端负责解密、去重和结算。 本代码仓库是该系统的**服务端**,并附带了一个软件模拟的 Mesh 网络,让你可以在一台笔记本电脑上演示整个流程,而无需任何真实的蓝牙硬件。 ## 🚀 在线演示 立即体验应用: **[https://upi-without-internet-coig.onrender.com/](https://upi-without-internet-coig.onrender.com/)** (由于使用的是 Render 的免费套餐,应用在空闲一段时间后可能会休眠——打开链接后,几秒钟内它就会唤醒。) ## 目录 1. [本演示验证了什么](#what-this-demo-proves) 2. [如何运行](#how-to-run-it) 3. [演示流程(逐步解析)](#the-demo-flow-step-by-step) 4. [系统架构](#architecture) 5. [三个棘手难题及其解决方案](#the-three-hard-problems-and-how-theyre-solved) 6. [逐文件代码解析](#file-by-file-walkthrough) 7. [API 参考](#api-reference) 8. [测试](#tests) 9. [哪些是非真实的(以及投入生产时需要改变的地方)](#whats-not-real-and-what-would-change-for-production) 10. [该概念的客观局限性](#honest-limitations-of-the-concept) ## 本演示验证了什么 该系统端到端地展示了以下三件事: 1. **支付指令可以通过不受信任的中间节点从发送方传输到后端**,且任何中间节点都无法读取或篡改它。(采用 RSA + AES-GCM 混合加密。) 2. **即使同一个支付指令同时通过多个网桥节点到达后端,它也只会精确结算一次。**(通过对密文哈希执行原子性的比较并设置来实现幂等性。) 3. **被篡改或重放的请求包会在触及账本之前被拒绝。** 你可以在仪表板中看到这三点的实际运行情况。 ## 如何运行 ### 前置条件 - 安装 **Node.js 18+** 和 npm。 - 仅此而已。SQLite 是通过 `better-sqlite3` 内嵌的。 ### 在 Windows / Mac / Linux 上运行 在项目文件夹中打开终端并运行: ``` npm install npm start ``` ### 打开控制面板 当你看到 `UPI Mesh Node demo listening on 3000` 时,打开: **http://localhost:3000** 你将看到一个深色主题的仪表板,其中包含了驱动演示所需的所有功能。 ### 停止服务器 在终端中按下 `Ctrl+C`。 ### 运行测试 ``` npm test ``` 主要的测试套件是 `test/crypto.test.js`——它验证了加密/解密、防篡改拒绝、精确一次结算、Mesh 网络的 gossip/flush 行为、指标以及错误响应。 ## 在 Render 上部署 本项目已配置好使用 Docker 在 Render 上进行部署。应用使用 SQLite,并将持久化磁盘挂载在 `/var/data`。 **已经部署了?**你的在线实例在这里: **[https://upi-without-internet-coig.onrender.com/](https://upi-without-internet-coig.onrender.com/)** **部署你自己的副本:** 1. 将此代码仓库推送到 GitHub。 2. 从该仓库创建一个新的 Render Web Service。 3. 使用项目包含的 `render.yaml`,以便 Render 读取 Dockerfile、磁盘和环境变量。 4. 创建服务后,每当你推送到 main 分支时,Render 都会自动部署。 必需的运行时变量(已在 `render.yaml` 中设置): - `DB_FILE=/var/data/upi_demo.db` - `IDEMPOTENCY_TTL_SECONDS=86400` - `PACKET_FRESHNESS_SECONDS=86400` 可选:你可以稍后从 Render 的环境设置中调整 TTL 值,而无需更改代码。 ## 演示流程(逐步解析) 仪表板上有四个按钮,引导你走完整个流水线。预期的操作顺序如下: ### 步骤 1 — 构造一笔支付 选择发送方、接收方、金额和 PIN。点击 **"Send"**。 **后端实际发生的事情:** - 服务器模拟发送方的手机。 - 它使用唯一的 nonce 和当前时间戳构建一个 `PaymentInstruction`。 - 使用服务器的 RSA 公钥对其进行加密(使用混合加密——见下文)。 - 将密文封装在一个 TTL 为 5 的 `MeshPacket` 中。 - 将该数据包交给 `phone-alice`,这是一个离线的虚拟设备。 你会看到 `phone-alice` 现在持有 1 个数据包。 ### 步骤 2 — 运行 Gossip 轮次 点击 **"Run Gossip Round"**。然后再点击一次。 在每一轮中,每个持有数据包的设备都会将其广播给处于其“蓝牙范围内”的所有其他设备(在我们的模拟器中,这意味着所有人)。TTL 会随着每次跳跃递减。 1 轮之后:每个设备都持有了该数据包。2 轮之后:仍然是每个设备都有——只是 TTL 变低了。 在真实系统中,随着人们在地下室里擦肩而过,这会自然而然地发生。 ### 步骤 3 — 网桥节点走到室外 点击 **"Flush Bridge"**。 `phone-bridge` 是唯一一个 `hasInternet=true` 的设备。仪表板模拟了这部手机走到室外并获得 4G 网络的过程。它将其持有的所有数据包 POST 到 `/api/bridge/ingest`。 后端流水线开始运行: 1. 对密文进行哈希处理(`SHA-256`)。 2. 尝试在幂等性缓存中声明该哈希。 3. 如果声明成功:使用服务器的 RSA 私钥进行解密。 4. 验证新鲜度(signedAt 在 24 小时内)。 5. 在单个 DB 事务中执行借记/贷记操作。 观察 **Account Balances** 表——资金已经发生了转移。观察 **Transaction Ledger**——出现了一行新记录。 ### 步骤 4 — 演示幂等性(精确一次结算) 重置 Mesh 网络。注入一个数据包。运行 2 次 gossip。现在**所有 5 个设备都持有同一个数据包;在更复杂的设置中,这会包括多个网桥**。 要真正看到幂等性的实际效果,你可以修改 `src/services/meshService.js` 来植入多个网桥设备,或者只需: 1. 点击 "Inject" 一次。 2. 点击 "Gossip" 两次。 3. 点击 "Flush Bridge"——在默认的种子数据中只有 `phone-bridge` 是一个网桥,因此只会发生一次上传。 要快速测试重复数据的情况,请运行: ``` npm test ``` 此测试路径创建一个数据包,通过多次网桥尝试进行传递,并验证只有一笔结算成功,而重复数据则被丢弃。 ## 架构 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SENDER PHONE (offline) │ │ PaymentInstruction { sender, receiver, amount, pinHash, nonce, time } │ │ │ │ │ ▼ encrypt with server's RSA public key │ │ MeshPacket { packetId, ttl, createdAt, ciphertext } │ └──────────────────────────────────────┬──────────────────────────────────┘ │ Bluetooth gossip ▼ ┌─────────┐ hop ┌─────────┐ hop ┌─────────┐ │stranger1│ ─────▶ │stranger2│ ─────▶ │ bridge │ ◀── walks outside └─────────┘ └─────────┘ └────┬────┘ gets 4G │ ▼ HTTPS POST ┌─────────────────────────────────────────────────────────────────────────┐ │ NODE + EXPRESS BACKEND (this project) │ │ │ │ /api/bridge/ingest │ │ │ │ │ ▼ │ │ [1] hash ciphertext (SHA-256) │ │ │ │ │ ▼ │ │ [2] idempotency claim in DB ◀── first claimer wins, │ │ │ SETNX). Duplicates rejected │ │ │ here, before any work. │ │ ▼ │ │ [3] decrypt(ciphertext) │ │ │ (RSA-OAEP unwraps AES key, AES-GCM decrypts payload │ │ │ AND verifies the auth tag — tampering = exception) │ │ ▼ │ │ [4] Freshness check: signedAt within last 24h │ │ │ │ │ ▼ │ │ [5] transactional settle │ │ debit sender, credit receiver, write ledger │ └─────────────────────────────────────────────────────────────────────────┘ ``` ## 三个棘手难题及其解决方案 ### 难题 1:不受信任的中间人 一个陌生人的手机正在传输你的交易数据。你如何阻止他们读取金额或进行篡改? **解决方案:混合加密 (RSA-OAEP + AES-GCM)。** 发送方使用服务器的公钥对 payload 进行加密。只有服务器持有私钥,因此中间人看到的只是不透明的密文。 但是 RSA 只能加密少量数据(对于 2048 位密钥大约只有 245 字节),而我们的 JSON payload 可能会超过这个限制。因此我们采用了标准的混合加密模式: 1. 为*此数据包*生成一个全新的 AES-256 密钥。 2. 使用 **AES-256-GCM** 对 JSON 进行加密(速度快且带身份验证)。 3. 使用 **RSA-OAEP** 仅对 AES 密钥进行加密。 4. 拼接结果:`[256 字节 RSA 加密的 AES 密钥][12 字节 IV][AES 密文 + 16 字节 GCM 标签]`。 **为什么特别指定使用 GCM?** 因为它是认证加密。如果中间人在密文的任何地方篡改了一个比特,解密过程就会抛出异常——GCM 标签将无法通过验证。服务器不可能被欺骗去处理被篡改的数据。 这与 TLS 使用的方案相同。详情请参阅 `src/services/cryptoService.js`。 ### 难题 2:重复数据风暴 三个网桥节点持有相同的数据包。它们在完全相同的时刻走到室外。它们在几毫秒内都向 `/api/bridge/ingest` 发起了 POST 请求。如果你简单地处理了所有三个请求,发送方将被扣除 ₹1500 而不是 ₹500。 **解决方案:对密文哈希执行原子性的比较并设置。** 服务器收到数据包后做的第一件事就是计算 `SHA-256(ciphertext)`,并尝试“声明”该哈希: ``` // idempotencyRepository.claim(packetHash) INSERT INTO idempotency(packetHash, claimedAt) VALUES (?, ?) // success = first claimer, conflict = duplicate ``` `idempotency.packetHash` 上的唯一键在数据库层面是原子性的。即使许多请求同时到达,也只有一个插入会成功;其余的将被视为重复数据,并以 `DUPLICATE_DROPPED` 的形式被短路处理。 **为什么对密文进行哈希,而不是对 packetId 或明文进行哈希?** - `packetId` 可能会被恶意的中间人重写。同一笔支付的两份副本可能具有不同的 packetId。这不是一个好的键。 - 明文需要先进行解密。我们希望在花费 CPU 资源进行 RSA 解密之前进行去重。 - 密文由 GCM 进行了身份验证,因此任何篡改在解密时都是可检测的。同一笔支付的两份合法传递具有逐字节完全相同的密文(对于给定的 key+IV+plaintext,AES 是确定性的,而相同的数据包意味着相同的 key+IV+plaintext)。 在生产环境中,这种由数据库支持的幂等性声明通常会被转移到 Redis:`SET key NX EX 86400`。 这里还有一个纵深防御的兜底机制:`transactions.packetHash` 具有唯一索引。如果两个结算操作由于某种原因试图写入相同的哈希值,数据库将拒绝第二个操作。 ### 难题 3:重放攻击 捕获了数周前密文的攻击者可能会在任何方便的时候重放它。 **解决方案:双层防护。** 1. **在加密的 payload 内部**,发送方包含了 `signedAt`(Unix 纪元毫秒数)。服务器会拒绝任何超过 24 小时的数据包。攻击者无法在不破坏 GCM 标签的情况下更改 `signedAt`。 2. **在加密的 payload 内部**,发送方包含了一个 **nonce** (UUID)。即使 Alice 合法地向 Bob 发送了两次 ₹100,nonces 也是不同的 → 密文不同 → 哈希不同 → 两笔都能结算。但是对某个特定已签名数据包的*重放*是逐字节完全相同的,因此幂等性缓存会将其捕获。 有关新鲜度检查,请参阅 `src/services/bridgeIngestService.js`。 ## 逐文件代码解析 ``` UPI_Without_Internet/ ├── index.js Express bootstrap + middleware ├── load_test.js Concurrent load test runner for `/api/demo/send` ├── package.json npm scripts and dependencies ├── src/ │ ├── config/index.js Env + SQLite schema + seeding │ ├── controllers/ API and dashboard controllers │ ├── data/demoSeed.js Seed accounts used by full demo reset │ ├── errors/appError.js Standardized API errors │ ├── repository/ accounts/transactions/idempotency data access │ ├── routes/index.js Route map │ ├── services/ mesh, demo send, bridge ingest, crypto orchestration │ ├── utils/crypto.js RSA/AES/hash helpers │ ├── validators/index.js Request validation │ └── views/dashboard.ejs SSR dashboard └── test/crypto.test.js End-to-end behavior tests ``` ## API 参考 | 方法 | 路径 | 功能描述 | |---|---|---| | GET | `/` | 仪表板 HTML | | GET | `/api/server-key` | 服务器的 RSA 公钥 (base64) | | GET | `/api/accounts` | 所有账户和余额 | | GET | `/api/transactions` | 最近 20 笔交易 | | GET | `/api/metrics` | 计数器和结算延迟指标 | | GET | `/api/mesh/state` | 每个虚拟设备的当前状态 | | POST | `/api/demo/send` | 模拟发送方手机 — 加密并注入数据包 | | POST | `/api/mesh/gossip` | 在整个 Mesh 网络中运行一轮 gossip | | POST | `/api/mesh/flush` | 具有互联网连接的 Mesh 网桥上传到后端 | | POST | `/api/mesh/reset` | 清除 Mesh 网络 + 幂等性缓存 | | POST | `/api/demo/reset-all` | 完整演示重置 (Mesh 网络 + 余额 + 交易 + 幂等性) | | POST | `/api/bridge/ingest` | **生产环境端点。** 真实的网桥会 POST 到这里 | ### `/api/bridge/ingest` 的请求格式 ``` POST /api/bridge/ingest Content-Type: application/json { "ciphertext": "base64-encoded-RSA-and-AES-blob", "bridgeId": "phone-bridge" } ``` 响应: ``` { "outcome": "SETTLED", // or "DUPLICATE_DROPPED" or "INVALID" "packetHash": "a3f8c9...", "reason": null, // populated on INVALID "transactionId": 42 // populated on SETTLED } ``` ## 测试 运行所有测试: ``` npm test ``` 运行负载测试脚本: ``` TOTAL_REQUESTS=100 npm run loadtest ``` 包含的三个测试: - **`encryptDecryptRoundTrip`**——健全性检查,验证混合加密是对称的。 - **`tamperedCiphertextIsRejected`**——在密文中翻转一个字节,验证 `BridgeIngestionService` 返回 `INVALID`,而不是崩溃或进行结算。 - **`singlePacketDeliveredByThreeBridgesSettlesExactlyOnce`**——核心测试。三个线程,一个数据包,并发传递。断言只有一个 `SETTLED,两个 `DUPLICATE_DROPPED`,并且发送方的余额只精确地改变了该金额一次。 ## 哪些是非真实的(以及投入生产时需要改变的地方) 本项目是有意作为作品集/演示实现来构建的;如果要推出生产环境,以下是我会进行的主要升级: | 演示中的内容 | 生产环境中应有的形态 | |---|---| | SQLite 文件数据库 (`better-sqlite3`) | 带有副本的 PostgreSQL / MySQL | | SQLite `idempotency` 表 | 使用 `SET NX EX` 的 Redis | | 每次启动时重新生成 RSA 密钥对 | 存储在 HSM (AWS KMS,HashiCorp Vault) 中的私钥。缓存在设备上的公钥。 | | 服务端演示发送 (`/api/demo/send`) | 运行在 Android 上的相同代码,以 Kotlin 移植 | | 软件模拟的 Mesh 网络 (`meshService`) | 手机之间真实的 BLE GATT 或 Wi-Fi Direct | | 拥有账本的单一结算服务 | 与 NPCI / 真正的银行核心系统进行集成 | | `/api/bridge/ingest` 上没有身份验证 | 双向 TLS 或签名的网桥节点证书 | | 启动/重置时植入的演示账户 | 真实通过 KYC 认证的用户、真实的 VPA、针对银行的真实 PIN 验证 | | 没有速率限制 | 每个网桥节点的速率限制,每个发送方的速度检查 | | 日志输出到控制台 | 将结构化日志输出到 SIEM,在 `INVALID` 激增时触发告警 | 加密和幂等性的代码基本上已经符合生产规范。需要改变的是围绕它们的基础设施。 ## 该概念的客观局限性 我希望当有人在审查你的项目时,这份 README 能对你有所帮助,因此让我们坦诚地谈谈这个设计**无法解决**的问题。这些不是实现上的 bug——它们是“链条中的任何一环都没有互联网”这一设定所固有的: 1. **接收方无法验证发送方是否真的有资金。** 当发送方把显示“已发送 ₹500”的手机递给接收方时,这只是一张借条 (IOU),而不是已结算的支付。如果当数据包最终到达后端时发送方的账户是空的,结算结果将会是 `REJECTED`,而接收方将白白损失 ₹500 且没有任何追索权。*这就是为什么真实的离线 UPI (UPI Lite) 使用预先注资的硬件支持的钱包*——为了在离线状态下提供可用资金的加密证明。 2. **恶意发送方可以进行离线双花。** 如果他们的账户里有 ₹500,他们可以在地下室 A 向 Bob 发送一个数据包,然后走到地下室 B,再向 Carol 发送另一个 ₹500 的数据包。无论哪个数据包先到达后端都会成功;另一个将被 `REJECTED`。这与第 1 点的根本原因相同。 3. **现实生活中的蓝牙非常困难。** 自 Android 8 以来,Android 上的后台 BLE 受到了严格的限制。iOS 的外设模式也被锁定。两部陌生人的手机在应用程序未主动打开的情况下,可靠地建立 GATT 连接是非常困难的,并且非常耗电。本演示通过模拟 Mesh 网络完全跳过了这个问题。 4. **隐私 / 责任。** 陌生人的手机上携带着你加密的交易数据包。他们无法读取它,但它的存在本身就是一种元数据。在实际部署中,你需要考虑监管披露以及如果设备被扣押会发生什么。 作为一个大学/作品集项目:请诚实地将这个概念命名为**“Mesh 路由的延迟结算”**,而不是“实时离线 UPI”,这样你的推介会更有说服力。这里涉及的加密和幂等性工作是真正的工程设计,非常值得展示。 ## 故障排除 **`node: command not found`**——请安装 Node.js 18+。 **端口 3000 已被占用**——停止占用 3000 端口的进程,或者使用其他端口运行:`PORT=4000 npm start`。 **首次运行 `npm install` 耗时较长**——SQLite 的原生依赖项安装可能在首次运行时会花费较长时间。 **Windows 上提示 `npm` 未被识别**——在安装 Node 后重新打开终端,以便刷新 PATH。 **测试失败并提示 `EADDRINUSE`**——端口 3000 上仍运行着另一个应用实例。请停止它并重新运行测试。 ## 许可证 演示代码,无许可证。你可以出于学习目的随意使用它。
标签:AES-GCM, Express, GNU通用公共许可证, IoT, MITM代理, Node.js, RSA, UPI, 分布式系统, 加密通信, 区块链, 去重, 后端开发, 响应大小分析, 支付结算, 数据包路由, 无网通信, 桥接节点, 离线支付, 移动支付, 端到端加密, 网状网络, 蓝牙, 请求拦截, 路由, 防御绕过, 防篡改