mirror of
https://github.com/bitwarden/clients.git
synced 2026-01-16 23:12:39 +00:00
Merge 40fc7a3baf into 404d925f84
This commit is contained in:
commit
3c03efc4ee
8 changed files with 4268 additions and 202 deletions
|
|
@ -156,16 +156,14 @@
|
|||
</ng-container>
|
||||
<ng-container *ngIf="format === 'keepercsv'">
|
||||
Log into the Keeper web vault (keepersecurity.com/vault). Click on your "account email"
|
||||
(top right) and select "Settings". Go to "Export" and find the "Export to .csv File"
|
||||
option. Click "Export" to save the CSV file.
|
||||
(top right) and select "Settings". Go to "Export" and in the "Export File" section select
|
||||
the "CSV" option. Click "Export" to save the CSV file.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="format === 'keeperjson'">
|
||||
Log into the Keeper web vault (keepersecurity.com/vault). Click on your "account email"
|
||||
(top right) and select "Settings". Go to "Export" and in the "Export File" section select
|
||||
the "JSON" option. Click "Export" to save the JSON file.
|
||||
</ng-container>
|
||||
<!--
|
||||
<ng-container *ngIf="format === 'keeperjson'">
|
||||
Log into the Keeper web vault (keepersecurity.com/vault). Click on your "account email" (top
|
||||
right) and select "Settings". Go to "Export" and find the "Export to .json File" option. Click
|
||||
"Export" to save the JSON file.
|
||||
</ng-container>
|
||||
-->
|
||||
<ng-container *ngIf="showChromiumInstructions$ | async">
|
||||
<span *ngIf="format !== 'chromecsv'">
|
||||
The process is exactly the same as importing from Google Chrome.
|
||||
|
|
|
|||
61
libs/importer/src/importers/keeper/README.md
Normal file
61
libs/importer/src/importers/keeper/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
## Keeper JSON importer
|
||||
|
||||
### Conversions
|
||||
|
||||
Currently these record types are supported by the current version of Keeper. It's not very clear
|
||||
what could be in the legacy vaults and there's no way to test that, unless there old vaults to be
|
||||
tested on.
|
||||
|
||||
By default all the records are converted to `Login` and then they might automatically convert to a
|
||||
`SecureNote` by the base importer if there is no login information on the entry. Possibly we want to
|
||||
handle some of the type conversion manually. Should the conversion types be forced?
|
||||
|
||||
- [ ] address -> `Identity`?
|
||||
- [ ] bankAccount
|
||||
- [x] bankCard -> `Card`
|
||||
- [ ] birthCertificate -> `Identity`?
|
||||
- [ ] contact
|
||||
- [ ] databaseCredentials
|
||||
- [ ] driverLicense -> `Identity`?
|
||||
- [ ] encryptedNotes
|
||||
- [ ] file
|
||||
- [ ] general
|
||||
- [ ] healthInsurance
|
||||
- [x] login -> `Login`
|
||||
- [ ] membership
|
||||
- [ ] passport
|
||||
- [ ] photo
|
||||
- [ ] serverCredentials
|
||||
- [ ] softwareLicense
|
||||
- [x] sshKeys -> `SshKey`
|
||||
- [ ] ssnCard
|
||||
- [ ] wifiCredentials
|
||||
|
||||
### Gotchas, weirdnesses and questions
|
||||
|
||||
- [x] What to do with the IDs? Import them as is? Generate new ones? Leave out blank?
|
||||
- [x] Multiple TOTP (currently the first one used, others ignored)
|
||||
- [ ] Schema is ignored (probably no use anyway)
|
||||
- [x] Custom fields names/types are not parsed and used as is
|
||||
- [x] Should `last_modified` be set on the cipher?
|
||||
- [ ] The base importer has a special way of handling custom fields, not used in this importer.
|
||||
Figure this out!
|
||||
- [x] No fingerprint on ssh keys
|
||||
- [x] login/password on ssh keys are stored as username/passphrase extra fields
|
||||
- [x] Custom fields have a weird format, like `$keyPair::1`. This needs to be figured out.
|
||||
- [x] Legacy exports are similar but not exactly the same. Need to support variants.
|
||||
- [ ] When importing dates, should a specific locale be used in `toLocaleString`?
|
||||
- [ ] Phone number format is a bit funky: `(AF) 5415558723 ext. 5577 (Work)`. Would be good to
|
||||
replace the region with +code.
|
||||
- [x] An invalid ssh key should be added as a secure note
|
||||
- [x] Some items could be both arrays and single objects
|
||||
- [ ] Some single/repeated items might get a generic name instead of the label in the vault
|
||||
|
||||
### Missing features
|
||||
|
||||
- [x] Shared folders
|
||||
- [-] File attachments
|
||||
- [x] PAM record types
|
||||
- [x] Some more enterprise record types
|
||||
- [x] Custom record types
|
||||
- [x] Referenced fields
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,47 +1,38 @@
|
|||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { FieldType } from "@bitwarden/common/vault/enums/field-type.enum";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { import_ssh_key, SshKeyView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ImportResult } from "../../models/import-result";
|
||||
import { BaseImporter } from "../base-importer";
|
||||
import { Importer } from "../importer";
|
||||
|
||||
import { KeeperJsonExport, RecordsEntity } from "./types/keeper-json-types";
|
||||
import { KeeperJsonExport, Record, CustomFields } from "./types/keeper-json-types";
|
||||
|
||||
type Reference = {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export class KeeperJsonImporter extends BaseImporter implements Importer {
|
||||
private references: Map<string, Reference[]> = new Map<string, Reference[]>();
|
||||
private idToCipher: Map<string, CipherView> = new Map<string, CipherView>();
|
||||
|
||||
parse(data: string): Promise<ImportResult> {
|
||||
const result = new ImportResult();
|
||||
const keeperExport: KeeperJsonExport = JSON.parse(data);
|
||||
if (keeperExport == null || keeperExport.records == null || keeperExport.records.length === 0) {
|
||||
if (!keeperExport?.records?.length) {
|
||||
result.success = false;
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
keeperExport.records.forEach((record) => {
|
||||
this.parseFolders(result, record);
|
||||
|
||||
const cipher = this.initLoginCipher();
|
||||
cipher.name = record.title;
|
||||
cipher.login.username = record.login;
|
||||
cipher.login.password = record.password;
|
||||
|
||||
cipher.login.uris = this.makeUriArray(record.login_url);
|
||||
cipher.notes = record.notes;
|
||||
|
||||
if (record.custom_fields != null) {
|
||||
let customfieldKeys = Object.keys(record.custom_fields);
|
||||
if (record.custom_fields["TFC:Keeper"] != null) {
|
||||
customfieldKeys = customfieldKeys.filter((item) => item !== "TFC:Keeper");
|
||||
cipher.login.totp = record.custom_fields["TFC:Keeper"];
|
||||
}
|
||||
|
||||
customfieldKeys.forEach((key) => {
|
||||
this.processKvp(cipher, key, record.custom_fields[key]);
|
||||
});
|
||||
}
|
||||
|
||||
this.convertToNoteIfNeeded(cipher);
|
||||
this.cleanupCipher(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
});
|
||||
this.parseSharedFolders(keeperExport, result);
|
||||
this.parseRecords(keeperExport, result);
|
||||
this.resolveReferences();
|
||||
|
||||
if (this.organization) {
|
||||
this.moveFoldersToCollections(result);
|
||||
|
|
@ -51,21 +42,528 @@ export class KeeperJsonImporter extends BaseImporter implements Importer {
|
|||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
private parseFolders(result: ImportResult, record: RecordsEntity) {
|
||||
if (record.folders == null || record.folders.length === 0) {
|
||||
private parseSharedFolders(keeperExport: KeeperJsonExport, result: ImportResult) {
|
||||
if (!keeperExport.shared_folders) {
|
||||
return;
|
||||
}
|
||||
|
||||
keeperExport.shared_folders.forEach((folder) => {
|
||||
this.processFolder(result, folder.path ?? "", false);
|
||||
});
|
||||
}
|
||||
|
||||
private parseRecords(keeperExport: KeeperJsonExport, result: ImportResult) {
|
||||
keeperExport.records.forEach((record) => {
|
||||
this.parseFolders(result, record);
|
||||
|
||||
const cipher = this.initLoginCipher();
|
||||
cipher.name = this.getValueOrDefault(record.title);
|
||||
cipher.notes = this.getValueOrDefault(record.notes);
|
||||
|
||||
cipher.login.username = this.getValueOrDefault(record.login);
|
||||
cipher.login.password = this.getValueOrDefault(record.password);
|
||||
cipher.login.uris = this.makeUriArray(record.login_url);
|
||||
|
||||
// Force type based on the record type
|
||||
switch (record.$type) {
|
||||
case "bankCard":
|
||||
this.importBankCard(record, cipher);
|
||||
break;
|
||||
case "sshKeys":
|
||||
// In Bitwarden the ssh key is supposed to be valid.
|
||||
// So we only set the type if we can actually import a key.
|
||||
if (!this.importSshKey(record, cipher)) {
|
||||
// Otherwise, fallback to secure note
|
||||
cipher.type = CipherType.SecureNote;
|
||||
// Make sure the passphrase is not lost, if any. The key pair will be imported as a custom field.
|
||||
this.addField(cipher, "Passphrase", cipher.login.password, FieldType.Hidden);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (record.custom_fields) {
|
||||
this.importCustomFields(record.custom_fields, cipher);
|
||||
}
|
||||
|
||||
if (record.uid && record.references) {
|
||||
const refs = [];
|
||||
for (const [key, values] of Object.entries(record.references)) {
|
||||
let [type] = this.parseFieldKey(key);
|
||||
|
||||
// Web exporter appends "Ref" to the type names
|
||||
if (type.endsWith("Ref")) {
|
||||
type = type.substring(0, type.length - 3);
|
||||
}
|
||||
|
||||
refs.push(...this.makeArray(values).map((id) => ({ id: id, type: type })));
|
||||
}
|
||||
|
||||
if (refs.length > 0) {
|
||||
this.references.set(record.uid, refs);
|
||||
}
|
||||
}
|
||||
|
||||
this.convertToNoteIfNeeded(cipher);
|
||||
this.cleanupCipher(cipher);
|
||||
|
||||
result.ciphers.push(cipher);
|
||||
|
||||
// This is needed for resolving references later
|
||||
if (record.uid) {
|
||||
this.idToCipher.set(record.uid, cipher);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveReferences() {
|
||||
for (const [uid, refs] of this.references) {
|
||||
const cipher = this.idToCipher.get(uid);
|
||||
if (!cipher) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { id, type } of refs) {
|
||||
const refCipher = this.idToCipher.get(id);
|
||||
if (!refCipher) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = this.findFieldByName(refCipher, type)?.value || "";
|
||||
if (value) {
|
||||
this.addField(cipher, type, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findFieldByName(cipher: CipherView, name: string): FieldView | null {
|
||||
return cipher.fields.find((f) => f.name === name) || null;
|
||||
}
|
||||
|
||||
private importBankCard(record: Record, cipher: CipherView) {
|
||||
cipher.type = CipherType.Card;
|
||||
cipher.card.cardholderName = this.findCustomField(record.custom_fields, "$text:cardholderName");
|
||||
cipher.card.number = this.findCustomField(record.custom_fields, "$paymentCard/cardNumber");
|
||||
cipher.card.code = this.findCustomField(record.custom_fields, "$paymentCard/cardSecurityCode");
|
||||
cipher.card.brand = CardView.getCardBrandByPatterns(cipher.card.number);
|
||||
|
||||
const expDate = this.findCustomField(record.custom_fields, "$paymentCard/cardExpirationDate");
|
||||
if (expDate) {
|
||||
const expDateParts = expDate.split("/");
|
||||
if (expDateParts.length === 2) {
|
||||
cipher.card.expMonth = expDateParts[0];
|
||||
cipher.card.expYear = expDateParts[1];
|
||||
} else {
|
||||
this.addField(cipher, "Expiration date", expDate);
|
||||
}
|
||||
}
|
||||
|
||||
const pinCode = this.findCustomField(record.custom_fields, "$pinCode");
|
||||
if (pinCode) {
|
||||
this.addField(cipher, "PIN", pinCode, FieldType.Hidden);
|
||||
}
|
||||
|
||||
this.copyLoginPropertiesAsCustomFields(cipher);
|
||||
|
||||
// These should not be imported as custom fields since they are mapped to card properties
|
||||
this.deleteTopLevelCustomField(record.custom_fields, "$paymentCard");
|
||||
this.deleteTopLevelCustomField(record.custom_fields, "$text:cardholderName");
|
||||
this.deleteTopLevelCustomField(record.custom_fields, "$pinCode");
|
||||
}
|
||||
|
||||
private importSshKey(record: Record, cipher: CipherView): boolean {
|
||||
const privateKey = this.findCustomField(record.custom_fields, "$keyPair/privateKey");
|
||||
if (!privateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let keyView: SshKeyView | null = null;
|
||||
try {
|
||||
keyView = import_ssh_key(privateKey, cipher.login.password);
|
||||
} catch {
|
||||
this.logService.warning(
|
||||
`Unable to import SSH key (id: ${record.uid}, title: ${record.title})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!keyView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cipher.type = CipherType.SshKey;
|
||||
cipher.sshKey.privateKey = keyView.privateKey;
|
||||
cipher.sshKey.publicKey = keyView.publicKey;
|
||||
cipher.sshKey.keyFingerprint = keyView.fingerprint;
|
||||
|
||||
this.copyLoginPropertiesAsCustomFields(cipher);
|
||||
|
||||
const hostName = this.findCustomField(record.custom_fields, "$host/hostName");
|
||||
if (hostName) {
|
||||
this.addField(cipher, "Hostname", hostName);
|
||||
}
|
||||
const port = this.findCustomField(record.custom_fields, "$host/port");
|
||||
if (port) {
|
||||
this.addField(cipher, "Port", port);
|
||||
}
|
||||
|
||||
// These should not be imported as custom fields since they are mapped to ssh key properties
|
||||
this.deleteTopLevelCustomField(record.custom_fields, "$keyPair");
|
||||
this.deleteTopLevelCustomField(record.custom_fields, "$host");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private copyLoginPropertiesAsCustomFields(cipher: CipherView) {
|
||||
if (!this.isNullOrWhitespace(cipher.login.username)) {
|
||||
this.addField(cipher, "Username", cipher.login.username!);
|
||||
cipher.login.username = undefined;
|
||||
}
|
||||
|
||||
if (!this.isNullOrWhitespace(cipher.login.password)) {
|
||||
this.addField(cipher, "Password", cipher.login.password!, FieldType.Hidden);
|
||||
cipher.login.password = undefined;
|
||||
}
|
||||
|
||||
if (cipher.login.uris) {
|
||||
cipher.login.uris.forEach((uri, index) => {
|
||||
this.addField(cipher, "URL", uri.uri);
|
||||
});
|
||||
cipher.login.uris = [];
|
||||
}
|
||||
}
|
||||
|
||||
private findCustomField(customFields: CustomFields, path: string): string {
|
||||
let root = customFields as any;
|
||||
for (const part of path.split("/")) {
|
||||
const keys = Object.keys(root);
|
||||
if (keys.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const key = keys.find((k) => k.replace(/:?:\d$/, "") === part);
|
||||
if (!key || root[key] == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
root = root[key];
|
||||
}
|
||||
|
||||
return root.toString();
|
||||
}
|
||||
|
||||
private deleteTopLevelCustomField(customFields: CustomFields, name: string) {
|
||||
const key = Object.keys(customFields).find((k) => k.replace(/:?:\d$/, "") === name);
|
||||
if (key) {
|
||||
delete customFields[key];
|
||||
}
|
||||
}
|
||||
|
||||
private importCustomFields(customFields: CustomFields, cipher: CipherView) {
|
||||
for (const [originalKey, originalValue] of Object.entries(customFields)) {
|
||||
const [type, name] = this.parseFieldKey(originalKey);
|
||||
|
||||
// These handle arrays internally
|
||||
if (this.tryImportArrayField(type, originalValue, cipher)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedName = name || type || originalKey;
|
||||
const values = this.makeArray(originalValue);
|
||||
|
||||
for (const value of values) {
|
||||
// These expand into multiple fields
|
||||
if (this.tryImportExpandingField(type, value, cipher)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Import as a single field with JSON.stringify as the last resort fallback
|
||||
this.importSingleField(type, importedName, value, cipher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tryImportArrayField(
|
||||
type: string,
|
||||
originalValue: string | string[],
|
||||
cipher: CipherView,
|
||||
): boolean {
|
||||
switch (type) {
|
||||
case "oneTimeCode":
|
||||
{
|
||||
const totps = this.makeArray(originalValue);
|
||||
if (totps.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Login has a dedicated TOTP field. All others get added as custom fields.
|
||||
if (cipher.type === CipherType.Login) {
|
||||
cipher.login.totp = totps[0];
|
||||
totps.shift();
|
||||
}
|
||||
|
||||
totps.forEach((code) => {
|
||||
this.addField(cipher, "TOTP", code, FieldType.Hidden);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "url":
|
||||
{
|
||||
const urls = this.makeUriArray(originalValue);
|
||||
if (cipher.type === CipherType.Login) {
|
||||
cipher.login.uris.push(...urls);
|
||||
} else {
|
||||
urls.forEach((url) => {
|
||||
this.addField(cipher, "URL", url.uri);
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private tryImportExpandingField(type: string, originalValue: any, cipher: CipherView): boolean {
|
||||
switch (type) {
|
||||
case "host":
|
||||
{
|
||||
const { hostName, port } = originalValue as { hostName?: string; port?: string };
|
||||
this.addField(cipher, "Hostname", hostName);
|
||||
this.addField(cipher, "Port", port);
|
||||
}
|
||||
break;
|
||||
case "keyPair":
|
||||
{
|
||||
const { publicKey, privateKey } = originalValue as {
|
||||
publicKey?: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
this.addField(cipher, "Public key", publicKey);
|
||||
this.addField(cipher, "Private key", privateKey, FieldType.Hidden);
|
||||
}
|
||||
break;
|
||||
case "securityQuestion":
|
||||
{
|
||||
for (const { question, answer } of this.makeArray(originalValue) as {
|
||||
question?: string;
|
||||
answer?: string;
|
||||
}[]) {
|
||||
this.addField(cipher, "Security question", question);
|
||||
this.addField(cipher, "Security question answer", answer, FieldType.Hidden);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "appFiller":
|
||||
// Ignored since it's an implementation detail of Keeper
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private importSingleField(type: string, importedName: string, value: any, cipher: CipherView) {
|
||||
let importedValue = this.convertToFieldValue(value);
|
||||
let importedType = FieldType.Text;
|
||||
|
||||
switch (type) {
|
||||
case "date":
|
||||
case "birthDate":
|
||||
case "expirationDate":
|
||||
importedValue = this.parseDate(value);
|
||||
break;
|
||||
case "name":
|
||||
{
|
||||
const { first, middle, last } = value as {
|
||||
first?: string;
|
||||
middle?: string;
|
||||
last?: string;
|
||||
};
|
||||
importedValue = [first, middle, last]
|
||||
.filter((x) => x)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
break;
|
||||
case "address":
|
||||
{
|
||||
const { street1, street2, city, state, zip, country } = value as {
|
||||
street1?: string;
|
||||
street2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
country?: string;
|
||||
};
|
||||
importedValue = [street1, street2, city, state, zip, country]
|
||||
.filter((x) => x)
|
||||
.join(", ")
|
||||
.trim();
|
||||
}
|
||||
break;
|
||||
case "phone":
|
||||
{
|
||||
const { region, number, ext, type } = value as {
|
||||
region?: string;
|
||||
number?: string;
|
||||
ext?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const parts = [];
|
||||
if (region) {
|
||||
// TODO: Convert to +<code> format?
|
||||
parts.push(`(${region})`);
|
||||
}
|
||||
if (number) {
|
||||
parts.push(number);
|
||||
}
|
||||
if (ext) {
|
||||
parts.push(`ext. ${ext}`);
|
||||
}
|
||||
if (type) {
|
||||
parts.push(`(${type})`);
|
||||
}
|
||||
|
||||
importedValue = parts.join(" ").trim();
|
||||
}
|
||||
break;
|
||||
case "bankAccount":
|
||||
{
|
||||
const { accountType, otherType, accountNumber, routingNumber } = value as {
|
||||
accountType?: string;
|
||||
otherType?: string;
|
||||
accountNumber?: string;
|
||||
routingNumber?: string;
|
||||
};
|
||||
|
||||
const parts = [];
|
||||
const type = otherType || accountType;
|
||||
if (type) {
|
||||
parts.push(`Type: ${type}`);
|
||||
}
|
||||
if (accountNumber) {
|
||||
parts.push(`Account Number: ${accountNumber}`);
|
||||
}
|
||||
if (routingNumber) {
|
||||
parts.push(`Routing Number: ${routingNumber}`);
|
||||
}
|
||||
|
||||
importedValue = parts.join(", ").trim();
|
||||
}
|
||||
break;
|
||||
case "pinCode":
|
||||
case "secret":
|
||||
importedType = FieldType.Hidden;
|
||||
break;
|
||||
default:
|
||||
// Do nothing, default conversion is fine
|
||||
break;
|
||||
}
|
||||
|
||||
this.addField(cipher, importedName, importedValue, importedType);
|
||||
}
|
||||
|
||||
private parseDate(timestamp: string | number): string {
|
||||
const date = new Date(timestamp);
|
||||
return isNaN(date.getTime()) ? "" : date.toLocaleString();
|
||||
}
|
||||
|
||||
// This function parses custom field keys of the form:
|
||||
// $<type>:<name>:<suffix> and returns [type, name]
|
||||
// It handles a bunch of edge cases as well. See tests for examples.
|
||||
// This function is modeled after the original implementation.
|
||||
private parseFieldKey(key: string): [string, string] {
|
||||
if (this.isNullOrWhitespace(key)) {
|
||||
return ["", ""];
|
||||
}
|
||||
|
||||
let fieldType = "";
|
||||
let fieldName = "";
|
||||
|
||||
if (key[0] === "$") {
|
||||
const pos = key.indexOf(":");
|
||||
if (pos > 0) {
|
||||
fieldType = key.substring(1, pos).trim();
|
||||
fieldName = key.substring(pos + 1).trim();
|
||||
} else {
|
||||
fieldType = key.substring(1).trim();
|
||||
fieldName = "";
|
||||
}
|
||||
} else {
|
||||
fieldType = "";
|
||||
fieldName = key;
|
||||
}
|
||||
|
||||
if (
|
||||
fieldName.length >= 2 &&
|
||||
fieldName[fieldName.length - 2] === ":" &&
|
||||
/\d/.test(fieldName[fieldName.length - 1])
|
||||
) {
|
||||
fieldName = fieldName.substring(0, fieldName.length - 2);
|
||||
}
|
||||
|
||||
return [fieldType, fieldName];
|
||||
}
|
||||
|
||||
private addField(cipher: CipherView, name: string, value: any, type: FieldType = FieldType.Text) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const field = new FieldView();
|
||||
field.type = type;
|
||||
field.name = name;
|
||||
field.value = this.convertToFieldValue(value);
|
||||
cipher.fields.push(field);
|
||||
}
|
||||
|
||||
// Just to be safe, value come in all kinds of flavors
|
||||
private convertToFieldValue(value: any): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
// Fallthrough
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private makeArray(value: any): any[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value != null) {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private parseFolders(result: ImportResult, record: Record) {
|
||||
if (!record.folders) {
|
||||
return;
|
||||
}
|
||||
|
||||
record.folders.forEach((item) => {
|
||||
if (item.folder != null) {
|
||||
this.processFolder(result, item.folder);
|
||||
return;
|
||||
this.processFolder(result, this.sanitizeFolderName(item.folder));
|
||||
}
|
||||
|
||||
if (item.shared_folder != null) {
|
||||
this.processFolder(result, item.shared_folder);
|
||||
return;
|
||||
this.processFolder(result, this.sanitizeFolderName(item.shared_folder));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sanitizeFolderName(name: string): string {
|
||||
// `\` and `/` are reserved characters in Bitwarden, but valid in Keeper, replace them with `-`.
|
||||
return name.replaceAll("\\\\", "-").replaceAll("/", "-");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,54 @@
|
|||
export interface KeeperJsonExport {
|
||||
shared_folders?: SharedFoldersEntity[] | null;
|
||||
records?: RecordsEntity[] | null;
|
||||
}
|
||||
// See https://docs.keeper.io/en/user-guides/import-records-1/import-json#creating-a-custom-.json-file-for-import-into-keeper
|
||||
// for reference on the Keeper JSON format. It's not comprehensive but covers the main fields.
|
||||
|
||||
export interface SharedFoldersEntity {
|
||||
path: string;
|
||||
manage_users: boolean;
|
||||
manage_records: boolean;
|
||||
can_edit: boolean;
|
||||
can_share: boolean;
|
||||
permissions?: PermissionsEntity[] | null;
|
||||
}
|
||||
|
||||
export interface PermissionsEntity {
|
||||
uid?: string | null;
|
||||
manage_users: boolean;
|
||||
manage_records: boolean;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface RecordsEntity {
|
||||
title: string;
|
||||
login: string;
|
||||
password: string;
|
||||
login_url: string;
|
||||
notes?: string;
|
||||
custom_fields?: CustomFields;
|
||||
folders?: FoldersEntity[] | null;
|
||||
}
|
||||
|
||||
export type CustomFields = {
|
||||
[key: string]: string | null;
|
||||
export type KeeperJsonExport = {
|
||||
records?: Record[] | null;
|
||||
shared_folders?: SharedFolder[] | null;
|
||||
};
|
||||
|
||||
export interface FoldersEntity {
|
||||
folder?: string | null;
|
||||
shared_folder?: string | null;
|
||||
can_edit?: boolean | null;
|
||||
can_share?: boolean | null;
|
||||
}
|
||||
export type Record = {
|
||||
$type?: string;
|
||||
|
||||
uid?: string;
|
||||
title?: string;
|
||||
login?: string;
|
||||
password?: string;
|
||||
login_url?: string;
|
||||
notes?: string;
|
||||
last_modified?: number;
|
||||
custom_fields?: CustomFields;
|
||||
references?: References;
|
||||
folders?: Folder[];
|
||||
|
||||
// Ignored at the moment
|
||||
schema?: any;
|
||||
};
|
||||
|
||||
export type CustomFields = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type References = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type Folder = {
|
||||
shared_folder?: string;
|
||||
folder?: string;
|
||||
|
||||
// Ignored at the moment
|
||||
can_edit?: boolean;
|
||||
can_share?: boolean;
|
||||
};
|
||||
|
||||
export type SharedFolder = {
|
||||
uid?: string;
|
||||
path?: string;
|
||||
manage_users?: boolean;
|
||||
manage_records?: boolean;
|
||||
can_edit?: boolean;
|
||||
can_share?: boolean;
|
||||
|
||||
// Ignored
|
||||
permissions?: any;
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,8 +23,7 @@ export const regularImportOptions = [
|
|||
{ id: "dashlanejson", name: "Dashlane (json)" },
|
||||
{ id: "roboformcsv", name: "RoboForm (csv)" },
|
||||
{ id: "keepercsv", name: "Keeper (csv)" },
|
||||
// Temporarily remove this option for the Feb release
|
||||
// { id: "keeperjson", name: "Keeper (json)" },
|
||||
{ id: "keeperjson", name: "Keeper (json)" },
|
||||
{ id: "enpasscsv", name: "Enpass (csv)" },
|
||||
{ id: "enpassjson", name: "Enpass (json)" },
|
||||
{ id: "protonpass", name: "ProtonPass (zip/json)" },
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import {
|
|||
KeePass2XmlImporter,
|
||||
KeePassXCsvImporter,
|
||||
KeeperCsvImporter,
|
||||
// KeeperJsonImporter,
|
||||
KeeperJsonImporter,
|
||||
LastPassCsvImporter,
|
||||
LogMeOnceCsvImporter,
|
||||
MSecureCsvImporter,
|
||||
|
|
@ -283,8 +283,8 @@ export class ImportService implements ImportServiceAbstraction {
|
|||
return new OnePasswordMacCsvImporter();
|
||||
case "keepercsv":
|
||||
return new KeeperCsvImporter();
|
||||
// case "keeperjson":
|
||||
// return new KeeperJsonImporter();
|
||||
case "keeperjson":
|
||||
return new KeeperJsonImporter();
|
||||
case "passworddragonxml":
|
||||
return new PasswordDragonXmlImporter();
|
||||
case "enpasscsv":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue