Repository navigation
Expand file tree
/
Copy pathBitwardenModel.js
More file actions
7059 lines (6313 loc) · 291 KB
/
Copy pathBitwardenModel.js
File metadata and controls
7059 lines (6313 loc) · 291 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// BitwardenModel.js -- pure helpers for the plugin: CLI command builders,
// output parsers, filtering and form builders.
.pragma library
const KEYRING_SERVICE = "qs-bitwarden-cli"
const KEYRING_ACCOUNT = "session"
const KEYRING_MASTER = "master_password"
const KEYRING_FIDO = "fido_password"
// `secret-tool store` reads stdin to EOF and Process.write() cannot close
// stdin, so secrets reach it through this env var, piped in by a shell. Never
// put a secret in argv: /proc/<pid>/cmdline is world-readable.
const KEYRING_SECRET_ENV = "QSBW_SECRET"
const KEYRING_PIN = "pin_blob"
const PIN_ENV = "QSBW_PIN"
// PBKDF2 rounds of the legacy PIN blob (AES-256-CBC, no MAC). Read only, to
// migrate it into the envelope on the next PIN unlock.
const PIN_ITERATIONS = 600000
// Each PIN guess costs Argon2id at 256 MiB x 4 passes, but the stored item
// is not a lock on guessing: a program running as the user can decrypt it
// with `systemd-creds --user` and try PINs offline on every core (see
// PIN_GUESSES_PER_SECOND). So six digits is the floor and eight the
// recommendation; six and seven are allowed with a warning that names the
// cost.
const PIN_MIN_LENGTH = 6
const PIN_RECOMMENDED_LENGTH = 8
// PINs set before the floor was raised (at 4) still unlock; only new ones
// must meet it.
const PIN_UNLOCK_MIN_LENGTH = 4
function keyringSecretEnvVar() {
return KEYRING_SECRET_ENV
}
function keyringAttributes(account) {
return " service " + shellQuote(KEYRING_SERVICE) + " account " + shellQuote(account)
}
function keyringStoreScript(label, account) {
return "printf '%s' \"$" + KEYRING_SECRET_ENV + "\" | secret-tool store --label=" + shellQuote(label)
+ keyringAttributes(account)
}
// A capped `secret-tool lookup` of one entry.
function keyringReadScript(account) {
return "secret-tool lookup" + keyringAttributes(account) + " 2>/dev/null | head -c " + MAX_TOKEN_BYTES
}
function keyringLookupEntryCommand(account) {
// Strip only secret-tool's trailing newline; trim() would also eat
// meaningful spaces in a password or client secret.
var script = "stored=$(" + keyringReadScript(account) + "); "
+ "__lookup_rc=$?; [ \"$__lookup_rc\" -eq 0 ] || exit \"$__lookup_rc\"; "
+ "printf '%s' \"$stored\""
return ["bash", "-c", cappedScript(script)]
}
function keyringClearEntryCommand(account) {
return ["secret-tool", "clear", "service", KEYRING_SERVICE, "account", account]
}
function keyringHasEntryCommand(account) {
var script = "if secret-tool lookup" + keyringAttributes(account)
+ " >/dev/null 2>&1; then echo yes; else echo no; fi"
return ["bash", "-c", script]
}
function shellQuote(value) {
return "'" + String(value || "").replace(/'/g, "'\\''") + "'"
}
// -------------------------------------------------------------------------
// Accounts
// -------------------------------------------------------------------------
//
// `bw` holds one account per data directory, so each account the panel knows
// lives in a slot: "default" is bw's own directory (the account a terminal
// `bw` sees, and the only one before multi-account), any other slot a private
// directory named by 16 hex digits, given to bw in BITWARDENCLI_APPDATA_DIR.
// A slot's keyring entries are the legacy names with "@<slot>" appended, so
// the default slot keeps its entries as they are, and no lookup for one slot
// can match another's (secret-tool matches attributes exactly).
var DEFAULT_ACCOUNT_SLOT = "default"
var ACCOUNT_SLOT_RE = /^[0-9a-f]{16}$/
var APPDATA_ENV = "BITWARDENCLI_APPDATA_DIR"
var ACCOUNTS_ENV = "QSBW_ACCOUNTS"
var ACCOUNTS_VERSION = 1
var MAX_ACCOUNTS = 10
var MAX_ACCOUNTS_BYTES = 64 * 1024
var ACCOUNTS_SUBDIR = "qs-bitwarden-cli/accounts"
var ACCOUNTS_DIR = "${XDG_DATA_HOME:-$HOME/.local/share}/" + ACCOUNTS_SUBDIR
function defaultAccountSlot() { return DEFAULT_ACCOUNT_SLOT }
function appDataEnvVar() { return APPDATA_ENV }
function accountsEnvVar() { return ACCOUNTS_ENV }
function maxAccounts() { return MAX_ACCOUNTS }
function isAccountSlot(slot) {
return slot === DEFAULT_ACCOUNT_SLOT || ACCOUNT_SLOT_RE.test(String(slot))
}
// Anything that is not a slot means the default one, which is what every
// caller got before slots existed.
function accountSlot(slot) {
return typeof slot === "string" && isAccountSlot(slot) ? slot : DEFAULT_ACCOUNT_SLOT
}
function keyringEntryName(base, slot) {
var s = accountSlot(slot)
return s === DEFAULT_ACCOUNT_SLOT ? base : base + "@" + s
}
// The slot's bw data directory, or "" for the default slot (bw's own). Built
// from the caller's environment, since a Process environment is not a shell.
function accountAppDataDir(slot, xdgDataHome, home) {
var s = accountSlot(slot)
if (s === DEFAULT_ACCOUNT_SLOT) return ""
var base = String(xdgDataHome || "")
if (!base || base.charAt(0) !== "/") {
var h = String(home || "")
if (!h || h.charAt(0) !== "/") return ""
base = h.replace(/\/+$/, "") + "/.local/share"
}
return base.replace(/\/+$/, "") + "/" + ACCOUNTS_SUBDIR + "/" + s
}
// For scripts not started with the vault's environment (a terminal login):
// the same directory, derived in the shell.
function accountAppDataExport(slot) {
var s = accountSlot(slot)
if (s === DEFAULT_ACCOUNT_SLOT) return ""
return "export " + APPDATA_ENV + "=\"" + ACCOUNTS_DIR + "/" + s + "\"; "
}
// bw creates its data directory with its default umask; create a slot's
// first, private, and refuse a symlink in its place.
function appDataDirPrelude() {
return "if [ -n \"${" + APPDATA_ENV + ":-}\" ]; then "
+ "__acct_parent=\"$(dirname -- \"$" + APPDATA_ENV + "\")\"; "
+ "if [ -L \"$__acct_parent\" ]; then exit 1; fi; "
+ "(umask 077 && mkdir -p -- \"$__acct_parent\") || exit 1; "
+ "__acct_dir=\"$" + APPDATA_ENV + "\"; " + privateDirScript("__acct_dir")
+ "fi; "
}
// A fresh slot not already in `taken`. Not a secret, so Math.random is enough.
function newAccountSlot(taken) {
var used = {}
for (var i = 0; taken && i < taken.length; i++) used[taken[i]] = true
for (var attempt = 0; attempt < 32; attempt++) {
var slot = ""
while (slot.length < 16) slot += Math.floor(Math.random() * 16).toString(16)
if (!used[slot]) return slot
}
return ""
}
function emptyAccountRegistry() {
return { version: ACCOUNTS_VERSION, active: DEFAULT_ACCOUNT_SLOT, accounts: [] }
}
function cleanAccountText(value, max) {
return String(value === undefined || value === null ? "" : value)
.replace(/[\x00-\x1f\x7f]/g, "").slice(0, max)
}
// The registry file: which slots hold which account, and the active one.
// Nothing in it is secret; anything malformed is dropped rather than trusted.
function parseAccountRegistry(raw) {
var parsed = null
try { parsed = JSON.parse(String(raw || "")) } catch (e) { parsed = null }
var out = emptyAccountRegistry()
if (!parsed || typeof parsed !== "object" || Number(parsed.version) !== ACCOUNTS_VERSION) return out
var seen = {}
var list = Array.isArray(parsed.accounts) ? parsed.accounts : []
for (var i = 0; i < list.length && out.accounts.length < MAX_ACCOUNTS; i++) {
var a = list[i]
if (!a || typeof a !== "object" || typeof a.slot !== "string" || !isAccountSlot(a.slot)) continue
if (seen[a.slot]) continue
seen[a.slot] = true
out.accounts.push({
slot: a.slot,
email: cleanAccountText(a.email, 320),
userId: cleanAccountText(a.userId, 128),
server: cleanAccountText(a.server, 512),
lastUsed: Math.max(0, Math.floor(Number(a.lastUsed)) || 0)
})
}
if (typeof parsed.active === "string" && isAccountSlot(parsed.active)) out.active = parsed.active
return out
}
function serializeAccountRegistry(registry) {
var r = registry || emptyAccountRegistry()
return JSON.stringify({ version: ACCOUNTS_VERSION, active: accountSlot(r.active), accounts: r.accounts || [] })
}
function copyAccountRegistry(registry) {
var r = registry || emptyAccountRegistry()
var accounts = []
for (var i = 0; r.accounts && i < r.accounts.length; i++) {
var a = r.accounts[i]
accounts.push({ slot: a.slot, email: a.email, userId: a.userId, server: a.server, lastUsed: a.lastUsed })
}
return { version: ACCOUNTS_VERSION, active: accountSlot(r.active), accounts: accounts }
}
function registryAccount(registry, slot) {
var list = registry && registry.accounts ? registry.accounts : []
for (var i = 0; i < list.length; i++) if (list[i].slot === slot) return list[i]
return null
}
function registryHasAccount(registry, slot) {
return registryAccount(registry, slot) !== null
}
// Records what `bw status` says the slot holds. Another slot already holding
// the same account (same user on the same server) is retired: two sign-ins of
// one account would only disagree. Returns { registry, retired: [slots] }.
function registryNoteAccount(registry, slot, info, now) {
var r = copyAccountRegistry(registry)
var s = accountSlot(slot)
var userId = cleanAccountText(info && info.userId, 128)
var server = cleanAccountText(info && info.server, 512)
var retired = []
var kept = []
for (var i = 0; i < r.accounts.length; i++) {
var a = r.accounts[i]
if (a.slot !== s && userId && a.userId === userId && a.server === server) retired.push(a.slot)
else kept.push(a)
}
r.accounts = kept
var entry = registryAccount(r, s)
if (!entry) {
if (r.accounts.length >= MAX_ACCOUNTS) return { registry: registry, retired: [], full: true }
entry = { slot: s, email: "", userId: "", server: "", lastUsed: 0 }
r.accounts.push(entry)
}
entry.email = cleanAccountText(info && info.email, 320) || entry.email
entry.userId = userId || entry.userId
entry.server = server
entry.lastUsed = Math.max(0, Math.floor(Number(now)) || 0)
r.active = s
return { registry: r, retired: retired, full: false }
}
function registryRemoveAccount(registry, slot) {
var r = copyAccountRegistry(registry)
r.accounts = r.accounts.filter(function(a) { return a.slot !== slot })
return r
}
function registrySetActive(registry, slot, now) {
var r = copyAccountRegistry(registry)
r.active = accountSlot(slot)
var entry = registryAccount(r, r.active)
if (entry && now !== undefined) entry.lastUsed = Math.max(0, Math.floor(Number(now)) || 0)
return r
}
// Where a new sign-in goes: the default slot while nothing holds it, so a
// single account always lives where a terminal `bw` finds it.
function slotForNewAccount(registry) {
if (!registryHasAccount(registry, DEFAULT_ACCOUNT_SLOT)) return DEFAULT_ACCOUNT_SLOT
var taken = (registry.accounts || []).map(function(a) { return a.slot })
return newAccountSlot(taken)
}
// The account to show after `leaving` goes away: the most recently used
// other one, or "" if none.
function registryNextAccount(registry, leaving) {
var best = null
var list = registry && registry.accounts ? registry.accounts : []
for (var i = 0; i < list.length; i++) {
if (list[i].slot === leaving) continue
if (!best || list[i].lastUsed > best.lastUsed) best = list[i]
}
return best ? best.slot : ""
}
function accountServerLabel(server) {
var s = String(server || "").trim().replace(/\/+$/, "")
if (!s || s === BITWARDEN_US_SERVER || s === "https://bitwarden.com") return ""
if (s === BITWARDEN_EU_SERVER || s === "https://bitwarden.eu") return "EU"
return s.replace(/^https?:\/\//i, "")
}
// Rows for the account picker, sorted by email; nothing secret.
function accountRows(registry, activeSlot) {
var list = registry && registry.accounts ? registry.accounts.slice() : []
list.sort(function(a, b) {
var x = String(a.email).toLowerCase()
var y = String(b.email).toLowerCase()
return x < y ? -1 : (x > y ? 1 : (a.slot < b.slot ? -1 : 1))
})
return list.map(function(a) {
var server = accountServerLabel(a.server)
return {
slot: a.slot,
email: a.email || "Account",
server: server,
label: (a.email || "Account") + (server ? " (" + server + ")" : ""),
active: a.slot === activeSlot
}
})
}
// A slot that another sign-in replaced: its keyring entries and learned
// suggestions too (a logout clears those itself first).
function accountSlotRetireCommand(slot) {
var s = accountSlot(slot)
var script = nestedScript(keyringClearAllCommand(s)) + "; "
+ nestedScript(associationsClearCommand(s)) + "; "
+ nestedScript(accountSlotRemoveCommand(s))
return ["bash", "-c", script]
}
function accountRegistryReadCommand() {
var script = "d=\"" + ACCOUNTS_DIR + "\"; f=\"$d/registry.json\"; "
+ "if [ -d \"$d\" ] && [ ! -L \"$d\" ] && [ -f \"$f\" ] && [ ! -L \"$f\" ]; then "
+ "head -c " + MAX_ACCOUNTS_BYTES + " \"$f\" 2>/dev/null || printf '{}'; else printf '{}'; fi"
return ["bash", "-c", script]
}
// Payload in the environment; written to a private temp file and renamed,
// like the learned associations.
function accountRegistryWriteCommand() {
var script = "set -e; __parent=\"$(dirname -- \"" + ACCOUNTS_DIR + "\")\"; "
+ "[ ! -L \"$__parent\" ]; (umask 077 && mkdir -p -- \"$__parent\"); "
+ "d=\"" + ACCOUNTS_DIR + "\"; " + privateDirScript("d")
+ "umask 077; tmp=$(mktemp -- \"$d/.registry.XXXXXXXX\"); "
+ "trap 'rm -f -- \"$tmp\"' EXIT HUP INT TERM; "
+ "printf '%s' \"$" + ACCOUNTS_ENV + "\" > \"$tmp\"; chmod 600 \"$tmp\"; "
+ "mv -fT -- \"$tmp\" \"$d/registry.json\"; trap - EXIT HUP INT TERM"
return ["bash", "-c", script]
}
// Signs a slot out of bw and deletes its data directory. The default slot's
// directory is bw's own and is never deleted, only signed out of.
function accountSlotRemoveCommand(slot) {
var s = accountSlot(slot)
var script = accountAppDataExport(s) + "bw logout >/dev/null 2>&1; "
if (s !== DEFAULT_ACCOUNT_SLOT) {
script += "d=\"" + ACCOUNTS_DIR + "\"; "
+ "if [ -d \"$d\" ] && [ ! -L \"$d\" ] && [ -e \"$d/" + s + "\" ]; then rm -rf -- \"$d/" + s + "\"; fi; "
}
script += "exit 0"
return ["bash", "-c", cappedScript(script, MAX_STDERR_BYTES)]
}
// Secrets never go in argv. The session token travels in BW_SESSION.
const SESSION_ENV = "BW_SESSION"
// bw reads these natively (--passwordenv, `login --apikey`), so the master
// password and API key reach no argv. Values are set by authEnv() in Service.qml.
const PASSWORD_ENV = "BW_PASSWORD"
const CLIENT_ID_ENV = "BW_CLIENTID"
const CLIENT_SECRET_ENV = "BW_CLIENTSECRET"
// bw takes the two-step code only from --code, an argv every local user can
// read. The login command carries TWOFACTOR_CODE_FLAG instead, which
// bw-login-code.js swaps for --code=$QSBW_CODE inside bw's own process.argv.
// Both names are repeated in that file.
const TWOFACTOR_CODE_ENV = "QSBW_CODE"
const TWOFACTOR_CODE_FLAG = "--qsbw-code-from-env"
// Auth commands run non-interactively so a hidden prompt fails fast. The
// exception is deviceVerificationLoginCommand().
const NOINTERACTION_ENV = "BW_NOINTERACTION"
// First bw release Bitwarden documents SSH keys for.
const SSH_CLI_MIN_VERSION = "2025.1.2"
// Earlier bw releases throw on SSH items with missing public fields, failing
// the whole `bw list items`; the panel names this release as the fix.
const SSH_MALFORMED_ITEM_FIX_VERSION = "2026.8.0"
// The new-device verification code: piped to bw by printf, so it reaches no argv.
const DEVICE_CODE_ENV = "QSBW_DEVICE_CODE"
function sessionEnvVar() {
return SESSION_ENV
}
// NODE_OPTIONS for every `bw`: the user's own, plus two preloads from the
// plugin directory: bw-login-code.js, which hands bw the two-step code without
// an argv, and bw-fast-exit.js, which saves the ~2 s bw idles after answering.
// Node reads NODE_OPTIONS with double quotes and backslash escapes. No plugin
// directory (not a file URL) leaves the options as they were.
var BW_LOGIN_CODE_FILE = "bw-login-code.js"
var BW_FAST_EXIT_FILE = "bw-fast-exit.js"
function bwNodeOptions(pluginDir, existing) {
var base = String(existing || "").trim()
var dir = String(pluginDir || "").replace(/\/+$/, "")
if (dir === "") return base
var options = []
var files = [BW_LOGIN_CODE_FILE, BW_FAST_EXIT_FILE]
for (var i = 0; i < files.length; i++) {
var file = dir + "/" + files[i]
options.push("--require \"" + file.replace(/(["\\])/g, "\\$1") + "\"")
}
var option = options.join(" ")
return base ? base + " " + option : option
}
function passwordEnvVar() {
return PASSWORD_ENV
}
function clientIdEnvVar() {
return CLIENT_ID_ENV
}
function clientSecretEnvVar() {
return CLIENT_SECRET_ENV
}
function twoFactorCodeEnvVar() {
return TWOFACTOR_CODE_ENV
}
function twoFactorCodeFlag() {
return TWOFACTOR_CODE_FLAG
}
function noInteractionEnvVar() {
return NOINTERACTION_ENV
}
function deviceCodeEnvVar() {
return DEVICE_CODE_ENV
}
// Producer-side (`head -c`) caps on every stream the shell collects.
var MAX_ITEMS_BYTES = 16 * 1024 * 1024 // 16 MB: large vault item list
var MAX_DETAIL_BYTES = 4 * 1024 * 1024 // 4 MB: single item with custom fields & notes
var MAX_SENDS_BYTES = 8 * 1024 * 1024 // 8 MB: send list
var MAX_COLLECTIONS_BYTES = 2 * 1024 * 1024 // 2 MB: org collections
var MAX_FOLDERS_BYTES = 2 * 1024 * 1024 // 2 MB: folder list
var MAX_ORGS_BYTES = 2 * 1024 * 1024 // 2 MB: organizations list
var MAX_STATUS_BYTES = 64 * 1024 // 64 KB: status json
var MAX_TOKEN_BYTES = 4096 // 4 KB: session token / password / TOTP
var MAX_HANDOFF_BYTES = 4096 // 4 KB: session handoff file
var MAX_ASSOC_BYTES = 1024 * 1024 // 1 MB: learned associations file
var MAX_STDERR_BYTES = 8192 // 8 KB: diagnostic stderr output
var MAX_MISC_BYTES = 64 * 1024 // 64 KB: create/edit/delete responses
// Attachments stream to disk: cap the file size, the transfer time, and keep
// free space in reserve.
var MAX_ATTACHMENT_BYTES = 512 * 1024 * 1024 // 512 MB: Bitwarden's own per-file ceiling
var ATTACHMENT_TIMEOUT_SECS = 900 // 15 min: a stalled transfer must not hold the queue
var ATTACHMENT_FREE_SLACK_BYTES = 64 * 1024 * 1024 // 64 MB: never fill the disk to the last byte
// `pipefail` keeps bw's exit status through a `head -c` cap, except 141: the
// SIGPIPE the cap sends when it truncates a healthy stream.
function cappedScript(script, maxStderrBytes) {
var out = ""
if (maxStderrBytes) {
out += "exec 2> >(head -c " + Number(maxStderrBytes) + " >&2); "
}
out += "set -o pipefail; " + script
// `case`, not `[ ] &&`, which fails on no match and would trip `set -e`.
out += "\n__rc=$?\ncase \"$__rc\" in 141) __rc=0 ;; esac\nexit \"$__rc\""
return out
}
// Arguments are shell-quoted. Callers put `--` before server-chosen ids so an
// id shaped like a flag stays positional; our own flags go before it.
function buildCappedCommand(args, maxStdoutBytes, maxStderrBytes) {
var inner = "bw"
if (args && args.length > 0) {
for (var i = 0; i < args.length; i++) {
var arg = String(args[i])
if (/^[a-zA-Z0-9_\-\.\/]+$/.test(arg)) {
inner += " " + arg
} else {
inner += " " + shellQuote(arg)
}
}
}
if (maxStdoutBytes) {
inner += " | head -c " + Number(maxStdoutBytes)
}
return ["bash", "-c", cappedScript(inner, maxStderrBytes)]
}
// A bw session key is base64 (88 chars for bw's 64 bytes). Anything not
// shaped like one yields "", which callers treat as failure.
var SESSION_TOKEN_RE = /^[A-Za-z0-9+/=_-]{32,}$/
function isSessionToken(value) {
return SESSION_TOKEN_RE.test(String(value || "").trim())
}
function extractSessionToken(raw) {
var s = String(raw || "").trim()
// `export BW_SESSION="..."`, which is what bw prints without --raw.
var match = s.match(/BW_SESSION="?([^"\n\r]+)"?/)
if (match && match[1] && isSessionToken(match[1])) {
return match[1].trim()
}
// --raw prints the key alone, but stray output can share the stream.
var lines = s.split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (isSessionToken(line)) {
return line
}
}
return ""
}
// -------------------------------------------------------------------------
// Vault generation
// -------------------------------------------------------------------------
//
// Nothing cancels a running `bw`, so a read started before a lock can land
// afterwards and repopulate the panel. Every reader records the vault
// generation it started under; the generation advances on lock, logout and
// unlock, and a result from an older generation is discarded.
function vaultReadIsStale(startedEpoch, currentEpoch, hasSession) {
if (!hasSession) return true
return Number(startedEpoch) !== Number(currentEpoch)
}
// -------------------------------------------------------------------------
// Collector scrubbing
// -------------------------------------------------------------------------
//
// A StdioCollector keeps its last output until its process runs again; there
// is no clear(). So after a lock, secrets (session keys, passwords, item
// lists, TOTPs) would stay in the shell's memory. A buffer is emptied by
// re-running its process with a command that prints nothing, and that
// command doubles as the marker that an empty result is a scrub, not an answer.
var SCRUB_COMMAND = ["bash", "-c", ""]
function scrubCommand() {
return SCRUB_COMMAND.slice()
}
function isScrubCommand(cmd) {
if (!cmd || Number(cmd.length) !== SCRUB_COMMAND.length) return false
for (var i = 0; i < SCRUB_COMMAND.length; i++) {
if (String(cmd[i]) !== SCRUB_COMMAND[i]) return false
}
return true
}
// Retry interval for a process still running at lock time. No retry limit:
// giving up would leave its final output resident.
var SCRUB_RETRY_MS = 1000
function scrubRetryMs() { return SCRUB_RETRY_MS }
// One pass over the scrub queue: `start` is scrubbed now, `waiting` is checked
// again next tick, anything else is done. Running processes are left alone
// (their read is still wanted); already-scrubbed ones need nothing.
function scrubPass(procs) {
var start = []
var waiting = []
for (var i = 0; i < (procs || []).length; i++) {
var p = procs[i]
if (!p) continue
if (p.running) { waiting.push(p); continue }
if (isScrubCommand(p.command)) continue
start.push(p)
waiting.push(p)
}
return { start: start, waiting: waiting }
}
// Drop a process from the queue once its scrub finished. Checking `command`
// later cannot prove that, since it describes the newest run.
function finishScrub(procs, finished) {
var remaining = []
for (var i = 0; i < (procs || []).length; i++) {
if (procs[i] && procs[i] !== finished) remaining.push(procs[i])
}
return remaining
}
// -------------------------------------------------------------------------
// CLI Commands
// -------------------------------------------------------------------------
function statusCommand() {
return buildCappedCommand(["status"], MAX_STATUS_BYTES)
}
// -------------------------------------------------------------------------
// Authentication prewarming
// -------------------------------------------------------------------------
//
// A password FIFO lets bw start up while the user types: bw waits on it via
// --passwordfile and the password is written only on submit. FIFOs live in a
// private dir under XDG_RUNTIME_DIR, one per auth flow, and are removed on
// every exit path.
var RUNTIME_SUBDIR = "qs-bitwarden-cli"
function authPasswordFifoName(channel) {
if (channel === "unlock") return "unlock-password.fifo"
if (channel === "login") return "login-password.fifo"
return ""
}
function supervisedProcessPrelude(cleanupCommand) {
var script = "__auth_job=''; "
script += "__auth_cleanup() { trap - EXIT HUP INT TERM; "
script += "if [ -n \"${__auth_job:-}\" ]; then "
script += "kill -TERM -- \"-$__auth_job\" 2>/dev/null || true; "
script += "wait \"$__auth_job\" 2>/dev/null || true; fi; "
if (cleanupCommand) script += cleanupCommand + "; "
script += "}; "
script += "trap '__auth_cleanup' EXIT; "
script += "trap '__auth_cleanup; exit 143' HUP INT TERM; "
return script
}
// Creates the directory in shell variable `name` mode 0700, or accepts it if
// it is a real directory (not a symlink); exits 1 otherwise.
function privateDirScript(name) {
var d = "\"$" + name + "\""
return "if [ -e " + d + " ]; then [ -d " + d + " ] && [ ! -L " + d + " ] || exit 1; "
+ "else (umask 077 && mkdir -p -- " + d + ") || exit 1; fi; chmod 700 -- " + d + " || exit 1; "
}
function authFifoPrelude(channel) {
var fifoName = authPasswordFifoName(channel)
if (!fifoName) return ""
// Refuse a symlinked directory so the FIFO cannot be redirected.
var script = "test -n \"${XDG_RUNTIME_DIR:-}\" || exit 1; "
script += "__auth_dir=\"$XDG_RUNTIME_DIR/" + RUNTIME_SUBDIR + "\"; "
script += privateDirScript("__auth_dir")
script += "__auth_fifo=\"$__auth_dir/" + fifoName + "\"; "
script += "rm -f -- \"$__auth_fifo\"; "
script += "mkfifo -m 600 -- \"$__auth_fifo\" || exit 1; "
// QProcess kills only this wrapper, so bw runs in its own process group
// that the cleanup trap can signal.
script += supervisedProcessPrelude("rm -f -- \"$__auth_fifo\"")
return script
}
// `prelude` runs first, outside the supervised job (appDataDirPrelude()).
function supervisedProcessCommand(command, prelude) {
var script = (prelude || "") + supervisedProcessPrelude("")
script += supervisedProcessRun(command)
return ["bash", "-c", script]
}
function supervisedProcessRun(command) {
// Clear the job id after a normal wait so the EXIT trap cannot signal a
// reaped (and possibly reused) process group.
var script = "set -m; (" + cappedScript(command, MAX_STDERR_BYTES) + ") & "
script += "__auth_job=$!; wait \"$__auth_job\"; __auth_rc=$?; "
script += "__auth_job=''; exit \"$__auth_rc\""
return script
}
function supervisedAuthCommand(channel, command, prelude) {
var script = (prelude || "") + authFifoPrelude(channel)
// `set -m` gives bw its own process group; the interruptible `wait` lets the
// signal traps run while bw blocks on the FIFO.
script += supervisedProcessRun(command)
return ["bash", "-c", script]
}
function unlockPrewarmCommand() {
var command = "bw unlock --passwordfile \"$__auth_fifo\" --raw | head -c " + MAX_TOKEN_BYTES
return supervisedAuthCommand("unlock", command)
}
// Every two-step method bw can act on, in bw's order. The CLI never offers
// Duo or WebAuthn, and bw cannot report which methods an account has, so a
// picker over these three is complete.
var TWO_FACTOR_METHODS = [
{ method: 0, label: "Authenticator app",
hint: "The rotating 6-digit code from your authenticator." },
{ method: 3, label: "YubiKey OTP",
hint: "Touch the key to type its one-time password." },
{ method: 1, label: "Email",
hint: "Bitwarden sends a code to your login address when you choose this." }
]
function twoFactorMethods() {
var out = []
for (var i = 0; i < TWO_FACTOR_METHODS.length; i++) {
var entry = {}
for (var k in TWO_FACTOR_METHODS[i]) entry[k] = TWO_FACTOR_METHODS[i][k]
out.push(entry)
}
return out
}
// --method is the only unquoted login argument, so only values from this
// table may reach it.
function isTwoFactorMethod(method) {
for (var i = 0; i < TWO_FACTOR_METHODS.length; i++) {
if (TWO_FACTOR_METHODS[i].method === method) return true
}
return false
}
function twoFactorMethodLabel(method) {
for (var i = 0; i < TWO_FACTOR_METHODS.length; i++) {
if (TWO_FACTOR_METHODS[i].method === method) return TWO_FACTOR_METHODS[i].label
}
return ""
}
// Remembered two-step methods, keyed by account email.
var MAX_REMEMBERED_ACCOUNTS = 10
// Bitwarden compares login emails case-insensitively.
function twoFactorAccountKey(email) {
return String(email || "").trim().toLowerCase()
}
// shell.json is unvalidated and the method goes into argv, so it is checked
// against the table on read; anything else is "not remembered" (-1).
function rememberedTwoFactorMethodFor(store, email) {
var key = twoFactorAccountKey(email)
if (!key || !store || typeof store !== "object") return -1
var raw = store[key]
// Number() alone reads null, "" and false as 0 (Authenticator).
var m = (typeof raw === "number" || (typeof raw === "string" && String(raw).trim() !== ""))
? Math.floor(Number(raw))
: NaN
return isFinite(m) && isTwoFactorMethod(m) ? m : -1
}
// Rebuilt, not mutated, so unknown or invalid entries are dropped; capped at
// MAX_REMEMBERED_ACCOUNTS.
function rememberTwoFactorMethodIn(store, email, method) {
var key = twoFactorAccountKey(email)
if (!key || !isTwoFactorMethod(method)) return null
var next = {}
var kept = 0
if (store && typeof store === "object") {
for (var k in store) {
var other = twoFactorAccountKey(k)
if (!other || other === key) continue
var existing = rememberedTwoFactorMethodFor(store, k)
if (existing < 0) continue
if (kept >= MAX_REMEMBERED_ACCOUNTS - 1) continue
next[other] = existing
kept++
}
}
next[key] = method
return next
}
function forgetTwoFactorMethodIn(store, email) {
var key = twoFactorAccountKey(email)
if (!key || !store || typeof store !== "object") return null
var next = {}
for (var k in store) {
var other = twoFactorAccountKey(k)
if (!other || other === key) continue
var existing = rememberedTwoFactorMethodFor(store, k)
if (existing >= 0) next[other] = existing
}
return next
}
// `bw config server <url> && `, or "" for the default server.
function serverConfigPrefix(serverUrl) {
var url = String(serverUrl || "").trim()
return url ? "bw config server " + shellQuote(url) + " >/dev/null 2>&1 && " : ""
}
function emailLoginPrewarmCommand(email, hasCode, serverUrl, method) {
var command = serverConfigPrefix(serverUrl)
command += "bw login " + shellQuote(email) + " --passwordfile \"$__auth_fifo\""
if (isTwoFactorMethod(method)) command += " --method " + String(method)
if (hasCode) command += " " + TWOFACTOR_CODE_FLAG
command += " --raw | head -c " + MAX_TOKEN_BYTES
return supervisedAuthCommand("login", command, appDataDirPrelude())
}
// Bounds the one interactive login so one that never prompts cannot hold the
// master password until the panel closes.
var DEVICE_VERIFICATION_TIMEOUT_S = 60
// The only login with bw's prompts enabled. New-device verification reads its
// code from an inquirer prompt and no flag, so the code is piped to stdin
// (from the env, so it reaches no argv). A pipe still ends: an unexpected
// second prompt fails with ERR_USE_AFTER_CLOSE instead of hanging, and
// `timeout` covers a bw that never prompts.
function deviceVerificationLoginCommand(email, serverUrl, method) {
var command = serverConfigPrefix(serverUrl)
command += "printf '%s\\n' \"$" + DEVICE_CODE_ENV + "\" | "
command += "timeout " + DEVICE_VERIFICATION_TIMEOUT_S + "s bw login " + shellQuote(email)
+ " --passwordfile \"$__auth_fifo\""
if (isTwoFactorMethod(method)) command += " --method " + String(method)
command += " --raw | head -c " + MAX_TOKEN_BYTES
return supervisedAuthCommand("login", command, appDataDirPrelude())
}
// stderr then stdout, lower-cased, for matching bw's messages.
function combinedOutput(stdoutText, stderrText) {
return (String(stderrText || "") + "\n" + String(stdoutText || "")).toLowerCase()
}
// inquirer's error for input that is not coming: bw hit an unexpected prompt.
function loginPromptRanOutOfInput(stdoutText, stderrText) {
return /err_use_after_close|readline was closed/.test(combinedOutput(stdoutText, stderrText))
}
// Interactive bw echoes its prompt and keystrokes on stderr. Strip escapes,
// prompt lines and any line containing the code, leaving bw's own message.
function sanitizeInteractiveStderr(raw, secret) {
var text = String(raw || "")
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
.replace(/\x1b[@-Z\\-_]/g, "")
.replace(/\r/g, "\n")
var code = String(secret || "").trim()
var parts = text.split("\n")
var kept = []
for (var i = 0; i < parts.length; i++) {
var line = parts[i].trim()
if (!line) continue
if (line.charAt(0) === "?") continue
if (code && line.indexOf(code) !== -1) continue
kept.push(line)
}
// One line of context at most (a node crash prints a stack trace).
return kept.join(" ").slice(0, 300)
}
// The shape of a login attempt for the journal, never its content: stdout is
// counted, stderr is sanitised.
function loginDiagnostic(stdoutText, stderrText, exitCode, branch) {
return "exit=" + String(exitCode)
+ " stdout=" + String(stdoutText || "").length + "b"
+ " branch=" + String(branch || "?")
+ " stderr=" + JSON.stringify(sanitizeInteractiveStderr(stderrText, "").slice(0, 200))
}
function loginCodeIsRequiredChallenge(stdoutText, stderrText) {
return /(?:^|[\r\n])\s*code\s+is\s+required[.!]?\s*(?=$|[\r\n])/.test(combinedOutput(stdoutText, stderrText))
}
function loginNeedsSecondFactor(stdoutText, stderrText) {
return /(?:two[ _-]?(?:step|factor)|2fa|verification[ _-]?code)/.test(combinedOutput(stdoutText, stderrText))
|| loginCodeIsRequiredChallenge(stdoutText, stderrText)
}
// New-device verification, which --code cannot answer. Its first reply is the
// same "Code is required." as a two-step challenge; only a retry that sent a
// code and still gets it tells them apart (a rejected code says "invalid").
function loginNeedsDeviceVerification(stdoutText, stderrText, codeWasSent) {
if (!codeWasSent) return false
return loginCodeIsRequiredChallenge(stdoutText, stderrText)
}
// bw rejected the placeholder for the two-step code: bw-login-code.js did not
// run (a standalone bw ignores NODE_OPTIONS), so the code was never sent.
function loginCodeChannelMissing(stdoutText, stderrText) {
return combinedOutput(stdoutText, stderrText).indexOf("unknown option '" + TWOFACTOR_CODE_FLAG + "'") !== -1
}
// bw's reply when several providers are usable and none was chosen: a menu it
// cannot show. The panel asks the user for --method instead of guessing.
function loginNeedsMethodChoice(stdoutText, stderrText) {
return /no\s+provider\s+selected/.test(combinedOutput(stdoutText, stderrText))
}
// Every two-step method on the account is one the CLI cannot do (passkey,
// Duo): only an API key login works.
function loginHasNoUsableProvider(stdoutText, stderrText) {
return /no\s+providers\s+available\s+for\s+this\s+client/.test(combinedOutput(stdoutText, stderrText))
}
// Writes BW_PASSWORD into the FIFO. The script names the variable, never its
// value; `timeout` stops a dead reader from blocking the writer forever.
function authPasswordWriteCommand(channel) {
var fifoName = authPasswordFifoName(channel)
if (!fifoName) return []
var script = "test -n \"${XDG_RUNTIME_DIR:-}\" || exit 1; "
script += "__auth_dir=\"$XDG_RUNTIME_DIR/" + RUNTIME_SUBDIR + "\"; "
script += "__auth_fifo=\"$__auth_dir/" + fifoName + "\"; "
// Started alongside the reader, so on the first login after boot the dir
// and FIFO may not exist yet: wait for both.
script += "for __auth_wait in {1..200}; do "
script += "if [ -d \"$__auth_dir\" ] && [ ! -L \"$__auth_dir\" ] && [ -p \"$__auth_fifo\" ] && [ ! -L \"$__auth_fifo\" ]; then "
script += "exec timeout 10s bash -c 'printf \"%s\" \"$" + PASSWORD_ENV + "\" > \"$1\"' _ \"$__auth_fifo\"; "
script += "fi; sleep 0.01; done; exit 1"
return ["bash", "-c", script]
}
// The custom server receives the master password. Returns "" if the URL is
// usable (empty means the default server), else why it was refused: only
// http(s), and plain http only to loopback (e.g. a local Vaultwarden or tunnel).
var SERVER_SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//
var LOOPBACK_HOST_RE = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)$/i
var BITWARDEN_US_SERVER = "https://vault.bitwarden.com"
var BITWARDEN_EU_SERVER = "https://vault.bitwarden.eu"
// Both regions are explicit because bw remembers its last server. Unknown
// input falls back to US, never to a stale custom URL.
function loginServerUrlFor(region, customUrl) {
var choice = String(region || "").toLowerCase()
if (choice === "eu") return BITWARDEN_EU_SERVER
if (choice === "custom") return String(customUrl || "").trim()
return BITWARDEN_US_SERVER
}
function validateServerUrl(raw) {
var url = String(raw || "").trim()
if (!url) return ""
// bw's URL parser treats `\` as `/` in http(s) authorities; the parser below
// does not, so `http://evil\@localhost` could pass as loopback.
if (url.indexOf("\\") !== -1) return "Server URL must not contain backslashes"
var m = url.match(SERVER_SCHEME_RE)
if (!m) return "Server URL must start with https:// (or http:// for localhost)"
var scheme = m[1].toLowerCase()
if (scheme !== "http" && scheme !== "https") {
return "Server URL must be http or https, not " + scheme + ":"
}
// Host is everything up to the first /, ? or #, minus any userinfo.
var rest = url.slice(m[0].length)
var host = rest.split(/[\/?#]/)[0]
var at = host.lastIndexOf("@")
if (at !== -1) host = host.slice(at + 1)
host = host.replace(/:\d*$/, "")
if (!host) return "Server URL is missing a host name"
if (scheme === "http" && !LOOPBACK_HOST_RE.test(host)) {
return "Refusing to send your master password over plain http to " + host
+ ". Use https:// (http is allowed only for localhost)."
}
return ""
}
// `login --apikey` authenticates but does not unlock, so the master password
// is still needed for the second step. Both come from the environment.
function apiKeyLoginCommand(serverUrl) {
var script = serverConfigPrefix(serverUrl)
script += "bw login --apikey >/dev/null 2>&1 && "
script += "bw unlock --passwordenv " + PASSWORD_ENV + " --raw | head -c " + MAX_TOKEN_BYTES
return supervisedProcessCommand(script, appDataDirPrelude())
}
// -------------------------------------------------------------------------
// Terminal login handoff
// -------------------------------------------------------------------------
//
// `bw login` in a terminal handles what the panel cannot (SSO, Duo, hardware
// keys). The terminal writes its session key to a file under XDG_RUNTIME_DIR,
// which the panel reads once and deletes. `--raw` keeps prompts on stderr.
// No /tmp fallback if XDG_RUNTIME_DIR is unset: fail closed rather than write
// a session key somewhere world-writable.
var HANDOFF_BASENAME = "session-handoff"
// `mode` is "login" or "unlock"; the panel already knows which, so no slow
// `bw status` probe first. `slot` is the account's (a detached terminal gets
// none of the vault's environment).
function terminalLoginCommand(mode, serverUrl, slot) {
var verb = (mode === "unlock") ? "unlock" : "login"
var configureServer = verb === "login" ? serverConfigPrefix(serverUrl) : ""
var inner = "set -u; " + accountAppDataExport(slot) + appDataDirPrelude()
+ "d=\"${XDG_RUNTIME_DIR:?no XDG_RUNTIME_DIR -- refusing to write a session key}/"
+ RUNTIME_SUBDIR + "\"; f=\"$d/" + HANDOFF_BASENAME + "\"; "
// umask before mkdir so the dir is created 0700; a failed chmod means the
// dir is not ours.
+ "umask 077; " + privateDirScript("d")
// Remove a stale entry before opening the output path, so a pre-created
// symlink is unlinked rather than followed by shell redirection.
+ "rm -f -- \"$f\" || exit 1; "
+ configureServer
+ "if bw " + verb + " --raw > \"$f\" && [ -s \"$f\" ]; then "
// Bring the panel back itself rather than making the user find it again.
// Only the method name crosses this boundary; the key never does.
+ "omarchy-shell io.github.elevate08.qs-bitwarden-cli open >/dev/null 2>&1 || true; "
+ "echo; echo 'Done. Returning to the Bitwarden panel...'; sleep 1; "
+ "else rm -f \"$f\"; echo; echo 'Not completed -- nothing was handed to the panel.'; "
+ "read -p 'Press enter to close...'; fi"
var script = "omarchy launch terminal -e bash -c " + shellQuote(inner)
+ " || alacritty -e bash -c " + shellQuote(inner)
return ["bash", "-c", script]
}
// How long after launching a terminal login its handoff is still accepted.
var HANDOFF_WINDOW_MS = 10 * 60 * 1000
// How long a login awaiting a second factor survives the panel closing, so an