Skip to content

Commit f4930d5

Browse files
authored
Merge pull request #2 from chovizzz/harden/team-server-round2
harden(team-server): full audit-driven security pass (round 1 + round 2)
2 parents f49dfdd + 438d316 commit f4930d5

44 files changed

Lines changed: 3929 additions & 836 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/team-server.md

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ launcher 之外新增一个**自建中心服务器**:集中存放环境配置 +
1616
|---|---|---|
1717
| Phase 1 | `server/` 骨架:用户/角色/登录、env/folder/proxy CRUD、ACL | ✅ 验收+回归 |
1818
| Phase 2 | 独占借出锁(checkout/lease/checkin/release/force-unlock)+ 快照 blob + 保留 GC | ✅ 验收+回归 |
19-
| Phase 3 | `shared/`(`shardx-core`):os_crypt v10 加解密 + 跨机重加密 + 快照打包(排缓存) |9/9 单测 |
19+
| Phase 3 | `shared/`(`shardx-core`):os_crypt v10 加解密 + 跨机重加密(Cookies/Web Data/Login Data)+ 快照打包(排缓存) |33 单测 |
2020
| Phase 4 | 启动器接入:`sync.rs`(pull/push/lease)、launch/退出钩子、`remote_*` 命令、`App.tsx` Team 视图 | ✅ build + e2e |
2121
| Phase 5 | TeamView 占用状态展示 + 管理员 Force-unlock、文档收尾 | ✅ build 门禁 |
2222
| 加固 | 安全审查后修复:ACL perm 强制 + folder 递归、代理凭据脱敏、锁 token/原子性、改密码+审计+token 失效、pull/push 恢复+sha256 校验 | ✅ 3 e2e 回归 |
@@ -90,19 +90,26 @@ reqwest multipart + `shardx_core` 跑通 checkout→checkin→download→unpack,
9090
| Linux | AES-128-CBC | `peanuts` 固定口令 | mac/linux 间可移植 |
9191
| Windows | AES-256-GCM | **DPAPI**(绑用户+机器)解出的 key | ❌ 任意机器间都不通用 |
9292

93-
**统一方案:快照里不存加密后的 Cookies/Login Data 文件,只存可移植的明文值。**
93+
**统一方案(已落地于 `shardx-core`):快照里的加密数据一律在 pack 时用源机 key 解密成
94+
可移植明文、在 unpack 时用目标机 key 重新封装。** 具体分两种落地形态:
9495

95-
- **checkin**:用 `cookies::export`(已实现,内部解密为明文 `Cookie` 结构)→ 写
96-
`snapshot/cookies.json`;`Login Data`(保存的密码)同理(需给 `cookies.rs`
97-
Login Data 表的解密)→ `snapshot/logins.json`。其余非加密目录原样打包。
98-
- **checkout**:解压目录后,用 `cookies::import`(已实现)按**本机密钥**重新加密
99-
写回本地 `Cookies` DB;`logins.json` 同理写回 `Login Data`
96+
- **Cookies —「排除原库 + 明文重建」**:pack 排除 `Cookies` DB,把每条 cookie 解密进
97+
`shardx-portable.json``cookies`(含 CHIPS `top_frame_site_key` 等唯一键分量);
98+
unpack 用本机 key 从明文**重建**整个 Cookies DB(`cookies::write`)。
99+
- **Web Data(卡号/CVC/IBAN)与 Login Data(保存的密码)—「原库随行 + 就地重封装」**:
100+
原始 SQLite DB 连同 `-wal`/`-shm` 随快照旅行;pack 只把加密列(`card_number_encrypted` /
101+
`password_value`)解密进 portable state,unpack 用本机 key **就地** UPDATE 回那几列
102+
(Web Data 按 `guid`、Login Data 按 SQLite `rowid` 定位),其余版本相关列原样保留,
103+
最后 `wal_checkpoint(TRUNCATE)` 使落地 DB 自包含。**账号绑定的 `Login Data For Account`
104+
(及其 `-wal`/`-shm`)不随行**——目标机从登录的 Google 账号重新同步。
100105

101-
这样 mac→win / win→mac / win-A→win-B 全走同一路径,无需判断源/目标 OS。
106+
两种形态 mac→win / win→mac / win-A→win-B 全走同一路径,无需判断源/目标 OS。所有加密列均为
107+
os_crypt v10 secret 方案(`LocalCrypt::{encrypt,decrypt}_secret`,无 host 前缀);解密失败
108+
**fail-closed**(拒绝 pack),绝不把空值封回去覆盖真实数据。
102109

103-
> 实现注意:`cookies::import` 需确认能在不存在 Cookies DB 时新建;`cookies.rs`
104-
> 当前只处理 `cookies` 表,需扩展同样的 per-OS 加解密到 `Login Data``logins`
105-
> (`password_value` blob 与 cookie 的 `encrypted_value` 用同一 os_crypt 方案)
110+
> 状态:Cookies / Web Data / Login Data 三条路径均已实现并单测覆盖(`shared/src/{cookies,
111+
> webdata,logins}.rs` + `snapshot.rs` 端到端)。launcher 侧手动 cookie 导入导出也已委托
112+
> `shardx-core`(`src-tauri/src/cookies.rs`),不再维护第二份 os_crypt 实现
106113
107114
---
108115

@@ -111,8 +118,10 @@ reqwest multipart + `shardx_core` 跑通 checkout→checkin→download→unpack,
111118
同一环境同一时刻只允许一人运行,否则并发登录会让登录态互相覆盖、触发风控。
112119

113120
**租约式锁(防客户端崩溃死锁)**
114-
- `checkout` 原子加锁,返回带 TTL 的租约(默认 90s)+ 最新快照版本/下载地址。
115-
- 客户端运行期间每 30s 调 `/lease` 续租。
121+
- `checkout` 原子加锁,返回带 TTL 的租约(默认 90s,服务端最小 15s)+ 最新快照
122+
版本/下载地址;响应含 `lease_ttl_secs` 供客户端计算续租节奏。
123+
- 客户端在续租响应里读到 TTL,按 ~TTL/3 调 `/lease` 续租(而非固定间隔),覆盖
124+
pull 下载/解包、浏览器运行、push 打包上传全程,使短 TTL 也不会在续租前过期。
116125
- 客户端崩溃 → 租约到期 → 管理员可 `force-unlock`,或自动回收;回收时环境标记
117126
"可能有未提交改动",由原借出方确认。
118127
- `checkin` 上传新快照 → version+1 → 释放锁;`release` 丢弃改动并释放锁。
@@ -191,6 +200,21 @@ checkin 不会互相覆盖。撤销 ACL 会立即中断续租/归还(这些操
191200
单 Docker 容器,挂载一个数据卷(`./data`)。配置走环境变量:监听地址、
192201
Token 签名密钥、存储路径、(可选)S3 端点。
193202

203+
**登录限速**:`/auth/login` 有指数退避锁定——按客户端 IP(阈值 5)和用户名(阈值 15,更宽
204+
松以免合法用户被 lockout DoS)分别计数,达阈值前置返回 429 + `Retry-After`(在 DB 查询/Argon2
205+
之前);另有全局信号量封顶并发 Argon2 验证数,挡并发首波 CPU 耗尽。客户端 IP 默认取真实 peer
206+
socket(不可伪造);`SHARDX_TRUST_PROXY=1` 时才信 `X-Forwarded-For`/`X-Real-IP`(需 IP 格式合法)——
207+
**仅当反代会覆盖入站该头且禁止直连时才可开**,否则客户端可伪造头绕过 per-IP 限速。状态进程内(重启即清)。
208+
209+
**安全默认**:裸机默认 `SHARDX_BIND=127.0.0.1:8080`(仅本机可达);Docker 镜像设为
210+
`0.0.0.0:8080`(经端口映射/反代暴露)。一旦 bind 非 loopback,服务器对**弱口令 admin
211+
拒绝启动**:① 首次 bootstrap 时口令为空/过短(<8)/占位符(admin、secret、change-me…)即
212+
`bail`;② 即使库里已有 admin(早先用 admin/admin 建过、或先 loopback 后改暴露),也会逐个
213+
对现有 admin 的 hash 校验占位符口令,命中即拒(已改强口令的放行;hash 无法还原长度,故只测
214+
占位符)。必须设强 `SHARDX_ADMIN_PASS`(并建议设 `SHARDX_TOKEN_SECRET` 让 token 跨重启
215+
有效)。确需暴露端口用弱口令(内网临时测试)可设 `SHARDX_ALLOW_INSECURE_ADMIN=1` 豁免。
216+
loopback bind 只告警不阻断。
217+
194218
---
195219

196220
## 5. 客户端改造(增量,不破坏单机模式)
@@ -222,23 +246,49 @@ Token 签名密钥、存储路径、(可选)S3 端点。
222246

223247
## 7. 已知风险 / 待定
224248

225-
- **快照含明文 cookie(威胁模型)**:快照为跨机可移植,内部存的是**解密后的明文 cookie**
226-
(§2.1)。因此“能下载某环境快照”≈“能离线导出该环境登录态”。已把下载收紧为**仅当前
227-
持锁方或 admin**,并写审计;但持锁期间导出无法从协议层阻止。部署须假设有权 use 某环境
228-
的成员即可获得其登录态——按此分配 ACL。若需更强隔离,后续可对快照做服务端信封加密
229-
(仅按需下发)或改为端到端加密。
249+
- **客户端凭据落盘(0600,非加密)**:launcher 把 team-server bearer token(`settings.json`)、
250+
每 profile 的 checkout `lock_token`(profile JSON)、代理凭据(`proxies.json`)、ProxyShard
251+
billing key(`psapi.json`)明文存在配置目录,写入时经 `store::write_private` 设 Unix `0600`
252+
(Windows 靠 `%APPDATA%` per-user ACL)。这只挡**同机其他用户读取**——不加密,且备份/云同步工具
253+
可能不保留 POSIX mode,凭据仍可能随备份外泄。高价值场景可后续改用系统 keychain/Credential
254+
Manager。`remote_logout` 清 token、`discard` 清 lock_token。
255+
- **快照含明文敏感数据(威胁模型,明确可信边界)**:快照为跨机可移植,portable state 里存的是
256+
**解密后的明文**;`Web Data` / `Login Data` 的原库虽随快照旅行,但其加密列在 pack 时也被解密进
257+
portable state。覆盖面:cookie(含会话/鉴权 token)、`Web Data` 支付/自动填充(信用卡号、CVC、
258+
IBAN)、**`Login Data` 保存的密码**(见 §2.1 与 `logins.rs`/`webdata.rs`)。因此“能下载某环境
259+
快照”≈“能离线导出该环境的全部登录态、支付信息**与保存的密码**”;且**服务端把 blob 存为普通
260+
文件、保留的历史快照会保留旧密码**
261+
**明确的可信边界(部署前提)**:凡有权 use 某环境(持锁成员 / admin)、或能读到 server 主机磁盘 /
262+
备份的人,即视为有权获得该环境的上述全部凭据。请据此分配 ACL,并把快照磁盘、备份、server 管理员
263+
纳入可信边界。已把下载收紧为**仅当前持锁方或 admin**并写审计,但持锁期间的导出无法从协议层阻止。
264+
**上线前加固项**(尚未实现):① 生产**强制** HTTPS(当前仅客户端告警,见下条);② server 端信封
265+
加密快照 blob(仅按需下发);③ 若连 server 管理员也不应见明文,则需端到端加密(而非仅信封)。
230266
- **传输安全(TLS)**:登录密码、JWT、代理凭据、快照明文都走 HTTP。**生产必须在反代后启用
231267
HTTPS**。客户端已加明文告警:`sync::insecure_transport_warning` 检测非 loopback 的 `http://`,
232268
TeamView 在用户输入服务器地址时实时红字提示,登录成功后再 toast 一次(`remote_transport_warning`
233269
命令 + `remote_login` 响应的 `insecure_transport` 字段)。https 或 localhost/127.0.0.1/::1 不告警。
234-
- **Login Data(保存的密码)不纳入首版**:多数站点登录态在 cookie 里。`Login Data` 用机器
235-
绑定密钥加密、跨机不可移植,快照**排除**它(`snapshot.rs` EXCLUDE 列表),`PortableState.logins`
236-
留空。如需纳入,须像 cookie 一样解密成明文再于目标机重建。
270+
- **Login Data(保存的密码)跨机归一化(已完成)**:与 `Web Data` 同一「原库随行 + 就地重封装」
271+
路径。`Login Data` 原 SQLite(连同 `-wal`/`-shm`)随快照打包;`logins.rs` 在 pack 时用源机 key
272+
解密 `password_value``PortableState.logins`(按 SQLite `rowid` 定位,不依赖 Chromium 版本
273+
相关的复合唯一键,天然处理同 realm+用户名多行),unpack 时按 rowid **就地用目标机 key 重加密**
274+
该列、其余列原样保留,再 `wal_checkpoint(TRUNCATE)` 折叠进主库。每条必须恰好 UPDATE 1 行,否则
275+
报错并不交换 staging(避免留下半重封装、不可解的 DB)。空密码行(如用户拉黑站点)跳过;非空 blob
276+
解密失败 **fail-closed** 拒绝 pack。账号绑定的 `Login Data For Account`(及 `-wal`/`-shm`)**
277+
随行**,目标机从登录账号重新同步。敏感面见上条威胁模型。
237278
- **unpack 原子化(已完成)**:快照先解到同级 `<id>.incoming` 暂存目录、在其中重建 Cookies,
238279
成功后再 rename 交换进 `user-data/<id>/`(旧目录先移到 `<id>.backup`,二次 rename 失败会回滚)。
239280
失败/崩溃只留下可被下次清理的暂存目录,现有 udd 不受影响;全量替换同时清除了远端已删除的
240-
本地残留文件。交换时**保留本机 `Local State`**(机器绑定的 os_crypt key),避免用新 key 覆盖
241-
后本机已加密的 Web Data(自动填充)失效——Windows 上关键,macOS/Linux 上 key 固定故为空操作。
281+
本地残留文件。交换时**保留本机 `Local State`**(机器绑定的 os_crypt key)。
282+
- **Web Data(支付/自动填充)跨机归一化(已完成)**:`Web Data` 原 SQLite 随快照打包,但其
283+
加密列(`credit_cards.card_number_encrypted``local_stored_cvc`/`local_ibans`
284+
`value_encrypted`)用源机 key 加密、跨机不可解。`webdata.rs` 在 pack 时用源机 key 解密进
285+
`PortableState.web_secrets`,unpack 时按行 `guid` **就地用目标机 key 重加密**(不重建整个
286+
多表 schema),重加密后 best-effort `wal_checkpoint(TRUNCATE)` 把结果折叠进主库(正确性不依赖
287+
它:即便 checkpoint 失败,后写入的目标 key frame 仍在 WAL 里、目标引擎读到的也是新值)(SQLite
288+
`-wal`/`-shm` 随快照保留,以免硬杀 checkin 时未 checkpoint 的已提交行丢失)。仅覆盖**本地、
289+
guid 键**的支付数据;账号/服务器绑定项(`unmasked_credit_cards`
290+
`server_stored_cvc``token_service`)登录后由账号重新同步,故不纳入。解不出的行跳过(残留孤儿,
291+
无害)。
242292
- **快照体积**:若某些环境 IndexedDB 很大,可在 Phase 2 后引入增量/分块(内容寻址)
243293
降低上传量;首版用整包压缩。
244294
- **跨 OS 指纹一致性**:一个环境的指纹固定声明某个 OS;成员在不同 host OS 上运行同一

openapi.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,24 @@ components:
999999
type: string
10001000
enum: [Strict, Lax, None, unspecified]
10011001
description: Case-insensitive on import.
1002+
top_frame_site_key:
1003+
type: string
1004+
description: >
1005+
CHIPS partition key — the top-level site a partitioned cookie is
1006+
scoped to (empty for an unpartitioned cookie). Round-trip it to keep
1007+
a partitioned cookie's scope on re-import.
1008+
has_cross_site_ancestor:
1009+
type: integer
1010+
nullable: true
1011+
description: Unique-key component; null → 1 (default).
1012+
source_scheme:
1013+
type: integer
1014+
nullable: true
1015+
description: Unique-key component; null → derived from `secure`.
1016+
source_port:
1017+
type: integer
1018+
nullable: true
1019+
description: Unique-key component; null → derived from `secure`.
10021020

10031021
ProxyEntry:
10041022
type: object

server/.env.example

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Copy to .env (or pass as -e flags to docker run). All values optional except
22
# you SHOULD set a stable SHARDX_TOKEN_SECRET in production.
33

4-
# Bind address.
4+
# Bind address. Defaults to 127.0.0.1:8080 (loopback only) when unset. Set
5+
# 0.0.0.0 to expose it (behind a reverse proxy) — but see SHARDX_ADMIN_PASS:
6+
# a network-facing bind refuses to start with a weak/default admin password.
57
SHARDX_BIND=0.0.0.0:8080
68

79
# Where the SQLite DB and snapshot blobs live (a Docker volume in production).
@@ -15,6 +17,14 @@ SHARDX_TOKEN_SECRET=change-me-to-a-long-random-string
1517
# Token lifetime in seconds (default 7 days).
1618
SHARDX_TOKEN_TTL_SECS=604800
1719

18-
# Bootstrap admin, created only when the user table is empty (first run).
20+
# Bootstrap admin, created only when the user table is empty (first run). On a
21+
# non-loopback bind this must be a STRONG secret (>=8 chars, not a placeholder
22+
# like admin/secret/change-me) or the server refuses to start. Set your own:
1923
SHARDX_ADMIN_USER=admin
20-
SHARDX_ADMIN_PASS=change-me
24+
SHARDX_ADMIN_PASS=
25+
26+
# Login-throttle client-IP source. Default 0: use the real peer socket
27+
# (unspoofable). Set 1 ONLY behind a reverse proxy that OVERWRITES the inbound
28+
# X-Forwarded-For / X-Real-IP header (and blocks direct access) — otherwise a
29+
# client could spoof the header to dodge the per-IP rate limit.
30+
SHARDX_TRUST_PROXY=0

server/Cargo.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ path = "src/main.rs"
1111
[dependencies]
1212
axum = { version = "0.7", features = ["multipart"] }
1313
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "net", "signal"] }
14+
tokio-util = { version = "0.7", features = ["io"] }
1415
serde = { version = "1", features = ["derive"] }
1516
serde_json = "1"
1617
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
@@ -30,3 +31,6 @@ tower-http = { version = "0.5", features = ["trace", "cors"] }
3031
# does (reqwest multipart) + pack/unpack via the shared crate.
3132
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
3233
shardx-core = { path = "../shared" }
34+
# Age a lease directly in the DB to test stale-lock takeover without waiting out
35+
# the (min-15s) lease TTL.
36+
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] }

server/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ FROM debian:bookworm-slim
1111
RUN useradd -m app && mkdir -p /data && chown app /data
1212
COPY --from=build /app/target/release/shardx-team-server /usr/local/bin/shardx-team-server
1313
USER app
14+
# Binds 0.0.0.0 so the container is reachable via a mapped port / reverse proxy.
15+
# Because that's network-facing, the server REFUSES to start on first run unless
16+
# SHARDX_ADMIN_PASS is set to a real secret (see bootstrap_admin). Also set
17+
# SHARDX_TOKEN_SECRET so issued tokens survive restarts.
1418
ENV SHARDX_BIND=0.0.0.0:8080 \
1519
SHARDX_DATA_DIR=/data
1620
EXPOSE 8080

server/README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,27 @@ and (in later phases) exclusive checkout locks + environment-data snapshots.
1212
## Run
1313

1414
```bash
15-
# dev
15+
# dev (binds 127.0.0.1 by default — loopback only, so a simple password is fine)
1616
cd server
17-
SHARDX_TOKEN_SECRET=dev-secret SHARDX_ADMIN_PASS=secret cargo run
17+
SHARDX_TOKEN_SECRET=dev-secret SHARDX_ADMIN_PASS=dev-strong-pass cargo run
1818

19-
# docker
19+
# docker (binds 0.0.0.0 → network-facing, so a strong admin password is REQUIRED;
20+
# a weak/placeholder one makes first start refuse to boot)
2021
docker build -t shardx-team-server server/
2122
docker run -p 8080:8080 -v "$PWD/data:/data" \
2223
-e SHARDX_TOKEN_SECRET=$(openssl rand -hex 32) \
23-
-e SHARDX_ADMIN_USER=admin -e SHARDX_ADMIN_PASS=secret \
24+
-e SHARDX_ADMIN_USER=admin -e SHARDX_ADMIN_PASS="$(openssl rand -base64 18)" \
2425
shardx-team-server
2526
```
2627

2728
Config is all environment variables — see [`.env.example`](.env.example).
2829
SQLite DB + snapshot blobs live under `SHARDX_DATA_DIR` (`/data` in Docker).
2930
On first start with an empty user table, an admin is bootstrapped from
30-
`SHARDX_ADMIN_USER` / `SHARDX_ADMIN_PASS`.
31+
`SHARDX_ADMIN_USER` / `SHARDX_ADMIN_PASS`. On a **non-loopback bind** (e.g. the
32+
Docker default `0.0.0.0`), the server **refuses to start** if that password is
33+
empty, too short, or a known placeholder (`admin`, `secret`, `change-me`, …), or
34+
if an existing admin still uses one — set a strong `SHARDX_ADMIN_PASS`, bind
35+
`127.0.0.1`, or set `SHARDX_ALLOW_INSECURE_ADMIN=1` to override.
3136

3237
## API (Phase 1)
3338

@@ -76,7 +81,7 @@ on every request, so demotion/deletion takes effect immediately.
7681

7782
```bash
7883
BASE=http://127.0.0.1:8080
79-
TOKEN=$(curl -s $BASE/auth/login -d '{"username":"admin","password":"secret"}' \
84+
TOKEN=$(curl -s $BASE/auth/login -d '{"username":"admin","password":"dev-strong-pass"}' \
8085
-H 'content-type: application/json' | jq -r .token)
8186

8287
curl -s $BASE/me -H "Authorization: Bearer $TOKEN" | jq .
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Migration 0002 added `lock_token` with a NON-NULL default of '' (empty), so
2+
-- any lock row that predated it carries an empty token. An empty token can't be
3+
-- authenticated: checkout re-mint and snapshot download explicitly reject it,
4+
-- and lease/checkin/release match on `lock_token = ?` — so once no empty-token
5+
-- row remains they can't act on one either. Left in place, such a legacy lock
6+
-- would stay weakly held until its lease expired. Clear them on upgrade: the
7+
-- environment just becomes re-checkoutable, the correct outcome for a stale lock.
8+
--
9+
-- This is safe because `checkout` always mints a fresh UUID token (never ''), so
10+
-- no NEW empty-token lock can be created — after this runs once, none exist.
11+
DELETE FROM locks WHERE lock_token = '' OR lock_token IS NULL;

0 commit comments

Comments
 (0)