From e016ed001e1f6f40390e7fccc2ad4e8e0249228f Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 16 Aug 2023 16:17:03 +0200 Subject: [PATCH] [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 * 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 Co-authored-by: Daniel James Smith --- apps/cli/src/locales/en/messages.json | 3 + apps/cli/src/tools/import.command.ts | 4 +- apps/cli/src/utils.ts | 5 +- .../tools/import-export/import.component.html | 4 + .../tools/import-export/import.component.ts | 16 +- .../spec/protonpass-json-importer.spec.ts | 117 ++++++++++++ .../protonpass-json/protonpass.json.ts | 174 ++++++++++++++++++ libs/importer/src/importers/index.ts | 1 + .../protonpass/protonpass-json-importer.ts | 105 +++++++++++ .../protonpass/types/protonpass-json-type.ts | 72 ++++++++ libs/importer/src/models/import-options.ts | 1 + libs/importer/src/services/import.service.ts | 3 + 12 files changed, 498 insertions(+), 7 deletions(-) create mode 100644 libs/importer/spec/protonpass-json-importer.spec.ts create mode 100644 libs/importer/spec/test-data/protonpass-json/protonpass.json.ts create mode 100644 libs/importer/src/importers/protonpass/protonpass-json-importer.ts create mode 100644 libs/importer/src/importers/protonpass/types/protonpass-json-type.ts diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index 4b6524b7ccb..e12b30af2c0 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -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." } } diff --git a/apps/cli/src/tools/import.command.ts b/apps/cli/src/tools/import.command.ts index 0080197aa74..c013d3c6b62 100644 --- a/apps/cli/src/tools/import.command.ts +++ b/apps/cli/src/tools/import.command.ts @@ -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); } diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts index d5e442ded19..f8780dbec63 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/utils.ts @@ -46,7 +46,7 @@ export class CliUtils { }); } - static extract1PuxContent(input: string): Promise { + static extractZipContent(input: string, filepath: string): Promise { return new Promise((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 diff --git a/apps/web/src/app/tools/import-export/import.component.html b/apps/web/src/app/tools/import-export/import.component.html index 67c2fe45b2a..c9e3285c291 100644 --- a/apps/web/src/app/tools/import-export/import.component.html +++ b/apps/web/src/app/tools/import-export/import.component.html @@ -341,6 +341,10 @@ Log in to "https://vault.passky.org" → "Import & Export" → "Export" in the Passky section. ("Backup" is unsupported as it is encrypted). + + In the ProtonPass browser extension, go to Settings > Export. Export without PGP encryption + and save the zip file. + {{ "selectImportFile" | i18n }} diff --git a/apps/web/src/app/tools/import-export/import.component.ts b/apps/web/src/app/tools/import-export/import.component.ts index 58f30b4ee48..eb9201f021d 100644 --- a/apps/web/src/app/tools/import-export/import.component.ts +++ b/apps/web/src/app/tools/import-export/import.component.ts @@ -326,7 +326,15 @@ export class ImportComponent implements OnInit, OnDestroy { private getFileContents(file: File): Promise { 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 { + private extractZipContent(zipFile: File, contentFilePath: string): Promise { 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) { diff --git a/libs/importer/spec/protonpass-json-importer.spec.ts b/libs/importer/spec/protonpass-json-importer.spec.ts new file mode 100644 index 00000000000..b8d57b9be7b --- /dev/null +++ b/libs/importer/spec/protonpass-json-importer.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts new file mode 100644 index 00000000000..4217ddd4d5b --- /dev/null +++ b/libs/importer/spec/test-data/protonpass-json/protonpass.json.ts @@ -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, + }, + ], + }, + }, +}; diff --git a/libs/importer/src/importers/index.ts b/libs/importer/src/importers/index.ts index 1a479a4cf5f..fff2edf3d54 100644 --- a/libs/importer/src/importers/index.ts +++ b/libs/importer/src/importers/index.ts @@ -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"; diff --git a/libs/importer/src/importers/protonpass/protonpass-json-importer.ts b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts new file mode 100644 index 00000000000..86f4444ec4d --- /dev/null +++ b/libs/importer/src/importers/protonpass/protonpass-json-importer.ts @@ -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 { + 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); + } +} diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts new file mode 100644 index 00000000000..27b38f434e7 --- /dev/null +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -0,0 +1,72 @@ +export type ProtonPassJsonFile = { + version: string; + userId: string; + encrypted: boolean; + vaults: Record; +}; + +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; +}; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 8a8ead96a49..2afe801d202 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -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)" }, diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 5920ec200d4..4ff15174c56 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -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; }