diff --git a/libs/importer/src/importers/lastpass/access/account.ts b/libs/importer/src/importers/lastpass/access/account.ts new file mode 100644 index 0000000000..d87c4e3ddc --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/account.ts @@ -0,0 +1,12 @@ +export class Account { + id: string; + name: string; + username: string; + password: string; + url: string; + path: string; + notes: string; + totp: string; + isFavorite: boolean; + isShared: boolean; +} diff --git a/libs/importer/src/importers/lastpass/access/binary-reader.ts b/libs/importer/src/importers/lastpass/access/binary-reader.ts new file mode 100644 index 0000000000..706afbd9e9 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/binary-reader.ts @@ -0,0 +1,76 @@ +export class BinaryReader { + private position: number; + private isLittleEndian: boolean; + + constructor(public arr: Uint8Array) { + this.position = 0; + + const uInt32 = new Uint32Array([0x11223344]); + const uInt8 = new Uint8Array(uInt32.buffer); + this.isLittleEndian = uInt8[0] === 0x44; + } + + readBytes(count: number): Uint8Array { + if (this.position + count > this.arr.length) { + throw "End of array reached"; + } + const slice = this.arr.subarray(this.position, this.position + count); + this.position += count; + return slice; + } + + readUInt16(): number { + const slice = this.readBytes(2); + const int = slice[0] | (slice[1] << 8); + // Convert to unsigned int + return int >>> 0; + } + + readUInt32(): number { + const slice = this.readBytes(4); + const int = slice[0] | (slice[1] << 8) | (slice[2] << 16) | (slice[3] << 24); + // Convert to unsigned int + return int >>> 0; + } + + readUInt16BigEndian(): number { + let result = this.readUInt16(); + if (this.isLittleEndian) { + // Extract the two bytes + const byte1 = result & 0xff; + const byte2 = (result >> 8) & 0xff; + // Create a big-endian value by swapping the bytes + result = (byte1 << 8) | byte2; + } + return result; + } + + readUInt32BigEndian(): number { + let result = this.readUInt32(); + if (this.isLittleEndian) { + // Extract individual bytes + const byte1 = (result >> 24) & 0xff; + const byte2 = (result >> 16) & 0xff; + const byte3 = (result >> 8) & 0xff; + const byte4 = result & 0xff; + // Create a big-endian value by reordering the bytes + result = (byte4 << 24) | (byte3 << 16) | (byte2 << 8) | byte1; + } + return result; + } + + seekFromCurrentPosition(offset: number) { + const newPosition = this.position + offset; + if (newPosition < 0) { + throw "Position cannot be negative"; + } + if (newPosition > this.arr.length) { + throw "Array not large enough to seek to this position"; + } + this.position = newPosition; + } + + atEnd(): boolean { + return this.position >= this.arr.length - 1; + } +} diff --git a/libs/importer/src/importers/lastpass/access/chunk.ts b/libs/importer/src/importers/lastpass/access/chunk.ts new file mode 100644 index 0000000000..8db56d5030 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/chunk.ts @@ -0,0 +1,4 @@ +export class Chunk { + id: string; + payload: Uint8Array; +} diff --git a/libs/importer/src/importers/lastpass/access/client-info.ts b/libs/importer/src/importers/lastpass/access/client-info.ts new file mode 100644 index 0000000000..fbe13d57d6 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/client-info.ts @@ -0,0 +1,7 @@ +import { Platform } from "./platform"; + +export class ClientInfo { + platform: Platform; + id: string; + description: string; +} diff --git a/libs/importer/src/importers/lastpass/access/client.ts b/libs/importer/src/importers/lastpass/access/client.ts new file mode 100644 index 0000000000..0a3c8fefe5 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/client.ts @@ -0,0 +1,545 @@ +import { HttpStatusCode } from "@bitwarden/common/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { Account } from "./account"; +import { BinaryReader } from "./binary-reader"; +import { Chunk } from "./chunk"; +import { ClientInfo } from "./client-info"; +import { CryptoUtils } from "./crypto-utils"; +import { OobResult } from "./oob-result"; +import { OtpMethod } from "./otp-method"; +import { OtpResult } from "./otp-result"; +import { Parser } from "./parser"; +import { ParserOptions } from "./parser-options"; +import { Platform } from "./platform"; +import { RestClient } from "./rest-client"; +import { Session } from "./session"; +import { SharedFolder } from "./shared-folder"; +import { Ui } from "./ui"; + +const PlatformToUserAgent = new Map([ + [Platform.Desktop, "cli"], + [Platform.Mobile, "android"], +]); + +const KnownOtpMethods = new Map([ + ["googleauthrequired", OtpMethod.GoogleAuth], + ["microsoftauthrequired", OtpMethod.MicrosoftAuth], + ["otprequired", OtpMethod.Yubikey], +]); + +export class Client { + constructor(private parser: Parser, private cryptoUtils: CryptoUtils) {} + + async openVault( + username: string, + password: string, + clientInfo: ClientInfo, + ui: Ui, + options: ParserOptions + ): Promise { + const lowercaseUsername = username.toLowerCase(); + const [session, rest] = await this.login(lowercaseUsername, password, clientInfo, ui); + try { + const blob = await this.downloadVault(session, rest); + const key = await this.cryptoUtils.deriveKey( + lowercaseUsername, + password, + session.keyIterationCount + ); + + let privateKey: Uint8Array = null; + if (session.encryptedPrivateKey != null && session.encryptedPrivateKey != "") { + privateKey = await this.parser.parseEncryptedPrivateKey(session.encryptedPrivateKey, key); + } + + return this.parseVault(blob, key, privateKey, options); + } finally { + await this.logout(session, rest); + } + } + + private async parseVault( + blob: Uint8Array, + encryptionKey: Uint8Array, + privateKey: Uint8Array, + options: ParserOptions + ): Promise { + const reader = new BinaryReader(blob); + const chunks = this.parser.extractChunks(reader); + if (!this.isComplete(chunks)) { + throw "Blob is truncated or corrupted"; + } + return await this.parseAccounts(chunks, encryptionKey, privateKey, options); + } + + private async parseAccounts( + chunks: Chunk[], + encryptionKey: Uint8Array, + privateKey: Uint8Array, + options: ParserOptions + ): Promise { + const accounts = new Array(); + let folder: SharedFolder = null; + for (const chunk of chunks) { + if (chunk.id === "ACCT") { + const key = folder == null ? encryptionKey : folder.encryptionKey; + const account = await this.parser.parseAcct(chunk, key, folder, options); + if (account != null) { + accounts.push(account); + } + } else if (chunk.id === "SHAR") { + folder = await this.parser.parseShar(chunk, encryptionKey, privateKey); + } + } + return accounts; + } + + private isComplete(chunks: Chunk[]): boolean { + if (chunks.length > 0 && chunks[chunks.length - 1].id === "ENDM") { + const okChunk = Utils.fromBufferToUtf8(chunks[chunks.length - 1].payload); + return okChunk === "OK"; + } + return false; + } + + private async login( + username: string, + password: string, + clientInfo: ClientInfo, + ui: Ui + ): Promise<[Session, RestClient]> { + const rest = new RestClient(); + rest.baseUrl = "https://lastpass.com"; + + /* + 1. First we need to request PBKDF2 key iteration count. + + We no longer request the iteration count from the server in a separate request because it + started to fail in weird ways. It seems there's a special combination or the UA and cookies + that returns the correct result. And that is not 100% reliable. After two or three attempts + it starts to fail again with an incorrect result. + + So we just went back a few years to the original way LastPass used to handle the iterations. + Namely, submit the default value and if it fails, the error would contain the correct value: + + */ + let keyIterationCount = 100_100; + + let response: Document = null; + let session: Session = null; + + // We have a maximum of 3 retries in case we need to try again with the correct domain and/or + // the number of KDF iterations the second/third time around. + for (let i = 0; i < 3; i++) { + // 2. Knowing the iterations count we can hash the password and log in. + // On the first attempt simply with the username and password. + response = await this.performSingleLoginRequest( + username, + password, + keyIterationCount, + new Map(), + clientInfo, + rest + ); + + session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo); + if (session != null) { + return [session, rest]; + } + + // It's possible we're being redirected to another region. + const server = this.getOptionalErrorAttribute(response, "server"); + if (server != null && server.trim() != "") { + rest.baseUrl = "https://" + server; + continue; + } + + // It's possible for the request above to come back with the correct iteration count. + // In this case we have to parse and repeat. + const correctIterationCount = this.getOptionalErrorAttribute(response, "iterations"); + if (correctIterationCount == null) { + break; + } + + try { + keyIterationCount = parseInt(correctIterationCount); + } catch { + throw ( + "Failed to parse the iteration count, expected an integer value '" + + correctIterationCount + + "'" + ); + } + } + + // 3. The simple login failed. This is usually due to some error, invalid credentials or + // a multifactor authentication being enabled. + const cause = this.getOptionalErrorAttribute(response, "cause"); + if (cause == null) { + throw this.makeLoginError(response); + } + + const optMethod = KnownOtpMethods.get(cause); + if (optMethod != null) { + // 3.1. One-time-password is required + session = await this.loginWithOtp( + username, + password, + keyIterationCount, + optMethod, + clientInfo, + ui, + rest + ); + } else if (cause === "outofbandrequired") { + // 3.2. Some out-of-bound authentication is enabled. This does not require any + // additional input from the user. + session = await this.loginWithOob( + username, + password, + keyIterationCount, + this.getAllErrorAttributes(response), + clientInfo, + ui, + rest + ); + } + + // Nothing worked + if (session == null) { + throw this.makeLoginError(response); + } + + // All good + return [session, rest]; + } + + private async loginWithOtp( + username: string, + password: string, + keyIterationCount: number, + method: OtpMethod, + clientInfo: ClientInfo, + ui: Ui, + rest: RestClient + ): Promise { + let passcode: OtpResult = null; + switch (method) { + case OtpMethod.GoogleAuth: + passcode = ui.provideGoogleAuthPasscode(); + break; + case OtpMethod.MicrosoftAuth: + passcode = ui.provideMicrosoftAuthPasscode(); + break; + case OtpMethod.Yubikey: + passcode = ui.provideYubikeyPasscode(); + break; + default: + throw "Invalid OTP method"; + } + + if (passcode == OtpResult.cancel) { + throw "Second factor step is canceled by the user"; + } + + const response = await this.performSingleLoginRequest( + username, + password, + keyIterationCount, + new Map([["otp", passcode.passcode]]), + clientInfo, + rest + ); + + const session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo); + if (session == null) { + throw this.makeLoginError(response); + } + if (passcode.rememberMe) { + await this.markDeviceAsTrusted(session, clientInfo, rest); + } + return session; + } + + private async loginWithOob( + username: string, + password: string, + keyIterationCount: number, + parameters: Map, + clientInfo: ClientInfo, + ui: Ui, + rest: RestClient + ): Promise { + const answer = this.approveOob(username, parameters, ui, rest); + if (answer == OobResult.cancel) { + throw "Out of band step is canceled by the user"; + } + + const extraParameters = new Map(); + if (answer.waitForOutOfBand) { + extraParameters.set("outofbandrequest", 1); + } else { + extraParameters.set("otp", answer.passcode); + } + + let session: Session = null; + for (;;) { + // In case of the OOB auth the server doesn't respond instantly. This works more like a long poll. + // The server times out in about 10 seconds so there's no need to back off. + const response = await this.performSingleLoginRequest( + username, + password, + keyIterationCount, + extraParameters, + clientInfo, + rest + ); + + session = this.extractSessionFromLoginResponse(response, keyIterationCount, clientInfo); + if (session != null) { + break; + } + + if (this.getOptionalErrorAttribute(response, "cause") != "outofbandrequired") { + throw this.makeLoginError(response); + } + + // Retry + extraParameters.set("outofbandretry", "1"); + extraParameters.set("outofbandretryid", this.getErrorAttribute(response, "retryid")); + } + + if (answer.rememberMe) { + await this.markDeviceAsTrusted(session, clientInfo, rest); + } + return session; + } + + private approveOob(username: string, parameters: Map, ui: Ui, rest: RestClient) { + const method = parameters.get("outofbandtype"); + if (method == null) { + throw "Out of band method is not specified"; + } + switch (method) { + case "lastpassauth": + return ui.approveLastPassAuth(); + case "duo": + return this.approveDuo(username, parameters, ui, rest); + case "salesforcehash": + return ui.approveSalesforceAuth(); + default: + throw "Out of band method " + method + " is not supported"; + } + } + + private approveDuo( + username: string, + parameters: Map, + ui: Ui, + rest: RestClient + ): OobResult { + return parameters.get("preferduowebsdk") == "1" + ? this.approveDuoWebSdk(username, parameters, ui, rest) + : ui.approveDuo(); + } + + private approveDuoWebSdk( + username: string, + parameters: Map, + ui: Ui, + rest: RestClient + ): OobResult { + // TODO: implement this + return OobResult.cancel; + } + + private async markDeviceAsTrusted(session: Session, clientInfo: ClientInfo, rest: RestClient) { + const parameters = new Map([ + ["uuid", clientInfo.id], + ["trustlabel", clientInfo.description], + ["token", session.token], + ]); + const response = await rest.postForm( + "trust.php", + parameters, + null, + this.getSessionCookies(session) + ); + if (response.status == HttpStatusCode.Ok) { + return; + } + this.makeError(response); + } + + private async logout(session: Session, rest: RestClient) { + const parameters = new Map([ + ["method", PlatformToUserAgent.get(session.platform)], + ["noredirect", 1], + ]); + const response = await rest.postForm( + "logout.php", + parameters, + null, + this.getSessionCookies(session) + ); + if (response.status == HttpStatusCode.Ok) { + return; + } + this.makeError(response); + } + + private async downloadVault(session: Session, rest: RestClient): Promise { + const endpoint = + "getaccts.php?mobile=1&b64=1&hash=0.0&hasplugin=3.0.23&requestsrc=" + + PlatformToUserAgent.get(session.platform); + const response = await rest.get(endpoint, null, this.getSessionCookies(session)); + if (response.status == HttpStatusCode.Ok) { + const b64 = await response.text(); + return Utils.fromB64ToArray(b64); + } + this.makeError(response); + } + + private getSessionCookies(session: Session) { + return new Map([["PHPSESSID", encodeURIComponent(session.id)]]); + } + + private getErrorAttribute(response: Document, name: string): string { + const attr = this.getOptionalErrorAttribute(response, name); + if (attr != null) { + return attr; + } + throw "Unknown response schema: attribute " + name + " is missing"; + } + + private getOptionalErrorAttribute(response: Document, name: string): string { + const error = response.querySelector("response > error"); + if (error == null) { + return null; + } + const attr = error.attributes.getNamedItem(name); + if (attr == null) { + return null; + } + return attr.value; + } + + private getAllErrorAttributes(response: Document): Map { + const error = response.querySelector("response > error"); + if (error == null) { + return null; + } + const map = new Map(); + for (const attr of Array.from(error.attributes)) { + map.set(attr.name, attr.value); + } + return map; + } + + private extractSessionFromLoginResponse( + response: Document, + keyIterationCount: number, + clientInfo: ClientInfo + ): Session { + const ok = response.querySelector("response > ok"); + if (ok == null) { + return null; + } + const sessionId = ok.attributes.getNamedItem("sessionid"); + if (sessionId == null) { + return null; + } + const token = ok.attributes.getNamedItem("token"); + if (token == null) { + return null; + } + + const session = new Session(); + session.id = sessionId.value; + session.keyIterationCount = keyIterationCount; + session.token = token.value; + session.platform = clientInfo.platform; + const privateKey = ok.attributes.getNamedItem("privatekeyenc"); + if (privateKey != null && privateKey.value != null && privateKey.value.trim() != "") { + session.encryptedPrivateKey = privateKey.value; + } + + return session; + } + + private async performSingleLoginRequest( + username: string, + password: string, + keyIterationCount: number, + extraParameters: Map, + clientInfo: ClientInfo, + rest: RestClient + ) { + const hash = await this.cryptoUtils.deriveKeyHash(username, password, keyIterationCount); + + const parameters = new Map([ + ["method", PlatformToUserAgent.get(clientInfo.platform)], + ["xml", "2"], + ["username", username], + ["hash", Utils.fromBufferToHex(hash.buffer)], + ["iterations", keyIterationCount], + ["includeprivatekeyenc", "1"], + ["outofbandsupported", "1"], + ["uuid", clientInfo.id], + // TODO: Test against the real server if it's ok to send this every time! + ["trustlabel", clientInfo.description], + ]); + for (const [key, value] of extraParameters) { + parameters.set(key, value); + } + + const response = await rest.postForm("login.php", parameters); + if (response.status == HttpStatusCode.Ok) { + const text = await response.text(); + const domParser = new window.DOMParser(); + return domParser.parseFromString(text, "text/xml"); + } + this.makeError(response); + } + + private makeError(response: Response) { + // TODO: error parsing + throw "HTTP request to " + response.url + " failed with status " + response.status + "."; + } + + private makeLoginError(response: Document): string { + const error = response.querySelector("response > error"); + if (error == null) { + return "Unknown response schema"; + } + + const cause = error.attributes.getNamedItem("cause"); + const message = error.attributes.getNamedItem("message"); + + if (cause != null) { + switch (cause.value) { + case "unknownemail": + return "Invalid username"; + case "unknownpassword": + return "Invalid password"; + case "googleauthfailed": + case "microsoftauthfailed": + case "otpfailed": + return "Second factor code is incorrect"; + case "multifactorresponsefailed": + return "Out of band authentication failed"; + default: + return message?.value ?? cause.value; + } + } + + // No cause, maybe at least a message + if (message != null) { + return message.value; + } + + // Nothing we know, just the error element + return "Unknown error"; + } +} diff --git a/libs/importer/src/importers/lastpass/access/crypto-utils.ts b/libs/importer/src/importers/lastpass/access/crypto-utils.ts new file mode 100644 index 0000000000..c8d9f8a168 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/crypto-utils.ts @@ -0,0 +1,118 @@ +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +export class CryptoUtils { + constructor(private cryptoFunctionService: CryptoFunctionService) {} + + async deriveKey(username: string, password: string, iterationCount: number) { + if (iterationCount < 0) { + throw "Iteration count should be positive"; + } + if (iterationCount == 1) { + return await this.cryptoFunctionService.hash(username + password, "sha256"); + } + return await this.cryptoFunctionService.pbkdf2(password, username, "sha256", iterationCount); + } + + async deriveKeyHash(username: string, password: string, iterationCount: number) { + const key = await this.deriveKey(username, password, iterationCount); + if (iterationCount == 1) { + return await this.cryptoFunctionService.hash( + Utils.fromBufferToHex(key.buffer) + password, + "sha256" + ); + } + return await this.cryptoFunctionService.pbkdf2(key, password, "sha256", 1); + } + + ExclusiveOr(arr1: Uint8Array, arr2: Uint8Array) { + if (arr1.length !== arr2.length) { + throw "Arrays must be the same length."; + } + const result = new Uint8Array(arr1.length); + for (let i = 0; i < arr1.length; i++) { + result[i] = arr1[i] ^ arr2[i]; + } + return result; + } + + async decryptAes256PlainWithDefault( + data: Uint8Array, + encryptionKey: Uint8Array, + defaultValue: string + ) { + try { + return this.decryptAes256Plain(data, encryptionKey); + } catch { + return defaultValue; + } + } + + async decryptAes256Base64WithDefault( + data: Uint8Array, + encryptionKey: Uint8Array, + defaultValue: string + ) { + try { + return this.decryptAes256Base64(data, encryptionKey); + } catch { + return defaultValue; + } + } + + async decryptAes256Plain(data: Uint8Array, encryptionKey: Uint8Array) { + if (data.length === 0) { + return ""; + } + // Byte 33 == character '!' + if (data[0] === 33 && data.length % 16 === 1 && data.length > 32) { + return this.decryptAes256CbcPlain(data, encryptionKey); + } + return this.decryptAes256EcbPlain(data, encryptionKey); + } + + async decryptAes256Base64(data: Uint8Array, encryptionKey: Uint8Array) { + if (data.length === 0) { + return ""; + } + // Byte 33 == character '!' + if (data[0] === 33) { + return this.decryptAes256CbcBase64(data, encryptionKey); + } + return this.decryptAes256EcbBase64(data, encryptionKey); + } + + async decryptAes256( + data: Uint8Array, + encryptionKey: Uint8Array, + mode: "cbc" | "ecb", + iv: Uint8Array = new Uint8Array(16) + ): Promise { + if (data.length === 0) { + return ""; + } + const plain = await this.cryptoFunctionService.aesDecrypt(data, iv, encryptionKey, mode); + return Utils.fromBufferToUtf8(plain); + } + + private async decryptAes256EcbPlain(data: Uint8Array, encryptionKey: Uint8Array) { + return this.decryptAes256(data, encryptionKey, "ecb"); + } + + private async decryptAes256EcbBase64(data: Uint8Array, encryptionKey: Uint8Array) { + const d = Utils.fromB64ToArray(Utils.fromBufferToUtf8(data)); + return this.decryptAes256(d, encryptionKey, "ecb"); + } + + private async decryptAes256CbcPlain(data: Uint8Array, encryptionKey: Uint8Array) { + const d = data.subarray(17); + const iv = data.subarray(1, 17); + return this.decryptAes256(d, encryptionKey, "cbc", iv); + } + + private async decryptAes256CbcBase64(data: Uint8Array, encryptionKey: Uint8Array) { + const d = Utils.fromB64ToArray(Utils.fromBufferToUtf8(data.subarray(26))); + const iv = Utils.fromB64ToArray(Utils.fromBufferToUtf8(data.subarray(1, 25))); + return this.decryptAes256(d, encryptionKey, "cbc", iv); + } +} diff --git a/libs/importer/src/importers/lastpass/access/duo-ui.ts b/libs/importer/src/importers/lastpass/access/duo-ui.ts new file mode 100644 index 0000000000..61b52d2582 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/duo-ui.ts @@ -0,0 +1,34 @@ +// Adds Duo functionality to the module-specific Ui class. +export abstract class DuoUi { + // To cancel return null + chooseDuoFactor: (devices: [DuoDevice]) => DuoChoice; + // To cancel return null or blank + provideDuoPasscode: (device: DuoDevice) => string; + // This updates the UI with the messages from the server. + updateDuoStatus: (status: DuoStatus, text: string) => void; +} + +export enum DuoFactor { + Push, + Call, + Passcode, + SendPasscodesBySms, +} + +export enum DuoStatus { + Success, + Error, + Info, +} + +export interface DuoChoice { + device: DuoDevice; + factor: DuoFactor; + rememberMe: boolean; +} + +export interface DuoDevice { + id: string; + name: string; + factors: DuoFactor[]; +} diff --git a/libs/importer/src/importers/lastpass/access/oob-result.ts b/libs/importer/src/importers/lastpass/access/oob-result.ts new file mode 100644 index 0000000000..ddd8b0d967 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/oob-result.ts @@ -0,0 +1,17 @@ +export class OobResult { + static cancel = new OobResult(false, "cancel", false); + + constructor( + public waitForOutOfBand: boolean, + public passcode: string, + public rememberMe: boolean + ) {} + + waitForApproval(rememberMe: boolean) { + return new OobResult(true, "", rememberMe); + } + + continueWithPasscode(passcode: string, rememberMe: boolean) { + return new OobResult(false, passcode, rememberMe); + } +} diff --git a/libs/importer/src/importers/lastpass/access/otp-method.ts b/libs/importer/src/importers/lastpass/access/otp-method.ts new file mode 100644 index 0000000000..6b940486ff --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/otp-method.ts @@ -0,0 +1,5 @@ +export enum OtpMethod { + GoogleAuth, + MicrosoftAuth, + Yubikey, +} diff --git a/libs/importer/src/importers/lastpass/access/otp-result.ts b/libs/importer/src/importers/lastpass/access/otp-result.ts new file mode 100644 index 0000000000..52c2c565f6 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/otp-result.ts @@ -0,0 +1,5 @@ +export class OtpResult { + static cancel = new OtpResult("cancel", false); + + constructor(public passcode: string, public rememberMe: boolean) {} +} diff --git a/libs/importer/src/importers/lastpass/access/parser-options.ts b/libs/importer/src/importers/lastpass/access/parser-options.ts new file mode 100644 index 0000000000..bdda099fb3 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/parser-options.ts @@ -0,0 +1,4 @@ +export class ParserOptions { + static default: ParserOptions = new ParserOptions(); + parseSecureNotesToAccount = true; +} diff --git a/libs/importer/src/importers/lastpass/access/parser.ts b/libs/importer/src/importers/lastpass/access/parser.ts new file mode 100644 index 0000000000..fc4b3b4a49 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/parser.ts @@ -0,0 +1,359 @@ +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { Account } from "./account"; +import { BinaryReader } from "./binary-reader"; +import { Chunk } from "./chunk"; +import { CryptoUtils } from "./crypto-utils"; +import { ParserOptions } from "./parser-options"; +import { SharedFolder } from "./shared-folder"; + +const AllowedSecureNoteTypes = new Set([ + "Server", + "Email Account", + "Database", + "Instant Messenger", +]); + +export class Parser { + constructor( + private cryptoFunctionService: CryptoFunctionService, + private cryptoUtils: CryptoUtils + ) {} + + /* + May return null when the chunk does not represent an account. + All secure notes are ACCTs but not all of them store account information. + + TODO: Add a test for the folder case! + TODO: Add a test case that covers secure note account! + */ + async parseAcct( + chunk: Chunk, + encryptionKey: Uint8Array, + folder: SharedFolder, + options: ParserOptions + ): Promise { + const placeholder = "decryption failed"; + const reader = new BinaryReader(chunk.payload); + + // Read all items + // 0: id + const id = Utils.fromBufferToUtf8(this.readItem(reader)); + + // 1: name + const name = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 2: group + const group = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 3: url + let url = Utils.fromBufferToUtf8( + Utils.fromHexToArray(Utils.fromBufferToUtf8(this.readItem(reader))) + ); + + // Ignore "group" accounts. They have no credentials. + if (url == "http://group") { + return null; + } + + // 4: extra (notes) + const notes = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 5: fav (is favorite) + const isFavorite = Utils.fromBufferToUtf8(this.readItem(reader)) === "1"; + + // 6: sharedfromaid (?) + this.skipItem(reader); + + // 7: username + let username = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 8: password + let password = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 9: pwprotect (?) + this.skipItem(reader); + + // 10: genpw (?) + this.skipItem(reader); + + // 11: sn (is secure note) + const isSecureNote = Utils.fromBufferToUtf8(this.readItem(reader)) === "1"; + + // Parse secure note + if (options.parseSecureNotesToAccount && isSecureNote) { + let type = ""; + // ParseSecureNoteServer + for (const i of notes.split("\n")) { + const keyValue = i.split(":", 2); + if (keyValue.length < 2) { + continue; + } + switch (keyValue[0]) { + case "NoteType": + type = keyValue[1]; + break; + case "Hostname": + url = keyValue[1]; + break; + case "Username": + username = keyValue[1]; + break; + case "Password": + password = keyValue[1]; + break; + } + } + + // Only the some secure notes contain account-like information + if (!AllowedSecureNoteTypes.has(type)) { + return null; + } + } + + // 12: last_touch_gmt (?) + this.skipItem(reader); + + // 13: autologin (?) + this.skipItem(reader); + + // 14: never_autofill (?) + this.skipItem(reader); + + // 15: realm (?) + this.skipItem(reader); + + // 16: id_again (?) + this.skipItem(reader); + + // 17: custom_js (?) + this.skipItem(reader); + + // 18: submit_id (?) + this.skipItem(reader); + + // 19: captcha_id (?) + this.skipItem(reader); + + // 20: urid (?) + this.skipItem(reader); + + // 21: basic_auth (?) + this.skipItem(reader); + + // 22: method (?) + this.skipItem(reader); + + // 23: action (?) + this.skipItem(reader); + + // 24: groupid (?) + this.skipItem(reader); + + // 25: deleted (?) + this.skipItem(reader); + + // 26: attachkey (?) + this.skipItem(reader); + + // 27: attachpresent (?) + this.skipItem(reader); + + // 28: individualshare (?) + this.skipItem(reader); + + // 29: notetype (?) + this.skipItem(reader); + + // 30: noalert (?) + this.skipItem(reader); + + // 31: last_modified_gmt (?) + this.skipItem(reader); + + // 32: hasbeenshared (?) + this.skipItem(reader); + + // 33: last_pwchange_gmt (?) + this.skipItem(reader); + + // 34: created_gmt (?) + this.skipItem(reader); + + // 35: vulnerable (?) + this.skipItem(reader); + + // 36: pwch (?) + this.skipItem(reader); + + // 37: breached (?) + this.skipItem(reader); + + // 38: template (?) + this.skipItem(reader); + + // 39: totp (?) + const totp = await this.cryptoUtils.decryptAes256PlainWithDefault( + this.readItem(reader), + encryptionKey, + placeholder + ); + + // 3 more left. Don't even bother skipping them. + + // 40: trustedHostnames (?) + // 41: last_credential_monitoring_gmt (?) + // 42: last_credential_monitoring_stat (?) + + // Adjust the path to include the group and the shared folder, if any. + const path = this.makeAccountPath(group, folder); + + const account = new Account(); + account.id = id; + account.name = name; + account.username = username; + account.password = password; + account.url = url; + account.path = path; + account.notes = notes; + account.totp = totp; + account.isFavorite = isFavorite; + account.isShared = folder != null; + return account; + } + + async parseShar( + chunk: Chunk, + encryptionKey: Uint8Array, + rsaKey: Uint8Array + ): Promise { + const reader = new BinaryReader(chunk.payload); + + // Id + const id = Utils.fromBufferToUtf8(this.readItem(reader)); + + // Key + const folderKey = this.readItem(reader); + const rsaEncryptedFolderKey = Utils.fromHexToArray(Utils.fromBufferToUtf8(folderKey)); + const decFolderKey = await this.cryptoFunctionService.rsaDecrypt( + rsaEncryptedFolderKey, + rsaKey, + "sha1" + ); + const key = Utils.fromHexToArray(Utils.fromBufferToUtf8(decFolderKey)); + + // Name + const encryptedName = this.readItem(reader); + const name = await this.cryptoUtils.decryptAes256Base64(encryptedName, key); + + const folder = new SharedFolder(); + folder.id = id; + folder.name = name; + folder.encryptionKey = key; + return folder; + } + + async parseEncryptedPrivateKey(encryptedPrivateKey: string, encryptionKey: Uint8Array) { + const decrypted = await this.cryptoUtils.decryptAes256( + Utils.fromHexToArray(encryptedPrivateKey), + encryptionKey, + "cbc", + encryptionKey.subarray(0, 16) + ); + + const header = "LastPassPrivateKey<"; + const footer = ">LastPassPrivateKey"; + if (!decrypted.startsWith(header) || !decrypted.endsWith(footer)) { + throw "Failed to decrypt private key"; + } + + const parsedKey = decrypted.substring(header.length, decrypted.length - footer.length); + const pkcs8 = Utils.fromHexToArray(parsedKey); + return pkcs8; + } + + makeAccountPath(group: string, folder: SharedFolder): string { + const groupEmpty = group == null || group.trim() === ""; + if (folder == null) { + return groupEmpty ? "(none)" : group; + } + return groupEmpty ? folder.name : folder.name + "\\" + group; + } + + extractChunks(reader: BinaryReader): Chunk[] { + const chunks = new Array(); + while (!reader.atEnd()) { + const chunk = this.readChunk(reader); + chunks.push(chunk); + + // TODO: catch end of stream exception? + // In case the stream is truncated we just ignore the incomplete chunk. + } + return chunks; + } + + private readChunk(reader: BinaryReader): Chunk { + /* + LastPass blob chunk is made up of 4-byte ID, big endian 4-byte size and payload of that size + Example: + 0000: 'IDID' + 0004: 4 + 0008: 0xDE 0xAD 0xBE 0xEF + 000C: --- Next chunk --- + */ + const chunk = new Chunk(); + chunk.id = this.readId(reader); + chunk.payload = this.readPayload(reader, this.readSize(reader)); + return chunk; + } + + private readItem(reader: BinaryReader): Uint8Array { + /* + An item in an itemized chunk is made up of the big endian size and the payload of that size + Example: + 0000: 4 + 0004: 0xDE 0xAD 0xBE 0xEF + 0008: --- Next item --- + See readItem for item description. + */ + return this.readPayload(reader, this.readSize(reader)); + } + + private skipItem(reader: BinaryReader): void { + // See readItem for item description. + reader.seekFromCurrentPosition(this.readSize(reader)); + } + + private readId(reader: BinaryReader): string { + return Utils.fromBufferToUtf8(reader.readBytes(4)); + } + + private readSize(reader: BinaryReader): number { + return reader.readUInt32BigEndian(); + } + + private readPayload(reader: BinaryReader, size: number): Uint8Array { + return reader.readBytes(size); + } +} diff --git a/libs/importer/src/importers/lastpass/access/platform.ts b/libs/importer/src/importers/lastpass/access/platform.ts new file mode 100644 index 0000000000..283e0c36ce --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/platform.ts @@ -0,0 +1,4 @@ +export enum Platform { + Desktop, + Mobile, +} diff --git a/libs/importer/src/importers/lastpass/access/rest-client.ts b/libs/importer/src/importers/lastpass/access/rest-client.ts new file mode 100644 index 0000000000..b26109d8e8 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/rest-client.ts @@ -0,0 +1,99 @@ +export class RestClient { + baseUrl: string; + isBrowser = true; + + async get( + endpoint: string, + headers: Map = null, + cookies: Map = null + ): Promise { + const requestInit: RequestInit = { + method: "GET", + credentials: "include", + }; + this.setHeaders(requestInit, headers, cookies); + const request = new Request(this.baseUrl + "/" + endpoint, requestInit); + const response = await fetch(request); + return response; + } + + async postForm( + endpoint: string, + parameters: Map = null, + headers: Map = null, + cookies: Map = null + ): Promise { + const setBody = (requestInit: RequestInit, headerMap: Map) => { + if (parameters != null && parameters.size > 0) { + const form = new FormData(); + for (const [key, value] of parameters) { + form.set(key, value); + } + requestInit.body = form; + } + }; + return await this.post(endpoint, setBody, headers, cookies); + } + + async postJson( + endpoint: string, + body: any, + headers: Map = null, + cookies: Map = null + ): Promise { + const setBody = (requestInit: RequestInit, headerMap: Map) => { + if (body != null) { + if (headerMap == null) { + headerMap = new Map(); + } + headerMap.set("Content-Type", "application/json; charset=utf-8"); + requestInit.body = JSON.stringify(body); + } + }; + return await this.post(endpoint, setBody, headers, cookies); + } + + private async post( + endpoint: string, + setBody: (requestInit: RequestInit, headers: Map) => void, + headers: Map = null, + cookies: Map = null + ) { + const requestInit: RequestInit = { + method: "POST", + credentials: "include", + }; + setBody(requestInit, headers); + this.setHeaders(requestInit, headers, cookies); + const request = new Request(this.baseUrl + "/" + endpoint, requestInit); + const response = await fetch(request); + return response; + } + + private setHeaders( + requestInit: RequestInit, + headers: Map = null, + cookies: Map = null + ) { + const requestHeaders = new Headers(); + let setHeaders = false; + if (headers != null && headers.size > 0) { + setHeaders = true; + for (const [key, value] of headers) { + requestHeaders.set(key, value); + } + } + // Cookies should be already automatically set for this origin by the browser + // TODO: set cookies for non-browser scenarios? + if (!this.isBrowser && cookies != null && cookies.size > 0) { + setHeaders = true; + const cookieString = Array.from(cookies.keys()) + .map((key) => `${key}=${cookies.get(key)}`) + .join("; "); + requestHeaders.set("cookie", cookieString); + } + if (setHeaders) { + requestInit.headers = requestHeaders; + } + } +} diff --git a/libs/importer/src/importers/lastpass/access/session.ts b/libs/importer/src/importers/lastpass/access/session.ts new file mode 100644 index 0000000000..4c71287263 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/session.ts @@ -0,0 +1,9 @@ +import { Platform } from "./platform"; + +export class Session { + id: string; + keyIterationCount: number; + token: string; + platform: Platform; + encryptedPrivateKey: string; +} diff --git a/libs/importer/src/importers/lastpass/access/shared-folder.ts b/libs/importer/src/importers/lastpass/access/shared-folder.ts new file mode 100644 index 0000000000..a9d3eb1346 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/shared-folder.ts @@ -0,0 +1,5 @@ +export class SharedFolder { + id: string; + name: string; + encryptionKey: Uint8Array; +} diff --git a/libs/importer/src/importers/lastpass/access/ui.ts b/libs/importer/src/importers/lastpass/access/ui.ts new file mode 100644 index 0000000000..fad8659618 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/ui.ts @@ -0,0 +1,29 @@ +import { DuoUi } from "./duo-ui"; +import { OobResult } from "./oob-result"; +import { OtpResult } from "./otp-result"; + +export abstract class Ui extends DuoUi { + // To cancel return OtpResult.Cancel, otherwise only valid data is expected. + provideGoogleAuthPasscode: () => OtpResult; + provideMicrosoftAuthPasscode: () => OtpResult; + provideYubikeyPasscode: () => OtpResult; + + /* + The UI implementations should provide the following possibilities for the user: + + 1. Cancel. Return OobResult.Cancel to cancel. + + 2. Go through with the out-of-band authentication where a third party app is used to approve or decline + the action. In this case return OobResult.waitForApproval(rememberMe). The UI should return as soon + as possible to allow the library to continue polling the service. Even though it's possible to return + control to the library only after the user performed the out-of-band action, it's not necessary. It + could be also done sooner. + + 3. Allow the user to provide the passcode manually. All supported OOB methods allow to enter the + passcode instead of performing an action in the app. In this case the UI should return + OobResult.continueWithPasscode(passcode, rememberMe). + */ + approveLastPassAuth: () => OobResult; + approveDuo: () => OobResult; + approveSalesforceAuth: () => OobResult; +} diff --git a/libs/importer/src/importers/lastpass/access/user-type.ts b/libs/importer/src/importers/lastpass/access/user-type.ts new file mode 100644 index 0000000000..8321cfa736 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/user-type.ts @@ -0,0 +1,34 @@ +export class UserType { + /* + Type values + 0 = Master Password + 3 = Federated + */ + type: number; + IdentityProviderGUID: string; + IdentityProviderURL: string; + OpenIDConnectAuthority: string; + OpenIDConnectClientId: string; + CompanyId: number; + /* + Provider Values + 0 = LastPass + 2 = Okta + */ + Provider: number; + PkceEnabled: boolean; + IsPasswordlessEnabled: boolean; + + isFederated(): boolean { + return ( + this.type === 3 && + this.hasValue(this.IdentityProviderURL) && + this.hasValue(this.OpenIDConnectAuthority) && + this.hasValue(this.OpenIDConnectClientId) + ); + } + + private hasValue(str: string) { + return str != null && str.trim() !== ""; + } +} diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts new file mode 100644 index 0000000000..6cbee8028c --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -0,0 +1,94 @@ +import { HttpStatusCode } from "@bitwarden/common/enums"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { Account } from "./account"; +import { Client } from "./client"; +import { ClientInfo } from "./client-info"; +import { CryptoUtils } from "./crypto-utils"; +import { Parser } from "./parser"; +import { ParserOptions } from "./parser-options"; +import { RestClient } from "./rest-client"; +import { Ui } from "./ui"; +import { UserType } from "./user-type"; + +export class Vault { + accounts: Account[]; + + private client: Client; + private cryptoUtils: CryptoUtils; + + constructor(private cryptoFunctionService: CryptoFunctionService) { + this.cryptoUtils = new CryptoUtils(cryptoFunctionService); + const parser = new Parser(cryptoFunctionService, this.cryptoUtils); + this.client = new Client(parser, this.cryptoUtils); + } + + async open( + username: string, + password: string, + clientInfo: ClientInfo, + ui: Ui, + parserOptions: ParserOptions = ParserOptions.default + ): Promise { + this.accounts = await this.client.openVault(username, password, clientInfo, ui, parserOptions); + } + + async openFederated( + username: string, + k1: string, + k2: string, + clientInfo: ClientInfo, + ui: Ui, + parserOptions: ParserOptions = ParserOptions.default + ): Promise { + const k1Arr = Utils.fromByteStringToArray(k1); + const k2Arr = Utils.fromB64ToArray(k2); + const hiddenPasswordArr = await this.cryptoFunctionService.hash( + this.cryptoUtils.ExclusiveOr(k1Arr, k2Arr), + "sha256" + ); + const hiddenPassword = Utils.fromBufferToB64(hiddenPasswordArr); + await this.open(username, hiddenPassword, clientInfo, ui, parserOptions); + } + + async getUserType(username: string): Promise { + const lowercaseUsername = username.toLowerCase(); + const rest = new RestClient(); + rest.baseUrl = "https://lastpass.com"; + const endpoint = "lmiapi/login/type?username=" + encodeURIComponent(lowercaseUsername); + const response = await rest.get(endpoint); + if (response.status === HttpStatusCode.Ok) { + const json = await response.json(); + const userType = new UserType(); + userType.CompanyId = json.CompanyId; + userType.IdentityProviderGUID = json.IdentityProviderGUID; + userType.IdentityProviderURL = json.IdentityProviderURL; + userType.IsPasswordlessEnabled = json.IsPasswordlessEnabled; + userType.OpenIDConnectAuthority = json.OpenIDConnectAuthority; + userType.OpenIDConnectClientId = json.OpenIDConnectClientId; + userType.PkceEnabled = json.PkceEnabled; + userType.Provider = json.Provider; + userType.type = json.type; + return userType; + } + throw "Cannot determine LastPass user type."; + } + + async getIdentityProviderKey(userType: UserType, idToken: string): Promise { + if (!userType.isFederated()) { + throw "Cannot get identity provider key for a LastPass user that is not federated."; + } + const rest = new RestClient(); + rest.baseUrl = userType.IdentityProviderURL; + const response = await rest.postJson("federatedlogin/api/v1/getkey", { + company_id: userType.CompanyId, + id_token: idToken, + }); + if (response.status === HttpStatusCode.Ok) { + const json = await response.json(); + return json["k2"] as string; + } + throw "Cannot get identity provider key from LastPass."; + } +}