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, 分布式系统, 加密通信, 区块链, 去重, 后端开发, 响应大小分析, 支付结算, 数据包路由, 无网通信, 桥接节点, 离线支付, 移动支付, 端到端加密, 网状网络, 蓝牙, 请求拦截, 路由, 防御绕过, 防篡改