1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-06-20 09:35:22 +02:00

[PM-2899] Implement ProtonPass json importer (#5766)

* Implement ProtonPass json importer

* Add protonpass-importer json type definition

* Fix alphabetical order in importer imports

* Add importer error message for encrypted protonpass imports

* Add i18n to protonpass importer

* Add protonpass (zip) importer

* Fix protonpass importer

* Add unit tests for protonpass importer

* Make protonpass importer not discard totp codes

* Merge protonpass json & zip importers

* Add protonpass creditcard import & fix note import

* Fix protonpass zip import not recognizing zip files on windows/chrome

* Make protonpass importer use vault types

* Make protonpass importer treat vaults as folders

* Make protonpass importer treat folders as collections for organizations

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>

* Add types to protonpass test data

* Fix protonpass importer's moveFoldersToCollections

* Add tests for folders/collections

* Remove unecessary type cast in protonpass importer

* Remove unecessary type annotations in protonpass importer

* Add assertion for credit card cvv in protonpass importer

* Handle trashed items in protonpass importer

* Fix setting expiry month on credit cards

* Fix wrong folder-assignment

Only the first item of a "vault" was getting assigned to a folder

Extend unit tests to verify behaviour

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <djsmith@web.de>
This commit is contained in:
Bernd Schoolmann 2023-08-16 16:17:03 +02:00 committed by GitHub
parent a4fcd62c99
commit e016ed001e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 498 additions and 7 deletions

View File

@ -46,5 +46,8 @@
},
"ssoKeyConnectorError": {
"message": "Key Connector error: make sure Key Connector is available and working correctly."
},
"unsupportedEncryptedImport": {
"message": "Importing encrypted files is currently not supported."
}
}

View File

