bitty-tui 0.0.6 → 0.0.7

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/dist/cli.js CHANGED
File without changes
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Bitwarden client for Node.js
3
+ * This client provides methods to interact with the Bitwarden API and decrypt/encrypt the ciphers.
4
+ *
5
+ * Authentication flow:
6
+ * 1. Prelogin API to get KDF iterations
7
+ * 2. Derive master key using email, password and KDF iterations
8
+ * 3. Get token API using derived password hash
9
+ * 4. Decode user and private keys
10
+ *
11
+ * Cipher decryption:
12
+ * 1. Fetch sync data from Bitwarden API
13
+ * 2. Decrypt organization keys using private key
14
+ * 3. Choose the appropriate decryption key:
15
+ * - User key for personal ciphers
16
+ * - Organization key for org ciphers
17
+ * - Cipher-specific key if available
18
+ * 4. Decrypt cipher fields using the chosen key
19
+ */
20
+ export declare class FetchError extends Error {
21
+ status: number;
22
+ data: string;
23
+ constructor(status: number, data: string, message?: string);
24
+ json(): any;
25
+ }
1
26
  export declare enum CipherType {
2
27
  Login = 1,
3
28
  SecureNote = 2,
@@ -14,6 +39,7 @@ export declare enum KeyType {
14
39
  RSA_SHA256_MAC = "5",
15
40
  RSA_SHA1_MAC = "6"
16
41
  }
42
+ export declare const TwoFactorProvider: Record<string, string>;
17
43
  export interface Cipher {
18
44
  id: string;
19
45
  type: CipherType;
@@ -68,6 +94,10 @@ export interface Cipher {
68
94
  }[];
69
95
  }
70
96
  export type CipherDto = Omit<Cipher, "id" | "data">;
97
+ export declare enum KdfType {
98
+ PBKDF2 = 0,
99
+ Argon2id = 1
100
+ }
71
101
  export interface SyncResponse {
72
102
  ciphers: Cipher[];
73
103
  profile?: {
@@ -109,10 +139,36 @@ export declare class Client {
109
139
  constructor(uri?: ClientConfig);
110
140
  private patchObject;
111
141
  setUrls(uri: ClientConfig): void;
112
- login(email: string, password: string): Promise<void>;
142
+ /**
143
+ * Authenticates a user with the Bitwarden server using their email and password.
144
+ * The login process involves three main steps:
145
+ * 1. Prelogin request to get KDF iterations
146
+ * 2. Master key derivation using email, password and KDF iterations (see Bw.deriveMasterKey)
147
+ * 3. Token acquisition using derived credentials
148
+ * 4. User and private key decryption (see Bw.decodeUserKeys)
149
+ *
150
+ * After successful authentication, it sets up the client with:
151
+ * - Access token for API requests
152
+ * - Refresh token for token renewal
153
+ * - Token expiration timestamp
154
+ * - Derived encryption keys (master key, user key, private key)
155
+ */
156
+ login(email: string, password: string, skipPrelogin?: boolean, opts?: Record<string, any>): Promise<void>;
157
+ sendEmailMfaCode(email: string): Promise<void>;
113
158
  checkToken(): Promise<void>;
159
+ /**
160
+ * Fetches the latest sync data from the Bitwarden server and decrypts organization keys if available.
161
+ * The orgKeys are decrypted using the private key derived during login.
162
+ * The sync data is cached for future use.
163
+ */
114
164
  syncRefresh(): Promise<SyncResponse | null>;
115
165
  decryptOrgKeys(): void;
166
+ /**
167
+ * Get the appropriate decryption key for a given cipher.
168
+ * If the cipher belongs to an organization, return the organization's key.
169
+ * If the cipher has its own custom key, the custom key is decrypted using the appropriate key and returned.
170
+ * Otherwise, it uses the user's key.
171
+ */
116
172
  getDecryptionKey(cipher: Partial<Cipher>): Key | undefined;
117
173
  getDecryptedSync({ forceRefresh }?: {
118
174
  forceRefresh?: boolean | undefined;
@@ -1,5 +1,37 @@
1
+ /**
2
+ * Bitwarden client for Node.js
3
+ * This client provides methods to interact with the Bitwarden API and decrypt/encrypt the ciphers.
4
+ *
5
+ * Authentication flow:
6
+ * 1. Prelogin API to get KDF iterations
7
+ * 2. Derive master key using email, password and KDF iterations
8
+ * 3. Get token API using derived password hash
9
+ * 4. Decode user and private keys
10
+ *
11
+ * Cipher decryption:
12
+ * 1. Fetch sync data from Bitwarden API
13
+ * 2. Decrypt organization keys using private key
14
+ * 3. Choose the appropriate decryption key:
15
+ * - User key for personal ciphers
16
+ * - Organization key for org ciphers
17
+ * - Cipher-specific key if available
18
+ * 4. Decrypt cipher fields using the chosen key
19
+ */
1
20
  import https from "node:https";
2
21
  import crypto from "node:crypto";
22
+ import * as argon2 from "argon2";
23
+ export class FetchError extends Error {
24
+ status;
25
+ data;
26
+ constructor(status, data, message) {
27
+ super(message ?? `FetchError: ${status} ${data}`);
28
+ this.status = status;
29
+ this.data = data;
30
+ }
31
+ json() {
32
+ return JSON.parse(this.data);
33
+ }
34
+ }
3
35
  function fetch(url, options = {}, body = null) {
4
36
  return new Promise((resolve, reject) => {
5
37
  const urlObj = new URL(url);
@@ -17,10 +49,12 @@ function fetch(url, options = {}, body = null) {
17
49
  const onEnd = () => {
18
50
  cleanup();
19
51
  if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
20
- reject(new Error(`HTTP error: ${res.statusCode} ${res.statusMessage}`));
52
+ console.error("HTTP error body:", data);
53
+ reject(new FetchError(res.statusCode, data, `HTTP error: ${res.statusCode} ${res.statusMessage}`));
21
54
  return;
22
55
  }
23
56
  resolve({
57
+ status: res.statusCode,
24
58
  json: () => Promise.resolve(JSON.parse(data)),
25
59
  text: () => Promise.resolve(data),
26
60
  });
@@ -65,9 +99,39 @@ export var KeyType;
65
99
  KeyType["RSA_SHA256_MAC"] = "5";
66
100
  KeyType["RSA_SHA1_MAC"] = "6";
67
101
  })(KeyType || (KeyType = {}));
102
+ export const TwoFactorProvider = {
103
+ "0": "Authenticator",
104
+ "1": "Email",
105
+ "2": "Fido2",
106
+ "3": "Yubikey",
107
+ "4": "Duo",
108
+ };
109
+ export var KdfType;
110
+ (function (KdfType) {
111
+ KdfType[KdfType["PBKDF2"] = 0] = "PBKDF2";
112
+ KdfType[KdfType["Argon2id"] = 1] = "Argon2id";
113
+ })(KdfType || (KdfType = {}));
114
+ const DEVICE_IDENTIFIER = "928f9664-5559-4a7b-9853-caf5bfa5dd57";
68
115
  class Bw {
69
- deriveMasterKey(email, password, kdfIterations) {
70
- const masterKey = this.derivePbkdf2(password, email, kdfIterations);
116
+ /**
117
+ * Derives the master key and related keys from the user's email and password.
118
+ *
119
+ * First, it derives the master key using PBKDF2 (Argon2 should be implemented in the future).
120
+ * The master key is derived from the password using the email as the salt and the specified number of iterations.
121
+ * The master password hash is then derived from the master key using the password as the salt with a single iteration of PBKDF2.
122
+ *
123
+ * The master password hash will be used for authentication.
124
+ * The master key is used to derive the encryption and MAC keys using HKDF with SHA-256.
125
+ * The encryption key will be used to decrypt the user keys.
126
+ */
127
+ async deriveMasterKey(email, password, prelogin) {
128
+ let masterKey;
129
+ if (prelogin.kdf === KdfType.PBKDF2) {
130
+ masterKey = this.derivePbkdf2(password, email, prelogin.kdfIterations);
131
+ }
132
+ else {
133
+ masterKey = await this.deriveArgon2(password, email, prelogin.kdfIterations, prelogin.kdfMemory, prelogin.kdfParallelism);
134
+ }
71
135
  const masterPasswordHashBytes = this.derivePbkdf2(masterKey, password, 1);
72
136
  const masterPasswordHash = Buffer.from(masterPasswordHashBytes).toString("base64");
73
137
  const encryptionKey = this.hkdfExpandSha256(masterKey, "enc");
@@ -81,6 +145,11 @@ class Bw {
81
145
  },
82
146
  };
83
147
  }
148
+ /**
149
+ * Decode the user key and private key.
150
+ * The user key is decrypted using the encryption key.
151
+ * The private key is decrypted using the user key.
152
+ */
84
153
  decodeUserKeys(userKey, privateKey, keys) {
85
154
  if (!keys.encryptionKey)
86
155
  throw new Error("Encryption key not derived yet");
@@ -98,6 +167,12 @@ class Bw {
98
167
  }
99
168
  return keys;
100
169
  }
170
+ /**
171
+ * Decrypt a Bitwarden-formatted string using the provided key.
172
+ * The function first parses the string to extract the type, IV, ciphertext, and HMAC.
173
+ * It then selects the appropriate decryption method based on the type.
174
+ * Supported types: AES-256(0), AES-256-MAC(2), AES-128-MAC(1), RSA-SHA1(4), RSA-SHA256(3), RSA-SHA1-MAC(6), and RSA-SHA256-MAC(5).
175
+ */
101
176
  decryptKey(value, key) {
102
177
  const data = this.parseBwString(value);
103
178
  try {
@@ -118,12 +193,15 @@ class Bw {
118
193
  }
119
194
  }
120
195
  catch (error) {
196
+ console.error("Error decrypting key:", error);
121
197
  throw error;
122
198
  }
123
199
  }
200
+ // Decrypt as UTF-8 string
124
201
  decrypt(value, key) {
125
202
  return this.decryptKey(value, key).toString("utf-8");
126
203
  }
204
+ // Encrypt a string using the given key
127
205
  encrypt(value, key) {
128
206
  if (!value || !key?.key) {
129
207
  throw new Error("Missing value or key for encryption");
@@ -157,15 +235,52 @@ class Bw {
157
235
  const macB64 = mac.toString("base64");
158
236
  return `2.${ivB64}|${encryptedB64}|${macB64}`;
159
237
  }
238
+ // PBKDF2 key derivation
160
239
  derivePbkdf2(password, salt, iterations) {
161
240
  return crypto.pbkdf2Sync(password, salt, iterations, 32, "sha256");
162
241
  }
242
+ // Argon2 key derivation
243
+ async deriveArgon2(password, salt, iterations, memory, parallelism) {
244
+ const saltHash = crypto
245
+ .createHash("sha256")
246
+ .update(Buffer.from(salt.toString(), "utf-8"))
247
+ .digest();
248
+ const hash = await argon2.hash(password.toString(), {
249
+ salt: saltHash,
250
+ timeCost: iterations,
251
+ memoryCost: memory * 1024,
252
+ parallelism,
253
+ hashLength: 32,
254
+ type: argon2.argon2id,
255
+ raw: true,
256
+ });
257
+ return Buffer.from(hash);
258
+ }
163
259
  hkdfExpandSha256(ikm, info) {
164
260
  const mac = crypto.createHmac("sha256", ikm);
165
261
  mac.update(info);
166
262
  mac.update(Buffer.from([0x01]));
167
263
  return mac.digest();
168
264
  }
265
+ /**
266
+ * Parse a Bitwarden-formatted string into its components.
267
+ * The function extracts the type, IV, ciphertext, and HMAC from the string.
268
+ * The bitwarden string format is as follows:
269
+ * A type (1 character) followed by a dot ('.'), then key parts separated by pipes ('|')
270
+ * <type>.[<iv_base64>|]<ciphertext_base64>|<hmac_base64>
271
+ *
272
+ * AES types (0, 1, 2) include the IV, while RSA types (3, 4, 5, 6) do not.
273
+ *
274
+ * Examples:
275
+ * - 0.MTIzNDU2Nzg5MGFiY2RlZg==|c29tZWNpcGhlcnRleHQ=|aG1hY3ZhbHVl
276
+ * - 3.c29tZWNpcGhlcnRleHQ=|aG1hY3ZhbHVl
277
+ *
278
+ * type iv_base64 ciphertext_base64 hmac_base64
279
+ * -------------------------------------------------------------------------
280
+ * 0. MTIzNDU2Nzg5MGFiY2RlZg== | c29tZWNpcGhlcnRleHQ= | aG1hY3ZhbHVl
281
+ * 3. c29tZWNpcGhlcnRleHQ= | aG1hY3ZhbHVl
282
+ *
283
+ */
169
284
  parseBwString(value) {
170
285
  if (!value?.length) {
171
286
  throw new Error("Empty value");
@@ -187,6 +302,7 @@ class Bw {
187
302
  const hmac = hmacB64 ? Buffer.from(hmacB64, "base64") : null;
188
303
  return { type, iv, key: ciphertext, mac: hmac };
189
304
  }
305
+ // AES decryption
190
306
  decryptAes(ciphertext, key, algorithm) {
191
307
  if (!ciphertext.iv)
192
308
  throw new Error("Missing IV for AES decryption");
@@ -203,6 +319,7 @@ class Bw {
203
319
  const final = decipher.final();
204
320
  return Buffer.concat([decrypted, final]);
205
321
  }
322
+ // RSA OAEP decryption
206
323
  decryptRsaOaep(ciphertext, key, hashAlgorithm) {
207
324
  const privateKey = crypto.createPrivateKey({
208
325
  key: Buffer.from(key.key),
@@ -269,22 +386,52 @@ export class Client {
269
386
  this.identityUrl = uri.identityUrl;
270
387
  }
271
388
  }
272
- async login(email, password) {
273
- const prelogin = await fetch(`${this.identityUrl}/accounts/prelogin`, {
274
- method: "POST",
275
- headers: {
276
- "Content-Type": "application/json",
277
- },
278
- }, {
279
- email,
280
- }).then((r) => r.json());
281
- const keys = mcbw.deriveMasterKey(email, password, prelogin.kdfIterations);
389
+ /**
390
+ * Authenticates a user with the Bitwarden server using their email and password.
391
+ * The login process involves three main steps:
392
+ * 1. Prelogin request to get KDF iterations
393
+ * 2. Master key derivation using email, password and KDF iterations (see Bw.deriveMasterKey)
394
+ * 3. Token acquisition using derived credentials
395
+ * 4. User and private key decryption (see Bw.decodeUserKeys)
396
+ *
397
+ * After successful authentication, it sets up the client with:
398
+ * - Access token for API requests
399
+ * - Refresh token for token renewal
400
+ * - Token expiration timestamp
401
+ * - Derived encryption keys (master key, user key, private key)
402
+ */
403
+ async login(email, password, skipPrelogin = false, opts) {
404
+ let keys = this.keys;
405
+ if (!skipPrelogin) {
406
+ const prelogin = await fetch(`${this.identityUrl}/accounts/prelogin`, {
407
+ method: "POST",
408
+ headers: {
409
+ "Content-Type": "application/json",
410
+ },
411
+ }, {
412
+ email,
413
+ }).then((r) => r.json());
414
+ keys = await mcbw.deriveMasterKey(email, password, prelogin);
415
+ this.keys = keys;
416
+ }
417
+ const bodyParams = new URLSearchParams();
418
+ bodyParams.append("username", email);
419
+ bodyParams.append("password", keys.masterPasswordHash);
420
+ bodyParams.append("grant_type", "password");
421
+ bodyParams.append("deviceName", "chrome");
422
+ bodyParams.append("deviceType", "9");
423
+ bodyParams.append("deviceIdentifier", DEVICE_IDENTIFIER);
424
+ bodyParams.append("client_id", "web");
425
+ bodyParams.append("scope", "api offline_access");
426
+ for (const [key, value] of Object.entries(opts || {})) {
427
+ bodyParams.append(key, value);
428
+ }
282
429
  const identityReq = await fetch(`${this.identityUrl}/connect/token`, {
283
430
  method: "POST",
284
431
  headers: {
285
432
  "Content-Type": "application/x-www-form-urlencoded",
286
433
  },
287
- }, `username=${email}&password=${keys.masterPasswordHash}&grant_type=password&deviceName=chrome&deviceType=9&deviceIdentifier=928f9664-5559-4a7b-9853-caf5bfa5dd57&client_id=web&scope=api%20offline_access`).then((r) => r.json());
434
+ }, bodyParams.toString()).then((r) => r.json());
288
435
  this.token = identityReq.access_token;
289
436
  this.refreshToken = identityReq.refresh_token;
290
437
  this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
@@ -298,6 +445,22 @@ export class Client {
298
445
  this.decryptedSyncCache = null;
299
446
  this.orgKeys = {};
300
447
  }
448
+ async sendEmailMfaCode(email) {
449
+ fetch("https://vault.bitwarden.eu/api/two-factor/send-email-login", {
450
+ method: "POST",
451
+ headers: {
452
+ "Content-Type": "application/json",
453
+ },
454
+ }, JSON.stringify({
455
+ email: email,
456
+ masterPasswordHash: this.keys.masterPasswordHash,
457
+ ssoEmail2FaSessionToken: "",
458
+ deviceIdentifier: DEVICE_IDENTIFIER,
459
+ authRequestAccessCode: "",
460
+ authRequestId: "",
461
+ }));
462
+ }
463
+ // Check and refresh token if needed
301
464
  async checkToken() {
302
465
  if (!this.tokenExpiration || Date.now() >= this.tokenExpiration) {
303
466
  if (!this.refreshToken) {
@@ -314,6 +477,11 @@ export class Client {
314
477
  this.tokenExpiration = Date.now() + identityReq.expires_in * 1000;
315
478
  }
316
479
  }
480
+ /**
481
+ * Fetches the latest sync data from the Bitwarden server and decrypts organization keys if available.
482
+ * The orgKeys are decrypted using the private key derived during login.
483
+ * The sync data is cached for future use.
484
+ */
317
485
  async syncRefresh() {
318
486
  await this.checkToken();
319
487
  this.syncCache = await fetch(`${this.apiUrl}/sync?excludeDomains=true`, {
@@ -338,6 +506,12 @@ export class Client {
338
506
  };
339
507
  }
340
508
  }
509
+ /**
510
+ * Get the appropriate decryption key for a given cipher.
511
+ * If the cipher belongs to an organization, return the organization's key.
512
+ * If the cipher has its own custom key, the custom key is decrypted using the appropriate key and returned.
513
+ * Otherwise, it uses the user's key.
514
+ */
341
515
  getDecryptionKey(cipher) {
342
516
  let key = this.keys.userKey;
343
517
  if (cipher.organizationId) {
@@ -348,6 +522,7 @@ export class Client {
348
522
  }
349
523
  return key;
350
524
  }
525
+ // fetch and decrypt the bw sync data
351
526
  async getDecryptedSync({ forceRefresh = false } = {}) {
352
527
  if (this.decryptedSyncCache && !forceRefresh) {
353
528
  return this.decryptedSyncCache;
@@ -361,24 +536,17 @@ export class Client {
361
536
  const key = this.getDecryptionKey(cipher);
362
537
  const ret = JSON.parse(JSON.stringify(cipher));
363
538
  ret.name = this.decrypt(cipher.name, key);
364
- ret.data.name = ret.name;
365
539
  ret.notes = this.decrypt(cipher.notes, key);
366
- ret.data.notes = ret.notes;
367
540
  if (cipher.login) {
368
541
  ret.login.username = this.decrypt(cipher.login.username, key);
369
- ret.data.username = ret.login.username;
370
542
  ret.login.password = this.decrypt(cipher.login.password, key);
371
- ret.data.password = ret.login.password;
372
543
  ret.login.totp = this.decrypt(cipher.login.totp, key);
373
- ret.data.totp = ret.login.totp;
374
544
  ret.login.uri = this.decrypt(cipher.login.uri, key);
375
- ret.data.uri = ret.login.uri;
376
545
  if (cipher.login.uris?.length) {
377
546
  ret.login.uris = cipher.login.uris?.map((uri) => ({
378
547
  uri: this.decrypt(uri.uri, key),
379
548
  uriChecksum: uri.uriChecksum,
380
549
  }));
381
- ret.data.uris = ret.login.uris;
382
550
  }
383
551
  }
384
552
  if (cipher.identity) {
@@ -433,7 +601,6 @@ export class Client {
433
601
  value: this.decrypt(field.value, key),
434
602
  };
435
603
  });
436
- ret.data.fields = ret.fields;
437
604
  }
438
605
  return ret;
439
606
  }),
@@ -508,6 +675,7 @@ export class Client {
508
675
  return null;
509
676
  const key = this.getDecryptionKey(patch);
510
677
  const data = this.patchObject(original, this.encryptCipher(obj, key));
678
+ data.data = undefined;
511
679
  const s = await fetch(`${this.apiUrl}/ciphers/${id}`, {
512
680
  method: "PUT",
513
681
  headers: {
@@ -532,7 +700,6 @@ export class Client {
532
700
  }
533
701
  encryptCipher(obj, key) {
534
702
  const { ...ret } = obj;
535
- delete obj.data;
536
703
  if (ret.name) {
537
704
  ret.name = this.encrypt(ret.name, key);
538
705
  }
@@ -3,8 +3,9 @@ import { ReactNode } from "react";
3
3
  type Props = {
4
4
  isActive?: boolean;
5
5
  doubleConfirm?: boolean;
6
+ autoFocus?: boolean;
6
7
  onClick: () => void;
7
8
  children: ReactNode;
8
9
  } & React.ComponentProps<typeof Box>;
9
- export declare const Button: ({ isActive, doubleConfirm, onClick, children, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
10
+ export declare const Button: ({ isActive, doubleConfirm, onClick, children, autoFocus, ...props }: Props) => import("react/jsx-runtime").JSX.Element;
10
11
  export {};
@@ -2,8 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text, Box, useFocus, useInput } from "ink";
3
3
  import { useRef, useState } from "react";
4
4
  import { primary } from "../theme/style.js";
5
- export const Button = ({ isActive = true, doubleConfirm, onClick, children, ...props }) => {
6
- const { isFocused } = useFocus();
5
+ export const Button = ({ isActive = true, doubleConfirm, onClick, children, autoFocus = false, ...props }) => {
6
+ const { isFocused } = useFocus({ autoFocus: autoFocus });
7
7
  const [askConfirm, setAskConfirm] = useState(false);
8
8
  const timeoutRef = useRef(null);
9
9
  useInput((input, key) => {
@@ -12,7 +12,7 @@ export function DashboardView({ onLogout }) {
12
12
  const { sync, error, fetchSync } = useBwSync();
13
13
  const [syncState, setSyncState] = useState(sync);
14
14
  const [searchQuery, setSearchQuery] = useState("");
15
- const [listIndex, setListIndex] = useState(0);
15
+ const [listSelected, setListSelected] = useState(null);
16
16
  const [showDetails, setShowDetails] = useState(true);
17
17
  const [focusedComponent, setFocusedComponent] = useState("list");
18
18
  const [detailMode, setDetailMode] = useState("view");
@@ -34,13 +34,18 @@ export function DashboardView({ onLogout }) {
34
34
  c.login?.username?.toLowerCase().includes(search));
35
35
  }) ?? []).sort((a, b) => a.name.localeCompare(b.name));
36
36
  }, [syncState, searchQuery]);
37
+ const listIndex = useMemo(() => {
38
+ if (!listSelected)
39
+ return 0;
40
+ return filteredCiphers.findIndex((c) => c.id === listSelected.id);
41
+ }, [listSelected, filteredCiphers]);
37
42
  const selectedCipher = detailMode === "new" ? editedCipher : filteredCiphers[listIndex];
38
43
  const logout = async () => {
39
44
  await clearConfig();
40
45
  onLogout();
41
46
  };
42
47
  useEffect(() => {
43
- setListIndex(0);
48
+ setListSelected(null);
44
49
  }, [searchQuery]);
45
50
  useEffect(() => {
46
51
  setSyncState(sync);
@@ -99,7 +104,7 @@ export function DashboardView({ onLogout }) {
99
104
  return (_jsxs(Box, { flexDirection: "column", width: "100%", height: stdout.rows - 1, children: [_jsx(Box, { borderStyle: "double", borderColor: primary, paddingX: 2, justifyContent: "center", flexShrink: 0, children: _jsx(Text, { bold: true, color: primary, children: "BiTTY" }) }), _jsxs(Box, { children: [_jsx(Box, { width: "40%", children: _jsx(TextInput, { id: "search", placeholder: focusedComponent === "search" ? "" : "[/] Search in vault", value: searchQuery, isActive: false, onChange: setSearchQuery, onSubmit: () => {
100
105
  setFocusedComponent("list");
101
106
  focusNext();
102
- } }) }), statusMessage && (_jsx(Box, { width: "60%", padding: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: focusedComponent === "list", selected: listIndex, onSelect: (index) => setListIndex(index) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, isFocused: focusedComponent === "detail", onChange: (cipher) => {
107
+ } }) }), statusMessage && (_jsx(Box, { width: "60%", padding: 1, children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }), _jsxs(Box, { minHeight: 20, flexGrow: 1, children: [_jsx(VaultList, { filteredCiphers: filteredCiphers, isFocused: focusedComponent === "list", selected: listIndex, onSelect: (index) => setListSelected(filteredCiphers[index] || null) }), _jsx(CipherDetail, { selectedCipher: showDetails ? selectedCipher : null, mode: detailMode, isFocused: focusedComponent === "detail", onChange: (cipher) => {
103
108
  if (detailMode === "new") {
104
109
  setEditedCipher(cipher);
105
110
  return;
package/dist/hooks/bw.js CHANGED
@@ -91,6 +91,7 @@ export const useBwSync = () => {
91
91
  setSync(sync);
92
92
  }
93
93
  catch (e) {
94
+ console.error("Error fetching sync data:", e);
94
95
  setError("Error fetching sync data");
95
96
  }
96
97
  }, []);
@@ -7,6 +7,7 @@ import { primary } from "../theme/style.js";
7
7
  import { bwClient, loadConfig, saveConfig } from "../hooks/bw.js";
8
8
  import { useStatusMessage } from "../hooks/status-message.js";
9
9
  import { Checkbox } from "../components/Checkbox.js";
10
+ import { FetchError, TwoFactorProvider } from "../clients/bw.js";
10
11
  const art = `
11
12
  ███████████ ███ ███████████ ███████████ █████ █████
12
13
  ░░███░░░░░███ ░░░ ░█░░░███░░░█░█░░░███░░░█░░███ ░░███
@@ -22,6 +23,8 @@ export function LoginView({ onLogin }) {
22
23
  const [url, setUrl] = useState("https://vault.bitwarden.eu");
23
24
  const [email, setEmail] = useState("");
24
25
  const [password, setPassword] = useState("");
26
+ const [mfaParams, setMfaParams] = useState(null);
27
+ const [askMfa, setAskMfa] = useState(null);
25
28
  const [rememberMe, setRememberMe] = useState(false);
26
29
  const { stdout } = useStdout();
27
30
  const { focusNext } = useFocusManager();
@@ -36,7 +39,30 @@ export function LoginView({ onLogin }) {
36
39
  if (url?.trim().length) {
37
40
  bwClient.setUrls({ baseUrl: url });
38
41
  }
39
- await bwClient.login(email, password);
42
+ try {
43
+ if (mfaParams) {
44
+ mfaParams.twoFactorRemember = rememberMe ? 1 : 0;
45
+ }
46
+ await bwClient.login(email, password, !!mfaParams, mfaParams);
47
+ }
48
+ catch (e) {
49
+ if (e instanceof FetchError) {
50
+ const data = e.json();
51
+ if (data.TwoFactorProviders) {
52
+ if (data.TwoFactorProviders.length === 1) {
53
+ setMfaParams({
54
+ twoFactorProvider: data.TwoFactorProviders[0],
55
+ });
56
+ }
57
+ else if (data.TwoFactorProviders.length > 1) {
58
+ setAskMfa(data.TwoFactorProviders);
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ throw e;
64
+ }
65
+ }
40
66
  if (!bwClient.refreshToken || !bwClient.keys)
41
67
  throw new Error("Missing URL or keys after login");
42
68
  onLogin();
@@ -68,12 +94,21 @@ export function LoginView({ onLogin }) {
68
94
  }
69
95
  })();
70
96
  }, []);
71
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", padding: 1, flexGrow: 1, minHeight: stdout.rows - 2, children: [_jsx(Box, { marginBottom: 2, children: _jsx(Text, { color: primary, children: art }) }), loading ? (_jsx(Text, { children: "Loading..." })) : (_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsx(TextInput, { placeholder: "Server URL", value: url, onChange: setUrl }), _jsx(TextInput, { autoFocus: true, placeholder: "Email address", value: email, onChange: setEmail }), _jsx(TextInput, { placeholder: "Master password", value: password, onChange: setPassword, onSubmit: () => {
97
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", padding: 1, flexGrow: 1, minHeight: stdout.rows - 2, children: [_jsx(Box, { marginBottom: 2, children: _jsx(Text, { color: primary, children: art }) }), loading ? (_jsx(Text, { children: "Loading..." })) : askMfa ? (_jsx(Box, { flexDirection: "column", width: "50%", children: Object.values(askMfa).map((provider) => (_jsx(Button, { autoFocus: true, onClick: () => {
98
+ if (provider === "1") {
99
+ bwClient.sendEmailMfaCode(email);
100
+ }
101
+ setMfaParams((p) => ({
102
+ ...p,
103
+ twoFactorProvider: provider,
104
+ }));
105
+ setAskMfa(null);
106
+ }, children: TwoFactorProvider[provider] }, provider))) })) : mfaParams && mfaParams.twoFactorProvider ? (_jsx(Box, { flexDirection: "column", width: "50%", children: _jsx(TextInput, { autoFocus: true, placeholder: `Enter your ${TwoFactorProvider[mfaParams.twoFactorProvider]} code`, value: mfaParams.twoFactorToken || "", onChange: (value) => setMfaParams((p) => ({ ...p, twoFactorToken: value })), onSubmit: () => handleLogin() }) })) : (_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsx(TextInput, { placeholder: "Server URL", value: url, onChange: setUrl }), _jsx(TextInput, { autoFocus: true, placeholder: "Email address", value: email, onChange: setEmail }), _jsx(TextInput, { placeholder: "Master password", value: password, onChange: setPassword, onSubmit: () => {
72
107
  if (email?.length && password?.length) {
73
108
  handleLogin();
74
109
  }
75
110
  else {
76
111
  focusNext();
77
112
  }
78
- }, isPassword: true }), _jsxs(Box, { children: [_jsx(Checkbox, { label: "Remember me (less secure)", value: rememberMe, width: "50%", onToggle: setRememberMe }), _jsx(Button, { width: "50%", onClick: handleLogin, children: "Log In" })] }), statusMessage && (_jsx(Box, { marginTop: 1, width: "100%", justifyContent: "center", children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }))] }));
113
+ }, isPassword: true }), _jsxs(Box, { children: [_jsx(Checkbox, { label: "Remember me (less secure)", value: rememberMe, width: "50%", onToggle: setRememberMe }), _jsx(Button, { width: "50%", onClick: () => handleLogin(), children: "Log In" })] }), statusMessage && (_jsx(Box, { marginTop: 1, width: "100%", justifyContent: "center", children: _jsx(Text, { color: statusMessageColor, children: statusMessage }) }))] }))] }));
79
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitty-tui",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "license": "MIT",
5
5
  "repository": "https://github.com/mceck/bitty",
6
6
  "keywords": [
@@ -29,6 +29,7 @@
29
29
  "dist"
30
30
  ],
31
31
  "dependencies": {
32
+ "argon2": "^0.44.0",
32
33
  "chalk": "^5.6.2",
33
34
  "clipboardy": "^4.0.0",
34
35
  "ink": "^6.3.0",
package/readme.md CHANGED
@@ -17,6 +17,13 @@ Works also with Vaultwarden.
17
17
 
18
18
  If you check "Remember me" during login, your vault encryption keys will be stored in plain text in your home folder (`$HOME/.config/bitty/config.json`). Use this option only if you are the only user of your machine.
19
19
 
20
+ ## TODO
21
+
22
+ - Implement Argon2 key derivation
23
+ - Collections support
24
+ - Handle more fields editing
25
+ - Handle creating different cipher types
26
+
20
27
  ## Acknowledgments
21
28
 
22
29
  - [Bitwarden](https://github.com/bitwarden)