MSAL cache encryption + log improvements (#22335)

This commit is contained in:
Cheena Malhotra
2023-03-16 16:53:16 -07:00
committed by GitHub
parent 931c44ac41
commit 4b02c26a52
19 changed files with 175 additions and 71 deletions

View File

@@ -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;
}

View File

@@ -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);
});
}
}
}

View File

@@ -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}`);

View File

@@ -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;
}
}

View File

@@ -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);