@ -67,7 +67,9 @@ export class ImportCommand {
try {
let contents;
if (format === "1password1pux") {
contents = await CliUtils.extract1PuxContent(filepath);
contents = await CliUtils.extractZipContent(filepath, "export.data");
} else if (format === "protonpass" && filepath.endsWith(".zip")) {
contents = await CliUtils.extractZipContent(filepath, "Proton Pass/data.json");
} else {
contents = await CliUtils.readFile(filepath);
}

View File

@ -46,7 +46,7 @@ export class CliUtils {
});
}
static extract1PuxContent(input: string): Promise<string> {
static extractZipContent(input: string, filepath: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
let p: string = null;
if (input != null && input !== "") {
@ -65,7 +65,7 @@ export class CliUtils {
}
JSZip.loadAsync(data).then(
(zip) => {
resolve(zip.file("export.data").async("string"));
resolve(zip.file(filepath).async("string"));
},
(reason) => {
reject(reason);
@ -74,6 +74,7 @@ export class CliUtils {
});
});
}
/**
* Save the given data to a file and determine the target file if necessary.
* If output is non-empty, it is used as target filename. Otherwise the target filename is

View File

@ -341,6 +341,10 @@
Log in to "https://vault.passky.org" &rarr; "Import & Export" &rarr; "Export" in the Passky
section. ("Backup" is unsupported as it is encrypted).
</ng-container>
<ng-container *ngIf="format === 'protonpass'">
In the ProtonPass browser extension, go to Settings > Export. Export without PGP encryption
and save the zip file.
</ng-container>
</bit-callout>
<bit-form-field>
<bit-label>{{ "selectImportFile" | i18n }}</bit-label>

View File

@ -326,7 +326,15 @@ export class ImportComponent implements OnInit, OnDestroy {
private getFileContents(file: File): Promise<string> {
if (this.format === "1password1pux") {
return this.extract1PuxContent(file);
return this.extractZipContent(file, "export.data");
}
if (
this.format === "protonpass" &&
(file.type === "application/zip" ||
file.type == "application/x-zip-compressed" ||
file.name.endsWith(".zip"))
) {
return this.extractZipContent(file, "Proton Pass/data.json");
}
return new Promise((resolve, reject) => {
@ -353,11 +361,11 @@ export class ImportComponent implements OnInit, OnDestroy {
});
}
private extract1PuxContent(file: File): Promise<string> {
private extractZipContent(zipFile: File, contentFilePath: string): Promise<string> {
return new JSZip()
.loadAsync(file)
.loadAsync(zipFile)
.then((zip) => {
return zip.file("export.data").async("string");
return zip.file(contentFilePath).async("string");
})
.then(
function success(content) {

View File

@ -0,0 +1,117 @@
import { MockProxy } from "jest-mock-extended";
import { FieldType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { ProtonPassJsonImporter } from "../src/importers";
import { testData } from "./test-data/protonpass-json/protonpass.json";
describe("Protonpass Json Importer", () => {
let importer: ProtonPassJsonImporter;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
importer = new ProtonPassJsonImporter(i18nService);
});
it("should parse login data", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
const cipher = result.ciphers.shift();
expect(cipher.name).toEqual("Test Login - Personal Vault");
expect(cipher.type).toEqual(CipherType.Login);
expect(cipher.login.username).toEqual("Username");
expect(cipher.login.password).toEqual("Password");
expect(cipher.login.uris.length).toEqual(2);
const uriView = cipher.login.uris.shift();
expect(uriView.uri).toEqual("https://example.com/");
expect(cipher.notes).toEqual("My login secure note.");
expect(cipher.fields.at(2).name).toEqual("second 2fa secret");
expect(cipher.fields.at(2).value).toEqual("TOTPCODE");
expect(cipher.fields.at(2).type).toEqual(FieldType.Hidden);
});
it("should parse note data", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
result.ciphers.shift();
const noteCipher = result.ciphers.shift();
expect(noteCipher.type).toEqual(CipherType.SecureNote);
expect(noteCipher.name).toEqual("My Secure Note");
expect(noteCipher.notes).toEqual("Secure note contents.");
});
it("should parse credit card data", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
result.ciphers.shift();
result.ciphers.shift();
const creditCardCipher = result.ciphers.shift();
expect(creditCardCipher.type).toBe(CipherType.Card);
expect(creditCardCipher.card.number).toBe("1234222233334444");
expect(creditCardCipher.card.cardholderName).toBe("Test name");
expect(creditCardCipher.card.expMonth).toBe("1");
expect(creditCardCipher.card.expYear).toBe("2025");
expect(creditCardCipher.card.code).toBe("333");
expect(creditCardCipher.fields.at(0).name).toEqual("PIN");
expect(creditCardCipher.fields.at(0).value).toEqual("1234");
expect(creditCardCipher.fields.at(0).type).toEqual(FieldType.Hidden);
});
it("should create folders if not part of an organization", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
const folders = result.folders;
expect(folders.length).toBe(2);
expect(folders[0].name).toBe("Personal");
expect(folders[1].name).toBe("Test");
// "My Secure Note" is assigned to folder "Personal"
expect(result.folderRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.folderRelationships[3]).toEqual([3, 1]);
});
it("should create collections if part of an organization", async () => {
const testDataJson = JSON.stringify(testData);
importer.organizationId = Utils.newGuid();
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
const collections = result.collections;
expect(collections.length).toBe(2);
expect(collections[0].name).toBe("Personal");
expect(collections[1].name).toBe("Test");
// "My Secure Note" is assigned to folder "Personal"
expect(result.collectionRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.collectionRelationships[3]).toEqual([3, 1]);
});
it("should not add deleted items", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
const ciphers = result.ciphers;
for (const cipher of ciphers) {
expect(cipher.name).not.toBe("My Deleted Note");
}
expect(ciphers.length).toBe(4);
});
});

View File

@ -0,0 +1,174 @@
import { ProtonPassJsonFile } from "../../../src/importers/protonpass/types/protonpass-json-type";
export const testData: ProtonPassJsonFile = {
version: "1.3.1",
userId: "REDACTED_USER_ID",
encrypted: false,
vaults: {
REDACTED_VAULT_ID_A: {
name: "Personal",
description: "Personal vault",
display: {
color: 0,
icon: 0,
},
items: [
{
itemId:
"yZENmDjtmZGODNy3Q_CZiPAF_IgINq8w-R-qazrOh-Nt9YJeVF3gu07ovzDS4jhYHoMdOebTw5JkYPGgIL1mwQ==",
shareId:
"SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==",
data: {
metadata: {
name: "Test Login - Personal Vault",
note: "My login secure note.",
itemUuid: "e8ee1a0c",
},
extraFields: [
{
fieldName: "non-hidden field",
type: "text",
data: {
content: "non-hidden field content",
},
},
{
fieldName: "hidden field",
type: "hidden",
data: {
content: "hidden field content",
},
},
{
fieldName: "second 2fa secret",
type: "totp",
data: {
totpUri: "TOTPCODE",
},
},
],
type: "login",
content: {
username: "Username",
password: "Password",
urls: ["https://example.com/", "https://example2.com/"],
totpUri:
"otpauth://totp/Test%20Login%20-%20Personal%20Vault:Username?issuer=Test%20Login%20-%20Personal%20Vault&secret=TOTPCODE&algorithm=SHA1&digits=6&period=30",
},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 1,
createTime: 1689182868,
modifyTime: 1689182868,
},
{
itemId:
"xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==",
shareId:
"SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==",
data: {
metadata: {
name: "My Secure Note",
note: "Secure note contents.",
itemUuid: "ad618070",
},
extraFields: [],
type: "note",
content: {},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 1,
createTime: 1689182908,
modifyTime: 1689182908,
},
{
itemId:
"ZmGzd-HNQYTr6wmfWlSfiStXQLqGic_PYB2Q2T_hmuRM2JIA4pKAPJcmFafxJrDpXxLZ2EPjgD6Noc9a0U6AVQ==",
shareId:
"SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==",
data: {
metadata: {
name: "Test Card",
note: "Credit Card Note",
itemUuid: "d8f45370",
},
extraFields: [],
type: "creditCard",
content: {
cardholderName: "Test name",
cardType: 0,
number: "1234222233334444",
verificationNumber: "333",
expirationDate: "012025",
pin: "1234",
},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 1,
createTime: 1691001643,
modifyTime: 1691001643,
},
{
itemId:
"xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==",
shareId:
"SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==",
data: {
metadata: {
name: "My Deleted Note",
note: "Secure note contents.",
itemUuid: "ad618070",
},
extraFields: [],
type: "note",
content: {},
},
state: 2,
aliasEmail: null,
contentFormatVersion: 1,
createTime: 1689182908,
modifyTime: 1689182908,
},
],
},
REDACTED_VAULT_ID_B: {
name: "Test",
description: "",
display: {
color: 4,
icon: 2,
},
items: [
{
itemId:
"U_J8-eUR15sC-PjUhjVcixDcayhjGuoerUZCr560RlAi0ZjBNkSaSKAytVzZn4E0hiFX1_y4qZbUetl6jO3aJw==",
shareId:
"OJz-4MnPqAuYnyemhctcGDlSLJrzsTnf2FnFSwxh1QP_oth9xyGDc2ZAqCv5FnqkVgTNHT5aPj62zcekNemfNw==",
data: {
metadata: {
name: "Other vault login",
note: "",
itemUuid: "f3429d44",
},
extraFields: [],
type: "login",
content: {
username: "other vault username",
password: "other vault password",
urls: [],
totpUri: "",
},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 1,
createTime: 1689182949,
modifyTime: 1689182949,
},
],
},
},
};

View File

@ -44,6 +44,7 @@ export { PasswordBossJsonImporter } from "./passwordboss-json-importer";
export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer";
export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer";
export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer";
export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer";
export { PsonoJsonImporter } from "./psono/psono-json-importer";
export { RememBearCsvImporter } from "./remembear-csv-importer";
export { RoboFormCsvImporter } from "./roboform-csv-importer";

View File

@ -0,0 +1,105 @@
import { FieldType, SecureNoteType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
import { Importer } from "../importer";
import {
ProtonPassCreditCardItemContent,
ProtonPassItemState,
ProtonPassJsonFile,
ProtonPassLoginItemContent,
} from "./types/protonpass-json-type";
export class ProtonPassJsonImporter extends BaseImporter implements Importer {
constructor(private i18nService: I18nService) {
super();
}
parse(data: string): Promise<ImportResult> {
const result = new ImportResult();
const results: ProtonPassJsonFile = JSON.parse(data);
if (results == null || results.vaults == null) {
result.success = false;
return Promise.resolve(result);
}
if (results.encrypted) {
result.success = false;
result.errorMessage = this.i18nService.t("unsupportedEncryptedImport");
return Promise.resolve(result);
}
for (const [, vault] of Object.entries(results.vaults)) {
for (const item of vault.items) {
if (item.state == ProtonPassItemState.TRASHED) {
continue;
}
this.processFolder(result, vault.name);
const cipher = this.initLoginCipher();
cipher.name = item.data.metadata.name;
cipher.notes = item.data.metadata.note;
switch (item.data.type) {
case "login": {
const loginContent = item.data.content as ProtonPassLoginItemContent;
cipher.login.uris = this.makeUriArray(loginContent.urls);
cipher.login.username = loginContent.username;
cipher.login.password = loginContent.password;
if (loginContent.totpUri != "") {
cipher.login.totp = new URL(loginContent.totpUri).searchParams.get("secret");
}
for (const extraField of item.data.extraFields) {
this.processKvp(
cipher,
extraField.fieldName,
extraField.type == "totp" ? extraField.data.totpUri : extraField.data.content,
extraField.type == "text" ? FieldType.Text : FieldType.Hidden
);
}
break;
}
case "note":
cipher.type = CipherType.SecureNote;
cipher.secureNote = new SecureNoteView();
cipher.secureNote.type = SecureNoteType.Generic;
break;
case "creditCard": {
const creditCardContent = item.data.content as ProtonPassCreditCardItemContent;
cipher.type = CipherType.Card;
cipher.card = new CardView();
cipher.card.cardholderName = creditCardContent.cardholderName;
cipher.card.number = creditCardContent.number;
cipher.card.brand = CardView.getCardBrandByPatterns(creditCardContent.number);
cipher.card.code = creditCardContent.verificationNumber;
if (!this.isNullOrWhitespace(creditCardContent.expirationDate)) {
cipher.card.expMonth = creditCardContent.expirationDate.substring(0, 2);
cipher.card.expMonth = cipher.card.expMonth.replace(/^0+/, "");
cipher.card.expYear = creditCardContent.expirationDate.substring(2, 6);
}
if (!this.isNullOrWhitespace(creditCardContent.pin)) {
this.processKvp(cipher, "PIN", creditCardContent.pin, FieldType.Hidden);
}
break;
}
}
this.cleanupCipher(cipher);
result.ciphers.push(cipher);
}
}
if (this.organization) {
this.moveFoldersToCollections(result);
}
result.success = true;
return Promise.resolve(result);
}
}

View File

@ -0,0 +1,72 @@
export type ProtonPassJsonFile = {
version: string;
userId: string;
encrypted: boolean;
vaults: Record<string, ProtonPassVault>;
};
export type ProtonPassVault = {
name: string;
description: string;
display: {
color: number;
icon: number;
};
items: ProtonPassItem[];
};
export type ProtonPassItem = {
itemId: string;
shareId: string;
data: ProtonPassItemData;
state: ProtonPassItemState;
aliasEmail: string | null;
contentFormatVersion: number;
createTime: number;
modifyTime: number;
};
export enum ProtonPassItemState {
ACTIVE = 1,
TRASHED = 2,
}
export type ProtonPassItemData = {
metadata: ProtonPassItemMetadata;
extraFields: ProtonPassItemExtraField[];
type: "login" | "alias" | "creditCard" | "note";
content: ProtonPassLoginItemContent | ProtonPassCreditCardItemContent;
};
export type ProtonPassItemMetadata = {
name: string;
note: string;
itemUuid: string;
};
export type ProtonPassItemExtraField = {
fieldName: string;
type: string;
data: ProtonPassItemExtraFieldData;
};
export type ProtonPassItemExtraFieldData = {
content?: string;
totpUri?: string;
};
export type ProtonPassLoginItemContent = {
username?: string;
password?: string;
urls?: string[];
totpUri?: string;
};
export type ProtonPassCreditCardItemContent = {
cardholderName?: string;
cardType?: number;
number?: string;
verificationNumber?: string;
expirationDate?: string;
pin?: string;
};

View File

@ -27,6 +27,7 @@ export const regularImportOptions = [
// { id: "keeperjson", name: "Keeper (json)" },
{ id: "enpasscsv", name: "Enpass (csv)" },
{ id: "enpassjson", name: "Enpass (json)" },
{ id: "protonpass", name: "ProtonPass (zip/json)" },
{ id: "safeincloudxml", name: "SafeInCloud (xml)" },
{ id: "pwsafexml", name: "Password Safe (xml)" },
{ id: "stickypasswordxml", name: "Sticky Password (xml)" },

View File

@ -62,6 +62,7 @@ import {
PasswordDragonXmlImporter,
PasswordSafeXmlImporter,
PasswordWalletTxtImporter,
ProtonPassJsonImporter,
PsonoJsonImporter,
RememBearCsvImporter,
RoboFormCsvImporter,
@ -319,6 +320,8 @@ export class ImportService implements ImportServiceAbstraction {
return new PsonoJsonImporter();
case "passkyjson":
return new PasskyJsonImporter();
case "protonpass":
return new ProtonPassJsonImporter(this.i18nService);
default:
return null;
}