mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-4194] LastPass access library (#6473)
* convert some pma models * some client work * fix comment * add ui classes * finish implementing login * more client work * update cookie comment * vault class * some chunk work in client * convert to array * parse chunks with binary reader * parsing and crypto * parse private keys * move fetching to rest client * houskeeping * set cookies if not browser * fix field name changes * extract crypto utils * error checks on seek * fix build errors * fix lint errors * rename lib folder to access * fixes * fix seek function * support opening federated vaults * add postJson rest method * add user type and k2 apis * pass mode
This commit is contained in:
parent
9212751553
commit
f43c3220dc
12
libs/importer/src/importers/lastpass/access/account.ts
Normal file
12
libs/importer/src/importers/lastpass/access/account.ts
Normal file
@ -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;
|
||||||
|
}
|
76
libs/importer/src/importers/lastpass/access/binary-reader.ts
Normal file
76
libs/importer/src/importers/lastpass/access/binary-reader.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
4
libs/importer/src/importers/lastpass/access/chunk.ts
Normal file
4
libs/importer/src/importers/lastpass/access/chunk.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export class Chunk {
|
||||||
|
id: string;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import { Platform } from "./platform";
|
||||||
|
|
||||||
|
export class ClientInfo {
|
||||||
|
platform: Platform;
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
}
|
545
libs/importer/src/importers/lastpass/access/client.ts
Normal file
545
libs/importer/src/importers/lastpass/access/client.ts
Normal file
@ -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, string>([
|
||||||
|
[Platform.Desktop, "cli"],
|
||||||
|
[Platform.Mobile, "android"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const KnownOtpMethods = new Map<string, OtpMethod>([
|
||||||
|
["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<Account[]> {
|
||||||
|
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<Account[]> {
|
||||||
|
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<Account[]> {
|
||||||
|
const accounts = new Array<Account>();
|
||||||
|
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:
|
||||||
|
<response><error iterations="5000" /></response>
|
||||||
|
*/
|
||||||
|
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<string, any>(),
|
||||||
|
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<Session> {
|
||||||
|
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<string, string>([["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<string, string>,
|
||||||
|
clientInfo: ClientInfo,
|
||||||
|
ui: Ui,
|
||||||
|
rest: RestClient
|
||||||
|
): Promise<Session> {
|
||||||
|
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<string, any>();
|
||||||
|
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<string, string>, 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<string, string>,
|
||||||
|
ui: Ui,
|
||||||
|
rest: RestClient
|
||||||
|
): OobResult {
|
||||||
|
return parameters.get("preferduowebsdk") == "1"
|
||||||
|
? this.approveDuoWebSdk(username, parameters, ui, rest)
|
||||||
|
: ui.approveDuo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private approveDuoWebSdk(
|
||||||
|
username: string,
|
||||||
|
parameters: Map<string, string>,
|
||||||
|
ui: Ui,
|
||||||
|
rest: RestClient
|
||||||
|
): OobResult {
|
||||||
|
// TODO: implement this
|
||||||
|
return OobResult.cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markDeviceAsTrusted(session: Session, clientInfo: ClientInfo, rest: RestClient) {
|
||||||
|
const parameters = new Map<string, string>([
|
||||||
|
["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<string, any>([
|
||||||
|
["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<Uint8Array> {
|
||||||
|
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<string, string>([["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<string, string> {
|
||||||
|
const error = response.querySelector("response > error");
|
||||||
|
if (error == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string, any>,
|
||||||
|
clientInfo: ClientInfo,
|
||||||
|
rest: RestClient
|
||||||
|
) {
|
||||||
|
const hash = await this.cryptoUtils.deriveKeyHash(username, password, keyIterationCount);
|
||||||
|
|
||||||
|
const parameters = new Map<string, any>([
|
||||||
|
["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";
|
||||||
|
}
|
||||||
|
}
|
118
libs/importer/src/importers/lastpass/access/crypto-utils.ts
Normal file
118
libs/importer/src/importers/lastpass/access/crypto-utils.ts
Normal file
@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
34
libs/importer/src/importers/lastpass/access/duo-ui.ts
Normal file
34
libs/importer/src/importers/lastpass/access/duo-ui.ts
Normal file
@ -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[];
|
||||||
|
}
|
17
libs/importer/src/importers/lastpass/access/oob-result.ts
Normal file
17
libs/importer/src/importers/lastpass/access/oob-result.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export enum OtpMethod {
|
||||||
|
GoogleAuth,
|
||||||
|
MicrosoftAuth,
|
||||||
|
Yubikey,
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export class OtpResult {
|
||||||
|
static cancel = new OtpResult("cancel", false);
|
||||||
|
|
||||||
|
constructor(public passcode: string, public rememberMe: boolean) {}
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
export class ParserOptions {
|
||||||
|
static default: ParserOptions = new ParserOptions();
|
||||||
|
parseSecureNotesToAccount = true;
|
||||||
|
}
|
359
libs/importer/src/importers/lastpass/access/parser.ts
Normal file
359
libs/importer/src/importers/lastpass/access/parser.ts
Normal file
@ -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<string>([
|
||||||
|
"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<Account> {
|
||||||
|
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<SharedFolder> {
|
||||||
|
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<Chunk>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
4
libs/importer/src/importers/lastpass/access/platform.ts
Normal file
4
libs/importer/src/importers/lastpass/access/platform.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum Platform {
|
||||||
|
Desktop,
|
||||||
|
Mobile,
|
||||||
|
}
|
99
libs/importer/src/importers/lastpass/access/rest-client.ts
Normal file
99
libs/importer/src/importers/lastpass/access/rest-client.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
export class RestClient {
|
||||||
|
baseUrl: string;
|
||||||
|
isBrowser = true;
|
||||||
|
|
||||||
|
async get(
|
||||||
|
endpoint: string,
|
||||||
|
headers: Map<string, string> = null,
|
||||||
|
cookies: Map<string, string> = null
|
||||||
|
): Promise<Response> {
|
||||||
|
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<string, any> = null,
|
||||||
|
headers: Map<string, string> = null,
|
||||||
|
cookies: Map<string, string> = null
|
||||||
|
): Promise<Response> {
|
||||||
|
const setBody = (requestInit: RequestInit, headerMap: Map<string, string>) => {
|
||||||
|
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<string, string> = null,
|
||||||
|
cookies: Map<string, string> = null
|
||||||
|
): Promise<Response> {
|
||||||
|
const setBody = (requestInit: RequestInit, headerMap: Map<string, string>) => {
|
||||||
|
if (body != null) {
|
||||||
|
if (headerMap == null) {
|
||||||
|
headerMap = new Map<string, string>();
|
||||||
|
}
|
||||||
|
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<string, string>) => void,
|
||||||
|
headers: Map<string, string> = null,
|
||||||
|
cookies: Map<string, string> = 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<string, string> = null,
|
||||||
|
cookies: Map<string, string> = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
libs/importer/src/importers/lastpass/access/session.ts
Normal file
9
libs/importer/src/importers/lastpass/access/session.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Platform } from "./platform";
|
||||||
|
|
||||||
|
export class Session {
|
||||||
|
id: string;
|
||||||
|
keyIterationCount: number;
|
||||||
|
token: string;
|
||||||
|
platform: Platform;
|
||||||
|
encryptedPrivateKey: string;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export class SharedFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
encryptionKey: Uint8Array;
|
||||||
|
}
|
29
libs/importer/src/importers/lastpass/access/ui.ts
Normal file
29
libs/importer/src/importers/lastpass/access/ui.ts
Normal file
@ -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;
|
||||||
|
}
|
34
libs/importer/src/importers/lastpass/access/user-type.ts
Normal file
34
libs/importer/src/importers/lastpass/access/user-type.ts
Normal file
@ -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() !== "";
|
||||||
|
}
|
||||||
|
}
|
94
libs/importer/src/importers/lastpass/access/vault.ts
Normal file
94
libs/importer/src/importers/lastpass/access/vault.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<UserType> {
|
||||||
|
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<string> {
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user