以下是 phix 系统的公开技术架构说明。所有敏感细节(密钥、令牌实例)均已脱敏。
phix 是一套把「心履」「Pinghe Launcher」「Pinghe Launcher Lite」打通的统一账号系统:一套用户名 + 密码登录,端到端加密地跨设备同步日程、选课、账号、课表、心情记录等数据。
服务端没有任何密码学依赖,看不懂用户上传的内容;它只存「密文信封 + 令牌 + 密钥包裹」。
三个产品共用同一套账号与数据模型,登录同一账号即可跨设备同步:心履是四端(网页版 + Windows + macOS + Android),Pinghe Launcher 是桌面端(Windows / macOS 13+)加网页版,Pinghe Launcher Lite 是轻量桌面端。
| 平台 | 定位 | 技术栈 / 形态 |
|---|---|---|
心履 | 心情记录 + 日程提醒 | 四端:网页端(Django)、桌面端(JavaFX,Windows / macOS)、安卓端(Java + Room),跨设备同步 |
| Pinghe Launcher | 日程与校园信息中枢 | 桌面端(Electron,Windows / macOS 13+);网页版(/app/)只有日程 / EduPage / ManageBac / 邮箱四个入口,不做真实收发 |
| Pinghe Launcher Lite | 轻量日程助手 | 桌面端(Python);本地独立可用,登录 phix 账号后可选开启云同步 |
引导期间任何一步失败都不阻塞,可以选择「跳过 / 稍后再说」;离线也能正常使用,联网后自动补齐同步。
一套用户名 + 密码,登录所有客户端与网页端;各产品不各自存密码。
@ / + / - / 中文,最长 150 字符。服务端只保存密文,你的口令与密钥不上传——它只收到派生出的 AuthHash,反推不出口令。
客户端在本地派生登录凭证,只把 AuthHash 发给服务器。两代 KDF 都支持:
scrypt-n15-r8-p1(scrypt N=2^15、r=8、p=1),由口令直接派生 KEK,服务端比对口令原文的兼容路径。scrypt-hkdf-v2 —— MK = scrypt(口令, salt),再由 HKDF 派生:HKDF("auth") 得 AuthHash(发服务器、用于登录校验),HKDF("enc") 得 KEK(永不出客户端)。auth_salt 派生 AuthHash(注册后不变);kdf_salt 派生 KEK(改密码时会变)。不可混用。v2: 口令 --scrypt--> MK ──HKDF("auth")──> AuthHash(发服务器,登录校验)
└──HKDF("enc")──> KEK(永不出客户端,本地解包 DEK)
HKDF 是单向的:拿到 AuthHash 反推不出口令、也算不出 KEK——即使服务器被入侵并记下登录时收到的一切,也解不开你的云端数据。
老用户用原用户名 + 原密码登录,官网 / 心履在本地与旧哈希比对:
scrypt-hkdf-v2,服务端永不见口令;老账号(v1)走 scrypt-n15-r8-p1 兼容路径。迁移采用「认证委托」:不改数据、不迁库。
key_wrap),云端密文一个字节都不用动。换密码 ≠ 重新加密数据:只更换「包裹 DEK 的那把钥匙」,密文不动。
phix 采用三层密钥架构,确保换密码时云端数据无需重新加密:
口令 →(scrypt/HKDF)→ KEK →(解开)→ DEK →(HKDF)→ 对象密钥 →(AES-GCM)→ 密文信封
服务端只保存密文,你的口令与密钥不上传;换密码只重新包裹 DEK,云端密文无需重新加密。
PHIX1.<nonce>.<密文> 信封上传;服务端只保存密文。AAD 把用户与对象名绑进认证范围,服务端把对象张冠李戴就解不开;换密码只需用新口令重新包裹 DEK,云端密文一个字节都不用重传。按图从上到下走一遍:
PHIX1.<nonce>.<密文||tag>;AAD 绑定 phix/v1/object|{user_id}|{对象名}。代价是:端到端加密没有「找回」。登录口令与注册时的一次性恢复码都丢失后,云端数据将无法解开——请把恢复码单独保存好。
同步的最小单位是一个具名对象,内容是一段密文信封,服务端完全不理解内容。
PHIX1.<base64url(nonce)>.<base64url(密文||tag)>,一个字符串便于存 JSON。phix/v1/identity|…(key_wrap / recovery_wrap / key_check);对象族 phix/v1/object|{user_id}|{对象名}(所有同步对象),绑定用户与对象名,服务端张冠李戴就解不开。PHIX1.<base64url(nonce)>.<base64url(ciphertext || tag)>
应用层加密:即使没有 HTTPS,网线上也只有密文。每个请求 / 响应都经过 X25519 密封盒 + HKDF(info phix/v1/seal)+ AES-256-GCM,临时会话密钥每次请求随机换一把。
请求:X-Phix-Enc: 1
内层 = {方法, 路径, 查询串, 请求体, 时间戳, nonce}
密文 = AES-256-GCM(会话密钥, iv, JSON(内层), aad="phix/v1/req|{方法}|{路径}")
体 = {sealed_sk, iv, ct}
带 X-Phix-Enc: 1 头的请求走加密路径;没有该头则走明文路径,兼容老客户端与调试工具。
这一层保护「网线上怎么走」,不替代「服务器上怎么存」;两层独立,都要有。
有人冒充服务器时公钥对不上即拒绝连接;重放的旧请求会被 nonce 去重拦下。
payload = {"sub": "<user_id>", "sid": "<会话id>",
"iat": …, "exp": …, "jti": …, "typ": "access"}
换来的新 refresh 一定要存下来,否则旧令牌失效后就得重新登录。
rotated:false,不重发新的。宽限期只覆盖「同一会话、几秒内的并发续期」,不是允许重放。
/auth/jwks 端点发布(JWKS 格式,免认证)。kid = 公钥 SHA-256 前 16 位;健康探测里也有,客户端可据此察觉服务器换钥匙。同步的最小单位是一个具名对象,内容是一段密文信封,服务端只存密文:
| 对象名 | 内容 | 谁写 |
|---|---|---|
settings.accounts | 四平台凭据(邮箱 / ManageBac / Edupage / 心履),两层字典、端到端加密 | 客户端;官网个人中心可读改写 |
settings.lessons | 选课(教学组) | 客户端 |
settings.ui | 界面排序偏好 | 客户端 |
settings.ai | AI 供应商与 Key | 客户端 |
schedule | 日程 {id, day, time, title, note, created} | 客户端 / Pinghe Launcher 网页版 |
timetable | 课表(按教学组展开的周视图) | 客户端 |
school | 学校数据快照(课表 / 作业 / 邮箱摘要,来自 EduPage 与 ManageBac 的抓取缓存) | 客户端;Pinghe Launcher 网页版只读 |
profile | {display_name, avatar(data URL,≤200KB,上传前压到 256×256), updated_at} | 任一端;官网个人中心可改头像 |
mood | 心情记录 {"entries":[{id, ts, text, intensity}]} | 心履客户端 |
agent:<会话id> | AI 会话记录(每个会话一个对象) | 客户端 |
各端的默认同步清单略有差异:Pinghe Launcher 默认 7 个(settings.accounts、settings.lessons、settings.ui、schedule、timetable、school、profile),Pinghe Launcher Lite 默认 8 个(再加 mood),官网与心履只碰各自需要的对象。可以按需增减,清单只是客户端的默认值。
这些只是「对象名」,服务端只存密文、零改动即可支持新增对象;对象名会进入 HKDF 与 AAD,改动即等于换密钥。对象名以字母数字开头,可含 . _ : - 三种符号。
revision,单调递增;服务端不会静默覆盖。base_revision 写入,对不上返回 409,客户端拉最新 → 解密 → 合并 → 重推(最多 3 次)。PUT /sync/objects/<name>
{"base_revision": 4, "payload": "PHIX1.…", "device": "笔记本"}
→ 成功 200;冲突 409 revision_conflict
配额(每账号):
| 项 | 默认值 |
|---|---|
| 单对象密文上限 | 8 MiB |
| 对象数 | 2000 |
| 总容量 | 200 MiB |
| 单次批量同步 | 50 个对象 |
限流(每 IP 每小时):
| 项 | 默认值 |
|---|---|
| 注册 | 10 次 |
| 找回(恢复码) | 8 次 |
| 取密钥材料 | 60 次 |
| 刷新令牌 | 120 次 |
以上均为默认值,部署时可调;限流在服务进程内存里维护,将来多进程部署需换成共享存储。
心履是一套「记录心情 + 日程提醒」的客户端矩阵,四端共用同一账号与数据模型:
账号打通:登录时本地派生 AuthHash 交 phix 校验;phix 不可用时退回本地账号校验,不影响离线使用。服务间调用 phix 校验接口时带共享服务密钥,请求同样走应用层信封。
Pinghe Launcher 是「日程与校园信息中枢」:课表、作业、邮箱、日程一屏管完。
/app/):只有日程 / EduPage / ManageBac / 邮箱入口四个标签,不做真实收发,邮箱页只提示「去客户端收发」。Pinghe Launcher Lite 是轻量日程助手,适合偏好轻量功能的用户。
错误统一为 {"error": {"code": "...", "message": "中文说明"}}:
| code | 含义 | 建议 |
|---|---|---|
bad_request | 请求参数不合法 | 检查请求体字段 |
bad_credentials | 账号或密码不对 | 提示用户核对凭据 |
unauthorized | 缺令牌 / 令牌无效或已吊销 | 跳登录页重新登录 |
forbidden | 权限不足 | 检查服务密钥或权限 |
not_found | 资源不存在 | 核对对象名 / 路径 |
revision_conflict | 乐观锁冲突 | 拉最新、合并、重推 |
quota_exceeded | 超出配额 | 清理对象或扩容 |
payload_too_large | 请求体或对象超上限 | 拆分或压缩 |
rate_limited | 触发限流 | 稍后重试 |
token_expired | 令牌过期,该续期 | 自动用 refresh 续期 |
name_invalid | 对象名不合法 | 检查对象名规则 |
server_error | 服务端内部错误 | 稍后重试或联系支持 |
两个 401 要分清:bad_credentials 是「你输错了」,unauthorized 是「你的登录失效了」。
问:忘记口令怎么办?
答:用注册时保存的恢复码解包 DEK 并重设口令。恢复码只展示一次,请妥善保存。
问:换密码云端数据会丢吗?
答:不会。换密码只替换「包裹 DEK 的钥匙」,云端密文一个字节都不动。
问:服务器能看到我的数据吗?
答:服务端只保存密文信封,你的口令与密钥不上传,因此它拿到的内容解不开。真正会离开本机的是 AI 请求:心履的 AI 回复由第三方服务商生成,发给它的内容会离开本机;Pinghe Launcher 只有在 API 模式下才会把内容发给服务商,本地模式不出本机;邮件内容不交给 AI。详见「隐私与数据安全」。
问:多台设备同时改会不会冲突?
答:靠乐观锁 + 三方合并,冲突保留并报告,不会静默覆盖。
问:本机上的数据也加密吗?
答:不是全部。云同步的对象是端到端加密的;但 Pinghe Launcher 与 Pinghe Launcher Lite 共享的账号文件当前以明文保存在本机共享数据目录(为了两个应用能互相读取),凭据库在系统支持时用系统密钥加密。
问:心履账号和 phix 账号什么关系?
答:统一账号。心履老账号零影响,新账号走「认证委托」自动关联。
这里把「能做什么」和「做不到什么」分开写清楚,避免把端到端加密理解过头:
schedule(日程)、mood(心情)、timetable(课表)、school(学校快照)、profile(头像昵称)、settings.accounts(凭据)、settings.lessons(选课)、settings.ui(界面偏好)、settings.ai(AI 配置)、agent:<会话id>(AI 会话)。客户端可以关闭同步,也可以删除云端对象。客户端支持本地保存与云端同步;重要内容建议定期备份。服务端即使数据库泄露或被入侵,没有口令与密钥也解不开这些密文对象。
「端到端加密」不等于「一定丢不了」:设备损坏、误删、口令与恢复码同时丢失,都可能让数据取不回来。重要内容请另外留一份备份。