mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-17 02:51:36 -05:00
MSAL cache encryption + log improvements (#22335)
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { promises as fs, constants as fsConstants } from 'fs';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export type ReadWriteHook = (contents: string) => Promise<string>;
|
||||
const noOpHook: ReadWriteHook = async (contents): Promise<string> => {
|
||||
@@ -97,7 +98,7 @@ export class FileDatabase {
|
||||
fileContents = await fs.readFile(this.dbPath, { encoding: 'utf8' });
|
||||
fileContents = await this.readHook(fileContents);
|
||||
} catch (ex) {
|
||||
console.log(`file db does not exist ${ex}`);
|
||||
Logger.error(`Error occurred when initializing File Database from file system cache, ADAL cache will be reset: ${ex}`);
|
||||
await this.createFile();
|
||||
this.db = {};
|
||||
this.isDirty = true;
|
||||
@@ -107,7 +108,7 @@ export class FileDatabase {
|
||||
try {
|
||||
this.db = JSON.parse(fileContents);
|
||||
} catch (ex) {
|
||||
console.log(`DB was corrupted, resetting it ${ex}`);
|
||||
Logger.error(`Error occurred when reading file database contents as JSON, ADAL cache will be reset: ${ex}`);
|
||||
await this.createFile();
|
||||
this.db = {};
|
||||
}
|
||||
@@ -139,7 +140,7 @@ export class FileDatabase {
|
||||
|
||||
this.isDirty = false;
|
||||
} catch (ex) {
|
||||
console.log(`File saving is erroring! ${ex}`);
|
||||
Logger.error(`Error occurred while saving cache contents to file storage, this may cause issues with ADAL cache persistence: ${ex}`);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@@ -3,32 +3,50 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as azdata from 'azdata';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import * as vscode from 'vscode';
|
||||
import { AuthLibrary } from '../../constants';
|
||||
import * as LocalizedConstants from '../../localizedConstants';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export class FileEncryptionHelper {
|
||||
constructor(
|
||||
private _credentialService: azdata.CredentialProvider,
|
||||
private _fileName: string,
|
||||
) { }
|
||||
private readonly _authLibrary: AuthLibrary,
|
||||
private readonly _credentialService: azdata.CredentialProvider,
|
||||
protected readonly _fileName: string
|
||||
) {
|
||||
this._algorithm = this._authLibrary === AuthLibrary.MSAL ? 'aes-256-cbc' : 'aes-256-gcm';
|
||||
this._bufferEncoding = this._authLibrary === AuthLibrary.MSAL ? 'utf16le' : 'hex';
|
||||
this._binaryEncoding = this._authLibrary === AuthLibrary.MSAL ? 'base64' : 'hex';
|
||||
}
|
||||
|
||||
private _algorithm: string;
|
||||
private _bufferEncoding: BufferEncoding;
|
||||
private _binaryEncoding: crypto.HexBase64BinaryEncoding;
|
||||
private _ivBuffer: Buffer | undefined;
|
||||
private _keyBuffer: Buffer | undefined;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const iv = await this._credentialService.readCredential(`${this._fileName}-iv`);
|
||||
const key = await this._credentialService.readCredential(`${this._fileName}-key`);
|
||||
if (!iv?.password || !key?.password) {
|
||||
public async init(): Promise<void> {
|
||||
|
||||
const ivCredId = `${this._fileName}-iv`;
|
||||
const keyCredId = `${this._fileName}-key`;
|
||||
|
||||
const iv = await this.readEncryptionKey(ivCredId);
|
||||
const key = await this.readEncryptionKey(keyCredId);
|
||||
|
||||
if (!iv || !key) {
|
||||
this._ivBuffer = crypto.randomBytes(16);
|
||||
this._keyBuffer = crypto.randomBytes(32);
|
||||
try {
|
||||
await this._credentialService.saveCredential(`${this._fileName}-iv`, this._ivBuffer.toString('hex'));
|
||||
await this._credentialService.saveCredential(`${this._fileName}-key`, this._keyBuffer.toString('hex'));
|
||||
} catch (ex) {
|
||||
console.log(ex);
|
||||
|
||||
if (!await this.saveEncryptionKey(ivCredId, this._ivBuffer.toString(this._bufferEncoding))
|
||||
|| !await this.saveEncryptionKey(keyCredId, this._keyBuffer.toString(this._bufferEncoding))) {
|
||||
Logger.error(`Encryption keys could not be saved in credential store, this will cause access token persistence issues.`);
|
||||
await this.showCredSaveErrorOnWindows();
|
||||
}
|
||||
} else {
|
||||
this._ivBuffer = Buffer.from(iv.password, 'hex');
|
||||
this._keyBuffer = Buffer.from(key.password, 'hex');
|
||||
this._ivBuffer = Buffer.from(iv, this._bufferEncoding);
|
||||
this._keyBuffer = Buffer.from(key, this._bufferEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +54,68 @@ export class FileEncryptionHelper {
|
||||
if (!this._keyBuffer || !this._ivBuffer) {
|
||||
await this.init();
|
||||
}
|
||||
const cipherIv = crypto.createCipheriv('aes-256-gcm', this._keyBuffer!, this._ivBuffer!);
|
||||
return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
|
||||
};
|
||||
const cipherIv = crypto.createCipheriv(this._algorithm, this._keyBuffer!, this._ivBuffer!);
|
||||
let cipherText = `${cipherIv.update(content, 'utf8', this._binaryEncoding)}${cipherIv.final(this._binaryEncoding)}`;
|
||||
if (this._authLibrary === AuthLibrary.ADAL) {
|
||||
cipherText += `%${(cipherIv as crypto.CipherGCM).getAuthTag().toString(this._binaryEncoding)}`;
|
||||
}
|
||||
return cipherText;
|
||||
}
|
||||
|
||||
fileOpener = async (content: string): Promise<string> => {
|
||||
if (!this._keyBuffer || !this._ivBuffer) {
|
||||
await this.init();
|
||||
}
|
||||
const decipherIv = crypto.createDecipheriv('aes-256-gcm', this._keyBuffer!, this._ivBuffer!);
|
||||
const split = content.split('%');
|
||||
if (split.length !== 2) {
|
||||
throw new Error('File didn\'t contain the auth tag.');
|
||||
let plaintext = content;
|
||||
const decipherIv = crypto.createDecipheriv(this._algorithm, this._keyBuffer!, this._ivBuffer!);
|
||||
if (this._authLibrary === AuthLibrary.ADAL) {
|
||||
const split = content.split('%');
|
||||
if (split.length !== 2) {
|
||||
throw new Error('File didn\'t contain the auth tag.');
|
||||
}
|
||||
(decipherIv as crypto.DecipherGCM).setAuthTag(Buffer.from(split[1], this._binaryEncoding));
|
||||
plaintext = split[0];
|
||||
}
|
||||
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
|
||||
return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`;
|
||||
};
|
||||
return `${decipherIv.update(plaintext, this._binaryEncoding, 'utf8')}${decipherIv.final('utf8')}`;
|
||||
}
|
||||
|
||||
protected async readEncryptionKey(credentialId: string): Promise<string | undefined> {
|
||||
return (await this._credentialService.readCredential(credentialId))?.password;
|
||||
}
|
||||
|
||||
protected async saveEncryptionKey(credentialId: string, password: string): Promise<boolean> {
|
||||
let status: boolean = false;
|
||||
try {
|
||||
await this._credentialService.saveCredential(credentialId, password)
|
||||
.then((result) => {
|
||||
status = result;
|
||||
if (result) {
|
||||
Logger.info(`FileEncryptionHelper: Successfully saved encryption key ${credentialId} for ${this._authLibrary} persistent cache encryption in system credential store.`);
|
||||
}
|
||||
}, (e => {
|
||||
throw Error(`FileEncryptionHelper: Could not save encryption key: ${credentialId}: ${e}`);
|
||||
}));
|
||||
} catch (ex) {
|
||||
if (os.platform() === 'win32') {
|
||||
Logger.error(`FileEncryptionHelper: Please try cleaning saved credentials from Windows Credential Manager created by Azure Data Studio to allow creating new credentials.`);
|
||||
}
|
||||
Logger.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
protected async showCredSaveErrorOnWindows(): Promise<void> {
|
||||
if (os.platform() === 'win32') {
|
||||
await vscode.window.showWarningMessage(LocalizedConstants.azureCredStoreSaveFailedError,
|
||||
LocalizedConstants.reloadChoice, LocalizedConstants.cancel)
|
||||
.then(async (selection) => {
|
||||
if (selection === LocalizedConstants.reloadChoice) {
|
||||
await vscode.commands.executeCommand('workbench.action.reloadWindow');
|
||||
}
|
||||
}, error => {
|
||||
Logger.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,23 @@ import { promises as fsPromises } from 'fs';
|
||||
|
||||
import * as lockFile from 'lockfile';
|
||||
import * as path from 'path';
|
||||
import { AccountsClearTokenCacheCommand } from '../../constants';
|
||||
import * as azdata from 'azdata';
|
||||
import { AccountsClearTokenCacheCommand, AuthLibrary } from '../../constants';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
import { FileEncryptionHelper } from './fileEncryptionHelper';
|
||||
|
||||
export class MsalCachePluginProvider {
|
||||
constructor(
|
||||
private readonly _serviceName: string,
|
||||
private readonly _msalFilePath: string,
|
||||
private readonly _credentialService: azdata.CredentialProvider
|
||||
) {
|
||||
this._msalFilePath = path.join(this._msalFilePath, this._serviceName);
|
||||
this._serviceName = this._serviceName.replace(/-/, '_');
|
||||
Logger.verbose(`MsalCachePluginProvider: Using cache path ${_msalFilePath} and serviceName ${_serviceName}`);
|
||||
this._fileEncryptionHelper = new FileEncryptionHelper(AuthLibrary.MSAL, this._credentialService, this._serviceName);
|
||||
}
|
||||
|
||||
private _lockTaken: boolean = false;
|
||||
private _fileEncryptionHelper: FileEncryptionHelper;
|
||||
|
||||
private getLockfilePath(): string {
|
||||
return this._msalFilePath + '.lockfile';
|
||||
@@ -33,8 +36,9 @@ export class MsalCachePluginProvider {
|
||||
await this.waitAndLock(lockFilePath);
|
||||
try {
|
||||
const cache = await fsPromises.readFile(this._msalFilePath, { encoding: 'utf8' });
|
||||
const decryptedData = await this._fileEncryptionHelper.fileOpener(cache!);
|
||||
try {
|
||||
cacheContext.tokenCache.deserialize(cache);
|
||||
cacheContext.tokenCache.deserialize(decryptedData);
|
||||
} catch (e) {
|
||||
// Handle deserialization error in cache file in case file gets corrupted.
|
||||
// Clearing cache here will ensure account is marked stale so re-authentication can be triggered.
|
||||
@@ -49,7 +53,8 @@ export class MsalCachePluginProvider {
|
||||
}
|
||||
else {
|
||||
Logger.error(`MsalCachePlugin: Failed to read from cache file: ${e}`);
|
||||
throw e;
|
||||
Logger.verbose(`MsalCachePlugin: Error occurred when trying to read cache file, file contents will be cleared: ${e.message}`);
|
||||
await fsPromises.writeFile(this._msalFilePath, '', { encoding: 'utf8' });
|
||||
}
|
||||
} finally {
|
||||
lockFile.unlockSync(lockFilePath);
|
||||
@@ -62,7 +67,8 @@ export class MsalCachePluginProvider {
|
||||
await this.waitAndLock(lockFilePath);
|
||||
try {
|
||||
const data = cacheContext.tokenCache.serialize();
|
||||
await fsPromises.writeFile(this._msalFilePath, data, { encoding: 'utf8' });
|
||||
const encryptedData = await this._fileEncryptionHelper.fileSaver(data!);
|
||||
await fsPromises.writeFile(this._msalFilePath, encryptedData, { encoding: 'utf8' });
|
||||
Logger.verbose(`MsalCachePlugin: Token written to cache successfully.`);
|
||||
} catch (e) {
|
||||
Logger.error(`MsalCachePlugin: Failed to write to cache file. ${e}`);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as keytarType from 'keytar';
|
||||
import { join, parse } from 'path';
|
||||
import { FileDatabase } from './fileDatabase';
|
||||
import * as azdata from 'azdata';
|
||||
import { FileEncryptionHelper } from './fileEncryptionHelper';
|
||||
import { AuthLibrary } from '../../constants';
|
||||
|
||||
function getSystemKeytar(): Keytar | undefined {
|
||||
try {
|
||||
return require('keytar');
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type MultipleAccountsResponse = { account: string, password: string }[];
|
||||
|
||||
// allow-any-unicode-next-line
|
||||
const separator = '§';
|
||||
|
||||
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
|
||||
const fileName = parse(filePath).base;
|
||||
const fileEncryptionHelper: FileEncryptionHelper = new FileEncryptionHelper(AuthLibrary.ADAL, credentialService, fileName);
|
||||
const db = new FileDatabase(filePath, fileEncryptionHelper.fileOpener, fileEncryptionHelper.fileSaver);
|
||||
await db.initialize();
|
||||
|
||||
const fileKeytar: Keytar = {
|
||||
async getPassword(service: string, account: string): Promise<string> {
|
||||
return db.get(`${service}${separator}${account}`);
|
||||
},
|
||||
|
||||
async setPassword(service: string, account: string, password: string): Promise<void> {
|
||||
await db.set(`${service}${separator}${account}`, password);
|
||||
},
|
||||
|
||||
async deletePassword(service: string, account: string): Promise<boolean> {
|
||||
await db.delete(`${service}${separator}${account}`);
|
||||
return true;
|
||||
},
|
||||
|
||||
async getPasswords(service: string): Promise<MultipleAccountsResponse> {
|
||||
const result = db.getPrefix(`${service}`);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.map(({ key, value }) => {
|
||||
return {
|
||||
account: key.split(separator)[1],
|
||||
password: value
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
return fileKeytar;
|
||||
}
|
||||
|
||||
|
||||
export type Keytar = {
|
||||
getPassword: typeof keytarType['getPassword'];
|
||||
setPassword: typeof keytarType['setPassword'];
|
||||
deletePassword: typeof keytarType['deletePassword'];
|
||||
getPasswords: (service: string) => Promise<MultipleAccountsResponse>;
|
||||
findCredentials?: typeof keytarType['findCredentials'];
|
||||
};
|
||||
|
||||
export class SimpleTokenCache {
|
||||
private keytar: Keytar | undefined;
|
||||
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private readonly userStoragePath: string,
|
||||
private readonly forceFileStorage: boolean = false,
|
||||
private readonly credentialService: azdata.CredentialProvider,
|
||||
) { }
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.serviceName = this.serviceName.replace(/-/g, '_');
|
||||
let keytar: Keytar | undefined;
|
||||
if (this.forceFileStorage === false) {
|
||||
keytar = getSystemKeytar();
|
||||
|
||||
// Add new method to keytar
|
||||
if (keytar) {
|
||||
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
|
||||
const [serviceName, accountPrefix] = service.split(separator);
|
||||
if (serviceName === undefined || accountPrefix === undefined) {
|
||||
throw new Error('Service did not have separator: ' + service);
|
||||
}
|
||||
|
||||
const results = await keytar!.findCredentials!(serviceName);
|
||||
return results.filter(({ account }) => {
|
||||
return account.startsWith(accountPrefix);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!keytar) {
|
||||
keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);
|
||||
}
|
||||
this.keytar = keytar;
|
||||
}
|
||||
|
||||
async saveCredential(id: string, key: string): Promise<void> {
|
||||
if (!this.forceFileStorage && key.length > 2500) { // Windows limitation
|
||||
throw new Error('Key length is longer than 2500 chars');
|
||||
}
|
||||
|
||||
if (id.includes(separator)) {
|
||||
throw new Error('Separator included in ID');
|
||||
}
|
||||
|
||||
try {
|
||||
const keytar = this.getKeytar();
|
||||
return await keytar.setPassword(this.serviceName, id, key);
|
||||
} catch (ex) {
|
||||
console.warn(`Adding key failed: ${ex}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCredential(id: string): Promise<string | undefined> {
|
||||
try {
|
||||
const keytar = this.getKeytar();
|
||||
const result = await keytar.getPassword(this.serviceName, id);
|
||||
|
||||
if (result === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (ex) {
|
||||
console.warn(`Getting key failed: ${ex}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async clearCredential(id: string): Promise<boolean> {
|
||||
try {
|
||||
const keytar = this.getKeytar();
|
||||
return await keytar.deletePassword(this.serviceName, id);
|
||||
} catch (ex) {
|
||||
console.warn(`Clearing key failed: ${ex}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async findCredentials(prefix: string): Promise<{ account: string, password: string }[]> {
|
||||
try {
|
||||
const keytar = this.getKeytar();
|
||||
return await keytar.getPasswords(`${this.serviceName}${separator}${prefix}`);
|
||||
} catch (ex) {
|
||||
console.warn(`Finding credentials failed: ${ex}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getKeytar(): Keytar {
|
||||
if (!this.keytar) {
|
||||
throw new Error('Keytar not initialized');
|
||||
}
|
||||
return this.keytar;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as http from 'http';
|
||||
import * as url from 'url';
|
||||
import { AddressInfo } from 'net';
|
||||
import { Logger } from '../../utils/Logger';
|
||||
|
||||
export type WebHandler = (req: http.IncomingMessage, reqUrl: url.UrlWithParsedQuery, res: http.ServerResponse) => void;
|
||||
|
||||
@@ -24,7 +25,7 @@ export class SimpleWebServer {
|
||||
const time = new Date().getTime();
|
||||
|
||||
if (time - this.lastUsed > this.autoShutoffTimer) {
|
||||
console.log('Shutting off webserver...');
|
||||
Logger.verbose('Shutting off webserver...');
|
||||
this.shutdown().catch(console.error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
Reference in New Issue
Block a user