Skip to content

Commit aac83a6

Browse files
committed
Replaces Keytar with safeStorage API
1 parent c439b8c commit aac83a6

10 files changed

Lines changed: 347 additions & 44 deletions

desktop/src/client/client.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { LocalDataStore } from "./core/local-data-store";
2424
import { BatchExplorerProperties } from "./core/properties";
2525
import { ClientTelemetryModule } from "./core/telemetry";
2626
import { TerminalService } from "./core/terminal";
27+
import { SafeStorageMainService } from "./core/secure-data-store/safe-storage-main.service";
2728
import { MenuModule } from "./menu/menu.module";
2829
import { ProxySettingsManager } from "./proxy";
2930

@@ -32,6 +33,7 @@ import { ProxySettingsManager } from "./proxy";
3233
*/
3334
const servicesToInitialize = [
3435
TerminalService,
36+
SafeStorageMainService,
3537
];
3638

3739
// make sure that the services are created on app start

desktop/src/client/core/batch-explorer-application.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { AADService, AuthenticationWindow } from "./aad";
2424
import { BatchExplorerInitializer } from "./batch-explorer-initializer";
2525
import { MainWindowManager } from "./main-window-manager";
2626
import { StorageBlobAdapter } from "./storage";
27+
import { SafeStorageMainService } from "./secure-data-store/safe-storage-main.service";
28+
import { SecureDataStore } from "./secure-data-store/secure-data-store";
2729
import { filter, first, map } from "rxjs/operators";
2830

2931
const osName = `${os.platform()}-${os.arch()}/${os.release()}`;
@@ -61,6 +63,7 @@ export class BatchExplorerApplication {
6163
private telemetryService: TelemetryService,
6264
private telemetryManager: TelemetryManager,
6365
private storageBlobAdapter: StorageBlobAdapter,
66+
private safeStorageMainService: SafeStorageMainService,
6467
configurationStore: UserConfigurationService<BEUserConfiguration>
6568
) {
6669
this.windows = new MainWindowManager(this, this.telemetryManager);
@@ -81,7 +84,10 @@ export class BatchExplorerApplication {
8184
this.proxySettings = this.injector.get(ProxySettingsManager);
8285

8386
this.ipcMain.init();
87+
this.safeStorageMainService.init();
8488
await this.aadService.init();
89+
90+
await this._checkForLegacyCredentials();
8591
this._registerProtocol();
8692
this._setupProcessEvents();
8793
this._registerFileProtocol();
@@ -365,4 +371,12 @@ export class BatchExplorerApplication {
365371
callback({ cancel: false, requestHeaders: details.requestHeaders });
366372
});
367373
}
368-
}
374+
375+
private async _checkForLegacyCredentials() {
376+
const secureDataStore = this.injector.get(SecureDataStore);
377+
if (await secureDataStore.legacyDataDetected()) {
378+
log.warn("Logging out to clear legacy encrypted credentials.");
379+
await this.aadService.logout();
380+
}
381+
}
382+
}
Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,43 @@
1-
import { CryptoService } from "./crypto-service";
1+
import { CryptoService, UnsupportedEncryptionVersionError } from "./crypto-service";
22

