dsh-vaultwarden 0.8.8 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/lib/atomic-file.js +97 -0
- package/lib/cache-store.js +4 -22
- package/lib/client.js +48 -7
- package/lib/index.js +7 -3
- package/lib/session-store.js +12 -15
- package/lib/vault.js +55 -6
- package/package.json +1 -1
- package/scripts/build.sh +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `dsh-vaultwarden` are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.8.9] — 2026-10-19
|
|
8
|
+
|
|
9
|
+
### 安全
|
|
10
|
+
|
|
11
|
+
- 修复「重新验证」授权过期后条目明文仍留在面板上的问题:窗口一失效,详情页立即收回为门禁态,并重新向宿主取一次答案。
|
|
12
|
+
- 修复「重新验证」授权过期后不再轮询倒计时的问题:解锁后的答案不含 `repromptRequired`,轮询因此提前停止,授权窗口永远到不了点。
|
|
13
|
+
- `bitwarden_get` 的 `field=all` 不再返回「隐藏」型自定义字段的值(占位 `<hidden>`),需要时用 `field=fields` 显式取;面板自身的读取不受影响。
|
|
14
|
+
- 检索结果不再回传排序分 `score`。
|
|
15
|
+
- 网络错误消息只报服务器 origin,不再回带完整接口路径。
|
|
16
|
+
|
|
17
|
+
### 修复
|
|
18
|
+
|
|
19
|
+
- 落盘写入改为独占创建 + 随机临时名 + fsync + 目录 fsync:临时文件被预置为 0644 时权限不再被沿用,崩溃不再可能留下 0 字节记录。
|
|
20
|
+
- 修正面板自定义字段注释与宿主投影不符的问题。
|
|
7
21
|
## [0.8.8] — 2026-10-18
|
|
8
22
|
|
|
9
23
|
### 新增
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private atomic file writes: the one place every on-disk record goes through.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* `renameSync` already made readers safe — they see the old inode or the new
|
|
7
|
+
* one, never half a file. The writer side was the weak half, for three reasons
|
|
8
|
+
* 0.8.8 shipped with:
|
|
9
|
+
*
|
|
10
|
+
* 1. The temp name was `${path}.${pid}.tmp`, and `writeFileSync`'s `mode` is
|
|
11
|
+
* ignored for a file that already exists. A leftover 0644 temp file (from a
|
|
12
|
+
* crash, or from anything else that can write the directory) kept its 0644
|
|
13
|
+
* while the complete record — master key included — was written into it,
|
|
14
|
+
* until the following `chmodSync` narrowed it. An `wx` open refuses an
|
|
15
|
+
* existing path outright instead of reusing it, and a fresh inode takes the
|
|
16
|
+
* stated mode even under a permissive umask, so that window is gone.
|
|
17
|
+
* 2. worker_threads share one `process.pid`, so two threads could pick the
|
|
18
|
+
* same temp path. The suffix is random now.
|
|
19
|
+
* 3. A rename still in the page cache can land before its data, so a power cut
|
|
20
|
+
* could leave a zero-length target — which `load()` reads as "corrupt",
|
|
21
|
+
* deletes, and the operator is silently signed out. fsync before the rename
|
|
22
|
+
* (and on the directory after it) is what makes the rename a promise
|
|
23
|
+
* instead of a hope.
|
|
24
|
+
*
|
|
25
|
+
* Note on scope: this file makes a completed write atomic and durable. It does
|
|
26
|
+
* NOT serialize two writers. That is the remaining gap — `touchSession()` does
|
|
27
|
+
* a lock-free read-modify-write (`vault.js`), and two DSH instances sharing
|
|
28
|
+
* `~/.dsh` can resurrect a rotated refresh token over each other's session.
|
|
29
|
+
* Closing that needs a cross-process lock (`withFileLock` from
|
|
30
|
+
* `@deepseek-ai/dsh-atomic-write`); see `.research/atomic-audit.md`.
|
|
31
|
+
*/
|
|
32
|
+
import { randomBytes } from 'node:crypto'
|
|
33
|
+
import { closeSync, fchmodSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from 'node:fs'
|
|
34
|
+
import { dirname } from 'node:path'
|
|
35
|
+
|
|
36
|
+
/** Default bits for a record that must stay readable only by its owner. */
|
|
37
|
+
const PRIVATE_MODE = 0o600
|
|
38
|
+
|
|
39
|
+
/** Create the parent tree privately. Existing directories keep their mode. */
|
|
40
|
+
export function ensurePrivateDir(path, dirMode = 0o700) {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true, mode: dirMode })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace `path` with `content` in one atomic, durable step: exclusive-create
|
|
46
|
+
* a random-suffix sibling, flush it, rename it over the target, then flush the
|
|
47
|
+
* directory entry.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} path final path receiving the content
|
|
50
|
+
* @param {string|Buffer} content complete next content
|
|
51
|
+
* @param {{mode?: number}} [options] permission bits for the replacement inode
|
|
52
|
+
*/
|
|
53
|
+
export function writeFilePrivate(path, content, options = {}) {
|
|
54
|
+
const mode = options.mode ?? PRIVATE_MODE
|
|
55
|
+
ensurePrivateDir(path)
|
|
56
|
+
const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`
|
|
57
|
+
let fd = null
|
|
58
|
+
try {
|
|
59
|
+
// `wx` is the point: it refuses to reuse — or follow — whatever sits at the
|
|
60
|
+
// temp path. fchmod then pins the exact bits, so the record is never wider
|
|
61
|
+
// than asked, not even for the duration of one syscall.
|
|
62
|
+
fd = openSync(temporary, 'wx', mode)
|
|
63
|
+
fchmodSync(fd, mode)
|
|
64
|
+
writeSync(fd, content)
|
|
65
|
+
fsyncSync(fd)
|
|
66
|
+
closeSync(fd)
|
|
67
|
+
fd = null
|
|
68
|
+
renameSync(temporary, path)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (fd !== null) {
|
|
71
|
+
try {
|
|
72
|
+
closeSync(fd)
|
|
73
|
+
} catch {
|
|
74
|
+
/* already closed */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
rmSync(temporary, { force: true })
|
|
79
|
+
} catch {
|
|
80
|
+
/* a temp file that cannot be removed is replaced by the next write */
|
|
81
|
+
}
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
// The rename is only durable once the directory entry itself is on disk.
|
|
85
|
+
// A filesystem that refuses to fsync a directory costs durability, not
|
|
86
|
+
// correctness, so this stays best-effort.
|
|
87
|
+
try {
|
|
88
|
+
const dir = openSync(dirname(path), 'r')
|
|
89
|
+
try {
|
|
90
|
+
fsyncSync(dir)
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(dir)
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* platforms that cannot open or sync a directory */
|
|
96
|
+
}
|
|
97
|
+
}
|
package/lib/cache-store.js
CHANGED
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
* read is the cheap guarantee that nothing can hide behind a probe bug.
|
|
44
44
|
*/
|
|
45
45
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
46
|
-
import {
|
|
46
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
47
47
|
import { dirname, join } from 'node:path'
|
|
48
48
|
import { gunzipSync, gzipSync } from 'node:zlib'
|
|
49
|
+
import { writeFilePrivate } from './atomic-file.js'
|
|
49
50
|
|
|
50
51
|
/** File format version; a mismatch discards the stored cache. */
|
|
51
52
|
const CACHE_VERSION = 2
|
|
@@ -149,21 +150,11 @@ export class CacheStore {
|
|
|
149
150
|
const canonical = this.#canonical({ ...record, version: CACHE_VERSION, savedAt: record.savedAt ?? this.now() })
|
|
150
151
|
const mac = this.#sign(canonical)
|
|
151
152
|
if (!mac) return false // without a key the record could never be verified
|
|
152
|
-
const temporary = `${this.path}.${process.pid}.tmp`
|
|
153
153
|
try {
|
|
154
154
|
const payload = gzipSync(JSON.stringify({ ...canonical, mac }))
|
|
155
|
-
|
|
156
|
-
writeFileSync(temporary, payload, { mode: 0o600 })
|
|
157
|
-
// writeFileSync's mode is masked by umask, so set it explicitly.
|
|
158
|
-
chmodSync(temporary, 0o600)
|
|
159
|
-
renameSync(temporary, this.path)
|
|
155
|
+
writeFilePrivate(this.path, payload, { mode: 0o600 })
|
|
160
156
|
return true
|
|
161
157
|
} catch {
|
|
162
|
-
try {
|
|
163
|
-
rmSync(temporary, { force: true })
|
|
164
|
-
} catch {
|
|
165
|
-
/* nothing else to do */
|
|
166
|
-
}
|
|
167
158
|
return false
|
|
168
159
|
}
|
|
169
160
|
}
|
|
@@ -225,19 +216,10 @@ export class CacheStore {
|
|
|
225
216
|
const existing = this.#readKey()
|
|
226
217
|
if (existing) return existing
|
|
227
218
|
const key = randomBytes(KEY_BYTES)
|
|
228
|
-
const temporary = `${this.keyPath}.${process.pid}.tmp`
|
|
229
219
|
try {
|
|
230
|
-
|
|
231
|
-
writeFileSync(temporary, key, { mode: 0o600 })
|
|
232
|
-
chmodSync(temporary, 0o600)
|
|
233
|
-
renameSync(temporary, this.keyPath)
|
|
220
|
+
writeFilePrivate(this.keyPath, key, { mode: 0o600 })
|
|
234
221
|
return key
|
|
235
222
|
} catch {
|
|
236
|
-
try {
|
|
237
|
-
rmSync(temporary, { force: true })
|
|
238
|
-
} catch {
|
|
239
|
-
/* nothing else to do */
|
|
240
|
-
}
|
|
241
223
|
return null
|
|
242
224
|
}
|
|
243
225
|
}
|
package/lib/client.js
CHANGED
|
@@ -396,7 +396,7 @@ const nextOffsetOf = (payload, fallback) => {
|
|
|
396
396
|
* `scripts/build.sh` stamps both constants from package.json on every
|
|
397
397
|
* build, so this fallback is never hand-maintained and cannot drift.
|
|
398
398
|
*/
|
|
399
|
-
const BUILD_VERSION = '0.8.
|
|
399
|
+
const BUILD_VERSION = '0.8.9'
|
|
400
400
|
const BUILD_REPOSITORY = 'https://github.com/BuvkB/dsh-vaultwarden'
|
|
401
401
|
/**
|
|
402
402
|
* Turn a package.json repository url into something a browser can open.
|
|
@@ -3955,6 +3955,23 @@ const nextOffsetOf = (payload, fallback) => {
|
|
|
3955
3955
|
const [accessMode, setAccessMode] = useState('readonly')
|
|
3956
3956
|
// Bumped after a write, so the open entry re-reads its new state.
|
|
3957
3957
|
const [detailGen, setDetailGen] = useState(0)
|
|
3958
|
+
/**
|
|
3959
|
+
* What is left of an entry once the host's answer is taken away: the
|
|
3960
|
+
* identity a locked pane still renders, and nothing else.
|
|
3961
|
+
*
|
|
3962
|
+
* `projectItem` answers a gated entry with exactly these keys (id, name,
|
|
3963
|
+
* type, uris, repromptRequired, hint). Reusing that shape here means the
|
|
3964
|
+
* pane does not need a second branch: it falls into the same
|
|
3965
|
+
* `value.repromptRequired` render it would have got from the host, and
|
|
3966
|
+
* the re-read that follows simply confirms it.
|
|
3967
|
+
*/
|
|
3968
|
+
const gatedStub = (value) => ({
|
|
3969
|
+
id: value?.id ?? null,
|
|
3970
|
+
name: value?.name ?? null,
|
|
3971
|
+
type: value?.type ?? null,
|
|
3972
|
+
uris: value?.uris,
|
|
3973
|
+
repromptRequired: true,
|
|
3974
|
+
})
|
|
3958
3975
|
/**
|
|
3959
3976
|
* The write step waiting for confirmation, or null. One slot for every
|
|
3960
3977
|
* action (archive, trash, restore, move): the confirmation is always the
|
|
@@ -4759,7 +4776,15 @@ const nextOffsetOf = (payload, fallback) => {
|
|
|
4759
4776
|
// was only a number — the reveal flag, the narrow card/SSH/note payloads
|
|
4760
4777
|
// and an open editor all outlived it, so a 5-minute proof left those
|
|
4761
4778
|
// secrets readable for as long as the entry stayed selected.
|
|
4762
|
-
|
|
4779
|
+
// The countdown is polled while a gated entry is on screen, and also
|
|
4780
|
+
// while a grant is still in force over an entry that HAS been unlocked:
|
|
4781
|
+
// the host's answer for an unlocked entry is the plaintext, which no
|
|
4782
|
+
// longer carries `repromptRequired`, so keying the poll on that flag
|
|
4783
|
+
// alone stopped watching the very window it is supposed to close — the
|
|
4784
|
+
// pane then kept the plaintext after the proof lapsed. `grant.granted`
|
|
4785
|
+
// is what keeps the watch alive for exactly as long as there is
|
|
4786
|
+
// something left to take away.
|
|
4787
|
+
const gateOnScreen = detail.status === 'ready' && (Boolean(detail.value?.repromptRequired) || grant.granted)
|
|
4763
4788
|
const grantRef = useRef(grant)
|
|
4764
4789
|
grantRef.current = grant
|
|
4765
4790
|
useEffect(() => {
|
|
@@ -4779,6 +4804,20 @@ const nextOffsetOf = (payload, fallback) => {
|
|
|
4779
4804
|
setRevealed(false)
|
|
4780
4805
|
setTypeFields({})
|
|
4781
4806
|
closeEditor()
|
|
4807
|
+
// The pane itself has to go back behind the gate. Clearing the
|
|
4808
|
+
// reveal flag only re-masked ONE row: `detail` still held the
|
|
4809
|
+
// host's full plaintext, so the password row's own 显示 toggle
|
|
4810
|
+
// put the value straight back on screen (and so did the notes
|
|
4811
|
+
// and custom-field rows) long after the proof expired — the
|
|
4812
|
+
// exact opposite of what this effect is for.
|
|
4813
|
+
//
|
|
4814
|
+
// Two steps, and the order matters. First the plaintext leaves
|
|
4815
|
+
// React state here, synchronously, so no rejected or slow re-read
|
|
4816
|
+
// can leave it readable in between. Then `detailGen` makes the
|
|
4817
|
+
// host answer again, which restores the authoritative gated
|
|
4818
|
+
// payload (id/name/type/uris only).
|
|
4819
|
+
setDetail((previous) => (previous.status === 'ready' ? { status: 'ready', value: gatedStub(previous.value) } : previous))
|
|
4820
|
+
setDetailGen((previous) => previous + 1)
|
|
4782
4821
|
}
|
|
4783
4822
|
} catch {
|
|
4784
4823
|
/* the host is busy or the panel is closing; next tick retries */
|
|
@@ -5791,11 +5830,13 @@ const nextOffsetOf = (payload, fallback) => {
|
|
|
5791
5830
|
*
|
|
5792
5831
|
* Every value is masked, like a card number or a private key:
|
|
5793
5832
|
* Bitwarden has a "hidden" field type that carries PINs, API keys and
|
|
5794
|
-
* recovery codes
|
|
5795
|
-
*
|
|
5796
|
-
*
|
|
5797
|
-
*
|
|
5798
|
-
*
|
|
5833
|
+
* recovery codes. The host does send `fields[].type` (it is what keeps
|
|
5834
|
+
* hidden values out of the model-facing `all` projection), but this
|
|
5835
|
+
* pane does not branch on it on purpose: the panel is where the
|
|
5836
|
+
* account's owner reads their own vault, so every field starts masked
|
|
5837
|
+
* and the human clicks 显示 — the same treatment the card number and
|
|
5838
|
+
* the private key get, hidden or not. The `type` is still carried in
|
|
5839
|
+
* the payload if a future row wants to label it.
|
|
5799
5840
|
*/
|
|
5800
5841
|
const fieldsSection = (value.fields ?? []).length
|
|
5801
5842
|
? h(
|
package/lib/index.js
CHANGED
|
@@ -77,7 +77,7 @@ const PROMPT_TEXT = [
|
|
|
77
77
|
'只有当 bitwarden_status 显示未配置、不可达或登录失败时,才请用户在 DSH 设置 → 插件 → bitwarden 中补全 serverUrl/email/主密码。',
|
|
78
78
|
'取到的明文凭据只用于完成任务,不要回显、不要写入文件或日志。',
|
|
79
79
|
'遇到 repromptRequired 的条目(Bitwarden 里对该条目开了「重新验证」)时不要反复试探:工具调用没有参数能绕开这道门,请转告用户在「设置 → 凭据库」里打开该条目并输入主密码,验证后短时间内可读取,或到官方客户端查看。',
|
|
80
|
-
'bitwarden_get 的 field=all
|
|
80
|
+
'bitwarden_get 的 field=all 只回密码、用户名、网址、备注与普通自定义字段:TOTP 验证码只在下一次过期前有意义,卡号/身份信息/安全笔记/SSH 私钥/隐藏型字段需要点名 field=totp / card / identity / secureNote / sshKey / fields 单独取。',
|
|
81
81
|
'取 TOTP 一律传 waitFresh: true:剩余不足 minRemaining(默认 20 秒)会等到下一个 30 秒窗口再返回,避免拿到来不及提交的废码;返回值里的 freshFor 是这枚码还剩多少秒可用。',
|
|
82
82
|
'写入(bitwarden_create/update/delete/restore)默认关闭:accessMode=readonly 一律拒绝,ask 需用户逐次确认,auto 才直接写回;个人条目与组织条目都支持(创建组织条目传 organizationId + collectionIds)。',
|
|
83
83
|
'bitwarden_delete 默认软删除进回收站(bitwarden_restore 可恢复);permanent: true 必须同时传 confirm: true 才会彻底删除。',
|
|
@@ -401,14 +401,14 @@ export function apply(ctx, config = {}) {
|
|
|
401
401
|
ctx.tools.register(
|
|
402
402
|
defineTool({
|
|
403
403
|
name: 'bitwarden_get',
|
|
404
|
-
description: '取出某条凭据的密码、用户名、当前 TOTP 码或自定义字段(id 或名称定位)。默认 field=all
|
|
404
|
+
description: '取出某条凭据的密码、用户名、当前 TOTP 码或自定义字段(id 或名称定位)。默认 field=all 只回常用字段;卡号、身份信息、安全笔记、SSH 私钥与隐藏型自定义字段必须点名各自的 field。',
|
|
405
405
|
parameters: {
|
|
406
406
|
id: { type: 'string', description: '条目 id(来自 bitwarden_find)' },
|
|
407
407
|
name: { type: 'string', description: '或条目名称(需唯一匹配)' },
|
|
408
408
|
field: {
|
|
409
409
|
type: 'string',
|
|
410
410
|
enum: ['all', 'password', 'username', 'totp', 'notes', 'fields', 'card', 'identity', 'sshKey', 'secureNote', 'passwordHistory'],
|
|
411
|
-
description: '取哪个字段,默认 all。all 不含 TOTP 验证码(30
|
|
411
|
+
description: '取哪个字段,默认 all。all 不含 TOTP 验证码(30 秒即失效)、卡号/身份信息/安全笔记正文/SSH 私钥/历史密码,以及「隐藏」型自定义字段的值(占位 <hidden>,需 field=fields 显式取),只用 hasTotp/hasCard 等布尔位与 passwordHistoryCount 告知存在',
|
|
412
412
|
},
|
|
413
413
|
includeTrashed: {
|
|
414
414
|
type: 'boolean',
|
|
@@ -431,6 +431,10 @@ export function apply(ctx, config = {}) {
|
|
|
431
431
|
includeTrashed: Boolean(args.includeTrashed),
|
|
432
432
|
waitFresh: Boolean(args.waitFresh),
|
|
433
433
|
minRemaining: args.minRemaining,
|
|
434
|
+
// This is the model-facing call: hidden custom field values are
|
|
435
|
+
// withheld from `all` here and only here. The panel's own
|
|
436
|
+
// reveal (vw/reveal) keeps them so it can render them masked.
|
|
437
|
+
maskHiddenFields: true,
|
|
434
438
|
})
|
|
435
439
|
} catch (error) {
|
|
436
440
|
return failure(error)
|
package/lib/session-store.js
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* the deadline, so a session that is used regularly never expires, while one
|
|
29
29
|
* left alone past `maxAgeDays` is dropped.
|
|
30
30
|
*/
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
31
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { writeFilePrivate } from './atomic-file.js'
|
|
33
34
|
|
|
34
35
|
/** File format version; a mismatch discards the stored session. */
|
|
35
36
|
const STORE_VERSION = 1
|
|
@@ -118,8 +119,14 @@ export class SessionStore {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
|
-
* Write the session. The write is atomic
|
|
122
|
-
*
|
|
122
|
+
* Write the session. The write is atomic and durable (exclusive-create temp
|
|
123
|
+
* file, fsync, rename, directory fsync) so a crash cannot leave a
|
|
124
|
+
* half-written file that fails to parse on the next boot — see
|
|
125
|
+
* lib/atomic-file.js for why each step is there.
|
|
126
|
+
*
|
|
127
|
+
* Serializing two writers is still NOT done here: this method can be
|
|
128
|
+
* overtaken by another process doing its own read-modify-write. Tracked in
|
|
129
|
+
* `.research/atomic-audit.md` (cross-process lock).
|
|
123
130
|
* @returns {boolean} whether the session was persisted
|
|
124
131
|
*/
|
|
125
132
|
save(record) {
|
|
@@ -129,20 +136,10 @@ export class SessionStore {
|
|
|
129
136
|
}
|
|
130
137
|
if (!record || (!record.refreshToken && !record.accessToken)) return false
|
|
131
138
|
const payload = JSON.stringify({ ...record, version: STORE_VERSION, lastUsedAt: record.lastUsedAt ?? this.now() }, null, 2)
|
|
132
|
-
const temporary = `${this.path}.${process.pid}.tmp`
|
|
133
139
|
try {
|
|
134
|
-
|
|
135
|
-
writeFileSync(temporary, payload, { mode: 0o600 })
|
|
136
|
-
// writeFileSync's mode is masked by umask, so set it explicitly.
|
|
137
|
-
chmodSync(temporary, 0o600)
|
|
138
|
-
renameSync(temporary, this.path)
|
|
140
|
+
writeFilePrivate(this.path, payload, { mode: 0o600 })
|
|
139
141
|
return true
|
|
140
142
|
} catch {
|
|
141
|
-
try {
|
|
142
|
-
rmSync(temporary, { force: true })
|
|
143
|
-
} catch {
|
|
144
|
-
/* nothing else to do */
|
|
145
|
-
}
|
|
146
143
|
return false
|
|
147
144
|
}
|
|
148
145
|
}
|
package/lib/vault.js
CHANGED
|
@@ -173,7 +173,19 @@ export async function httpJson(fetchImpl, url, options = {}, timeoutMs = DEFAULT
|
|
|
173
173
|
try {
|
|
174
174
|
response = await fetchImpl(url, { method, headers, body, signal: controller.signal })
|
|
175
175
|
} catch (error) {
|
|
176
|
-
|
|
176
|
+
// Origin only, never the full URL: the path names the endpoint being
|
|
177
|
+
// probed (/identity/connect/token, /api/sync), and this message ends up in
|
|
178
|
+
// the panel's sync tooltip and in `bitwarden_status`. The rest of the
|
|
179
|
+
// plugin already refuses to hand out deployment details (`gateway.js`
|
|
180
|
+
// strips the session file's absolute path for the same reason).
|
|
181
|
+
const origin = (() => {
|
|
182
|
+
try {
|
|
183
|
+
return new URL(url).origin
|
|
184
|
+
} catch {
|
|
185
|
+
return '服务器'
|
|
186
|
+
}
|
|
187
|
+
})()
|
|
188
|
+
const reason = signal?.aborted ? '请求已取消' : `无法连接 ${origin}(${error?.message ?? error})`
|
|
177
189
|
throw new VaultError(reason, {
|
|
178
190
|
code: 'network_error',
|
|
179
191
|
hint: '检查网络连通性、TLS 证书与服务地址;自签证书需在服务器侧换成受信任证书',
|
|
@@ -565,8 +577,12 @@ function decryptCipher(cipher, userKey, orgKeys, folderNames, collectionNames) {
|
|
|
565
577
|
}
|
|
566
578
|
const dec = (value) => decryptString(value, key)
|
|
567
579
|
const uris = (cipher.login?.uris ?? []).map((entry) => dec(entry?.uri)).filter(Boolean)
|
|
580
|
+
// `type` is kept because it is not decoration: Bitwarden's 1 means a hidden
|
|
581
|
+
// field (PIN, API key, recovery code) — the one kind an official client never
|
|
582
|
+
// puts on screen unasked. `projectItem` uses it to keep the wider `all`
|
|
583
|
+
// projection from handing those values to the model.
|
|
568
584
|
const fields = (cipher.fields ?? [])
|
|
569
|
-
.map((field) => ({ name: dec(field?.name), value: dec(field?.value), type: field?.type }))
|
|
585
|
+
.map((field) => ({ name: dec(field?.name), value: dec(field?.value), type: Number(field?.type ?? 0) }))
|
|
570
586
|
.filter((field) => field.name || field.value)
|
|
571
587
|
// Attachment metadata only: the file names are encrypted with the item key
|
|
572
588
|
// and the sizes are plain, but the bytes are a separate encrypted blob behind
|
|
@@ -788,7 +804,16 @@ function resolveItem(vault, ref) {
|
|
|
788
804
|
fail(`凭据库中没有匹配 "${needle}" 的条目`, 'not_found', '换关键词调用 bitwarden_find,或请用户确认条目标题')
|
|
789
805
|
}
|
|
790
806
|
|
|
791
|
-
|
|
807
|
+
/**
|
|
808
|
+
* What `field: 'all'` puts in place of a hidden custom field's value.
|
|
809
|
+
*
|
|
810
|
+
* A marker, not a mask: the caller has to be able to tell "this is a secret I
|
|
811
|
+
* have not been given" from "this field is empty", and the name it reads back
|
|
812
|
+
* is what it passes to `field: 'fields'` when it genuinely needs the value.
|
|
813
|
+
*/
|
|
814
|
+
const HIDDEN_FIELD_VALUE = '<hidden>'
|
|
815
|
+
|
|
816
|
+
function projectItem(item, field, at, confirm = false, options = {}) {
|
|
792
817
|
const base = { id: item.id, name: item.name, type: item.type }
|
|
793
818
|
// A trashed item is still readable by id, but the caller must never mistake
|
|
794
819
|
// it for a live one (its password may already be gone from the account).
|
|
@@ -828,6 +853,8 @@ function projectItem(item, field, at, confirm = false) {
|
|
|
828
853
|
return { ...base, totp: withTotp() ?? null }
|
|
829
854
|
case 'notes':
|
|
830
855
|
return { ...base, notes: truncate(item.notes ?? '') ?? null }
|
|
856
|
+
// Naming this field IS the ask: a caller that wants a hidden value has to
|
|
857
|
+
// say so, exactly like the card number and the SSH key below.
|
|
831
858
|
case 'fields':
|
|
832
859
|
return { ...base, fields: item.fields }
|
|
833
860
|
// The narrow cases below exist so that `all` can stop being a shotgun.
|
|
@@ -872,8 +899,17 @@ function projectItem(item, field, at, confirm = false) {
|
|
|
872
899
|
// For a secure note the `notes` field IS the secret, so `all` leaves it
|
|
873
900
|
// out the same way it leaves out the card number: the caller has to
|
|
874
901
|
// name `field=secureNote` to get the body.
|
|
902
|
+
// Ordinary notes ride along (they are usually "which tenant was this
|
|
903
|
+
// again"), but a secure note's body does not — see `secureNote` below.
|
|
875
904
|
notes: item.notes && item.type !== 'secureNote' ? truncate(item.notes) : undefined,
|
|
876
|
-
|
|
905
|
+
// Custom fields, with hidden ones withheld. Bitwarden's `type: 1` is
|
|
906
|
+
// where people put the PIN, the API key and the recovery code, and
|
|
907
|
+
// `all` was the one projection that still handed those values over
|
|
908
|
+
// without being asked for them. The name and the placeholder stay so a
|
|
909
|
+
// caller can see the field exists and name it back (field: 'fields').
|
|
910
|
+
fields: item.fields.length
|
|
911
|
+
? item.fields.map((field) => (field.type === 1 && options.maskHiddenFields ? { name: field.name, type: field.type, value: HIDDEN_FIELD_VALUE } : field))
|
|
912
|
+
: undefined,
|
|
877
913
|
// Presence flags instead of the payloads. Card numbers, identity
|
|
878
914
|
// numbers, secure notes and SSH keys each need a dedicated `field`
|
|
879
915
|
// call now; `all` still tells the caller they exist and what shape.
|
|
@@ -1185,7 +1221,11 @@ function searchPayload(vault, query, limit, { allowEmpty = false, offset = 0, in
|
|
|
1185
1221
|
hasMore: start + page.length < scored.length,
|
|
1186
1222
|
vaultItems: pool.length,
|
|
1187
1223
|
hint: scored.length ? '用 bitwarden_get(id 或 name)取出密码等字段' : '换个关键词,或先确认条目是否存在于该账户',
|
|
1188
|
-
|
|
1224
|
+
// The score stays inside this function: it is a ranking device, and its
|
|
1225
|
+
// value band tells the caller which KIND of field matched (20 = custom
|
|
1226
|
+
// field, 10 = notes), which is an existence oracle for anyone probing a
|
|
1227
|
+
// guessed token prefix. Callers get the order, not the arithmetic.
|
|
1228
|
+
items: page.map(({ item }) => summary(item)),
|
|
1189
1229
|
}
|
|
1190
1230
|
const archivedCount = vault.items.length - vault.items.filter((item) => !item.archived).length
|
|
1191
1231
|
if (archivedCount && !includeArchived) {
|
|
@@ -2617,7 +2657,16 @@ export class VaultClient {
|
|
|
2617
2657
|
}
|
|
2618
2658
|
return JSON.stringify(projected, null, 2)
|
|
2619
2659
|
}
|
|
2620
|
-
|
|
2660
|
+
// `maskHiddenFields` is the MODEL-facing boundary, not a display rule:
|
|
2661
|
+
// the host's own panel (revealEntry) is the account owner reading their
|
|
2662
|
+
// own vault, so it keeps the values it then renders masked behind a
|
|
2663
|
+
// click. A tool call is a different principal and does not get them for
|
|
2664
|
+
// free — see the `all` projection in projectItem.
|
|
2665
|
+
return JSON.stringify(
|
|
2666
|
+
projectItem(item, field, this.now(), this.repromptGranted, { maskHiddenFields: Boolean(options.maskHiddenFields) }),
|
|
2667
|
+
null,
|
|
2668
|
+
2,
|
|
2669
|
+
)
|
|
2621
2670
|
}
|
|
2622
2671
|
|
|
2623
2672
|
/** Configuration / connectivity / unlock report. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vaultwarden",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "Vaultwarden/Bitwarden real-time sync for DeepSeek Harness: WebSocket push notifications, on-disk vault cache with revision probing, read/write tools and an entry browser panel. Independent implementation; see THIRD-PARTY-NOTICES.md.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/scripts/build.sh
CHANGED
|
@@ -132,6 +132,13 @@ if [ "${SKIP_TESTS:-0}" != "1" ]; then
|
|
|
132
132
|
echo "=== write-back regression test (trash / concurrency / field preservation) ==="
|
|
133
133
|
node test/write-back.test.mjs
|
|
134
134
|
|
|
135
|
+
# The on-disk privacy contract every record obeys: exclusive-create temp path
|
|
136
|
+
# (a wider file already sitting there is never reused), 0600 carried by the
|
|
137
|
+
# fresh inode instead of a chmod afterwards, no predictable temp name, and a
|
|
138
|
+
# clean failure that leaves neither debris nor a half-written record.
|
|
139
|
+
echo "=== atomic file privacy test ==="
|
|
140
|
+
node test/atomic-file.test.mjs
|
|
141
|
+
|
|
135
142
|
# Host-entry smoke test: imports lib/index.js and runs apply() against a fake
|
|
136
143
|
# host context, so a broken Config schema or registration path fails here
|
|
137
144
|
# instead of at startup.
|