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 +0 -0
- package/dist/clients/bw.d.ts +57 -1
- package/dist/clients/bw.js +190 -23
- package/dist/components/Button.d.ts +2 -1
- package/dist/components/Button.js +2 -2
- package/dist/dashboard/DashboardView.js +8 -3
- package/dist/hooks/bw.js +1 -0
- package/dist/login/LoginView.js +38 -3
- package/package.json +2 -1
- package/readme.md +7 -0
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/clients/bw.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/clients/bw.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
},
|
|
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 [
|
|
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
|
-
|
|
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) =>
|
|
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
package/dist/login/LoginView.js
CHANGED
|
@@ -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
|
-
|
|
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..." })) : (
|
|
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.
|
|
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)
|