磁盘锁成立的前提是 state.json 不和密文一起泄露。 state.json 里存的是口令的校验值,
不是 AES 密钥 —— 但它的 scrypt 参数、盐、派生值都在,足够让人离线跑 scrypt 猜口令。
拷走整个 ~/.dsh 目录(里面有 sessions/ 和 session-vault/)的人,拿到的是密文 + 可离线攻击的校验值。
单独拷走某个 session.v4.jsonl.zstd 则不构成威胁。
dsh-session-vault
给 DSH 的会话上锁。锁着的会话在侧栏还在,打开要口令;可选把磁盘上的日志也换成密文。
适配 DSH v0.2.2-rc2。本插件 patch 了 DSH 的会话持久化后端(见「后端补丁」),
不保证跨版本兼容。卸载前请先用「永久解除」把锁清掉,否则遗留的密文需要自己用
WALLBK 格式解。
两种锁
| 界面锁 | 磁盘锁 | |
|---|---|---|
| 挡谁 | 只挡 DSH UI 层:不输口令看不到正文 | 拿到文件、拿到硬盘的人 |
| 同账户下的其他进程直接读文件 | 挡不住 —— 日志是明文 | 挡得住(文件是密文) |
| 别的账户登录这台机器 | 不需要(NTFS ACL 已挡住) | 不需要(同上) |
| 本账户、会话锁着 | 挡得住 | 挡得住 —— 但前提是对方拿不到 state.json(见下) |
| 本账户、会话刚解锁过 | 挡不住 | 挡不住 —— 口令在进程内存里 |
| 本账户、会话已永久解除 | 挡不住 | 挡不住 —— 磁盘上是明文 |
| 文件被拷走、硬盘被拿走 | 挡不住 | 挡得住 —— 前提是 state.json 没有一起被拷走 |
| 依赖 | 无 | 后端补丁 |
| 上锁时勾选 | 不勾 | 勾「同时加密到磁盘」 |
界面锁不是磁盘保护。 同一账户下任何进程都能直接读明文会话日志,界面锁完全不约束它。 界面锁只约束「通过 DSH 界面点开这条会话」这一个入口。
磁盘锁成立的前提是 state.json 不和密文一起泄露。 state.json 里存的是口令的校验值,
不是 AES 密钥 —— 但它的 scrypt 参数、盐、派生值都在,足够让人离线跑 scrypt 猜口令。
拷走整个 ~/.dsh 目录(里面有 sessions/ 和 session-vault/)的人,拿到的是密文 + 可离线攻击的校验值。
单独拷走某个 session.v4.jsonl.zstd 则不构成威胁。
如果后端补丁装不上(取不到 sessionPersistence),勾选磁盘锁会被拒绝并返回错误,
不会静默降级成界面锁。诊断文件里的 backend.ok 是这一点的依据。
默认策略:磁盘锁开,空闲 10 分钟自动上锁。权威值在 lib/state.mjs 的 DEFAULT_POLICY,
state.json 里没有 policy 字段时用它。
用法
把鼠标移到侧栏会话上,出现小锁:
- 点它 → 给这条会话设口令(两次输入)。之后打开这条会话就要输它。
- 再点一次(已上锁且当前已解锁)→ 收回授权,立刻重新上锁。
- 右键会话行 → 同一套动作。
口令是按会话独立的:每条锁有自己的盐和 scrypt 记录,明文口令不落盘,校验用
timingSafeEqual。没有全局口令,也没有「回退到别处的口令」这回事。
解锁状态只活在内存里。进程退出、空闲超时、手动重新上锁,三者都会让口令从内存消失。
口令丢了就打不开。 没有找回通道,没有后门。
永久解除(把密文解回明文)
这是破坏性操作:磁盘上那份 WALLBK 密文会被解回明文,.header 被删掉,
锁登记从 state.json 里移除。之后这个会话就是普通明文会话,不再受保护。
只有两个入口,都要求输入这条会话自己的口令:
| 入口 | 位置 | 按钮 |
|---|---|---|
| 锁屏卡片 | 打开一条已锁会话时弹出 | 解锁并永久解除(卡片右侧的次要按钮) |
| 会话锁设置 | 设置 → 会话锁设置 → 已锁定的会话 → 每行的「解除」 | 解除(这个入口只做解除,不做解锁) |
右键菜单里没有永久解除。 会话行的右键菜单只有「解锁这个会话」和「锁定会话」, 因为永久解除会毁掉密文,不该混在日常操作里。
对应的 HTTP action 是 unlock-session,详见下表。
为什么 clear 清不掉
clear 只删锁登记,不解密文件。如果磁盘上还有锁着的会话,删掉登记会让那些密文
永久失去钥匙来源——既没有口令记录,也没有登记说明它是个密文文件。所以 clear
在这种情况下直接拒绝。
要让 clear 通过,先对每条会话执行一次「永久解除」。
WALLBK 容器
偏移 长度 内容
0 8 magic "WALLBK\x01\x00"
8 1 version 当前 1
9 4 scrypt n big-endian
13 4 scrypt r big-endian
17 4 scrypt p big-endian
21 16 salt
37 12 nonce
49 N 密文本体
49+N 16 GCM 认证标签(追加在末尾,不在头里)
头部 49 B + 标签 16 B = 65 B 固定开销,与「密文体积增量」那一行一致。
nonce:randomBytes(12),每次加密重新生成,存在容器头里。不用计数器,
所以不存在「同一把钥匙复用同一个 nonce」的编码路径——重复只可能来自随机碰撞
(96 位空间下可忽略)。
AAD:只绑 magic || version,不绑会话 id、文件名、路径。后果:
- 两个会话的密文互换 → 但它们的钥匙不同(盐不同 → scrypt 输出不同),GCM 认证会失败。
- 同一个会话的旧密文被回滚(replay)→ 检测不出来。这是已知缺口,见「缺口登记」。
钥匙派生链:两条路径,盐前缀不同,输出落在不同的 256 位空间:
校验值(存进 state.json) scrypt(口令, "session-vault:check:" || salt, 64)
AES 密钥(不落盘) scrypt(口令, "session-vault:aes-key:" || salt, 32)
为什么要分开:scrypt 的最后一步是 PBKDF2-HMAC-SHA256,而 PBKDF2 的输出是流式前缀 ——
scrypt(pw, salt, 64)[0..32) 与 scrypt(pw, salt, 32) 完全相同(实测确认)。
如果校验值直接取 scrypt(pw, salt, 64),它的前 32 字节就是 AES 密钥,
拿到 state.json 的人可以不猜口令直接解密。加盐前缀之后这条路被堵死,
test/host-selftest.mjs 的 [4b] 节有三条断言守着它。
salt 是 16 字节,同时存在于容器头和 state.json 的口令记录里。scrypt 的 n/r/p 也写进容器头,
所以旧文件换参数后仍能解开。
salt 是 16 字节,同时存在于容器头和 state.json 的口令记录里。scrypt 的 n/r/p 也写进容器头,
所以旧文件换参数后仍能解开。
状态表
每条都写依据。「读数」指当下的运行态产物或实测值,「处置」指代码路径。
| 项 | 状态 | 依据 |
|---|---|---|
| 加密算法 | AES-256-GCM | 处置:index.js 的 sealWithKey / openWithKey |
| 密钥派生 | scrypt N=131072, r=8, p=1 → 32 B 直接做 AES 密钥 | 读数:lib/scrypt.mjs 的 SCRYPT;无 HKDF |
| 派生内存 | 128 MiB / 次 | 读数:scryptMemoryNeed() = 128 × 131072 × 8 |
| 派生耗时 | 385 ms / 次 | 读数:本机 node v24.21.0 win32/x64,单次 scryptSync |
| maxmem 余量 | 需求量的 2 倍 | 处置:lib/scrypt.mjs 的 scryptMaxMem;实测 maxmem 等于需求量会抛 MEMORY_LIMIT_EXCEEDED |
| 密文体积增量 | 65 B / 文件 | 读数:1,000,000 B 明文 → 1,000,065 B 密文,回环一致 |
| nonce | 12 B 随机,每次加密新生成,存容器头 | 处置:sealWithKey 里的 randomBytes(NONCE_LEN) |
| AAD | `magic | |
| salt | 16 B 随机,每条口令记录一个,存容器头 + state.json |
处置:makePasswordRecord 与 sealWithKey |
| 口令存储 | 只存校验值,不存 AES 密钥:scrypt(口令, "session-vault:check:" || salt, 64),与钥匙的派生路径用不同盐前缀分开 |
处置:lib/secret.mjs 的 CHECK_DOMAIN / KEY_DOMAIN;测试 [4b] 断言 hash 推不出钥匙 |
| 解锁状态生命周期 | 进程内存 | 处置:lib/secret.mjs 的 Grants;无持久化写入 |
| 后端 patch 生效 | 是 | 读数:_diagnostics.json 的 backend.ok 与 backend.methods |
| 磁盘锁可用 | 是 | 读数:_diagnostics.json 的 disk.available |
| 自检通过 | 是 | 处置:test/host-selftest.mjs 与 test/client-selftest.mjs,均以 ALL PASS 退出 |
后端补丁:读写路径
DSH 的 dsh-session-persistence-jsonl 用 import { open } from "node:fs/promises" 读会话。
Node 在 ESM 里导入 CJS 的具名导出时复制的是快照,所以改 fs.promises.* 对它不可见。
改类原型上的方法才生效——每次调用都走一次属性查找。
lib/disk.mjs 的 HOOKS 列了被替换的五个方法,每个都有对应的路径:
| 方法 | 方向 | 做什么 |
|---|---|---|
readFirstZstdLine |
读 | list() 读会话头。锁定时返回 undefined(靠 .header 旁路) |
decodeStoredLog |
读 | 读正文。密文在这里解密到内存 |
appendLines |
写 | 追加事件。解 → 内存拼接 → 整体重新加密 → 原子写密文 |
repair |
写 | 截断撕裂尾。同上,全程在内存 |
open |
读 / 写 | 锁定时拒绝 access === 'write' 的打开,避免往密文里掺明文 |
所有写路径都经过 appendLines / repair / open 这三个 hook,它们都在 lib/disk.mjs 里。
没有其他模块直接写会话日志文件。
补丁在 apply() 里装上,靠 ctx.effect 在插件卸载时还原。不修改 app.asar,
所以 DSH 升级不会让它失效,也不需要重新打包。
锁定时 readFirstZstdLine 返回 undefined,list() 会跳过这条会话。为了让它仍出现在
侧栏,磁盘锁会在同目录写一个 .header 文件(见「挡不住的」)。
HTTP 接口
挂在 /api/session.vault,走宿主的 connection.fetch 注册,继承浏览器的签名 cookie 栅栏。
口令从请求体读,不进日志。
| action | 作用 | 需要口令 |
|---|---|---|
status |
锁列表 + 授权快照。只回 id、标题、lockedAt、disk、orphan |
否 |
lock |
上锁;带 password 时写这条会话自己的记录 |
是(新录入口令) |
verify |
验这条会话的口令;通过后只放行这一条 | 是 |
relock |
收回这一条的授权 | 否(收紧永远安全) |
touch |
上报活动,推迟空闲超时 | 否 |
password |
改这一条的口令,并用新盐重新加密它的磁盘文件 | 是(当前 + 新) |
policy |
自动锁定分钟数、磁盘锁开关 | 否 —— 见下 |
clear |
删掉全部锁。磁盘上还有锁着的会话时拒绝 | 否 |
请求体字段、状态码、错误文案以 lib/routes.mjs 的 handlers 为准,本文档不复述。
policy 不验口令是个已知的弱化。 任何能调到这个接口的上下文都能把自动锁定时间调长、
或关掉磁盘锁的默认值,而不需要任何口令。它的威胁模型是「本地偏好」——改它不泄露数据,
只降低未来上锁的强度。relock 同理:收回授权只会更严,不会更松。
verify 的暴力破解面
timingSafeEqual 只防时序侧信道,不防暴力破解。
- 在线:
verify没有失败次数限制,也没有退避。唯一的门槛是宿主的签名 cookie 栅栏。 - 离线:拿到
state.json的人可以本地跑 scrypt 循环猜口令。N=131072 让单次成本约 128 MiB 内存 + 385 ms,但它不改变「口令弱就能猜出来」这个事实。
所以口令强度仍然重要。 这个插件不给弱口令兜底,也挡不住有 state.json 读权限的人离线猜。
挡不住的
WALLBK 用 AES-256-GCM,认证失败就拒绝。但磁盘锁只保护「文件被拷走」这一种场景。
明文残留
| 位置 | 内容 | 为什么有 |
|---|---|---|
session.v4.jsonl.zstd.header |
会话第一行:id、创建时间、工作目录 | list() 要靠它才能在侧栏列出这条会话 |
state.json |
每条锁的标题、scrypt 派生值、盐、策略 | 锁登记本身 |
_diagnostics.json |
锁的 id、标题、legacy 之类的状态 |
排查用 |
session-vault.log |
会话 id、动作 | 排查用 |
.header 里的 cwd 可能泄露用户名、项目名、客户名。 路径本身经常就是敏感信息。
想避免这一点,上锁前把会话的 cwd 改成与项目无关的路径——但这个字段同时是 DSH 用来
归档会话的,改了会导致会话被归到别的项目下,请自己权衡。
会话标题在 state.json 和 _diagnostics.json 里是明文,也通过 status 接口可见。
标题是 DSH 自己存的,本插件不额外加密它。
其他残留面
- swap / 休眠文件 / core dump:进程内存里的明文可能被操作系统写到盘上
- 文件系统日志:NTFS 的 journal 可能留有旧内容的片段
- SSD wear leveling:擦除不等于物理清除,旧块可能还有数据
- 备份软件 / 回收站 / 卷影副本:加密前的明文副本可能已经在别处
- 屏幕录制 / 截图:解锁期间的内容是可见的
- 内存取证 / 键盘记录:口令在解锁期间就在进程里
其他
- 自动锁在系统休眠时可能不按时触发。人离开机器就手动点一次小锁,不要依赖计时器。
- 空闲超时的实现是客户端每 3 秒打一次
status,服务端在每次status里检查grants.expireIdle()。所以「空闲」指的是「没有客户端打 status」,不是「没有键盘输入」。 轮询间隔本身我没量——见缺口登记。 - 不要锁正在运行的会话。 磁盘锁会把文件换成密文,正在写入的句柄会让两边不一致。
- 自检用假 ctx / 假 window,覆盖的是路由、口令、加解密、slot 注册、弹窗渲染。 它不覆盖并发写入、崩溃恢复、nonce 唯一性、rate limit。自检通过不等于安全审计通过。
崩溃与原子性
所有写盘都走 writeAtomic:写 .vault-tmp → fsync → renameSync(NTFS 同卷原子)。
rename 之前目标文件一直是旧的完整内容;rename 之后是新的完整内容。不存在中间态。
- 临时文件里的内容是密文(
sealWithKey的输出),不是明文。 - 断电可能留下
*.vault-tmp残片,是截断的密文垃圾,不含明文。下次启动会清理。 - 改口令的顺序是:先写「新旧两条记录都在」的 state → 换钥匙 → 清掉旧记录。 任一步失败,至少有一个口令能打开会话。
缺口登记
| 读者是谁 | 缺的词 | 见证它的判据 |
|---|---|---|
| 想评估重放攻击的人 | 「同一个会话的旧密文被换回去」检测不出来,因为 AAD 不含会话 id 也不含版本号 | 状态表里 AAD 那一行写明只绑 `magic |
| 想评估空闲锁可靠性的人 | 轮询间隔和「空闲」的判定口径 | 「挡不住的」里写了机制,但这一层我没量 |
| 想确认卸载后能恢复的人 | 卸载后遗留密文怎么手工解开——README 只给了容器格式,没给脚本 | 「WALLBK 容器」那节是格式说明,不是还原工具 |
| 在改口令时崩溃、又忘了旧口令的人 | 界面把「钥匙处于中间状态、需要用旧口令」显示成「口令不对」,用户无法区分「真输错了」和「这条会话卡在崩溃窗口里」 | 状态表里没有「改口令的中间状态」那一行;verify 的 409 与 401 在 client.js 里被合并成同一句文案 |
| 想在崩溃后恢复的人 | 崩溃窗口①的恢复路径:新口令已写进 state.json 但钥匙没换,而旧钥匙无法从 scrypt 哈希反推,所以必须由人输入旧口令才能完成换钥匙。没有自动收敛 |
lib/routes.mjs 的 password 路由三步顺序;verify 在 key 打不开时返回 409 而非接受新口令 |
自检
node test/host-selftest.mjs
node test/client-selftest.mjs
两个都在不启动 DSH 的前提下跑:前者用假的 ctx 走一遍路由、口令、磁盘加解密、 后端补丁;后者用假的 window 走一遍 slot 注册、弹窗渲染、菜单状态。红色就表示当前 工作区不该提交。
真机验证需要重启 DSH(宿主半边不热重载),然后看
~/.dsh/session-vault/_diagnostics.json 和同目录的 session-vault.log。
许可
GPL-3.0-or-later。全文见 LICENSE。
DSH 以 MIT License 开源,本插件以独立进程内模块的形式加载,不修改其二进制或 app.asar。
GPL 义务覆盖的是本仓库的源码。
No comments yet. Be the first to write one.