DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

12345-mcpython /

12345-mcpython/dsh-session-vault

Verified

A plugin to encrypt session data in radical way

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@e3d32a08

磁盘锁成立的前提是 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 义务覆盖的是本仓库的源码。

—/ 5

No ratings yet

Verified DSH bundle

Commit e3d32a08da17

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout