[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:
Kyle Spearrin 2023-10-04 17:07:53 -04:00 committed by GitHub
parent 9212751553
commit f43c3220dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1460 additions and 0 deletions

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

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

View File

@ -0,0 +1,4 @@
export class Chunk {
id: string;
payload: Uint8Array;
}

View File

@ -0,0 +1,7 @@
import { Platform } from "./platform";
export class ClientInfo {
platform: Platform;
id: string;
description: string;
}

View 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";
}
}

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

View 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[];
}

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

View File

@ -0,0 +1,5 @@
export enum OtpMethod {
GoogleAuth,
MicrosoftAuth,
Yubikey,
}

View File

@ -0,0 +1,5 @@
export class OtpResult {
static cancel = new OtpResult("cancel", false);
constructor(public passcode: string, public rememberMe: boolean) {}
}

View File

@ -0,0 +1,4 @@
export class ParserOptions {
static default: ParserOptions = new ParserOptions();
parseSecureNotesToAccount = true;
}

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

View File

@ -0,0 +1,4 @@
export enum Platform {
Desktop,
Mobile,
}

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

View File

@ -0,0 +1,9 @@
import { Platform } from "./platform";
export class Session {
id: string;
keyIterationCount: number;
token: string;
platform: Platform;
encryptedPrivateKey: string;
}

View File

@ -0,0 +1,5 @@
export class SharedFolder {
id: string;
name: string;
encryptionKey: Uint8Array;
}

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

View 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() !== "";
}
}

View 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.";
}
}