33
describe("CryptoService", () => {
44
let service: CryptoService;
55
let keytarSpy;
66

7-
let masterKey: string | null = null;
7+
let masterKeys: { [key: string]: string | null } = {};
88

99
beforeEach(() => {
10-
// Fake testing key needs to be 32 characters long
11-
masterKey = "------fake-key-for-testing------";
10+
// Current key is 64 chars (32 bytes hex)
11+
masterKeys = {
12+
"BatchExplorer:master-v2": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
13+
};
14+
1215
keytarSpy = {
13-
setPassword: jasmine.createSpy("setPassword").and.callFake((x) => {
14-
masterKey = x;
15-
return Promise.resolve();
16+
setPassword: jasmine.createSpy("setPassword").and.callFake(async (service, account, password) => {
17+
const key = `${service}:${account}`;
18+
masterKeys[key] = password;
19+
}),
20+
getPassword: jasmine.createSpy("getPassword").and.callFake(async (service, account) => {
21+
const key = `${service}:${account}`;
22+
return masterKeys[key] || null;
1623
}),
17-
getPassword: jasmine.createSpy("getPassword").and.callFake(() => Promise.resolve(masterKey)),
1824
};
1925

2026
service = new CryptoService(keytarSpy);
2127
});
2228

23-
it("sets a new master password if not is found", async () => {
24-
masterKey = null;
29+
it("sets a new master password if not found", async () => {
30+
masterKeys["BatchExplorer:master-v2"] = null;
2531
service = new CryptoService(keytarSpy);
26-
const key = await (service as any)._masterKey;
32+
const key = await (service as any)._ensureMasterKey();
2733
expect(key).not.toBeFalsy();
28-
expect(key!.length).toBe(32);
34+
expect(key.length).toBe(64);
2935
});
3036

3137
it("retrieve the master password", async () => {
32-
const key = await (service as any)._masterKey;
38+
const key = await (service as any)._ensureMasterKey();
3339
expect(key).not.toBeFalsy();
34-
expect(key!.length).toBe(32);
40+
expect(key.length).toBe(64);
3541
});
3642

3743
it("encrypt and decrypt a string correctly", async () => {
@@ -41,4 +47,23 @@ describe("CryptoService", () => {
4147
const decrypted = await service.decrypt(encrypted);
4248
expect(decrypted).toEqual(message);
4349
});
50+
51+
it("rejects legacy encrypted data", async () => {
52+
// Simulate legacy format: no version header, just [iv(16)] + [encrypted_data]
53+
const crypto = require("crypto");
54+
const legacyKeyHex = "0123456789abcdef0123456789abcdef"; // 32-char hex string
55+
56+
// Create legacy encrypted data that's long enough to pass length check
57+
const message = "legacy message that needs to be long enough for the minimum length";
58+
let iv: Buffer;
59+
do {
60+
iv = crypto.randomBytes(16);
61+
} while (iv[0] <= 10);
62+
63+
const cipher = crypto.createCipheriv("aes-256-ctr", legacyKeyHex, iv);
64+
const legacyEncrypted = Buffer.concat([iv, cipher.update(Buffer.from(message)), cipher.final()]);
65+
66+
await expectAsync(service.decrypt(legacyEncrypted.toString("base64")))
67+
.toBeRejectedWithError(/Unsupported encryption version.*Please re-authenticate/);
68+
});
4469
});

desktop/src/client/core/secure-data-store/crypto-service.ts

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,57 @@ import { KeytarService } from "./keytar.service";
66
* Keytar service and key to save the master key
77
*/
88
const BATCH_APPLICATION = "BatchExplorer";
9-
const KEYTAR_KEY = "master";
9+
const KEYTAR_KEY = "master-v2";
1010

1111
/**
1212
* Length of the initialization vector
1313
*/
1414
const IV_BYTES = 16;
1515

1616
/**
17-
* Algorithm to use when encrypting
17+
* Current encryption algorithm: AES-256-GCM with 32-byte key
1818
*/
19-
const ENCRYPT_ALGORITHM = "aes-256-ctr";
19+
const ALGORITHM = "aes-256-gcm";
20+
const ALGORITHM_VERSION = 2;
21+
22+
/**
23+
* GCM tag length for authenticated encryption
24+
*/
25+
const GCM_TAG_LENGTH = 16;
26+
27+
/**
28+
* Version header length (1 byte)
29+
*/
30+
const VERSION_HEADER_LENGTH = 1;
31+
32+
/**
33+
* Key size in bytes (32 bytes = 256 bits)
34+
*/
35+
const KEY_BYTES = 32;
2036

2137
// What encoding to use when converting buffer to string
2238
const DEFAULT_STRING_ENCODING = "base64";
2339

40+
export class UnsupportedEncryptionVersionError extends Error {
41+
constructor(version: number) {
42+
super(`Unsupported encryption version: ${version}. Please re-authenticate.`);
43+
this.name = "UnsupportedEncryptionVersionError";
44+
}
45+
}
46+
2447
@Injectable({ providedIn: "root" })
2548
export class CryptoService {
26-
private _masterKey: Promise<string>;
49+
private _masterKey: Promise<string> | null = null;
2750

2851
constructor(private keytar: KeytarService) {
29-
this._masterKey = this._loadMasterKey();
52+
// Don't load keys in constructor - defer until first use to avoid IPC initialization issues
53+
}
54+
55+
private _ensureMasterKey(): Promise<string> {
56+
if (!this._masterKey) {
57+
this._masterKey = this._loadMasterKey();
58+
}
59+
return this._masterKey;
3060
}
3161

3262
public async encrypt(content: Buffer): Promise<Buffer>;
@@ -52,33 +82,56 @@ export class CryptoService {
5282
}
5383

5484
private async _encryptBuffer(content: Buffer): Promise<Buffer> {
55-
const key = await this._masterKey;
85+
const keyHex = await this._ensureMasterKey();
86+
const key = Buffer.from(keyHex, "hex") as crypto.CipherKey;
5687
const iv = this._getIV();
57-
const cipher = crypto.createCipheriv(ENCRYPT_ALGORITHM, key, iv);
58-
return Buffer.concat([iv, cipher.update(content), cipher.final()]);
88+
89+
const cipher = crypto.createCipheriv(ALGORITHM as any, key, iv as crypto.BinaryLike);
90+
const encrypted = cipher.update(content as crypto.BinaryLike);
91+
cipher.final();
92+
const tag = cipher.getAuthTag();
93+
94+
// Format: [version(1)] + [iv(16)] + [tag(16)] + [encrypted_data]
95+
return Buffer.concat([
96+
new Uint8Array([ALGORITHM_VERSION]),
97+
iv,
98+
tag,
99+
encrypted
100+
]);
59101
}
60102

61103
private async _decryptBuffer(content: Buffer): Promise<Buffer> {
62-
const key = await this._masterKey;
63-
const iv = content.slice(0, IV_BYTES);
64-
const decipher = crypto.createDecipheriv(ENCRYPT_ALGORITHM, key, iv);
65-
return Buffer.concat([decipher.update(content.slice(16)), decipher.final()]);
104+
// Verify minimum length and version header
105+
if (content.length < VERSION_HEADER_LENGTH + IV_BYTES + GCM_TAG_LENGTH) {
106+
throw new Error("Invalid encrypted data: content too short");
107+
}
108+
109+
const version = content[0];
110+
if (version !== ALGORITHM_VERSION) {
111+
throw new UnsupportedEncryptionVersionError(version);
112+
}
113+
114+
const keyHex = await this._ensureMasterKey();
115+
const key = Buffer.from(keyHex, "hex") as crypto.CipherKey;
116+
const iv = content.slice(VERSION_HEADER_LENGTH, VERSION_HEADER_LENGTH + IV_BYTES);
117+
const tag = content.slice(VERSION_HEADER_LENGTH + IV_BYTES, VERSION_HEADER_LENGTH + IV_BYTES + GCM_TAG_LENGTH);
118+
const encrypted = content.slice(VERSION_HEADER_LENGTH + IV_BYTES + GCM_TAG_LENGTH);
119+
120+
const decipher = crypto.createDecipheriv(ALGORITHM as any, key, iv as crypto.BinaryLike);
121+
decipher.setAuthTag(tag);
122+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
66123
}
67124

68125
private async _loadMasterKey(): Promise<string> {
69126
let masterKey = await this.keytar.getPassword(BATCH_APPLICATION, KEYTAR_KEY);
70127
if (!masterKey) {
71-
masterKey = this._generateMasterKey();
128+
masterKey = crypto.randomBytes(KEY_BYTES).toString("hex");
72129
await this.keytar.setPassword(BATCH_APPLICATION, KEYTAR_KEY, masterKey);
73130
}
74131
return masterKey;
75132
}
76133

77-
private _generateMasterKey() {
78-
return crypto.randomBytes(16).toString("hex");
79-
}
80-
81-
private _getIV() {
134+
private _getIV(): Buffer {
82135
return crypto.randomBytes(IV_BYTES);
83136
}
84137
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
export * from "./secure-data-store";
21
export * from "./crypto-service";
2+
export * from "./keytar.service";
3+
export * from "./safe-storage-main.service";
4+
export * from "./secure-data-store";
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { KeytarService } from "./keytar.service";
2+
3+
describe("KeytarService", () => {
4+
let service: KeytarService;
5+
let safeStorageSpy;
6+
7+
beforeEach(() => {
8+
safeStorageSpy = {
9+
setPassword: jasmine.createSpy("setPassword").and.returnValue(Promise.resolve()),
10+
getPassword: jasmine.createSpy("getPassword").and.returnValue(Promise.resolve(null))
11+
};
12+
13+
service = new KeytarService(safeStorageSpy);
14+
});
15+
16+
describe("setPassword", () => {
17+
it("should call safeStorage.setPassword with correct arguments", async () => {
18+
await service.setPassword("testService", "testAccount", "testPassword");
19+
20+
expect(safeStorageSpy.setPassword).toHaveBeenCalledWith(
21+
"testService:testAccount",
22+
"testPassword"
23+
);
24+
});
25+
26+
it("should not throw when safeStorage.setPassword succeeds", async () => {
27+
await expectAsync(service.setPassword("testService", "testAccount", "testPassword"))
28+
.toBeResolved();
29+
});
30+
31+
it("should catch and log errors when safeStorage.setPassword fails", async () => {
32+
const consoleWarnSpy = spyOn(console, "warn");
33+
const error = new Error("Encryption not available");
34+
safeStorageSpy.setPassword.and.returnValue(Promise.reject(error));
35+
36+
await service.setPassword("testService", "testAccount", "testPassword");
37+
38+
expect(consoleWarnSpy).toHaveBeenCalledWith(
39+
"Failed to store credentials securely for testService:testAccount:",
40+
"Encryption not available"
41+
);
42+
});
43+
44+
it("should not throw when safeStorage.setPassword fails", async () => {
45+
safeStorageSpy.setPassword.and.returnValue(
46+
Promise.reject(new Error("Encryption not available"))
47+
);
48+
49+
await expectAsync(service.setPassword("testService", "testAccount", "testPassword"))
50+
.toBeResolved();
51+
});
52+
});
53+
54+
describe("getPassword", () => {
55+
it("should call safeStorage.getPassword with correct arguments", async () => {
56+
await service.getPassword("testService", "testAccount");
57+
58+
expect(safeStorageSpy.getPassword).toHaveBeenCalledWith(
59+
"testService:testAccount"
60+
);
61+
});
62+
63+
it("should return the password from safeStorage.getPassword", async () => {
64+
safeStorageSpy.getPassword.and.returnValue(Promise.resolve("retrievedPassword"));
65+
66+
const result = await service.getPassword("testService", "testAccount");
67+
68+
expect(result).toBe("retrievedPassword");
69+
});
70+
71+
it("should return null when password is not found", async () => {
72+
safeStorageSpy.getPassword.and.returnValue(Promise.resolve(null));
73+
74+
const result = await service.getPassword("testService", "testAccount");
75+
76+
expect(result).toBeNull();
77+
});
78+
79+
it("should propagate errors from safeStorage.getPassword", async () => {
80+
const error = new Error("Storage error");
81+
safeStorageSpy.getPassword.and.returnValue(Promise.reject(error));
82+
83+
await expectAsync(service.getPassword("testService", "testAccount"))
84+
.toBeRejectedWith(error);
85+
});
86+
});
87+
});

0 commit comments

Comments
 (0)