零知识架构:除了技术标识符,服务器不掌握任何解密密钥。所有有意义的字段——电话号码、 地址、坐标、Webhook 密钥、Wi-Fi BSSID、问候语——都在主人的设备上加密,在服务器上只是 一团不透明的数据。解密密钥随邀请链接送达访客,藏在地址的片段部分,而浏览器从不把它 发给服务器。
Algorithm: AES-256-GCM。JVM 上开箱可用(通过 javax.crypto),并受 Android Keystore 支持。它一次性提供保密性与认证。
v1:<base64url(nonce || ciphertext || tag)>
v1: ——版本前缀,便于日后更换格式。nonce ——12 个随机字节,每次加密都重新生成。ciphertext+tag ——由 Cipher.doFinal返回的全部内容;其中包含 128 位标签。SecureRandom() (系统提供的)。在 Android 上就是 /dev/urandom + Linux 的 prng。
EncryptedSharedPreferences ,名称为 master_key ,且绝不以明文离开设备。K_obj ,以便本地保存。encrypt_k_master(K_obj) ,键名为 obj_key_{id}.data_cipher 在服务器上)。K_obj_new → 重新加密 data_cipher 以及所有携带过它的捆绑包。{obj_id: K_obj} ,对应访客获准的对象。bundle_cipher。 K_guest 本身,服务器从来看不到。entrixy.com/key#<user_key><base64url(K_guest)> ——一个 75 个字符的整块。
片段不会随 HTTP 请求发往服务器,这是浏览器的基本保证。K_guest 只能通过签发新链接来实现。吊销正是建立在这一点上:
服务器删除密钥记录,访客便不再收到最新的 bundle_cipher。| Key | 取值 |
|---|---|
master_key | 32 个明文字节(SharedPreferences 本来就会加密)。 |
obj_key_{id} | 对象的 K_obj,用 K_master 包裹。 |
obj_plain_{id} | (可选)解密后 JSON 的缓存,让界面绘制得更快。 |
| 表 / 字段 | 目录 |
|---|---|
numbers.data_cipher | 用 K_obj 加密的 JSON: {phone, url, secret, geo_lat, geo_lon, share_lat, share_lon, snapshot, welcome, label}. |
numbers.id / type | 明文——路由和显示图标类型都需要。 |
keys.bundle_cipher | 用 K_guest 加密的 JSON: {obj_ids: [1,2,3], obj_keys: {1: K_obj1, 2: K_obj2, ...}, welcome_cipher: ..., ...}. |
keys.user_key / mode / force_when_busy | 明文——用于路由和快速的访问校验。 |
https://entrixy.com/key#<user_key(32)><K_guest_base64url(43)>
user_key ,加上 43 个字符的访客密钥。user_key ——服务端的标识符。没有它,服务器不会交出捆绑包。#...) 永远不会出现在服务器日志里 ——这是浏览器的保证。/key ——就是普通 JS:它读取 location.hash,解析该块,并把密钥交给
应用或网页版 /app/。服务器从不看到该密钥。/download/android,
应用会自己从那里取走密钥。K_obj.K_obj → data_cipher.POST /api/number_add ,带 data_cipher 加上类型。服务器返回一个 id。obj_key_{id} = encrypt_k_master(K_obj).在本地构建新的 JSON,用已有的 K_obj → POST /api/number_update.
K_obj 加密,该密钥不变。
[obj1, obj2, obj3]。于是生成 K_guest.{obj_ids: [...], obj_keys: {1: K_obj1, ...}, welcome_cipher: ...}.K_guest → bundle_cipher.POST /api/key_create ,带 bundle_cipher, obj_ids (明文——用于访问矩阵)。user_key.entrixy.com/key#{user_key}{base64url(K_guest)}./key reads location.hash ,并把密钥交给应用或网页版。POST /api/key_bundle.php ,带 user_key → 得到 bundle_cipher 以及一份 obj_ids.K_guest→ 得到 {obj_keys, welcome}.K_guest 以及解密后的 obj_keys 存入 EncryptedSharedPreferences 访客客户端的存储中。data_cipher 对象数据 → 用本地的 obj_key_{id}.主人按下“删除” → POST /api/key_revoke.php → 服务器删除密钥记录。
访客下次请求捆绑包时会被拒绝,从而失去访问权。
主人在对象设置里按下“重新生成密钥”。于是生成 K_obj_new,
并重新加密所有包含该对象的 bundle_cipher。客户端并不凭空知道它们,
因此主人的客户端会重建捆绑包:取回自己每把密钥的 bundle_cipher,解密,放入 K_obj_new,再重新加密。
新的数据块一批发往服务器。
Losing K_master 意味着失去对所有对象和密钥的访问,因此导出必不可少。
K_master → 得到 master_backup_blob.obj_keys ——它们就在主人自己密钥的捆绑包里——然后在本地重新加密。K_master,因此口令短语是必须的。Add Crypto.kt:AES-256-GCM 包装层,生成并保存 K_master,
以及用于 K_obj的辅助函数,格式 v1:<base64url>的序列化。
单元测试:加密 → 解密 → 比对。
目前还没有任何东西发往服务器——只是把管道铺好了。
所有敏感字段都迁入了 numbers.data_cipher 与 user_keys.bundle_cipher。
旧的明文列(phone, label, radius, time_*, geo_*, wifi_*, share_*, has_avatar, security_level, user_keys.label, hosts.label, pending_actions.phone, hosts.last_ip)已从数据库中删除(第 7 阶段)。
webhook_url 与 webhook_secret 仅在以下情况下保持明文: webhook_mode='server' ——没有它们服务器就发不出 HTTP 请求。而在 webhook_mode='phone' 的情况下,它们会被加密进 data_cipher.
创建或更新时,客户端加密 JSON 并发送 data_cipher。 同步时读取数据块并解密。新对象完全存活在数据块中;没有 K_obj 的旧对象 出于向后兼容仍继续使用明文字段。
创建密钥时生成 K_guest,组装捆绑包并发送 bundle_cipher到服务器。
链接把密钥带在片段里,而页面 /key 读取哈希并把密钥交给应用。
设置界面加上“导出主密钥”:口令短语 → Argon2id → 二维码。 通过扫码恢复,并测试在设备之间迁移。 尚未实现。
客户端在新版本首次启动时执行的一次性流程:
K_obj 。data_cipher.phone=NULL, …)——但仅在确认之后。安全审计,外部或自查。我们核实服务器确实看不到敏感数据: 数据库转储里除了数据块不应有别的东西。
| Risk | Measure |
|---|---|
| K_master 丢失 | 强制性的备份流程(第 5 阶段)。在没有副本之前,会显示警告横幅。 |
| 主人设备被攻破 | 无法完全避免:应用入口由 PIN 或生物识别把守,数据交给 EncryptedSharedPreferences 与 Android Keystore。 |
| 轮换对象密钥需要重建全部捆绑包 | 主人的客户端取来全部 bundle_cipher(本地或来自服务器),重建后一批发出。这是罕见操作。 |
| 服务器篡改捆绑包 | AES-GCM 标签对不上,客户端会报告密钥已损坏。 |
| K_guest 因二维码截图而泄露 | 技术上无法防范:密钥本身就在码里。只把二维码给你信任的访客看。 |
| 地址片段留在浏览器历史里 | 页面 /key 在读取哈希后立即调用 history.replaceState(..., '#'),从而抹掉片段。 |
最后更新:2026 年 4 月 19 日