This commit is contained in:
Dmitry Yakimenko 2026-01-09 23:53:29 -05:00 committed by GitHub
commit 3c03efc4ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 4268 additions and 202 deletions

View file

@ -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.

View 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

View file

@ -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("/", "-");
}
}

View file

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

View file

@ -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)" },

View file

@ -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":