mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +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