1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[SG-998] and [SG-999] Vault and Autofill team refactor (#4542)

* Move DeprecatedVaultFilterService to vault folder

* [libs] move VaultItemsComponent

* [libs] move AddEditComponent

* [libs] move AddEditCustomFields

* [libs] move attachmentsComponent

* [libs] folderAddEditComponent

* [libs] IconComponent

* [libs] PasswordRepormptComponent

* [libs] PremiumComponent

* [libs] ViewCustomFieldsComponent

* [libs] ViewComponent

* [libs] PasswordRepromptService

* [libs] Move FolderService and FolderApiService abstractions

* [libs] FolderService imports

* [libs] PasswordHistoryComponent

* [libs] move Sync and SyncNotifier abstractions

* [libs] SyncService imports

* [libs] fix file casing for passwordReprompt abstraction

* [libs] SyncNotifier import fix

* [libs] CipherServiceAbstraction

* [libs] PasswordRepromptService abstraction

* [libs] Fix file casing for angular passwordReprompt service

* [libs] fix file casing for SyncNotifierService

* [libs] CipherRepromptType

* [libs] rename CipherRepromptType

* [libs] CipherType

* [libs] Rename CipherType

* [libs] CipherData

* [libs] FolderData

* [libs] PasswordHistoryData

* [libs] AttachmentData

* [libs] CardData

* [libs] FieldData

* [libs] IdentityData

* [libs] LocalData

* [libs] LoginData

* [libs] SecureNoteData

* [libs] LoginUriData

* [libs] Domain classes

* [libs] SecureNote

* [libs] Request models

* [libs] Response models

* [libs] View part 1

* [libs] Views part 2

* [libs] Move folder services

* [libs] Views fixes

* [libs] Move sync services

* [libs] cipher service

* [libs] Types

* [libs] Sync file casing

* [libs] Fix folder service import

* [libs] Move spec files

* [libs] casing fixes on spec files

* [browser] Autofill background, clipboard, commands

* [browser] Fix ContextMenusBackground casing

* [browser] Rename fix

* [browser] Autofill content

* [browser] autofill.js

* [libs] enpass importer spec fix

* [browser] autofill models

* [browser] autofill manifest path updates

* [browser] Autofill notification files

* [browser] autofill services

* [browser] Fix file casing

* [browser] Vault popup loose components

* [browser] Vault components

* [browser] Manifest fixes

* [browser] Vault services

* [cli] vault commands and models

* [browser] File capitilization fixes

* [desktop] Vault components and services

* [web] vault loose components

* [web] Vault components

* [browser] Fix misc-utils import

* [libs] Fix psono spec imports

* [fix] Add comments to address lint rules
This commit is contained in:
Robyn MacCallum
2023-01-31 16:08:37 -05:00
committed by GitHub
parent bf1df6ebf6
commit 7ebedbecfb
472 changed files with 1371 additions and 1328 deletions

View File

@@ -0,0 +1,80 @@
import { UriMatchType } from "../../enums/uriMatchType";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { CipherType } from "../enums/cipher-type";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { Field } from "../models/domain/field";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
export abstract class CipherService {
clearCache: (userId?: string) => Promise<void>;
encrypt: (
model: CipherView,
key?: SymmetricCryptoKey,
originalCipher?: Cipher
) => Promise<Cipher>;
encryptFields: (fieldsModel: FieldView[], key: SymmetricCryptoKey) => Promise<Field[]>;
encryptField: (fieldModel: FieldView, key: SymmetricCryptoKey) => Promise<Field>;
get: (id: string) => Promise<Cipher>;
getAll: () => Promise<Cipher[]>;
getAllDecrypted: () => Promise<CipherView[]>;
getAllDecryptedForGrouping: (groupingId: string, folder?: boolean) => Promise<CipherView[]>;
getAllDecryptedForUrl: (
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchType
) => Promise<CipherView[]>;
getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
getLastUsedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getLastLaunchedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getNextCipherForUrl: (url: string) => Promise<CipherView>;
updateLastUsedIndexForUrl: (url: string) => void;
updateLastUsedDate: (id: string) => Promise<void>;
updateLastLaunchedDate: (id: string) => Promise<void>;
saveNeverDomain: (domain: string) => Promise<void>;
createWithServer: (cipher: Cipher) => Promise<any>;
updateWithServer: (cipher: Cipher) => Promise<any>;
shareWithServer: (
cipher: CipherView,
organizationId: string,
collectionIds: string[]
) => Promise<any>;
shareManyWithServer: (
ciphers: CipherView[],
organizationId: string,
collectionIds: string[]
) => Promise<any>;
saveAttachmentWithServer: (
cipher: Cipher,
unencryptedFile: any,
admin?: boolean
) => Promise<Cipher>;
saveAttachmentRawWithServer: (
cipher: Cipher,
filename: string,
data: ArrayBuffer,
admin?: boolean
) => Promise<Cipher>;
saveCollectionsWithServer: (cipher: Cipher) => Promise<any>;
upsert: (cipher: CipherData | CipherData[]) => Promise<any>;
replace: (ciphers: { [id: string]: CipherData }) => Promise<any>;
clear: (userId: string) => Promise<any>;
moveManyWithServer: (ids: string[], folderId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;
deleteWithServer: (id: string) => Promise<any>;
deleteManyWithServer: (ids: string[]) => Promise<any>;
deleteAttachment: (id: string, attachmentId: string) => Promise<void>;
deleteAttachmentWithServer: (id: string, attachmentId: string) => Promise<void>;
sortCiphersByLastUsed: (a: CipherView, b: CipherView) => number;
sortCiphersByLastUsedThenName: (a: CipherView, b: CipherView) => number;
getLocaleSortingFunction: () => (a: CipherView, b: CipherView) => number;
softDelete: (id: string | string[]) => Promise<any>;
softDeleteWithServer: (id: string) => Promise<any>;
softDeleteManyWithServer: (ids: string[]) => Promise<any>;
restore: (
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[]
) => Promise<any>;
restoreWithServer: (id: string) => Promise<any>;
restoreManyWithServer: (ids: string[]) => Promise<any>;
}

View File

@@ -0,0 +1,8 @@
import { Folder } from "../../models/domain/folder";
import { FolderResponse } from "../../models/response/folder.response";
export class FolderApiServiceAbstraction {
save: (folder: Folder) => Promise<any>;
delete: (id: string) => Promise<any>;
get: (id: string) => Promise<FolderResponse>;
}

View File

@@ -0,0 +1,31 @@
import { Observable } from "rxjs";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { FolderData } from "../../models/data/folder.data";
import { Folder } from "../../models/domain/folder";
import { FolderView } from "../../models/view/folder.view";
export abstract class FolderService {
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
clearCache: () => Promise<void>;
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
getAllFromState: () => Promise<Folder[]>;
/**
* @deprecated Only use in CLI!
*/
getFromState: (id: string) => Promise<Folder>;
/**
* @deprecated Only use in CLI!
*/
getAllDecryptedFromState: () => Promise<FolderView[]>;
}
export abstract class InternalFolderService extends FolderService {
upsert: (folder: FolderData | FolderData[]) => Promise<void>;
replace: (folders: { [id: string]: FolderData }) => Promise<void>;
clear: (userId: string) => Promise<any>;
delete: (id: string | string[]) => Promise<any>;
}

View File

@@ -0,0 +1,5 @@
export abstract class PasswordRepromptService {
protectedFields: () => string[];
showPasswordPrompt: () => Promise<boolean>;
enabled: () => Promise<boolean>;
}

View File

@@ -0,0 +1,8 @@
import { Observable } from "rxjs";
import { SyncEventArgs } from "../../types/sync-event-args";
export abstract class SyncNotifierService {
sync$: Observable<SyncEventArgs>;
next: (event: SyncEventArgs) => void;
}

View File

@@ -0,0 +1,19 @@
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../../models/response/notification.response";
export abstract class SyncService {
syncInProgress: boolean;
getLastSync: () => Promise<Date>;
setLastSync: (date: Date, userId?: string) => Promise<any>;
fullSync: (forceSync: boolean, allowThrowOnError?: boolean) => Promise<boolean>;
syncUpsertFolder: (notification: SyncFolderNotification, isEdit: boolean) => Promise<boolean>;
syncDeleteFolder: (notification: SyncFolderNotification) => Promise<boolean>;
syncUpsertCipher: (notification: SyncCipherNotification, isEdit: boolean) => Promise<boolean>;
syncDeleteCipher: (notification: SyncFolderNotification) => Promise<boolean>;
syncUpsertSend: (notification: SyncSendNotification, isEdit: boolean) => Promise<boolean>;
syncDeleteSend: (notification: SyncSendNotification) => Promise<boolean>;
}

View File

@@ -0,0 +1,4 @@
export enum CipherRepromptType {
None = 0,
Password = 1,
}

View File

@@ -0,0 +1,6 @@
export enum CipherType {
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
}

View File

@@ -0,0 +1,22 @@
import { AttachmentResponse } from "../response/attachment.response";
export class AttachmentData {
id: string;
url: string;
fileName: string;
key: string;
size: string;
sizeName: string;
constructor(response?: AttachmentResponse) {
if (response == null) {
return;
}
this.id = response.id;
this.url = response.url;
this.fileName = response.fileName;
this.key = response.key;
this.size = response.size;
this.sizeName = response.sizeName;
}
}

View File

@@ -0,0 +1,23 @@
import { CardApi } from "../../../models/api/card.api";
export class CardData {
cardholderName: string;
brand: string;
number: string;
expMonth: string;
expYear: string;
code: string;
constructor(data?: CardApi) {
if (data == null) {
return;
}
this.cardholderName = data.cardholderName;
this.brand = data.brand;
this.number = data.number;
this.expMonth = data.expMonth;
this.expYear = data.expYear;
this.code = data.code;
}
}

View File

@@ -0,0 +1,85 @@
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherResponse } from "../response/cipher.response";
import { AttachmentData } from "./attachment.data";
import { CardData } from "./card.data";
import { FieldData } from "./field.data";
import { IdentityData } from "./identity.data";
import { LoginData } from "./login.data";
import { PasswordHistoryData } from "./password-history.data";
import { SecureNoteData } from "./secure-note.data";
export class CipherData {
id: string;
organizationId: string;
folderId: string;
edit: boolean;
viewPassword: boolean;
organizationUseTotp: boolean;
favorite: boolean;
revisionDate: string;
type: CipherType;
name: string;
notes: string;
login?: LoginData;
secureNote?: SecureNoteData;
card?: CardData;
identity?: IdentityData;
fields?: FieldData[];
attachments?: AttachmentData[];
passwordHistory?: PasswordHistoryData[];
collectionIds?: string[];
creationDate: string;
deletedDate: string;
reprompt: CipherRepromptType;
constructor(response?: CipherResponse, collectionIds?: string[]) {
if (response == null) {
return;
}
this.id = response.id;
this.organizationId = response.organizationId;
this.folderId = response.folderId;
this.edit = response.edit;
this.viewPassword = response.viewPassword;
this.organizationUseTotp = response.organizationUseTotp;
this.favorite = response.favorite;
this.revisionDate = response.revisionDate;
this.type = response.type;
this.name = response.name;
this.notes = response.notes;
this.collectionIds = collectionIds != null ? collectionIds : response.collectionIds;
this.creationDate = response.creationDate;
this.deletedDate = response.deletedDate;
this.reprompt = response.reprompt;
switch (this.type) {
case CipherType.Login:
this.login = new LoginData(response.login);
break;
case CipherType.SecureNote:
this.secureNote = new SecureNoteData(response.secureNote);
break;
case CipherType.Card:
this.card = new CardData(response.card);
break;
case CipherType.Identity:
this.identity = new IdentityData(response.identity);
break;
default:
break;
}
if (response.fields != null) {
this.fields = response.fields.map((f) => new FieldData(f));
}
if (response.attachments != null) {
this.attachments = response.attachments.map((a) => new AttachmentData(a));
}
if (response.passwordHistory != null) {
this.passwordHistory = response.passwordHistory.map((ph) => new PasswordHistoryData(ph));
}
}
}

View File

@@ -0,0 +1,20 @@
import { FieldType } from "../../../enums/fieldType";
import { LinkedIdType } from "../../../enums/linkedIdType";
import { FieldApi } from "../../../models/api/field.api";
export class FieldData {
type: FieldType;
name: string;
value: string;
linkedId: LinkedIdType;
constructor(response?: FieldApi) {
if (response == null) {
return;
}
this.type = response.type;
this.name = response.name;
this.value = response.value;
this.linkedId = response.linkedId;
}
}

View File

@@ -0,0 +1,13 @@
import { FolderResponse } from "../response/folder.response";
export class FolderData {
id: string;
name: string;
revisionDate: string;
constructor(response: FolderResponse) {
this.name = response.name;
this.id = response.id;
this.revisionDate = response.revisionDate;
}
}

View File

@@ -0,0 +1,47 @@
import { IdentityApi } from "../../../models/api/identity.api";
export class IdentityData {
title: string;
firstName: string;
middleName: string;
lastName: string;
address1: string;
address2: string;
address3: string;
city: string;
state: string;
postalCode: string;
country: string;
company: string;
email: string;
phone: string;
ssn: string;
username: string;
passportNumber: string;
licenseNumber: string;
constructor(data?: IdentityApi) {
if (data == null) {
return;
}
this.title = data.title;
this.firstName = data.firstName;
this.middleName = data.middleName;
this.lastName = data.lastName;
this.address1 = data.address1;
this.address2 = data.address2;
this.address3 = data.address3;
this.city = data.city;
this.state = data.state;
this.postalCode = data.postalCode;
this.country = data.country;
this.company = data.company;
this.email = data.email;
this.phone = data.phone;
this.ssn = data.ssn;
this.username = data.username;
this.passportNumber = data.passportNumber;
this.licenseNumber = data.licenseNumber;
}
}

View File

@@ -0,0 +1,4 @@
export type LocalData = {
lastUsedDate?: number;
lastLaunched?: number;
};

View File

@@ -0,0 +1,15 @@
import { UriMatchType } from "../../../enums/uriMatchType";
import { LoginUriApi } from "../../../models/api/login-uri.api";
export class LoginUriData {
uri: string;
match: UriMatchType = null;
constructor(data?: LoginUriApi) {
if (data == null) {
return;
}
this.uri = data.uri;
this.match = data.match;
}
}

View File

@@ -0,0 +1,28 @@
import { LoginApi } from "../../../models/api/login.api";
import { LoginUriData } from "./login-uri.data";
export class LoginData {
uris: LoginUriData[];
username: string;
password: string;
passwordRevisionDate: string;
totp: string;
autofillOnPageLoad: boolean;
constructor(data?: LoginApi) {
if (data == null) {
return;
}
this.username = data.username;
this.password = data.password;
this.passwordRevisionDate = data.passwordRevisionDate;
this.totp = data.totp;
this.autofillOnPageLoad = data.autofillOnPageLoad;
if (data.uris) {
this.uris = data.uris.map((u) => new LoginUriData(u));
}
}
}

View File

@@ -0,0 +1,15 @@
import { PasswordHistoryResponse } from "../response/password-history.response";
export class PasswordHistoryData {
password: string;
lastUsedDate: string;
constructor(response?: PasswordHistoryResponse) {
if (response == null) {
return;
}
this.password = response.password;
this.lastUsedDate = response.lastUsedDate;
}
}

View File

@@ -0,0 +1,14 @@
import { SecureNoteType } from "../../../enums/secureNoteType";
import { SecureNoteApi } from "../../../models/api/secure-note.api";
export class SecureNoteData {
type: SecureNoteType;
constructor(data?: SecureNoteApi) {
if (data == null) {
return;
}
this.type = data.type;
}
}

View File

@@ -0,0 +1,154 @@
import { mock, MockProxy } from "jest-mock-extended";
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
import { CryptoService } from "../../../abstractions/crypto.service";
import { EncryptService } from "../../../abstractions/encrypt.service";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { ContainerService } from "../../../services/container.service";
import { AttachmentData } from "../../models/data/attachment.data";
import { Attachment } from "../../models/domain/attachment";
describe("Attachment", () => {
let data: AttachmentData;
beforeEach(() => {
data = {
id: "id",
url: "url",
fileName: "fileName",
key: "key",
size: "1100",
sizeName: "1.1 KB",
};
});
it("Convert from empty", () => {
const data = new AttachmentData();
const attachment = new Attachment(data);
expect(attachment).toEqual({
id: null,
url: null,
size: undefined,
sizeName: null,
key: null,
fileName: null,
});
});
it("Convert", () => {
const attachment = new Attachment(data);
expect(attachment).toEqual({
size: "1100",
id: "id",
url: "url",
sizeName: "1.1 KB",
fileName: { encryptedString: "fileName", encryptionType: 0 },
key: { encryptedString: "key", encryptionType: 0 },
});
});
it("toAttachmentData", () => {
const attachment = new Attachment(data);
expect(attachment.toAttachmentData()).toEqual(data);
});
describe("decrypt", () => {
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
beforeEach(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
(window as any).bitwardenContainerService = new ContainerService(
cryptoService,
encryptService
);
});
it("expected output", async () => {
const attachment = new Attachment();
attachment.id = "id";
attachment.url = "url";
attachment.size = "1100";
attachment.sizeName = "1.1 KB";
attachment.key = mockEnc("key");
attachment.fileName = mockEnc("fileName");
encryptService.decryptToBytes.mockResolvedValue(makeStaticByteArray(32));
const view = await attachment.decrypt(null);
expect(view).toEqual({
id: "id",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "fileName",
key: expect.any(SymmetricCryptoKey),
});
});
describe("decrypts attachment.key", () => {
let attachment: Attachment;
beforeEach(() => {
attachment = new Attachment();
attachment.key = mock<EncString>();
});
it("uses the provided key without depending on CryptoService", async () => {
const providedKey = mock<SymmetricCryptoKey>();
await attachment.decrypt(null, providedKey);
expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, providedKey);
});
it("gets an organization key if required", async () => {
const orgKey = mock<SymmetricCryptoKey>();
cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey);
await attachment.decrypt("orgId", null);
expect(cryptoService.getOrgKey).toHaveBeenCalledWith("orgId");
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, orgKey);
});
it("gets the user's decryption key if required", async () => {
const userKey = mock<SymmetricCryptoKey>();
cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey);
await attachment.decrypt(null, null);
expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalled();
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, userKey);
});
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const actual = Attachment.fromJSON({
key: "myKey",
fileName: "myFileName",
});
expect(actual).toEqual({
key: "myKey_fromJSON",
fileName: "myFileName_fromJSON",
});
expect(actual).toBeInstanceOf(Attachment);
});
it("returns null if object is null", () => {
expect(Attachment.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,108 @@
import { Jsonify } from "type-fest";
import { Utils } from "../../../misc/utils";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { AttachmentData } from "../data/attachment.data";
import { AttachmentView } from "../view/attachment.view";
export class Attachment extends Domain {
id: string;
url: string;
size: string;
sizeName: string; // Readable size, ex: "4.2 KB" or "1.43 GB"
key: EncString;
fileName: EncString;
constructor(obj?: AttachmentData) {
super();
if (obj == null) {
return;
}
this.size = obj.size;
this.buildDomainModel(
this,
obj,
{
id: null,
url: null,
sizeName: null,
fileName: null,
key: null,
},
["id", "url", "sizeName"]
);
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<AttachmentView> {
const view = await this.decryptObj(
new AttachmentView(this),
{
fileName: null,
},
orgId,
encKey
);
if (this.key != null) {
view.key = await this.decryptAttachmentKey(orgId, encKey);
}
return view;
}
private async decryptAttachmentKey(orgId: string, encKey?: SymmetricCryptoKey) {
try {
if (encKey == null) {
encKey = await this.getKeyForDecryption(orgId);
}
const encryptService = Utils.getContainerService().getEncryptService();
const decValue = await encryptService.decryptToBytes(this.key, encKey);
return new SymmetricCryptoKey(decValue);
} catch (e) {
// TODO: error?
}
}
private async getKeyForDecryption(orgId: string) {
const cryptoService = Utils.getContainerService().getCryptoService();
return orgId != null
? await cryptoService.getOrgKey(orgId)
: await cryptoService.getKeyForUserEncryption();
}
toAttachmentData(): AttachmentData {
const a = new AttachmentData();
a.size = this.size;
this.buildDataModel(
this,
a,
{
id: null,
url: null,
sizeName: null,
fileName: null,
key: null,
},
["id", "url", "sizeName"]
);
return a;
}
static fromJSON(obj: Partial<Jsonify<Attachment>>): Attachment {
if (obj == null) {
return null;
}
const key = EncString.fromJSON(obj.key);
const fileName = EncString.fromJSON(obj.fileName);
return Object.assign(new Attachment(), obj, {
key,
fileName,
});
}
}

View File

@@ -0,0 +1,102 @@
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { EncString } from "../../../models/domain/enc-string";
import { CardData } from "../../../vault/models/data/card.data";
import { Card } from "../../models/domain/card";
describe("Card", () => {
let data: CardData;
beforeEach(() => {
data = {
cardholderName: "encHolder",
brand: "encBrand",
number: "encNumber",
expMonth: "encMonth",
expYear: "encYear",
code: "encCode",
};
});
it("Convert from empty", () => {
const data = new CardData();
const card = new Card(data);
expect(card).toEqual({
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
});
});
it("Convert", () => {
const card = new Card(data);
expect(card).toEqual({
cardholderName: { encryptedString: "encHolder", encryptionType: 0 },
brand: { encryptedString: "encBrand", encryptionType: 0 },
number: { encryptedString: "encNumber", encryptionType: 0 },
expMonth: { encryptedString: "encMonth", encryptionType: 0 },
expYear: { encryptedString: "encYear", encryptionType: 0 },
code: { encryptedString: "encCode", encryptionType: 0 },
});
});
it("toCardData", () => {
const card = new Card(data);
expect(card.toCardData()).toEqual(data);
});
it("Decrypt", async () => {
const card = new Card();
card.cardholderName = mockEnc("cardHolder");
card.brand = mockEnc("brand");
card.number = mockEnc("number");
card.expMonth = mockEnc("expMonth");
card.expYear = mockEnc("expYear");
card.code = mockEnc("code");
const view = await card.decrypt(null);
expect(view).toEqual({
_brand: "brand",
_number: "number",
_subTitle: null,
cardholderName: "cardHolder",
code: "code",
expMonth: "expMonth",
expYear: "expYear",
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const actual = Card.fromJSON({
cardholderName: "mockCardHolder",
brand: "mockBrand",
number: "mockNumber",
expMonth: "mockExpMonth",
expYear: "mockExpYear",
code: "mockCode",
});
expect(actual).toEqual({
cardholderName: "mockCardHolder_fromJSON",
brand: "mockBrand_fromJSON",
number: "mockNumber_fromJSON",
expMonth: "mockExpMonth_fromJSON",
expYear: "mockExpYear_fromJSON",
code: "mockCode_fromJSON",
});
expect(actual).toBeInstanceOf(Card);
});
it("returns null if object is null", () => {
expect(Card.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,87 @@
import { Jsonify } from "type-fest";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { CardData } from "../data/card.data";
import { CardView } from "../view/card.view";
export class Card extends Domain {
cardholderName: EncString;
brand: EncString;
number: EncString;
expMonth: EncString;
expYear: EncString;
code: EncString;
constructor(obj?: CardData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
},
[]
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<CardView> {
return this.decryptObj(
new CardView(),
{
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
},
orgId,
encKey
);
}
toCardData(): CardData {
const c = new CardData();
this.buildDataModel(this, c, {
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
});
return c;
}
static fromJSON(obj: Partial<Jsonify<Card>>): Card {
if (obj == null) {
return null;
}
const cardholderName = EncString.fromJSON(obj.cardholderName);
const brand = EncString.fromJSON(obj.brand);
const number = EncString.fromJSON(obj.number);
const expMonth = EncString.fromJSON(obj.expMonth);
const expYear = EncString.fromJSON(obj.expYear);
const code = EncString.fromJSON(obj.code);
return Object.assign(new Card(), obj, {
cardholderName,
brand,
number,
expMonth,
expYear,
code,
});
}
}

View File

@@ -0,0 +1,677 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { Jsonify } from "type-fest";
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { FieldType } from "../../../enums/fieldType";
import { SecureNoteType } from "../../../enums/secureNoteType";
import { UriMatchType } from "../../../enums/uriMatchType";
import { EncString } from "../../../models/domain/enc-string";
import { InitializerKey } from "../../../services/cryptography/initializer-key";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherData } from "../../models/data/cipher.data";
import { Attachment } from "../../models/domain/attachment";
import { Card } from "../../models/domain/card";
import { Cipher } from "../../models/domain/cipher";
import { Field } from "../../models/domain/field";
import { Identity } from "../../models/domain/identity";
import { Login } from "../../models/domain/login";
import { Password } from "../../models/domain/password";
import { SecureNote } from "../../models/domain/secure-note";
import { CardView } from "../../models/view/card.view";
import { IdentityView } from "../../models/view/identity.view";
import { LoginView } from "../../models/view/login.view";
describe("Cipher DTO", () => {
it("Convert from empty CipherData", () => {
const data = new CipherData();
const cipher = new Cipher(data);
expect(cipher).toEqual({
initializerKey: InitializerKey.Cipher,
id: null,
organizationId: null,
folderId: null,
name: null,
notes: null,
type: undefined,
favorite: undefined,
organizationUseTotp: undefined,
edit: undefined,
viewPassword: true,
revisionDate: null,
collectionIds: undefined,
localData: null,
creationDate: null,
deletedDate: null,
reprompt: undefined,
attachments: null,
fields: null,
passwordHistory: null,
});
});
describe("LoginCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Login,
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
login: {
uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }],
username: "EncryptedString",
password: "EncryptedString",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "EncryptedString",
autofillOnPageLoad: false,
},
passwordHistory: [
{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" },
],
attachments: [
{
id: "a1",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
{
id: "a2",
url: "url",
size: "1100",
sizeName: "1.1 KB",
fileName: "file",
key: "EncKey",
},
],
fields: [
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Text,
linkedId: null,
},
{
name: "EncryptedString",
value: "EncryptedString",
type: FieldType.Hidden,
linkedId: null,
},
],
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
initializerKey: InitializerKey.Cipher,
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 1,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
login: {
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
username: { encryptedString: "EncryptedString", encryptionType: 0 },
password: { encryptedString: "EncryptedString", encryptionType: 0 },
totp: { encryptedString: "EncryptedString", encryptionType: 0 },
uris: [{ match: 0, uri: { encryptedString: "EncryptedString", encryptionType: 0 } }],
},
attachments: [
{
fileName: { encryptedString: "file", encryptionType: 0 },
id: "a1",
key: { encryptedString: "EncKey", encryptionType: 0 },
size: "1100",
sizeName: "1.1 KB",
url: "url",
},
{
fileName: { encryptedString: "file", encryptionType: 0 },
id: "a2",
key: { encryptedString: "EncKey", encryptionType: 0 },
size: "1100",
sizeName: "1.1 KB",
url: "url",
},
],
fields: [
{
linkedId: null,
name: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 0,
value: { encryptedString: "EncryptedString", encryptionType: 0 },
},
{
linkedId: null,
name: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 1,
value: { encryptedString: "EncryptedString", encryptionType: 0 },
},
],
passwordHistory: [
{
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
password: { encryptedString: "EncryptedString", encryptionType: 0 },
},
],
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData()).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Login;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const loginView = new LoginView();
loginView.username = "username";
loginView.password = "password";
const login = Substitute.for<Login>();
login.decrypt(Arg.any(), Arg.any()).resolves(loginView);
cipher.login = login;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 1,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
login: loginView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("SecureNoteCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.SecureNote,
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
secureNote: {
type: SecureNoteType.Generic,
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
initializerKey: InitializerKey.Cipher,
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 2,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
secureNote: { type: SecureNoteType.Generic },
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData()).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.SecureNote;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
cipher.secureNote = new SecureNote();
cipher.secureNote.type = SecureNoteType.Generic;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 2,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
secureNote: { type: 0 },
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("CardCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Card,
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
card: {
cardholderName: "EncryptedString",
brand: "EncryptedString",
number: "EncryptedString",
expMonth: "EncryptedString",
expYear: "EncryptedString",
code: "EncryptedString",
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
initializerKey: InitializerKey.Cipher,
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 3,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
card: {
cardholderName: { encryptedString: "EncryptedString", encryptionType: 0 },
brand: { encryptedString: "EncryptedString", encryptionType: 0 },
number: { encryptedString: "EncryptedString", encryptionType: 0 },
expMonth: { encryptedString: "EncryptedString", encryptionType: 0 },
expYear: { encryptedString: "EncryptedString", encryptionType: 0 },
code: { encryptedString: "EncryptedString", encryptionType: 0 },
},
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData()).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Card;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const cardView = new CardView();
cardView.cardholderName = "cardholderName";
cardView.number = "4111111111111111";
const card = Substitute.for<Card>();
card.decrypt(Arg.any(), Arg.any()).resolves(cardView);
cipher.card = card;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 3,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
card: cardView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("IdentityCipher", () => {
let cipherData: CipherData;
beforeEach(() => {
cipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Identity,
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
reprompt: CipherRepromptType.None,
identity: {
title: "EncryptedString",
firstName: "EncryptedString",
middleName: "EncryptedString",
lastName: "EncryptedString",
address1: "EncryptedString",
address2: "EncryptedString",
address3: "EncryptedString",
city: "EncryptedString",
state: "EncryptedString",
postalCode: "EncryptedString",
country: "EncryptedString",
company: "EncryptedString",
email: "EncryptedString",
phone: "EncryptedString",
ssn: "EncryptedString",
username: "EncryptedString",
passportNumber: "EncryptedString",
licenseNumber: "EncryptedString",
},
};
});
it("Convert", () => {
const cipher = new Cipher(cipherData);
expect(cipher).toEqual({
initializerKey: InitializerKey.Cipher,
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: { encryptedString: "EncryptedString", encryptionType: 0 },
notes: { encryptedString: "EncryptedString", encryptionType: 0 },
type: 4,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
collectionIds: undefined,
localData: null,
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
identity: {
title: { encryptedString: "EncryptedString", encryptionType: 0 },
firstName: { encryptedString: "EncryptedString", encryptionType: 0 },
middleName: { encryptedString: "EncryptedString", encryptionType: 0 },
lastName: { encryptedString: "EncryptedString", encryptionType: 0 },
address1: { encryptedString: "EncryptedString", encryptionType: 0 },
address2: { encryptedString: "EncryptedString", encryptionType: 0 },
address3: { encryptedString: "EncryptedString", encryptionType: 0 },
city: { encryptedString: "EncryptedString", encryptionType: 0 },
state: { encryptedString: "EncryptedString", encryptionType: 0 },
postalCode: { encryptedString: "EncryptedString", encryptionType: 0 },
country: { encryptedString: "EncryptedString", encryptionType: 0 },
company: { encryptedString: "EncryptedString", encryptionType: 0 },
email: { encryptedString: "EncryptedString", encryptionType: 0 },
phone: { encryptedString: "EncryptedString", encryptionType: 0 },
ssn: { encryptedString: "EncryptedString", encryptionType: 0 },
username: { encryptedString: "EncryptedString", encryptionType: 0 },
passportNumber: { encryptedString: "EncryptedString", encryptionType: 0 },
licenseNumber: { encryptedString: "EncryptedString", encryptionType: 0 },
},
attachments: null,
fields: null,
passwordHistory: null,
});
});
it("toCipherData", () => {
const cipher = new Cipher(cipherData);
expect(cipher.toCipherData()).toEqual(cipherData);
});
it("Decrypt", async () => {
const cipher = new Cipher();
cipher.id = "id";
cipher.organizationId = "orgId";
cipher.folderId = "folderId";
cipher.edit = true;
cipher.viewPassword = true;
cipher.organizationUseTotp = true;
cipher.favorite = false;
cipher.revisionDate = new Date("2022-01-31T12:00:00.000Z");
cipher.type = CipherType.Identity;
cipher.name = mockEnc("EncryptedString");
cipher.notes = mockEnc("EncryptedString");
cipher.creationDate = new Date("2022-01-01T12:00:00.000Z");
cipher.deletedDate = null;
cipher.reprompt = CipherRepromptType.None;
const identityView = new IdentityView();
identityView.firstName = "firstName";
identityView.lastName = "lastName";
const identity = Substitute.for<Identity>();
identity.decrypt(Arg.any(), Arg.any()).resolves(identityView);
cipher.identity = identity;
const cipherView = await cipher.decrypt();
expect(cipherView).toMatchObject({
id: "id",
organizationId: "orgId",
folderId: "folderId",
name: "EncryptedString",
notes: "EncryptedString",
type: 4,
favorite: false,
organizationUseTotp: true,
edit: true,
viewPassword: true,
identity: identityView,
attachments: null,
fields: null,
passwordHistory: null,
collectionIds: undefined,
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
creationDate: new Date("2022-01-01T12:00:00.000Z"),
deletedDate: null,
reprompt: 0,
localData: undefined,
});
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(Attachment, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(Field, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(Password, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const deletedDate = new Date("2022-09-04T01:06:40.441Z");
const actual = Cipher.fromJSON({
name: "myName",
notes: "myNotes",
revisionDate: revisionDate.toISOString(),
attachments: ["attachment1", "attachment2"] as any,
fields: ["field1", "field2"] as any,
passwordHistory: ["ph1", "ph2"] as any,
deletedDate: deletedDate.toISOString(),
} as Jsonify<Cipher>);
expect(actual).toMatchObject({
name: "myName_fromJSON",
notes: "myNotes_fromJSON",
revisionDate: revisionDate,
attachments: ["attachment1_fromJSON", "attachment2_fromJSON"],
fields: ["field1_fromJSON", "field2_fromJSON"],
passwordHistory: ["ph1_fromJSON", "ph2_fromJSON"],
deletedDate: deletedDate,
});
expect(actual).toBeInstanceOf(Cipher);
});
test.each([
// Test description, CipherType, expected output
["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }],
["CardView", CipherType.Card, { card: "myCard_fromJSON" }],
["IdentityView", CipherType.Identity, { identity: "myIdentity_fromJSON" }],
["Secure Note", CipherType.SecureNote, { secureNote: "mySecureNote_fromJSON" }],
])("initializes %s", (description: string, cipherType: CipherType, expected: any) => {
jest.spyOn(Login, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(Identity, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(Card, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(SecureNote, "fromJSON").mockImplementation(mockFromJson);
const actual = Cipher.fromJSON({
login: "myLogin",
card: "myCard",
identity: "myIdentity",
secureNote: "mySecureNote",
type: cipherType,
} as any);
expect(actual).toMatchObject(expected);
});
it("returns null if object is null", () => {
expect(Cipher.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,290 @@
import { Jsonify } from "type-fest";
import { Decryptable } from "../../../interfaces/decryptable.interface";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { InitializerKey } from "../../../services/cryptography/initializer-key";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { CipherData } from "../data/cipher.data";
import { LocalData } from "../data/local.data";
import { CipherView } from "../view/cipher.view";
import { Attachment } from "./attachment";
import { Card } from "./card";
import { Field } from "./field";
import { Identity } from "./identity";
import { Login } from "./login";
import { Password } from "./password";
import { SecureNote } from "./secure-note";
export class Cipher extends Domain implements Decryptable<CipherView> {
readonly initializerKey = InitializerKey.Cipher;
id: string;
organizationId: string;
folderId: string;
name: EncString;
notes: EncString;
type: CipherType;
favorite: boolean;
organizationUseTotp: boolean;
edit: boolean;
viewPassword: boolean;
revisionDate: Date;
localData: LocalData;
login: Login;
identity: Identity;
card: Card;
secureNote: SecureNote;
attachments: Attachment[];
fields: Field[];
passwordHistory: Password[];
collectionIds: string[];
creationDate: Date;
deletedDate: Date;
reprompt: CipherRepromptType;
constructor(obj?: CipherData, localData: LocalData = null) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
id: null,
organizationId: null,
folderId: null,
name: null,
notes: null,
},
["id", "organizationId", "folderId"]
);
this.type = obj.type;
this.favorite = obj.favorite;
this.organizationUseTotp = obj.organizationUseTotp;
this.edit = obj.edit;
if (obj.viewPassword != null) {
this.viewPassword = obj.viewPassword;
} else {
this.viewPassword = true; // Default for already synced Ciphers without viewPassword
}
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
this.collectionIds = obj.collectionIds;
this.localData = localData;
this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null;
this.deletedDate = obj.deletedDate != null ? new Date(obj.deletedDate) : null;
this.reprompt = obj.reprompt;
switch (this.type) {
case CipherType.Login:
this.login = new Login(obj.login);
break;
case CipherType.SecureNote:
this.secureNote = new SecureNote(obj.secureNote);
break;
case CipherType.Card:
this.card = new Card(obj.card);
break;
case CipherType.Identity:
this.identity = new Identity(obj.identity);
break;
default:
break;
}
if (obj.attachments != null) {
this.attachments = obj.attachments.map((a) => new Attachment(a));
} else {
this.attachments = null;
}
if (obj.fields != null) {
this.fields = obj.fields.map((f) => new Field(f));
} else {
this.fields = null;
}
if (obj.passwordHistory != null) {
this.passwordHistory = obj.passwordHistory.map((ph) => new Password(ph));
} else {
this.passwordHistory = null;
}
}
async decrypt(encKey?: SymmetricCryptoKey): Promise<CipherView> {
const model = new CipherView(this);
await this.decryptObj(
model,
{
name: null,
notes: null,
},
this.organizationId,
encKey
);
switch (this.type) {
case CipherType.Login:
model.login = await this.login.decrypt(this.organizationId, encKey);
break;
case CipherType.SecureNote:
model.secureNote = await this.secureNote.decrypt(this.organizationId, encKey);
break;
case CipherType.Card:
model.card = await this.card.decrypt(this.organizationId, encKey);
break;
case CipherType.Identity:
model.identity = await this.identity.decrypt(this.organizationId, encKey);
break;
default:
break;
}
const orgId = this.organizationId;
if (this.attachments != null && this.attachments.length > 0) {
const attachments: any[] = [];
await this.attachments.reduce((promise, attachment) => {
return promise
.then(() => {
return attachment.decrypt(orgId, encKey);
})
.then((decAttachment) => {
attachments.push(decAttachment);
});
}, Promise.resolve());
model.attachments = attachments;
}
if (this.fields != null && this.fields.length > 0) {
const fields: any[] = [];
await this.fields.reduce((promise, field) => {
return promise
.then(() => {
return field.decrypt(orgId, encKey);
})
.then((decField) => {
fields.push(decField);
});
}, Promise.resolve());
model.fields = fields;
}
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
const passwordHistory: any[] = [];
await this.passwordHistory.reduce((promise, ph) => {
return promise
.then(() => {
return ph.decrypt(orgId, encKey);
})
.then((decPh) => {
passwordHistory.push(decPh);
});
}, Promise.resolve());
model.passwordHistory = passwordHistory;
}
return model;
}
toCipherData(): CipherData {
const c = new CipherData();
c.id = this.id;
c.organizationId = this.organizationId;
c.folderId = this.folderId;
c.edit = this.edit;
c.viewPassword = this.viewPassword;
c.organizationUseTotp = this.organizationUseTotp;
c.favorite = this.favorite;
c.revisionDate = this.revisionDate != null ? this.revisionDate.toISOString() : null;
c.type = this.type;
c.collectionIds = this.collectionIds;
c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null;
c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null;
c.reprompt = this.reprompt;
this.buildDataModel(this, c, {
name: null,
notes: null,
});
switch (c.type) {
case CipherType.Login:
c.login = this.login.toLoginData();
break;
case CipherType.SecureNote:
c.secureNote = this.secureNote.toSecureNoteData();
break;
case CipherType.Card:
c.card = this.card.toCardData();
break;
case CipherType.Identity:
c.identity = this.identity.toIdentityData();
break;
default:
break;
}
if (this.fields != null) {
c.fields = this.fields.map((f) => f.toFieldData());
}
if (this.attachments != null) {
c.attachments = this.attachments.map((a) => a.toAttachmentData());
}
if (this.passwordHistory != null) {
c.passwordHistory = this.passwordHistory.map((ph) => ph.toPasswordHistoryData());
}
return c;
}
static fromJSON(obj: Jsonify<Cipher>) {
if (obj == null) {
return null;
}
const domain = new Cipher();
const name = EncString.fromJSON(obj.name);
const notes = EncString.fromJSON(obj.notes);
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a));
const fields = obj.fields?.map((f: any) => Field.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph));
Object.assign(domain, obj, {
name,
notes,
revisionDate,
deletedDate,
attachments,
fields,
passwordHistory,
});
switch (obj.type) {
case CipherType.Card:
domain.card = Card.fromJSON(obj.card);
break;
case CipherType.Identity:
domain.identity = Identity.fromJSON(obj.identity);
break;
case CipherType.Login:
domain.login = Login.fromJSON(obj.login);
break;
case CipherType.SecureNote:
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
break;
default:
break;
}
return domain;
}
}

View File

@@ -0,0 +1,85 @@
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { FieldType } from "../../../enums/fieldType";
import { EncString } from "../../../models/domain/enc-string";
import { FieldData } from "../../models/data/field.data";
import { Field } from "../../models/domain/field";
describe("Field", () => {
let data: FieldData;
beforeEach(() => {
data = {
type: FieldType.Text,
name: "encName",
value: "encValue",
linkedId: null,
};
});
it("Convert from empty", () => {
const data = new FieldData();
const field = new Field(data);
expect(field).toEqual({
type: undefined,
name: null,
value: null,
linkedId: undefined,
});
});
it("Convert", () => {
const field = new Field(data);
expect(field).toEqual({
type: FieldType.Text,
name: { encryptedString: "encName", encryptionType: 0 },
value: { encryptedString: "encValue", encryptionType: 0 },
linkedId: null,
});
});
it("toFieldData", () => {
const field = new Field(data);
expect(field.toFieldData()).toEqual(data);
});
it("Decrypt", async () => {
const field = new Field();
field.type = FieldType.Text;
field.name = mockEnc("encName");
field.value = mockEnc("encValue");
const view = await field.decrypt(null);
expect(view).toEqual({
type: 0,
name: "encName",
value: "encValue",
newField: false,
showCount: false,
showValue: false,
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const actual = Field.fromJSON({
name: "myName",
value: "myValue",
});
expect(actual).toEqual({
name: "myName_fromJSON",
value: "myValue_fromJSON",
});
expect(actual).toBeInstanceOf(Field);
});
it("returns null if object is null", () => {
expect(Field.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,77 @@
import { Jsonify } from "type-fest";
import { FieldType } from "../../../enums/fieldType";
import { LinkedIdType } from "../../../enums/linkedIdType";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { FieldData } from "../data/field.data";
import { FieldView } from "../view/field.view";
export class Field extends Domain {
name: EncString;
value: EncString;
type: FieldType;
linkedId: LinkedIdType;
constructor(obj?: FieldData) {
super();
if (obj == null) {
return;
}
this.type = obj.type;
this.linkedId = obj.linkedId;
this.buildDomainModel(
this,
obj,
{
name: null,
value: null,
},
[]
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj(
new FieldView(this),
{
name: null,
value: null,
},
orgId,
encKey
);
}
toFieldData(): FieldData {
const f = new FieldData();
this.buildDataModel(
this,
f,
{
name: null,
value: null,
type: null,
linkedId: null,
},
["type", "linkedId"]
);
return f;
}
static fromJSON(obj: Partial<Jsonify<Field>>): Field {
if (obj == null) {
return null;
}
const name = EncString.fromJSON(obj.name);
const value = EncString.fromJSON(obj.value);
return Object.assign(new Field(), obj, {
name,
value,
});
}
}

View File

@@ -0,0 +1,63 @@
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { EncString } from "../../../models/domain/enc-string";
import { FolderData } from "../../models/data/folder.data";
import { Folder } from "../../models/domain/folder";
describe("Folder", () => {
let data: FolderData;
beforeEach(() => {
data = {
id: "id",
name: "encName",
revisionDate: "2022-01-31T12:00:00.000Z",
};
});
it("Convert", () => {
const field = new Folder(data);
expect(field).toEqual({
id: "id",
name: { encryptedString: "encName", encryptionType: 0 },
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
it("Decrypt", async () => {
const folder = new Folder();
folder.id = "id";
folder.name = mockEnc("encName");
folder.revisionDate = new Date("2022-01-31T12:00:00.000Z");
const view = await folder.decrypt();
expect(view).toEqual({
id: "id",
name: "encName",
revisionDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
describe("fromJSON", () => {
jest.mock("../../../models/domain/enc-string");
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
it("initializes nested objects", () => {
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const actual = Folder.fromJSON({
revisionDate: revisionDate.toISOString(),
name: "name",
id: "id",
});
const expected = {
revisionDate: revisionDate,
name: "name_fromJSON",
id: "id",
};
expect(actual).toMatchObject(expected);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Jsonify } from "type-fest";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { FolderData } from "../data/folder.data";
import { FolderView } from "../view/folder.view";
export class Folder extends Domain {
id: string;
name: EncString;
revisionDate: Date;
constructor(obj?: FolderData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
id: null,
name: null,
},
["id"]
);
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
}
decrypt(): Promise<FolderView> {
return this.decryptObj(
new FolderView(this),
{
name: null,
},
null
);
}
static fromJSON(obj: Jsonify<Folder>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new Folder(), obj, { name: EncString.fromJSON(obj.name), revisionDate });
}
}

View File

@@ -0,0 +1,187 @@
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { EncString } from "../../../models/domain/enc-string";
import { IdentityData } from "../../models/data/identity.data";
import { Identity } from "../../models/domain/identity";
describe("Identity", () => {
let data: IdentityData;
beforeEach(() => {
data = {
title: "enctitle",
firstName: "encfirstName",
middleName: "encmiddleName",
lastName: "enclastName",
address1: "encaddress1",
address2: "encaddress2",
address3: "encaddress3",
city: "enccity",
state: "encstate",
postalCode: "encpostalCode",
country: "enccountry",
company: "enccompany",
email: "encemail",
phone: "encphone",
ssn: "encssn",
username: "encusername",
passportNumber: "encpassportNumber",
licenseNumber: "enclicenseNumber",
};
});
it("Convert from empty", () => {
const data = new IdentityData();
const identity = new Identity(data);
expect(identity).toEqual({
address1: null,
address2: null,
address3: null,
city: null,
company: null,
country: null,
email: null,
firstName: null,
lastName: null,
licenseNumber: null,
middleName: null,
passportNumber: null,
phone: null,
postalCode: null,
ssn: null,
state: null,
title: null,
username: null,
});
});
it("Convert", () => {
const identity = new Identity(data);
expect(identity).toEqual({
title: { encryptedString: "enctitle", encryptionType: 0 },
firstName: { encryptedString: "encfirstName", encryptionType: 0 },
middleName: { encryptedString: "encmiddleName", encryptionType: 0 },
lastName: { encryptedString: "enclastName", encryptionType: 0 },
address1: { encryptedString: "encaddress1", encryptionType: 0 },
address2: { encryptedString: "encaddress2", encryptionType: 0 },
address3: { encryptedString: "encaddress3", encryptionType: 0 },
city: { encryptedString: "enccity", encryptionType: 0 },
state: { encryptedString: "encstate", encryptionType: 0 },
postalCode: { encryptedString: "encpostalCode", encryptionType: 0 },
country: { encryptedString: "enccountry", encryptionType: 0 },
company: { encryptedString: "enccompany", encryptionType: 0 },
email: { encryptedString: "encemail", encryptionType: 0 },
phone: { encryptedString: "encphone", encryptionType: 0 },
ssn: { encryptedString: "encssn", encryptionType: 0 },
username: { encryptedString: "encusername", encryptionType: 0 },
passportNumber: { encryptedString: "encpassportNumber", encryptionType: 0 },
licenseNumber: { encryptedString: "enclicenseNumber", encryptionType: 0 },
});
});
it("toIdentityData", () => {
const identity = new Identity(data);
expect(identity.toIdentityData()).toEqual(data);
});
it("Decrypt", async () => {
const identity = new Identity();
identity.title = mockEnc("mockTitle");
identity.firstName = mockEnc("mockFirstName");
identity.middleName = mockEnc("mockMiddleName");
identity.lastName = mockEnc("mockLastName");
identity.address1 = mockEnc("mockAddress1");
identity.address2 = mockEnc("mockAddress2");
identity.address3 = mockEnc("mockAddress3");
identity.city = mockEnc("mockCity");
identity.state = mockEnc("mockState");
identity.postalCode = mockEnc("mockPostalCode");
identity.country = mockEnc("mockCountry");
identity.company = mockEnc("mockCompany");
identity.email = mockEnc("mockEmail");
identity.phone = mockEnc("mockPhone");
identity.ssn = mockEnc("mockSsn");
identity.username = mockEnc("mockUsername");
identity.passportNumber = mockEnc("mockPassportNumber");
identity.licenseNumber = mockEnc("mockLicenseNumber");
const view = await identity.decrypt(null);
expect(view).toEqual({
_firstName: "mockFirstName",
_lastName: "mockLastName",
_subTitle: null,
address1: "mockAddress1",
address2: "mockAddress2",
address3: "mockAddress3",
city: "mockCity",
company: "mockCompany",
country: "mockCountry",
email: "mockEmail",
licenseNumber: "mockLicenseNumber",
middleName: "mockMiddleName",
passportNumber: "mockPassportNumber",
phone: "mockPhone",
postalCode: "mockPostalCode",
ssn: "mockSsn",
state: "mockState",
title: "mockTitle",
username: "mockUsername",
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const actual = Identity.fromJSON({
firstName: "mockFirstName",
lastName: "mockLastName",
address1: "mockAddress1",
address2: "mockAddress2",
address3: "mockAddress3",
city: "mockCity",
company: "mockCompany",
country: "mockCountry",
email: "mockEmail",
licenseNumber: "mockLicenseNumber",
middleName: "mockMiddleName",
passportNumber: "mockPassportNumber",
phone: "mockPhone",
postalCode: "mockPostalCode",
ssn: "mockSsn",
state: "mockState",
title: "mockTitle",
username: "mockUsername",
});
expect(actual).toEqual({
firstName: "mockFirstName_fromJSON",
lastName: "mockLastName_fromJSON",
address1: "mockAddress1_fromJSON",
address2: "mockAddress2_fromJSON",
address3: "mockAddress3_fromJSON",
city: "mockCity_fromJSON",
company: "mockCompany_fromJSON",
country: "mockCountry_fromJSON",
email: "mockEmail_fromJSON",
licenseNumber: "mockLicenseNumber_fromJSON",
middleName: "mockMiddleName_fromJSON",
passportNumber: "mockPassportNumber_fromJSON",
phone: "mockPhone_fromJSON",
postalCode: "mockPostalCode_fromJSON",
ssn: "mockSsn_fromJSON",
state: "mockState_fromJSON",
title: "mockTitle_fromJSON",
username: "mockUsername_fromJSON",
});
expect(actual).toBeInstanceOf(Identity);
});
it("returns null if object is null", () => {
expect(Identity.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,160 @@
import { Jsonify } from "type-fest";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { IdentityData } from "../data/identity.data";
import { IdentityView } from "../view/identity.view";
export class Identity extends Domain {
title: EncString;
firstName: EncString;
middleName: EncString;
lastName: EncString;
address1: EncString;
address2: EncString;
address3: EncString;
city: EncString;
state: EncString;
postalCode: EncString;
country: EncString;
company: EncString;
email: EncString;
phone: EncString;
ssn: EncString;
username: EncString;
passportNumber: EncString;
licenseNumber: EncString;
constructor(obj?: IdentityData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
},
[]
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<IdentityView> {
return this.decryptObj(
new IdentityView(),
{
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
},
orgId,
encKey
);
}
toIdentityData(): IdentityData {
const i = new IdentityData();
this.buildDataModel(this, i, {
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
});
return i;
}
static fromJSON(obj: Jsonify<Identity>): Identity {
if (obj == null) {
return null;
}
const title = EncString.fromJSON(obj.title);
const firstName = EncString.fromJSON(obj.firstName);
const middleName = EncString.fromJSON(obj.middleName);
const lastName = EncString.fromJSON(obj.lastName);
const address1 = EncString.fromJSON(obj.address1);
const address2 = EncString.fromJSON(obj.address2);
const address3 = EncString.fromJSON(obj.address3);
const city = EncString.fromJSON(obj.city);
const state = EncString.fromJSON(obj.state);
const postalCode = EncString.fromJSON(obj.postalCode);
const country = EncString.fromJSON(obj.country);
const company = EncString.fromJSON(obj.company);
const email = EncString.fromJSON(obj.email);
const phone = EncString.fromJSON(obj.phone);
const ssn = EncString.fromJSON(obj.ssn);
const username = EncString.fromJSON(obj.username);
const passportNumber = EncString.fromJSON(obj.passportNumber);
const licenseNumber = EncString.fromJSON(obj.licenseNumber);
return Object.assign(new Identity(), obj, {
title,
firstName,
middleName,
lastName,
address1,
address2,
address3,
city,
state,
postalCode,
country,
company,
email,
phone,
ssn,
username,
passportNumber,
licenseNumber,
});
}
}

View File

@@ -0,0 +1,79 @@
import { Jsonify } from "type-fest";
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { UriMatchType } from "../../../enums/uriMatchType";
import { EncString } from "../../../models/domain/enc-string";
import { LoginUriData } from "../data/login-uri.data";
import { LoginUri } from "./login-uri";
describe("LoginUri", () => {
let data: LoginUriData;
beforeEach(() => {
data = {
uri: "encUri",
match: UriMatchType.Domain,
};
});
it("Convert from empty", () => {
const data = new LoginUriData();
const loginUri = new LoginUri(data);
expect(loginUri).toEqual({
match: null,
uri: null,
});
});
it("Convert", () => {
const loginUri = new LoginUri(data);
expect(loginUri).toEqual({
match: 0,
uri: { encryptedString: "encUri", encryptionType: 0 },
});
});
it("toLoginUriData", () => {
const loginUri = new LoginUri(data);
expect(loginUri.toLoginUriData()).toEqual(data);
});
it("Decrypt", async () => {
const loginUri = new LoginUri();
loginUri.match = UriMatchType.Exact;
loginUri.uri = mockEnc("uri");
const view = await loginUri.decrypt(null);
expect(view).toEqual({
_canLaunch: null,
_domain: null,
_host: null,
_hostname: null,
_uri: "uri",
match: 3,
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const actual = LoginUri.fromJSON({
uri: "myUri",
} as Jsonify<LoginUri>);
expect(actual).toEqual({
uri: "myUri_fromJSON",
});
expect(actual).toBeInstanceOf(LoginUri);
});
it("returns null if object is null", () => {
expect(LoginUri.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,66 @@
import { Jsonify } from "type-fest";
import { UriMatchType } from "../../../enums/uriMatchType";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { LoginUriData } from "../data/login-uri.data";
import { LoginUriView } from "../view/login-uri.view";
export class LoginUri extends Domain {
uri: EncString;
match: UriMatchType;
constructor(obj?: LoginUriData) {
super();
if (obj == null) {
return;
}
this.match = obj.match;
this.buildDomainModel(
this,
obj,
{
uri: null,
},
[]
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<LoginUriView> {
return this.decryptObj(
new LoginUriView(this),
{
uri: null,
},
orgId,
encKey
);
}
toLoginUriData(): LoginUriData {
const u = new LoginUriData();
this.buildDataModel(
this,
u,
{
uri: null,
match: null,
},
["match"]
);
return u;
}
static fromJSON(obj: Jsonify<LoginUri>): LoginUri {
if (obj == null) {
return null;
}
const uri = EncString.fromJSON(obj.uri);
return Object.assign(new LoginUri(), obj, {
uri,
});
}
}

View File

@@ -0,0 +1,131 @@
// eslint-disable-next-line no-restricted-imports
import { Substitute, Arg } from "@fluffy-spoon/substitute";
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { UriMatchType } from "../../../enums/uriMatchType";
import { EncString } from "../../../models/domain/enc-string";
import { LoginData } from "../../models/data/login.data";
import { Login } from "../../models/domain/login";
import { LoginUri } from "../../models/domain/login-uri";
import { LoginUriView } from "../../models/view/login-uri.view";
describe("Login DTO", () => {
it("Convert from empty LoginData", () => {
const data = new LoginData();
const login = new Login(data);
expect(login).toEqual({
passwordRevisionDate: null,
autofillOnPageLoad: undefined,
username: null,
password: null,
totp: null,
});
});
it("Convert from full LoginData", () => {
const data: LoginData = {
uris: [{ uri: "uri", match: UriMatchType.Domain }],
username: "username",
password: "password",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "123",
autofillOnPageLoad: false,
};
const login = new Login(data);
expect(login).toEqual({
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
autofillOnPageLoad: false,
username: { encryptedString: "username", encryptionType: 0 },
password: { encryptedString: "password", encryptionType: 0 },
totp: { encryptedString: "123", encryptionType: 0 },
uris: [{ match: 0, uri: { encryptedString: "uri", encryptionType: 0 } }],
});
});
it("Initialize without LoginData", () => {
const login = new Login();
expect(login).toEqual({});
});
it("Decrypts correctly", async () => {
const loginUri = Substitute.for<LoginUri>();
const loginUriView = new LoginUriView();
loginUriView.uri = "decrypted uri";
loginUri.decrypt(Arg.any()).resolves(loginUriView);
const login = new Login();
login.uris = [loginUri];
login.username = mockEnc("encrypted username");
login.password = mockEnc("encrypted password");
login.passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z");
login.totp = mockEnc("encrypted totp");
login.autofillOnPageLoad = true;
const loginView = await login.decrypt(null);
expect(loginView).toEqual({
username: "encrypted username",
password: "encrypted password",
passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"),
totp: "encrypted totp",
uris: [
{
match: null,
_uri: "decrypted uri",
_domain: null,
_hostname: null,
_host: null,
_canLaunch: null,
},
],
autofillOnPageLoad: true,
});
});
it("Converts from LoginData and back", () => {
const data: LoginData = {
uris: [{ uri: "uri", match: UriMatchType.Domain }],
username: "username",
password: "password",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "123",
autofillOnPageLoad: false,
};
const login = new Login(data);
const loginData = login.toLoginData();
expect(loginData).toEqual(data);
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(LoginUri, "fromJSON").mockImplementation(mockFromJson);
const passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z");
const actual = Login.fromJSON({
uris: ["loginUri1", "loginUri2"] as any,
username: "myUsername",
password: "myPassword",
passwordRevisionDate: passwordRevisionDate.toISOString(),
totp: "myTotp",
});
expect(actual).toEqual({
uris: ["loginUri1_fromJSON", "loginUri2_fromJSON"] as any,
username: "myUsername_fromJSON",
password: "myPassword_fromJSON",
passwordRevisionDate: passwordRevisionDate,
totp: "myTotp_fromJSON",
});
expect(actual).toBeInstanceOf(Login);
});
it("returns null if object is null", () => {
expect(Login.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,111 @@
import { Jsonify } from "type-fest";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { LoginData } from "../data/login.data";
import { LoginView } from "../view/login.view";
import { LoginUri } from "./login-uri";
export class Login extends Domain {
uris: LoginUri[];
username: EncString;
password: EncString;
passwordRevisionDate?: Date;
totp: EncString;
autofillOnPageLoad: boolean;
constructor(obj?: LoginData) {
super();
if (obj == null) {
return;
}
this.passwordRevisionDate =
obj.passwordRevisionDate != null ? new Date(obj.passwordRevisionDate) : null;
this.autofillOnPageLoad = obj.autofillOnPageLoad;
this.buildDomainModel(
this,
obj,
{
username: null,
password: null,
totp: null,
},
[]
);
if (obj.uris) {
this.uris = [];
obj.uris.forEach((u) => {
this.uris.push(new LoginUri(u));
});
}
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<LoginView> {
const view = await this.decryptObj(
new LoginView(this),
{
username: null,
password: null,
totp: null,
},
orgId,
encKey
);
if (this.uris != null) {
view.uris = [];
for (let i = 0; i < this.uris.length; i++) {
const uri = await this.uris[i].decrypt(orgId, encKey);
view.uris.push(uri);
}
}
return view;
}
toLoginData(): LoginData {
const l = new LoginData();
l.passwordRevisionDate =
this.passwordRevisionDate != null ? this.passwordRevisionDate.toISOString() : null;
l.autofillOnPageLoad = this.autofillOnPageLoad;
this.buildDataModel(this, l, {
username: null,
password: null,
totp: null,
});
if (this.uris != null && this.uris.length > 0) {
l.uris = [];
this.uris.forEach((u) => {
l.uris.push(u.toLoginUriData());
});
}
return l;
}
static fromJSON(obj: Partial<Jsonify<Login>>): Login {
if (obj == null) {
return null;
}
const username = EncString.fromJSON(obj.username);
const password = EncString.fromJSON(obj.password);
const totp = EncString.fromJSON(obj.totp);
const passwordRevisionDate =
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
const uris = obj.uris?.map((uri: any) => LoginUri.fromJSON(uri));
return Object.assign(new Login(), obj, {
username,
password,
totp,
passwordRevisionDate: passwordRevisionDate,
uris: uris,
});
}
}

View File

@@ -0,0 +1,73 @@
import { mockEnc, mockFromJson } from "../../../../spec/utils";
import { EncString } from "../../../models/domain/enc-string";
import { PasswordHistoryData } from "../../models/data/password-history.data";
import { Password } from "../../models/domain/password";
describe("Password", () => {
let data: PasswordHistoryData;
beforeEach(() => {
data = {
password: "encPassword",
lastUsedDate: "2022-01-31T12:00:00.000Z",
};
});
it("Convert from empty", () => {
const data = new PasswordHistoryData();
const password = new Password(data);
expect(password).toMatchObject({
password: null,
});
});
it("Convert", () => {
const password = new Password(data);
expect(password).toEqual({
password: { encryptedString: "encPassword", encryptionType: 0 },
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
it("toPasswordHistoryData", () => {
const password = new Password(data);
expect(password.toPasswordHistoryData()).toEqual(data);
});
it("Decrypt", async () => {
const password = new Password();
password.password = mockEnc("password");
password.lastUsedDate = new Date("2022-01-31T12:00:00.000Z");
const view = await password.decrypt(null);
expect(view).toEqual({
password: "password",
lastUsedDate: new Date("2022-01-31T12:00:00.000Z"),
});
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson);
const lastUsedDate = new Date("2022-01-31T12:00:00.000Z");
const actual = Password.fromJSON({
password: "myPassword",
lastUsedDate: lastUsedDate.toISOString(),
});
expect(actual).toEqual({
password: "myPassword_fromJSON",
lastUsedDate: lastUsedDate,
});
expect(actual).toBeInstanceOf(Password);
});
it("returns null if object is null", () => {
expect(Password.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,58 @@
import { Jsonify } from "type-fest";
import Domain from "../../../models/domain/domain-base";
import { EncString } from "../../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { PasswordHistoryData } from "../data/password-history.data";
import { PasswordHistoryView } from "../view/password-history.view";
export class Password extends Domain {
password: EncString;
lastUsedDate: Date;
constructor(obj?: PasswordHistoryData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(this, obj, {
password: null,
});
this.lastUsedDate = new Date(obj.lastUsedDate);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj(
new PasswordHistoryView(this),
{
password: null,
},
orgId,
encKey
);
}
toPasswordHistoryData(): PasswordHistoryData {
const ph = new PasswordHistoryData();
ph.lastUsedDate = this.lastUsedDate.toISOString();
this.buildDataModel(this, ph, {
password: null,
});
return ph;
}
static fromJSON(obj: Partial<Jsonify<Password>>): Password {
if (obj == null) {
return null;
}
const password = EncString.fromJSON(obj.password);
const lastUsedDate = obj.lastUsedDate == null ? null : new Date(obj.lastUsedDate);
return Object.assign(new Password(), obj, {
password,
lastUsedDate,
});
}
}

View File

@@ -0,0 +1,53 @@
import { SecureNoteType } from "../../../enums/secureNoteType";
import { SecureNoteData } from "../data/secure-note.data";
import { SecureNote } from "./secure-note";
describe("SecureNote", () => {
let data: SecureNoteData;
beforeEach(() => {
data = {
type: SecureNoteType.Generic,
};
});
it("Convert from empty", () => {
const data = new SecureNoteData();
const secureNote = new SecureNote(data);
expect(secureNote).toEqual({
type: undefined,
});
});
it("Convert", () => {
const secureNote = new SecureNote(data);
expect(secureNote).toEqual({
type: 0,
});
});
it("toSecureNoteData", () => {
const secureNote = new SecureNote(data);
expect(secureNote.toSecureNoteData()).toEqual(data);
});
it("Decrypt", async () => {
const secureNote = new SecureNote();
secureNote.type = SecureNoteType.Generic;
const view = await secureNote.decrypt(null);
expect(view).toEqual({
type: 0,
});
});
describe("fromJSON", () => {
it("returns null if object is null", () => {
expect(SecureNote.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,38 @@
import { Jsonify } from "type-fest";
import { SecureNoteType } from "../../../enums/secureNoteType";
import Domain from "../../../models/domain/domain-base";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { SecureNoteData } from "../data/secure-note.data";
import { SecureNoteView } from "../view/secure-note.view";
export class SecureNote extends Domain {
type: SecureNoteType;
constructor(obj?: SecureNoteData) {
super();
if (obj == null) {
return;
}
this.type = obj.type;
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SecureNoteView> {
return Promise.resolve(new SecureNoteView(this));
}
toSecureNoteData(): SecureNoteData {
const n = new SecureNoteData();
n.type = this.type;
return n;
}
static fromJSON(obj: Jsonify<SecureNote>): SecureNote {
if (obj == null) {
return null;
}
return Object.assign(new SecureNote(), obj);
}
}

View File

@@ -0,0 +1,87 @@
import { CipherView } from "../view/cipher.view";
const CacheTTL = 3000;
export class SortedCiphersCache {
private readonly sortedCiphersByUrl: Map<string, Ciphers> = new Map<string, Ciphers>();
private readonly timeouts: Map<string, any> = new Map<string, any>();
constructor(private readonly comparator: (a: CipherView, b: CipherView) => number) {}
isCached(url: string) {
return this.sortedCiphersByUrl.has(url);
}
addCiphers(url: string, ciphers: CipherView[]) {
ciphers.sort(this.comparator);
this.sortedCiphersByUrl.set(url, new Ciphers(ciphers));
this.resetTimer(url);
}
getLastUsed(url: string) {
this.resetTimer(url);
return this.isCached(url) ? this.sortedCiphersByUrl.get(url).getLastUsed() : null;
}
getLastLaunched(url: string) {
return this.isCached(url) ? this.sortedCiphersByUrl.get(url).getLastLaunched() : null;
}
getNext(url: string) {
this.resetTimer(url);
return this.isCached(url) ? this.sortedCiphersByUrl.get(url).getNext() : null;
}
updateLastUsedIndex(url: string) {
if (this.isCached(url)) {
this.sortedCiphersByUrl.get(url).updateLastUsedIndex();
}
}
clear() {
this.sortedCiphersByUrl.clear();
this.timeouts.clear();
}
private resetTimer(url: string) {
clearTimeout(this.timeouts.get(url));
this.timeouts.set(
url,
setTimeout(() => {
this.sortedCiphersByUrl.delete(url);
this.timeouts.delete(url);
}, CacheTTL)
);
}
}
class Ciphers {
lastUsedIndex = -1;
constructor(private readonly ciphers: CipherView[]) {}
getLastUsed() {
this.lastUsedIndex = Math.max(this.lastUsedIndex, 0);
return this.ciphers[this.lastUsedIndex];
}
getLastLaunched() {
const usedCiphers = this.ciphers.filter((cipher) => cipher.localData?.lastLaunched);
const sortedCiphers = usedCiphers.sort(
(x, y) => y.localData.lastLaunched.valueOf() - x.localData.lastLaunched.valueOf()
);
return sortedCiphers[0];
}
getNextIndex() {
return (this.lastUsedIndex + 1) % this.ciphers.length;
}
getNext() {
return this.ciphers[this.getNextIndex()];
}
updateLastUsedIndex() {
this.lastUsedIndex = this.getNextIndex();
}
}

View File

@@ -0,0 +1,6 @@
export class AttachmentRequest {
fileName: string;
key: string;
fileSize: number;
adminRequest: boolean;
}

View File

@@ -0,0 +1,9 @@
export class CipherBulkDeleteRequest {
ids: string[];
organizationId: string;
constructor(ids: string[], organizationId?: string) {
this.ids = ids == null ? [] : ids;
this.organizationId = organizationId;
}
}

View File

@@ -0,0 +1,9 @@
export class CipherBulkMoveRequest {
ids: string[];
folderId: string;
constructor(ids: string[], folderId: string) {
this.ids = ids == null ? [] : ids;
this.folderId = folderId;
}
}

View File

@@ -0,0 +1,7 @@
export class CipherBulkRestoreRequest {
ids: string[];
constructor(ids: string[]) {
this.ids = ids == null ? [] : ids;
}
}

View File

@@ -0,0 +1,18 @@
import { Cipher } from "../domain/cipher";
import { CipherWithIdRequest } from "./cipher-with-id.request";
export class CipherBulkShareRequest {
ciphers: CipherWithIdRequest[];
collectionIds: string[];
constructor(ciphers: Cipher[], collectionIds: string[]) {
if (ciphers != null) {
this.ciphers = [];
ciphers.forEach((c) => {
this.ciphers.push(new CipherWithIdRequest(c));
});
}
this.collectionIds = collectionIds;
}
}

View File

@@ -0,0 +1,7 @@
export class CipherCollectionsRequest {
collectionIds: string[];
constructor(collectionIds: string[]) {
this.collectionIds = collectionIds == null ? [] : collectionIds;
}
}

View File

@@ -0,0 +1,13 @@
import { Cipher } from "../domain/cipher";
import { CipherRequest } from "./cipher.request";
export class CipherCreateRequest {
cipher: CipherRequest;
collectionIds: string[];
constructor(cipher: Cipher) {
this.cipher = new CipherRequest(cipher);
this.collectionIds = cipher.collectionIds;
}
}

View File

@@ -0,0 +1,11 @@
import { Cipher } from "../domain/cipher";
export class CipherPartialRequest {
folderId: string;
favorite: boolean;
constructor(cipher: Cipher) {
this.folderId = cipher.folderId;
this.favorite = cipher.favorite;
}
}

View File

@@ -0,0 +1,13 @@
import { Cipher } from "../domain/cipher";
import { CipherRequest } from "./cipher.request";
export class CipherShareRequest {
cipher: CipherRequest;
collectionIds: string[];
constructor(cipher: Cipher) {
this.cipher = new CipherRequest(cipher);
this.collectionIds = cipher.collectionIds;
}
}

View File

@@ -0,0 +1,12 @@
import { Cipher } from "../domain/cipher";
import { CipherRequest } from "./cipher.request";
export class CipherWithIdRequest extends CipherRequest {
id: string;
constructor(cipher: Cipher) {
super(cipher);
this.id = cipher.id;
}
}

View File

@@ -0,0 +1,164 @@
import { CardApi } from "../../../models/api/card.api";
import { FieldApi } from "../../../models/api/field.api";
import { IdentityApi } from "../../../models/api/identity.api";
import { LoginUriApi } from "../../../models/api/login-uri.api";
import { LoginApi } from "../../../models/api/login.api";
import { SecureNoteApi } from "../../../models/api/secure-note.api";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { Cipher } from "../domain/cipher";
import { AttachmentRequest } from "./attachment.request";
import { PasswordHistoryRequest } from "./password-history.request";
export class CipherRequest {
type: CipherType;
folderId: string;
organizationId: string;
name: string;
notes: string;
favorite: boolean;
login: LoginApi;
secureNote: SecureNoteApi;
card: CardApi;
identity: IdentityApi;
fields: FieldApi[];
passwordHistory: PasswordHistoryRequest[];
// Deprecated, remove at some point and rename attachments2 to attachments
attachments: { [id: string]: string };
attachments2: { [id: string]: AttachmentRequest };
lastKnownRevisionDate: Date;
reprompt: CipherRepromptType;
constructor(cipher: Cipher) {
this.type = cipher.type;
this.folderId = cipher.folderId;
this.organizationId = cipher.organizationId;
this.name = cipher.name ? cipher.name.encryptedString : null;
this.notes = cipher.notes ? cipher.notes.encryptedString : null;
this.favorite = cipher.favorite;
this.lastKnownRevisionDate = cipher.revisionDate;
this.reprompt = cipher.reprompt;
switch (this.type) {
case CipherType.Login:
this.login = new LoginApi();
this.login.uris = null;
this.login.username = cipher.login.username ? cipher.login.username.encryptedString : null;
this.login.password = cipher.login.password ? cipher.login.password.encryptedString : null;
this.login.passwordRevisionDate =
cipher.login.passwordRevisionDate != null
? cipher.login.passwordRevisionDate.toISOString()
: null;
this.login.totp = cipher.login.totp ? cipher.login.totp.encryptedString : null;
this.login.autofillOnPageLoad = cipher.login.autofillOnPageLoad;
if (cipher.login.uris != null) {
this.login.uris = cipher.login.uris.map((u) => {
const uri = new LoginUriApi();
uri.uri = u.uri != null ? u.uri.encryptedString : null;
uri.match = u.match != null ? u.match : null;
return uri;
});
}
break;
case CipherType.SecureNote:
this.secureNote = new SecureNoteApi();
this.secureNote.type = cipher.secureNote.type;
break;
case CipherType.Card:
this.card = new CardApi();
this.card.cardholderName =
cipher.card.cardholderName != null ? cipher.card.cardholderName.encryptedString : null;
this.card.brand = cipher.card.brand != null ? cipher.card.brand.encryptedString : null;
this.card.number = cipher.card.number != null ? cipher.card.number.encryptedString : null;
this.card.expMonth =
cipher.card.expMonth != null ? cipher.card.expMonth.encryptedString : null;
this.card.expYear =
cipher.card.expYear != null ? cipher.card.expYear.encryptedString : null;
this.card.code = cipher.card.code != null ? cipher.card.code.encryptedString : null;
break;
case CipherType.Identity:
this.identity = new IdentityApi();
this.identity.title =
cipher.identity.title != null ? cipher.identity.title.encryptedString : null;
this.identity.firstName =
cipher.identity.firstName != null ? cipher.identity.firstName.encryptedString : null;
this.identity.middleName =
cipher.identity.middleName != null ? cipher.identity.middleName.encryptedString : null;
this.identity.lastName =
cipher.identity.lastName != null ? cipher.identity.lastName.encryptedString : null;
this.identity.address1 =
cipher.identity.address1 != null ? cipher.identity.address1.encryptedString : null;
this.identity.address2 =
cipher.identity.address2 != null ? cipher.identity.address2.encryptedString : null;
this.identity.address3 =
cipher.identity.address3 != null ? cipher.identity.address3.encryptedString : null;
this.identity.city =
cipher.identity.city != null ? cipher.identity.city.encryptedString : null;
this.identity.state =
cipher.identity.state != null ? cipher.identity.state.encryptedString : null;
this.identity.postalCode =
cipher.identity.postalCode != null ? cipher.identity.postalCode.encryptedString : null;
this.identity.country =
cipher.identity.country != null ? cipher.identity.country.encryptedString : null;
this.identity.company =
cipher.identity.company != null ? cipher.identity.company.encryptedString : null;
this.identity.email =
cipher.identity.email != null ? cipher.identity.email.encryptedString : null;
this.identity.phone =
cipher.identity.phone != null ? cipher.identity.phone.encryptedString : null;
this.identity.ssn =
cipher.identity.ssn != null ? cipher.identity.ssn.encryptedString : null;
this.identity.username =
cipher.identity.username != null ? cipher.identity.username.encryptedString : null;
this.identity.passportNumber =
cipher.identity.passportNumber != null
? cipher.identity.passportNumber.encryptedString
: null;
this.identity.licenseNumber =
cipher.identity.licenseNumber != null
? cipher.identity.licenseNumber.encryptedString
: null;
break;
default:
break;
}
if (cipher.fields != null) {
this.fields = cipher.fields.map((f) => {
const field = new FieldApi();
field.type = f.type;
field.name = f.name ? f.name.encryptedString : null;
field.value = f.value ? f.value.encryptedString : null;
field.linkedId = f.linkedId;
return field;
});
}
if (cipher.passwordHistory != null) {
this.passwordHistory = [];
cipher.passwordHistory.forEach((ph) => {
this.passwordHistory.push({
lastUsedDate: ph.lastUsedDate,
password: ph.password ? ph.password.encryptedString : null,
});
});
}
if (cipher.attachments != null) {
this.attachments = {};
this.attachments2 = {};
cipher.attachments.forEach((attachment) => {
const fileName = attachment.fileName ? attachment.fileName.encryptedString : null;
this.attachments[attachment.id] = fileName;
const attachmentRequest = new AttachmentRequest();
attachmentRequest.fileName = fileName;
if (attachment.key != null) {
attachmentRequest.key = attachment.key.encryptedString;
}
this.attachments2[attachment.id] = attachmentRequest;
});
}
}
}

View File

@@ -0,0 +1,12 @@
import { Folder } from "../domain/folder";
import { FolderRequest } from "./folder.request";
export class FolderWithIdRequest extends FolderRequest {
id: string;
constructor(folder: Folder) {
super(folder);
this.id = folder.id;
}
}

View File

@@ -0,0 +1,9 @@
import { Folder } from "../domain/folder";
export class FolderRequest {
name: string;
constructor(folder: Folder) {
this.name = folder.name ? folder.name.encryptedString : null;
}
}

View File

@@ -0,0 +1,4 @@
export class PasswordHistoryRequest {
password: string;
lastUsedDate: Date;
}

View File

@@ -0,0 +1,23 @@
import { FileUploadType } from "../../../enums/fileUploadType";
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "./cipher.response";
export class AttachmentUploadDataResponse extends BaseResponse {
attachmentId: string;
fileUploadType: FileUploadType;
cipherResponse: CipherResponse;
cipherMiniResponse: CipherResponse;
url: string = null;
constructor(response: any) {
super(response);
this.attachmentId = this.getResponseProperty("AttachmentId");
this.fileUploadType = this.getResponseProperty("FileUploadType");
const cipherResponse = this.getResponseProperty("CipherResponse");
const cipherMiniResponse = this.getResponseProperty("CipherMiniResponse");
this.cipherResponse = cipherResponse == null ? null : new CipherResponse(cipherResponse);
this.cipherMiniResponse =
cipherMiniResponse == null ? null : new CipherResponse(cipherMiniResponse);
this.url = this.getResponseProperty("Url");
}
}

View File

@@ -0,0 +1,20 @@
import { BaseResponse } from "../../../models/response/base.response";
export class AttachmentResponse extends BaseResponse {
id: string;
url: string;
fileName: string;
key: string;
size: string;
sizeName: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.url = this.getResponseProperty("Url");
this.fileName = this.getResponseProperty("FileName");
this.key = this.getResponseProperty("Key");
this.size = this.getResponseProperty("Size");
this.sizeName = this.getResponseProperty("SizeName");
}
}

View File

@@ -0,0 +1,94 @@
import { CardApi } from "../../../models/api/card.api";
import { FieldApi } from "../../../models/api/field.api";
import { IdentityApi } from "../../../models/api/identity.api";
import { LoginApi } from "../../../models/api/login.api";
import { SecureNoteApi } from "../../../models/api/secure-note.api";
import { BaseResponse } from "../../../models/response/base.response";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { AttachmentResponse } from "./attachment.response";
import { PasswordHistoryResponse } from "./password-history.response";
export class CipherResponse extends BaseResponse {
id: string;
organizationId: string;
folderId: string;
type: number;
name: string;
notes: string;
fields: FieldApi[];
login: LoginApi;
card: CardApi;
identity: IdentityApi;
secureNote: SecureNoteApi;
favorite: boolean;
edit: boolean;
viewPassword: boolean;
organizationUseTotp: boolean;
revisionDate: string;
attachments: AttachmentResponse[];
passwordHistory: PasswordHistoryResponse[];
collectionIds: string[];
creationDate: string;
deletedDate: string;
reprompt: CipherRepromptType;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.folderId = this.getResponseProperty("FolderId") || null;
this.type = this.getResponseProperty("Type");
this.name = this.getResponseProperty("Name");
this.notes = this.getResponseProperty("Notes");
this.favorite = this.getResponseProperty("Favorite") || false;
this.edit = !!this.getResponseProperty("Edit");
if (this.getResponseProperty("ViewPassword") == null) {
this.viewPassword = true;
} else {
this.viewPassword = this.getResponseProperty("ViewPassword");
}
this.organizationUseTotp = this.getResponseProperty("OrganizationUseTotp");
this.revisionDate = this.getResponseProperty("RevisionDate");
this.collectionIds = this.getResponseProperty("CollectionIds");
this.creationDate = this.getResponseProperty("CreationDate");
this.deletedDate = this.getResponseProperty("DeletedDate");
const login = this.getResponseProperty("Login");
if (login != null) {
this.login = new LoginApi(login);
}
const card = this.getResponseProperty("Card");
if (card != null) {
this.card = new CardApi(card);
}
const identity = this.getResponseProperty("Identity");
if (identity != null) {
this.identity = new IdentityApi(identity);
}
const secureNote = this.getResponseProperty("SecureNote");
if (secureNote != null) {
this.secureNote = new SecureNoteApi(secureNote);
}
const fields = this.getResponseProperty("Fields");
if (fields != null) {
this.fields = fields.map((f: any) => new FieldApi(f));
}
const attachments = this.getResponseProperty("Attachments");
if (attachments != null) {
this.attachments = attachments.map((a: any) => new AttachmentResponse(a));
}
const passwordHistory = this.getResponseProperty("PasswordHistory");
if (passwordHistory != null) {
this.passwordHistory = passwordHistory.map((h: any) => new PasswordHistoryResponse(h));
}
this.reprompt = this.getResponseProperty("Reprompt") || CipherRepromptType.None;
}
}

View File

@@ -0,0 +1,14 @@
import { BaseResponse } from "../../../models/response/base.response";
export class FolderResponse extends BaseResponse {
id: string;
name: string;
revisionDate: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.name = this.getResponseProperty("Name");
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -0,0 +1,12 @@
import { BaseResponse } from "../../../models/response/base.response";
export class PasswordHistoryResponse extends BaseResponse {
password: string;
lastUsedDate: string;
constructor(response: any) {
super(response);
this.password = this.getResponseProperty("Password");
this.lastUsedDate = this.getResponseProperty("LastUsedDate");
}
}

View File

@@ -0,0 +1,58 @@
import { BaseResponse } from "../../../models/response/base.response";
import { CollectionDetailsResponse } from "../../../models/response/collection.response";
import { DomainsResponse } from "../../../models/response/domains.response";
import { PolicyResponse } from "../../../models/response/policy.response";
import { ProfileResponse } from "../../../models/response/profile.response";
import { SendResponse } from "../../../models/response/send.response";
import { CipherResponse } from "./cipher.response";
import { FolderResponse } from "./folder.response";
export class SyncResponse extends BaseResponse {
profile?: ProfileResponse;
folders: FolderResponse[] = [];
collections: CollectionDetailsResponse[] = [];
ciphers: CipherResponse[] = [];
domains?: DomainsResponse;
policies?: PolicyResponse[] = [];
sends: SendResponse[] = [];
constructor(response: any) {
super(response);
const profile = this.getResponseProperty("Profile");
if (profile != null) {
this.profile = new ProfileResponse(profile);
}
const folders = this.getResponseProperty("Folders");
if (folders != null) {
this.folders = folders.map((f: any) => new FolderResponse(f));
}
const collections = this.getResponseProperty("Collections");
if (collections != null) {
this.collections = collections.map((c: any) => new CollectionDetailsResponse(c));
}
const ciphers = this.getResponseProperty("Ciphers");
if (ciphers != null) {
this.ciphers = ciphers.map((c: any) => new CipherResponse(c));
}
const domains = this.getResponseProperty("Domains");
if (domains != null) {
this.domains = new DomainsResponse(domains);
}
const policies = this.getResponseProperty("Policies");
if (policies != null) {
this.policies = policies.map((p: any) => new PolicyResponse(p));
}
const sends = this.getResponseProperty("Sends");
if (sends != null) {
this.sends = sends.map((s: any) => new SendResponse(s));
}
}
}

View File

@@ -0,0 +1,18 @@
import { mockFromJson } from "../../../../spec/utils";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { AttachmentView } from "./attachment.view";
jest.mock("../../../models/domain/symmetric-crypto-key");
describe("AttachmentView", () => {
it("fromJSON initializes nested objects", () => {
jest.spyOn(SymmetricCryptoKey, "fromJSON").mockImplementation(mockFromJson);
const actual = AttachmentView.fromJSON({
key: "encKeyB64" as any,
});
expect(actual.key).toEqual("encKeyB64_fromJSON");
});
});

View File

@@ -0,0 +1,41 @@
import { Jsonify } from "type-fest";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { View } from "../../../models/view/view";
import { Attachment } from "../domain/attachment";
export class AttachmentView implements View {
id: string = null;
url: string = null;
size: string = null;
sizeName: string = null;
fileName: string = null;
key: SymmetricCryptoKey = null;
constructor(a?: Attachment) {
if (!a) {
return;
}
this.id = a.id;
this.url = a.url;
this.size = a.size;
this.sizeName = a.sizeName;
}
get fileSize(): number {
try {
if (this.size != null) {
return parseInt(this.size, null);
}
} catch {
// Invalid file size.
}
return 0;
}
static fromJSON(obj: Partial<Jsonify<AttachmentView>>): AttachmentView {
const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key);
return Object.assign(new AttachmentView(), obj, { key: key });
}
}

View File

@@ -0,0 +1,84 @@
import { Jsonify } from "type-fest";
import { CardLinkedId as LinkedId } from "../../../enums/linkedIdType";
import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator";
import { ItemView } from "./item.view";
export class CardView extends ItemView {
@linkedFieldOption(LinkedId.CardholderName)
cardholderName: string = null;
@linkedFieldOption(LinkedId.ExpMonth, "expirationMonth")
expMonth: string = null;
@linkedFieldOption(LinkedId.ExpYear, "expirationYear")
expYear: string = null;
@linkedFieldOption(LinkedId.Code, "securityCode")
code: string = null;
private _brand: string = null;
private _number: string = null;
private _subTitle: string = null;
get maskedCode(): string {
return this.code != null ? "•".repeat(this.code.length) : null;
}
get maskedNumber(): string {
return this.number != null ? "•".repeat(this.number.length) : null;
}
@linkedFieldOption(LinkedId.Brand)
get brand(): string {
return this._brand;
}
set brand(value: string) {
this._brand = value;
this._subTitle = null;
}
@linkedFieldOption(LinkedId.Number)
get number(): string {
return this._number;
}
set number(value: string) {
this._number = value;
this._subTitle = null;
}
get subTitle(): string {
if (this._subTitle == null) {
this._subTitle = this.brand;
if (this.number != null && this.number.length >= 4) {
if (this._subTitle != null && this._subTitle !== "") {
this._subTitle += ", ";
} else {
this._subTitle = "";
}
// Show last 5 on amex, last 4 for all others
const count =
this.number.length >= 5 && this.number.match(new RegExp("^3[47]")) != null ? 5 : 4;
this._subTitle += "*" + this.number.substr(this.number.length - count);
}
}
return this._subTitle;
}
get expiration(): string {
if (!this.expMonth && !this.expYear) {
return null;
}
let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__";
exp += " / " + (this.expYear != null ? this.formatYear(this.expYear) : "____");
return exp;
}
private formatYear(year: string): string {
return year.length === 2 ? "20" + year : year;
}
static fromJSON(obj: Partial<Jsonify<CardView>>): CardView {
return Object.assign(new CardView(), obj);
}
}

View File

@@ -0,0 +1,76 @@
import { mockFromJson } from "../../../../spec/utils";
import { CipherType } from "../../enums/cipher-type";
import { AttachmentView } from "./attachment.view";
import { CardView } from "./card.view";
import { CipherView } from "./cipher.view";
import { FieldView } from "./field.view";
import { IdentityView } from "./identity.view";
import { LoginView } from "./login.view";
import { PasswordHistoryView } from "./password-history.view";
import { SecureNoteView } from "./secure-note.view";
jest.mock("../../models/view/login.view");
jest.mock("../../models/view/attachment.view");
jest.mock("../../models/view/field.view");
jest.mock("../../models/view/password-history.view");
describe("CipherView", () => {
beforeEach(() => {
(LoginView as any).mockClear();
(AttachmentView as any).mockClear();
(FieldView as any).mockClear();
(PasswordHistoryView as any).mockClear();
});
describe("fromJSON", () => {
it("initializes nested objects", () => {
jest.spyOn(AttachmentView, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(FieldView, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(PasswordHistoryView, "fromJSON").mockImplementation(mockFromJson);
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const deletedDate = new Date("2022-09-04T01:06:40.441Z");
const actual = CipherView.fromJSON({
revisionDate: revisionDate.toISOString(),
deletedDate: deletedDate.toISOString(),
attachments: ["attachment1", "attachment2"] as any,
fields: ["field1", "field2"] as any,
passwordHistory: ["ph1", "ph2", "ph3"] as any,
});
const expected = {
revisionDate: revisionDate,
deletedDate: deletedDate,
attachments: ["attachment1_fromJSON", "attachment2_fromJSON"],
fields: ["field1_fromJSON", "field2_fromJSON"],
passwordHistory: ["ph1_fromJSON", "ph2_fromJSON", "ph3_fromJSON"],
};
expect(actual).toMatchObject(expected);
});
test.each([
// Test description, CipherType, expected output
["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }],
["CardView", CipherType.Card, { card: "myCard_fromJSON" }],
["IdentityView", CipherType.Identity, { identity: "myIdentity_fromJSON" }],
["Secure Note", CipherType.SecureNote, { secureNote: "mySecureNote_fromJSON" }],
])("initializes %s", (description: string, cipherType: CipherType, expected: any) => {
jest.spyOn(LoginView, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(IdentityView, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(CardView, "fromJSON").mockImplementation(mockFromJson);
jest.spyOn(SecureNoteView, "fromJSON").mockImplementation(mockFromJson);
const actual = CipherView.fromJSON({
login: "myLogin",
card: "myCard",
identity: "myIdentity",
secureNote: "mySecureNote",
type: cipherType,
} as any);
expect(actual).toMatchObject(expected);
});
});
});

View File

@@ -0,0 +1,179 @@
import { Jsonify } from "type-fest";
import { LinkedIdType } from "../../../enums/linkedIdType";
import { InitializerMetadata } from "../../../interfaces/initializer-metadata.interface";
import { View } from "../../../models/view/view";
import { InitializerKey } from "../../../services/cryptography/initializer-key";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { CipherType } from "../../enums/cipher-type";
import { LocalData } from "../data/local.data";
import { Cipher } from "../domain/cipher";
import { AttachmentView } from "./attachment.view";
import { CardView } from "./card.view";
import { FieldView } from "./field.view";
import { IdentityView } from "./identity.view";
import { LoginView } from "./login.view";
import { PasswordHistoryView } from "./password-history.view";
import { SecureNoteView } from "./secure-note.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
id: string = null;
organizationId: string = null;
folderId: string = null;
name: string = null;
notes: string = null;
type: CipherType = null;
favorite = false;
organizationUseTotp = false;
edit = false;
viewPassword = true;
localData: LocalData;
login = new LoginView();
identity = new IdentityView();
card = new CardView();
secureNote = new SecureNoteView();
attachments: AttachmentView[] = null;
fields: FieldView[] = null;
passwordHistory: PasswordHistoryView[] = null;
collectionIds: string[] = null;
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
reprompt: CipherRepromptType = CipherRepromptType.None;
constructor(c?: Cipher) {
if (!c) {
return;
}
this.id = c.id;
this.organizationId = c.organizationId;
this.folderId = c.folderId;
this.favorite = c.favorite;
this.organizationUseTotp = c.organizationUseTotp;
this.edit = c.edit;
this.viewPassword = c.viewPassword;
this.type = c.type;
this.localData = c.localData;
this.collectionIds = c.collectionIds;
this.revisionDate = c.revisionDate;
this.creationDate = c.creationDate;
this.deletedDate = c.deletedDate;
// Old locally stored ciphers might have reprompt == null. If so set it to None.
this.reprompt = c.reprompt ?? CipherRepromptType.None;
}
private get item() {
switch (this.type) {
case CipherType.Login:
return this.login;
case CipherType.SecureNote:
return this.secureNote;
case CipherType.Card:
return this.card;
case CipherType.Identity:
return this.identity;
default:
break;
}
return null;
}
get subTitle(): string {
return this.item.subTitle;
}
get hasPasswordHistory(): boolean {
return this.passwordHistory && this.passwordHistory.length > 0;
}
get hasAttachments(): boolean {
return this.attachments && this.attachments.length > 0;
}
get hasOldAttachments(): boolean {
if (this.hasAttachments) {
for (let i = 0; i < this.attachments.length; i++) {
if (this.attachments[i].key == null) {
return true;
}
}
}
return false;
}
get hasFields(): boolean {
return this.fields && this.fields.length > 0;
}
get passwordRevisionDisplayDate(): Date {
if (this.type !== CipherType.Login || this.login == null) {
return null;
} else if (this.login.password == null || this.login.password === "") {
return null;
}
return this.login.passwordRevisionDate;
}
get isDeleted(): boolean {
return this.deletedDate != null;
}
get linkedFieldOptions() {
return this.item.linkedFieldOptions;
}
linkedFieldValue(id: LinkedIdType) {
const linkedFieldOption = this.linkedFieldOptions?.get(id);
if (linkedFieldOption == null) {
return null;
}
const item = this.item;
return this.item[linkedFieldOption.propertyKey as keyof typeof item];
}
linkedFieldI18nKey(id: LinkedIdType): string {
return this.linkedFieldOptions.get(id)?.i18nKey;
}
static fromJSON(obj: Partial<Jsonify<CipherView>>): CipherView {
const view = new CipherView();
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
Object.assign(view, obj, {
revisionDate: revisionDate,
deletedDate: deletedDate,
attachments: attachments,
fields: fields,
passwordHistory: passwordHistory,
});
switch (obj.type) {
case CipherType.Card:
view.card = CardView.fromJSON(obj.card);
break;
case CipherType.Identity:
view.identity = IdentityView.fromJSON(obj.identity);
break;
case CipherType.Login:
view.login = LoginView.fromJSON(obj.login);
break;
case CipherType.SecureNote:
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
break;
default:
break;
}
return view;
}
}

View File

@@ -0,0 +1,33 @@
import { Jsonify } from "type-fest";
import { FieldType } from "../../../enums/fieldType";
import { LinkedIdType } from "../../../enums/linkedIdType";
import { View } from "../../../models/view/view";
import { Field } from "../domain/field";
export class FieldView implements View {
name: string = null;
value: string = null;
type: FieldType = null;
newField = false; // Marks if the field is new and hasn't been saved
showValue = false;
showCount = false;
linkedId: LinkedIdType = null;
constructor(f?: Field) {
if (!f) {
return;
}
this.type = f.type;
this.linkedId = f.linkedId;
}
get maskedValue(): string {
return this.value != null ? "••••••••" : null;
}
static fromJSON(obj: Partial<Jsonify<FieldView>>): FieldView {
return Object.assign(new FieldView(), obj);
}
}

View File

@@ -0,0 +1,22 @@
import { FolderView } from "./folder.view";
describe("FolderView", () => {
describe("fromJSON", () => {
it("initializes nested objects", () => {
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
const actual = FolderView.fromJSON({
revisionDate: revisionDate.toISOString(),
name: "name",
id: "id",
});
const expected = {
revisionDate: revisionDate,
name: "name",
id: "id",
};
expect(actual).toMatchObject(expected);
});
});
});

View File

@@ -0,0 +1,25 @@
import { Jsonify } from "type-fest";
import { ITreeNodeObject } from "../../../models/domain/tree-node";
import { View } from "../../../models/view/view";
import { Folder } from "../domain/folder";
export class FolderView implements View, ITreeNodeObject {
id: string = null;
name: string = null;
revisionDate: Date = null;
constructor(f?: Folder) {
if (!f) {
return;
}
this.id = f.id;
this.revisionDate = f.revisionDate;
}
static fromJSON(obj: Jsonify<FolderView>) {
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
return Object.assign(new FolderView(), obj, { revisionDate });
}
}

View File

@@ -0,0 +1,148 @@
import { Jsonify } from "type-fest";
import { IdentityLinkedId as LinkedId } from "../../../enums/linkedIdType";
import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator";
import { Utils } from "../../../misc/utils";
import { ItemView } from "./item.view";
export class IdentityView extends ItemView {
@linkedFieldOption(LinkedId.Title)
title: string = null;
@linkedFieldOption(LinkedId.MiddleName)
middleName: string = null;
@linkedFieldOption(LinkedId.Address1)
address1: string = null;
@linkedFieldOption(LinkedId.Address2)
address2: string = null;
@linkedFieldOption(LinkedId.Address3)
address3: string = null;
@linkedFieldOption(LinkedId.City, "cityTown")
city: string = null;
@linkedFieldOption(LinkedId.State, "stateProvince")
state: string = null;
@linkedFieldOption(LinkedId.PostalCode, "zipPostalCode")
postalCode: string = null;
@linkedFieldOption(LinkedId.Country)
country: string = null;
@linkedFieldOption(LinkedId.Company)
company: string = null;
@linkedFieldOption(LinkedId.Email)
email: string = null;
@linkedFieldOption(LinkedId.Phone)
phone: string = null;
@linkedFieldOption(LinkedId.Ssn)
ssn: string = null;
@linkedFieldOption(LinkedId.Username)
username: string = null;
@linkedFieldOption(LinkedId.PassportNumber)
passportNumber: string = null;
@linkedFieldOption(LinkedId.LicenseNumber)
licenseNumber: string = null;
private _firstName: string = null;
private _lastName: string = null;
private _subTitle: string = null;
constructor() {
super();
}
@linkedFieldOption(LinkedId.FirstName)
get firstName(): string {
return this._firstName;
}
set firstName(value: string) {
this._firstName = value;
this._subTitle = null;
}
@linkedFieldOption(LinkedId.LastName)
get lastName(): string {
return this._lastName;
}
set lastName(value: string) {
this._lastName = value;
this._subTitle = null;
}
get subTitle(): string {
if (this._subTitle == null && (this.firstName != null || this.lastName != null)) {
this._subTitle = "";
if (this.firstName != null) {
this._subTitle = this.firstName;
}
if (this.lastName != null) {
if (this._subTitle !== "") {
this._subTitle += " ";
}
this._subTitle += this.lastName;
}
}
return this._subTitle;
}
@linkedFieldOption(LinkedId.FullName)
get fullName(): string {
if (
this.title != null ||
this.firstName != null ||
this.middleName != null ||
this.lastName != null
) {
let name = "";
if (this.title != null) {
name += this.title + " ";
}
if (this.firstName != null) {
name += this.firstName + " ";
}
if (this.middleName != null) {
name += this.middleName + " ";
}
if (this.lastName != null) {
name += this.lastName;
}
return name.trim();
}
return null;
}
get fullAddress(): string {
let address = this.address1;
if (!Utils.isNullOrWhitespace(this.address2)) {
if (!Utils.isNullOrWhitespace(address)) {
address += ", ";
}
address += this.address2;
}
if (!Utils.isNullOrWhitespace(this.address3)) {
if (!Utils.isNullOrWhitespace(address)) {
address += ", ";
}
address += this.address3;
}
return address;
}
get fullAddressPart2(): string {
if (this.city == null && this.state == null && this.postalCode == null) {
return null;
}
const city = this.city || "-";
const state = this.state;
const postalCode = this.postalCode || "-";
let addressPart2 = city;
if (!Utils.isNullOrWhitespace(state)) {
addressPart2 += ", " + state;
}
addressPart2 += ", " + postalCode;
return addressPart2;
}
static fromJSON(obj: Partial<Jsonify<IdentityView>>): IdentityView {
return Object.assign(new IdentityView(), obj);
}
}

View File

@@ -0,0 +1,7 @@
import { LinkedMetadata } from "../../../misc/linkedFieldOption.decorator";
import { View } from "../../../models/view/view";
export abstract class ItemView implements View {
linkedFieldOptions: Map<number, LinkedMetadata>;
abstract get subTitle(): string;
}

View File

@@ -0,0 +1,66 @@
import { UriMatchType } from "../../../enums/uriMatchType";
import { LoginUriView } from "./login-uri.view";
const testData = [
{
match: UriMatchType.Host,
uri: "http://example.com/login",
expected: "http://example.com/login",
},
{
match: UriMatchType.Host,
uri: "bitwarden.com",
expected: "http://bitwarden.com",
},
{
match: UriMatchType.Host,
uri: "bitwarden.de",
expected: "http://bitwarden.de",
},
{
match: UriMatchType.Host,
uri: "bitwarden.br",
expected: "http://bitwarden.br",
},
];
describe("LoginUriView", () => {
it("isWebsite() given an invalid domain should return false", async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: UriMatchType.Host, uri: "bit!:_&ward.com" });
expect(uri.isWebsite).toBe(false);
});
testData.forEach((data) => {
it(`isWebsite() given ${data.uri} should return true`, async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: data.match, uri: data.uri });
expect(uri.isWebsite).toBe(true);
});
it(`launchUri() given ${data.uri} should return ${data.expected}`, async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: data.match, uri: data.uri });
expect(uri.launchUri).toBe(data.expected);
});
it(`canLaunch() given ${data.uri} should return true`, async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: data.match, uri: data.uri });
expect(uri.canLaunch).toBe(true);
});
});
it(`canLaunch should return false when MatchDetection is set to Regex`, async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: UriMatchType.RegularExpression, uri: "bitwarden.com" });
expect(uri.canLaunch).toBe(false);
});
it(`canLaunch() should return false when the given protocol does not match CanLaunchWhiteList`, async () => {
const uri = new LoginUriView();
Object.assign(uri, { match: UriMatchType.Host, uri: "someprotocol://bitwarden.com" });
expect(uri.canLaunch).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import { Jsonify } from "type-fest";
import { UriMatchType } from "../../../enums/uriMatchType";
import { Utils } from "../../../misc/utils";
import { View } from "../../../models/view/view";
import { LoginUri } from "../domain/login-uri";
const CanLaunchWhitelist = [
"https://",
"http://",
"ssh://",
"ftp://",
"sftp://",
"irc://",
"vnc://",
// https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/remote-desktop-uri
"rdp://", // Legacy RDP URI scheme
"ms-rd:", // Preferred RDP URI scheme
"chrome://",
"iosapp://",
"androidapp://",
];
export class LoginUriView implements View {
match: UriMatchType = null;
private _uri: string = null;
private _domain: string = null;
private _hostname: string = null;
private _host: string = null;
private _canLaunch: boolean = null;
constructor(u?: LoginUri) {
if (!u) {
return;
}
this.match = u.match;
}
get uri(): string {
return this._uri;
}
set uri(value: string) {
this._uri = value;
this._domain = null;
this._canLaunch = null;
}
get domain(): string {
if (this._domain == null && this.uri != null) {
this._domain = Utils.getDomain(this.uri);
if (this._domain === "") {
this._domain = null;
}
}
return this._domain;
}
get hostname(): string {
if (this.match === UriMatchType.RegularExpression) {
return null;
}
if (this._hostname == null && this.uri != null) {
this._hostname = Utils.getHostname(this.uri);
if (this._hostname === "") {
this._hostname = null;
}
}
return this._hostname;
}
get host(): string {
if (this.match === UriMatchType.RegularExpression) {
return null;
}
if (this._host == null && this.uri != null) {
this._host = Utils.getHost(this.uri);
if (this._host === "") {
this._host = null;
}
}
return this._host;
}
get hostnameOrUri(): string {
return this.hostname != null ? this.hostname : this.uri;
}
get hostOrUri(): string {
return this.host != null ? this.host : this.uri;
}
get isWebsite(): boolean {
return (
this.uri != null &&
(this.uri.indexOf("http://") === 0 ||
this.uri.indexOf("https://") === 0 ||
(this.uri.indexOf("://") < 0 && !Utils.isNullOrWhitespace(Utils.getDomain(this.uri))))
);
}
get canLaunch(): boolean {
if (this._canLaunch != null) {
return this._canLaunch;
}
if (this.uri != null && this.match !== UriMatchType.RegularExpression) {
const uri = this.launchUri;
for (let i = 0; i < CanLaunchWhitelist.length; i++) {
if (uri.indexOf(CanLaunchWhitelist[i]) === 0) {
this._canLaunch = true;
return this._canLaunch;
}
}
}
this._canLaunch = false;
return this._canLaunch;
}
get launchUri(): string {
return this.uri.indexOf("://") < 0 && !Utils.isNullOrWhitespace(Utils.getDomain(this.uri))
? "http://" + this.uri
: this.uri;
}
static fromJSON(obj: Partial<Jsonify<LoginUriView>>): LoginUriView {
return Object.assign(new LoginUriView(), obj);
}
}

View File

@@ -0,0 +1,28 @@
import { mockFromJson } from "../../../../spec/utils";
import { LoginUriView } from "./login-uri.view";
import { LoginView } from "./login.view";
jest.mock("../../models/view/login-uri.view");
describe("LoginView", () => {
beforeEach(() => {
(LoginUriView as any).mockClear();
});
it("fromJSON initializes nested objects", () => {
jest.spyOn(LoginUriView, "fromJSON").mockImplementation(mockFromJson);
const passwordRevisionDate = new Date();
const actual = LoginView.fromJSON({
passwordRevisionDate: passwordRevisionDate.toISOString(),
uris: ["uri1", "uri2", "uri3"] as any,
});
expect(actual).toMatchObject({
passwordRevisionDate: passwordRevisionDate,
uris: ["uri1_fromJSON", "uri2_fromJSON", "uri3_fromJSON"],
});
});
});

View File

@@ -0,0 +1,76 @@
import { Jsonify } from "type-fest";
import { LoginLinkedId as LinkedId } from "../../../enums/linkedIdType";
import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator";
import { Utils } from "../../../misc/utils";
import { Login } from "../domain/login";
import { ItemView } from "./item.view";
import { LoginUriView } from "./login-uri.view";
export class LoginView extends ItemView {
@linkedFieldOption(LinkedId.Username)
username: string = null;
@linkedFieldOption(LinkedId.Password)
password: string = null;
passwordRevisionDate?: Date = null;
totp: string = null;
uris: LoginUriView[] = null;
autofillOnPageLoad: boolean = null;
constructor(l?: Login) {
super();
if (!l) {
return;
}
this.passwordRevisionDate = l.passwordRevisionDate;
this.autofillOnPageLoad = l.autofillOnPageLoad;
}
get uri(): string {
return this.hasUris ? this.uris[0].uri : null;
}
get maskedPassword(): string {
return this.password != null ? "••••••••" : null;
}
get subTitle(): string {
return this.username;
}
get canLaunch(): boolean {
return this.hasUris && this.uris.some((u) => u.canLaunch);
}
get hasTotp(): boolean {
return !Utils.isNullOrWhitespace(this.totp);
}
get launchUri(): string {
if (this.hasUris) {
const uri = this.uris.find((u) => u.canLaunch);
if (uri != null) {
return uri.launchUri;
}
}
return null;
}
get hasUris(): boolean {
return this.uris != null && this.uris.length > 0;
}
static fromJSON(obj: Partial<Jsonify<LoginView>>): LoginView {
const passwordRevisionDate =
obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate);
const uris = obj.uris?.map((uri: any) => LoginUriView.fromJSON(uri));
return Object.assign(new LoginView(), obj, {
passwordRevisionDate: passwordRevisionDate,
uris: uris,
});
}
}

View File

@@ -0,0 +1,13 @@
import { PasswordHistoryView } from "./password-history.view";
describe("PasswordHistoryView", () => {
it("fromJSON initializes nested objects", () => {
const lastUsedDate = new Date();
const actual = PasswordHistoryView.fromJSON({
lastUsedDate: lastUsedDate.toISOString(),
});
expect(actual.lastUsedDate).toEqual(lastUsedDate);
});
});

View File

@@ -0,0 +1,25 @@
import { Jsonify } from "type-fest";
import { View } from "../../../models/view/view";
import { Password } from "../domain/password";
export class PasswordHistoryView implements View {
password: string = null;
lastUsedDate: Date = null;
constructor(ph?: Password) {
if (!ph) {
return;
}
this.lastUsedDate = ph.lastUsedDate;
}
static fromJSON(obj: Partial<Jsonify<PasswordHistoryView>>): PasswordHistoryView {
const lastUsedDate = obj.lastUsedDate == null ? null : new Date(obj.lastUsedDate);
return Object.assign(new PasswordHistoryView(), obj, {
lastUsedDate: lastUsedDate,
});
}
}

View File

@@ -0,0 +1,27 @@
import { Jsonify } from "type-fest";
import { SecureNoteType } from "../../../enums/secureNoteType";
import { SecureNote } from "../domain/secure-note";
import { ItemView } from "./item.view";
export class SecureNoteView extends ItemView {
type: SecureNoteType = null;
constructor(n?: SecureNote) {
super();
if (!n) {
return;
}
this.type = n.type;
}
get subTitle(): string {
return null;
}
static fromJSON(obj: Partial<Jsonify<SecureNoteView>>): SecureNoteView {
return Object.assign(new SecureNoteView(), obj);
}
}

View File

@@ -0,0 +1,74 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../abstractions/crypto.service";
import { EncryptService } from "../../abstractions/encrypt.service";
import { FileUploadService } from "../../abstractions/fileUpload.service";
import { I18nService } from "../../abstractions/i18n.service";
import { LogService } from "../../abstractions/log.service";
import { SearchService } from "../../abstractions/search.service";
import { SettingsService } from "../../abstractions/settings.service";
import { StateService } from "../../abstractions/state.service";
import { EncArrayBuffer } from "../../models/domain/enc-array-buffer";
import { EncString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { Cipher } from "../models/domain/cipher";
import { CipherService } from "./cipher.service";
const ENCRYPTED_TEXT = "This data has been encrypted";
const ENCRYPTED_BYTES = Substitute.for<EncArrayBuffer>();
describe("Cipher Service", () => {
let cryptoService: SubstituteOf<CryptoService>;
let stateService: SubstituteOf<StateService>;
let settingsService: SubstituteOf<SettingsService>;
let apiService: SubstituteOf<ApiService>;
let fileUploadService: SubstituteOf<FileUploadService>;
let i18nService: SubstituteOf<I18nService>;
let searchService: SubstituteOf<SearchService>;
let logService: SubstituteOf<LogService>;
let encryptService: SubstituteOf<EncryptService>;
let cipherService: CipherService;
beforeEach(() => {
cryptoService = Substitute.for<CryptoService>();
stateService = Substitute.for<StateService>();
settingsService = Substitute.for<SettingsService>();
apiService = Substitute.for<ApiService>();
fileUploadService = Substitute.for<FileUploadService>();
i18nService = Substitute.for<I18nService>();
searchService = Substitute.for<SearchService>();
logService = Substitute.for<LogService>();
encryptService = Substitute.for<EncryptService>();
cryptoService.encryptToBytes(Arg.any(), Arg.any()).resolves(ENCRYPTED_BYTES);
cryptoService.encrypt(Arg.any(), Arg.any()).resolves(new EncString(ENCRYPTED_TEXT));
cipherService = new CipherService(
cryptoService,
settingsService,
apiService,
fileUploadService,
i18nService,
() => searchService,
logService,
stateService,
encryptService
);
});
it("attachments upload encrypted file contents", async () => {
const fileName = "filename";
const fileData = new Uint8Array(10).buffer;
cryptoService.getOrgKey(Arg.any()).resolves(new SymmetricCryptoKey(new Uint8Array(32).buffer));
await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData);
fileUploadService
.received(1)
.uploadCipherAttachment(Arg.any(), Arg.any(), new EncString(ENCRYPTED_TEXT), ENCRYPTED_BYTES);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { ApiService } from "../../../abstractions/api.service";
import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { FolderData } from "../../../vault/models/data/folder.data";
import { Folder } from "../../../vault/models/domain/folder";
import { FolderRequest } from "../../../vault/models/request/folder.request";
import { FolderResponse } from "../../../vault/models/response/folder.response";
export class FolderApiService implements FolderApiServiceAbstraction {
constructor(private folderService: InternalFolderService, private apiService: ApiService) {}
async save(folder: Folder): Promise<any> {
const request = new FolderRequest(folder);
let response: FolderResponse;
if (folder.id == null) {
response = await this.postFolder(request);
folder.id = response.id;
} else {
response = await this.putFolder(folder.id, request);
}
const data = new FolderData(response);
await this.folderService.upsert(data);
}
async delete(id: string): Promise<any> {
await this.deleteFolder(id);
await this.folderService.delete(id);
}
async get(id: string): Promise<FolderResponse> {
const r = await this.apiService.send("GET", "/folders/" + id, null, true, true);
return new FolderResponse(r);
}
private async postFolder(request: FolderRequest): Promise<FolderResponse> {
const r = await this.apiService.send("POST", "/folders", request, true, true);
return new FolderResponse(r);
}
async putFolder(id: string, request: FolderRequest): Promise<FolderResponse> {
const r = await this.apiService.send("PUT", "/folders/" + id, request, true, true);
return new FolderResponse(r);
}
private deleteFolder(id: string): Promise<any> {
return this.apiService.send("DELETE", "/folders/" + id, null, true, false);
}
}

View File

@@ -0,0 +1,202 @@
// eslint-disable-next-line no-restricted-imports
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { CryptoService } from "../../../abstractions/crypto.service";
import { EncryptService } from "../../../abstractions/encrypt.service";
import { I18nService } from "../../../abstractions/i18n.service";
import { EncString } from "../../../models/domain/enc-string";
import { ContainerService } from "../../../services/container.service";
import { StateService } from "../../../services/state.service";
import { CipherService } from "../../abstractions/cipher.service";
import { FolderData } from "../../models/data/folder.data";
import { FolderView } from "../../models/view/folder.view";
import { FolderService } from "../../services/folder/folder.service";
describe("Folder Service", () => {
let folderService: FolderService;
let cryptoService: SubstituteOf<CryptoService>;
let encryptService: SubstituteOf<EncryptService>;
let i18nService: SubstituteOf<I18nService>;
let cipherService: SubstituteOf<CipherService>;
let stateService: SubstituteOf<StateService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
beforeEach(() => {
cryptoService = Substitute.for();
encryptService = Substitute.for();
i18nService = Substitute.for();
cipherService = Substitute.for();
stateService = Substitute.for();
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
stateService.getEncryptedFolders().resolves({
"1": folderData("1", "test"),
});
stateService.activeAccount$.returns(activeAccount);
stateService.activeAccountUnlocked$.returns(activeAccountUnlocked);
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
folderService = new FolderService(cryptoService, i18nService, cipherService, stateService);
});
it("encrypt", async () => {
const model = new FolderView();
model.id = "2";
model.name = "Test Folder";
cryptoService.encrypt(Arg.any()).resolves(new EncString("ENC"));
cryptoService.decryptToUtf8(Arg.any()).resolves("DEC");
const result = await folderService.encrypt(model);
expect(result).toEqual({
id: "2",
name: {
encryptedString: "ENC",
encryptionType: 0,
},
});
});
describe("get", () => {
it("exists", async () => {
const result = await folderService.get("1");
expect(result).toEqual({
id: "1",
name: {
decryptedValue: [],
encryptedString: "test",
encryptionType: 0,
},
revisionDate: null,
});
});
it("not exists", async () => {
const result = await folderService.get("2");
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await folderService.upsert(folderData("2", "test 2"));
expect(await firstValueFrom(folderService.folders$)).toEqual([
{
id: "1",
name: {
decryptedValue: [],
encryptedString: "test",
encryptionType: 0,
},
revisionDate: null,
},
{
id: "2",
name: {
decryptedValue: [],
encryptedString: "test 2",
encryptionType: 0,
},
revisionDate: null,
},
]);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: "1", name: [], revisionDate: null },
{ id: "2", name: [], revisionDate: null },
{ id: null, name: [], revisionDate: null },
]);
});
it("replace", async () => {
await folderService.replace({ "2": folderData("2", "test 2") });
expect(await firstValueFrom(folderService.folders$)).toEqual([
{
id: "2",
name: {
decryptedValue: [],
encryptedString: "test 2",
encryptionType: 0,
},
revisionDate: null,
},
]);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: "2", name: [], revisionDate: null },
{ id: null, name: [], revisionDate: null },
]);
});
it("delete", async () => {
await folderService.delete("1");
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect(await firstValueFrom(folderService.folderViews$)).toEqual([
{ id: null, name: [], revisionDate: null },
]);
});
it("clearCache", async () => {
await folderService.clearCache();
expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
it("locking should clear", async () => {
activeAccountUnlocked.next(false);
// Sleep for 100ms to avoid timing issues
await new Promise((r) => setTimeout(r, 100));
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
describe("clear", () => {
it("null userId", async () => {
await folderService.clear();
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
it("matching userId", async () => {
stateService.getUserId().resolves("1");
await folderService.clear("1");
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(0);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(0);
});
it("missmatching userId", async () => {
await folderService.clear("12");
stateService.received(1).setEncryptedFolders(Arg.any(), Arg.any());
expect((await firstValueFrom(folderService.folders$)).length).toBe(1);
expect((await firstValueFrom(folderService.folderViews$)).length).toBe(2);
});
});
function folderData(id: string, name: string) {
const data = new FolderData({} as any);
data.id = id;
data.name = name;
return data;
}
});

View File

@@ -0,0 +1,193 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { CryptoService } from "../../../abstractions/crypto.service";
import { I18nService } from "../../../abstractions/i18n.service";
import { StateService } from "../../../abstractions/state.service";
import { Utils } from "../../../misc/utils";
import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { FolderData } from "../../../vault/models/data/folder.data";
import { Folder } from "../../../vault/models/domain/folder";
import { FolderView } from "../../../vault/models/view/folder.view";
export class FolderService implements InternalFolderServiceAbstraction {
protected _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
protected _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
folders$ = this._folders.asObservable();
folderViews$ = this._folderViews.asObservable();
constructor(
private cryptoService: CryptoService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateService: StateService
) {
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (Utils.global.bitwardenContainerService == null) {
return;
}
if (!unlocked) {
this._folders.next([]);
this._folderViews.next([]);
return;
}
const data = await this.stateService.getEncryptedFolders();
await this.updateObservables(data);
})
)
.subscribe();
}
async clearCache(): Promise<void> {
this._folderViews.next([]);
}
// TODO: This should be moved to EncryptService or something
async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise<Folder> {
const folder = new Folder();
folder.id = model.id;
folder.name = await this.cryptoService.encrypt(model.name, key);
return folder;
}
async get(id: string): Promise<Folder> {
const folders = this._folders.getValue();
return folders.find((folder) => folder.id === id);
}
async getAllFromState(): Promise<Folder[]> {
const folders = await this.stateService.getEncryptedFolders();
const response: Folder[] = [];
for (const id in folders) {
// eslint-disable-next-line
if (folders.hasOwnProperty(id)) {
response.push(new Folder(folders[id]));
}
}
return response;
}
/**
* @deprecated For the CLI only
* @param id id of the folder
*/
async getFromState(id: string): Promise<Folder> {
const foldersMap = await this.stateService.getEncryptedFolders();
const folder = foldersMap[id];
if (folder == null) {
return null;
}
return new Folder(folder);
}
/**
* @deprecated Only use in CLI!
*/
async getAllDecryptedFromState(): Promise<FolderView[]> {
const data = await this.stateService.getEncryptedFolders();
const folders = Object.values(data || {}).map((f) => new Folder(f));
return this.decryptFolders(folders);
}
async upsert(folder: FolderData | FolderData[]): Promise<void> {
let folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
folders = {};
}
if (folder instanceof FolderData) {
const f = folder as FolderData;
folders[f.id] = f;
} else {
(folder as FolderData[]).forEach((f) => {
folders[f.id] = f;
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
}
async replace(folders: { [id: string]: FolderData }): Promise<void> {
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
}
async clear(userId?: string): Promise<any> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._folders.next([]);
this._folderViews.next([]);
}
await this.stateService.setEncryptedFolders(null, { userId: userId });
}
async delete(id: string | string[]): Promise<any> {
const folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
return;
}
if (typeof id === "string") {
if (folders[id] == null) {
return;
}
delete folders[id];
} else {
(id as string[]).forEach((i) => {
delete folders[i];
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.stateService.getEncryptedCiphers();
if (ciphers != null) {
const updates: CipherData[] = [];
for (const cId in ciphers) {
if (ciphers[cId].folderId === id) {
ciphers[cId].folderId = null;
updates.push(ciphers[cId]);
}
}
if (updates.length > 0) {
this.cipherService.upsert(updates);
}
}
}
private async updateObservables(foldersMap: { [id: string]: FolderData }) {
const folders = Object.values(foldersMap || {}).map((f) => new Folder(f));
this._folders.next(folders);
if (await this.cryptoService.hasKey()) {
this._folderViews.next(await this.decryptFolders(folders));
}
}
private async decryptFolders(folders: Folder[]) {
const decryptFolderPromises = folders.map((f) => f.decrypt());
const decryptedFolders = await Promise.all(decryptFolderPromises);
decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
const noneFolder = new FolderView();
noneFolder.name = this.i18nService.t("noneFolder");
decryptedFolders.push(noneFolder);
return decryptedFolders;
}
}

View File

@@ -0,0 +1,18 @@
import { Subject } from "rxjs";
import { SyncNotifierService as SyncNotifierServiceAbstraction } from "../../abstractions/sync/sync-notifier.service.abstraction";
import { SyncEventArgs } from "../../types/sync-event-args";
/**
* This class should most likely have 0 dependencies because it will hopefully
* be rolled into SyncService once upon a time.
*/
export class SyncNotifierService implements SyncNotifierServiceAbstraction {
private _sync = new Subject<SyncEventArgs>();
sync$ = this._sync.asObservable();
next(event: SyncEventArgs): void {
this._sync.next(event);
}
}

View File

@@ -0,0 +1,401 @@
import { ApiService } from "../../../abstractions/api.service";
import { CollectionService } from "../../../abstractions/collection.service";
import { CryptoService } from "../../../abstractions/crypto.service";
import { KeyConnectorService } from "../../../abstractions/keyConnector.service";
import { LogService } from "../../../abstractions/log.service";
import { MessagingService } from "../../../abstractions/messaging.service";
import { InternalOrganizationService } from "../../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "../../../abstractions/policy/policy.service.abstraction";
import { ProviderService } from "../../../abstractions/provider.service";
import { SendService } from "../../../abstractions/send.service";
import { SettingsService } from "../../../abstractions/settings.service";
import { StateService } from "../../../abstractions/state.service";
import { sequentialize } from "../../../misc/sequentialize";
import { CollectionData } from "../../../models/data/collection.data";
import { OrganizationData } from "../../../models/data/organization.data";
import { PolicyData } from "../../../models/data/policy.data";
import { ProviderData } from "../../../models/data/provider.data";
import { SendData } from "../../../models/data/send.data";
import { CollectionDetailsResponse } from "../../../models/response/collection.response";
import { DomainsResponse } from "../../../models/response/domains.response";
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../../models/response/notification.response";
import { PolicyResponse } from "../../../models/response/policy.response";
import { ProfileResponse } from "../../../models/response/profile.response";
import { SendResponse } from "../../../models/response/send.response";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderApiServiceAbstraction } from "../../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SyncService as SyncServiceAbstraction } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { FolderData } from "../../../vault/models/data/folder.data";
import { CipherResponse } from "../../../vault/models/response/cipher.response";
import { FolderResponse } from "../../../vault/models/response/folder.response";
export class SyncService implements SyncServiceAbstraction {
syncInProgress = false;
constructor(
private apiService: ApiService,
private settingsService: SettingsService,
private folderService: InternalFolderService,
private cipherService: CipherService,
private cryptoService: CryptoService,
private collectionService: CollectionService,
private messagingService: MessagingService,
private policyService: InternalPolicyService,
private sendService: SendService,
private logService: LogService,
private keyConnectorService: KeyConnectorService,
private stateService: StateService,
private providerService: ProviderService,
private folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationService,
private logoutCallback: (expired: boolean) => Promise<void>
) {}
async getLastSync(): Promise<Date> {
if ((await this.stateService.getUserId()) == null) {
return null;
}
const lastSync = await this.stateService.getLastSync();
if (lastSync) {
return new Date(lastSync);
}
return null;
}
async setLastSync(date: Date, userId?: string): Promise<any> {
await this.stateService.setLastSync(date.toJSON(), { userId: userId });
}
@sequentialize(() => "fullSync")
async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
this.syncStarted();
const isAuthenticated = await this.stateService.getIsAuthenticated();
if (!isAuthenticated) {
return this.syncCompleted(false);
}
const now = new Date();
let needsSync = false;
try {
needsSync = await this.needsSyncing(forceSync);
} catch (e) {
if (allowThrowOnError) {
throw e;
}
}
if (!needsSync) {
await this.setLastSync(now);
return this.syncCompleted(false);
}
try {
await this.apiService.refreshIdentityToken();
const response = await this.apiService.getSync();
await this.syncProfile(response.profile);
await this.syncFolders(response.folders);
await this.syncCollections(response.collections);
await this.syncCiphers(response.ciphers);
await this.syncSends(response.sends);
await this.syncSettings(response.domains);
await this.syncPolicies(response.policies);
await this.setLastSync(now);
return this.syncCompleted(true);
} catch (e) {
if (allowThrowOnError) {
throw e;
} else {
return this.syncCompleted(false);
}
}
}
async syncUpsertFolder(notification: SyncFolderNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
const localFolder = await this.folderService.get(notification.id);
if (
(!isEdit && localFolder == null) ||
(isEdit && localFolder != null && localFolder.revisionDate < notification.revisionDate)
) {
const remoteFolder = await this.folderApiService.get(notification.id);
if (remoteFolder != null) {
await this.folderService.upsert(new FolderData(remoteFolder));
this.messagingService.send("syncedUpsertedFolder", { folderId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteFolder(notification: SyncFolderNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.folderService.delete(notification.id);
this.messagingService.send("syncedDeletedFolder", { folderId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
async syncUpsertCipher(notification: SyncCipherNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
let shouldUpdate = true;
const localCipher = await this.cipherService.get(notification.id);
if (localCipher != null && localCipher.revisionDate >= notification.revisionDate) {
shouldUpdate = false;
}
let checkCollections = false;
if (shouldUpdate) {
if (isEdit) {
shouldUpdate = localCipher != null;
checkCollections = true;
} else {
if (notification.collectionIds == null || notification.organizationId == null) {
shouldUpdate = localCipher == null;
} else {
shouldUpdate = false;
checkCollections = true;
}
}
}
if (
!shouldUpdate &&
checkCollections &&
notification.organizationId != null &&
notification.collectionIds != null &&
notification.collectionIds.length > 0
) {
const collections = await this.collectionService.getAll();
if (collections != null) {
for (let i = 0; i < collections.length; i++) {
if (notification.collectionIds.indexOf(collections[i].id) > -1) {
shouldUpdate = true;
break;
}
}
}
}
if (shouldUpdate) {
const remoteCipher = await this.apiService.getFullCipherDetails(notification.id);
if (remoteCipher != null) {
await this.cipherService.upsert(new CipherData(remoteCipher));
this.messagingService.send("syncedUpsertedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
if (e != null && e.statusCode === 404 && isEdit) {
await this.cipherService.delete(notification.id);
this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
}
}
return this.syncCompleted(false);
}
async syncDeleteCipher(notification: SyncCipherNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.cipherService.delete(notification.id);
this.messagingService.send("syncedDeletedCipher", { cipherId: notification.id });
return this.syncCompleted(true);
}
return this.syncCompleted(false);
}
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
try {
const localSend = await this.sendService.get(notification.id);
if (
(!isEdit && localSend == null) ||
(isEdit && localSend != null && localSend.revisionDate < notification.revisionDate)
) {
const remoteSend = await this.apiService.getSend(notification.id);
if (remoteSend != null) {
await this.sendService.upsert(new SendData(remoteSend));
this.messagingService.send("syncedUpsertedSend", { sendId: notification.id });
return this.syncCompleted(true);
}
}
} catch (e) {
this.logService.error(e);
}
}
return this.syncCompleted(false);
}
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
await this.sendService.delete(notification.id);
this.messagingService.send("syncedDeletedSend", { sendId: notification.id });
this.syncCompleted(true);
return true;
}
return this.syncCompleted(false);
}
// Helpers
private syncStarted() {
this.syncInProgress = true;
this.messagingService.send("syncStarted");
}
private syncCompleted(successfully: boolean): boolean {
this.syncInProgress = false;
this.messagingService.send("syncCompleted", { successfully: successfully });
return successfully;
}
private async needsSyncing(forceSync: boolean) {
if (forceSync) {
return true;
}
const lastSync = await this.getLastSync();
if (lastSync == null || lastSync.getTime() === 0) {
return true;
}
const response = await this.apiService.getAccountRevisionDate();
if (new Date(response) <= lastSync) {
return false;
}
return true;
}
private async syncProfile(response: ProfileResponse) {
const stamp = await this.stateService.getSecurityStamp();
if (stamp != null && stamp !== response.securityStamp) {
if (this.logoutCallback != null) {
await this.logoutCallback(true);
}
throw new Error("Stamp has changed");
}
await this.cryptoService.setEncKey(response.key);
await this.cryptoService.setEncPrivateKey(response.privateKey);
await this.cryptoService.setProviderKeys(response.providers);
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
await this.stateService.setAvatarColor(response.avatarColor);
await this.stateService.setSecurityStamp(response.securityStamp);
await this.stateService.setEmailVerified(response.emailVerified);
await this.stateService.setHasPremiumPersonally(response.premiumPersonally);
await this.stateService.setHasPremiumFromOrganization(response.premiumFromOrganization);
await this.stateService.setForcePasswordReset(response.forcePasswordReset);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
const organizations: { [id: string]: OrganizationData } = {};
response.organizations.forEach((o) => {
organizations[o.id] = new OrganizationData(o);
});
const providers: { [id: string]: ProviderData } = {};
response.providers.forEach((p) => {
providers[p.id] = new ProviderData(p);
});
response.providerOrganizations.forEach((o) => {
if (organizations[o.id] == null) {
organizations[o.id] = new OrganizationData(o);
organizations[o.id].isProviderUser = true;
}
});
await this.organizationService.replace(organizations);
await this.providerService.save(providers);
if (await this.keyConnectorService.userNeedsMigration()) {
await this.keyConnectorService.setConvertAccountRequired(true);
this.messagingService.send("convertAccountToKeyConnector");
} else {
this.keyConnectorService.removeConvertAccountRequired();
}
}
private async syncFolders(response: FolderResponse[]) {
const folders: { [id: string]: FolderData } = {};
response.forEach((f) => {
folders[f.id] = new FolderData(f);
});
return await this.folderService.replace(folders);
}
private async syncCollections(response: CollectionDetailsResponse[]) {
const collections: { [id: string]: CollectionData } = {};
response.forEach((c) => {
collections[c.id] = new CollectionData(c);
});
return await this.collectionService.replace(collections);
}
private async syncCiphers(response: CipherResponse[]) {
const ciphers: { [id: string]: CipherData } = {};
response.forEach((c) => {
ciphers[c.id] = new CipherData(c);
});
return await this.cipherService.replace(ciphers);
}
private async syncSends(response: SendResponse[]) {
const sends: { [id: string]: SendData } = {};
response.forEach((s) => {
sends[s.id] = new SendData(s);
});
return await this.sendService.replace(sends);
}
private async syncSettings(response: DomainsResponse) {
let eqDomains: string[][] = [];
if (response != null && response.equivalentDomains != null) {
eqDomains = eqDomains.concat(response.equivalentDomains);
}
if (response != null && response.globalEquivalentDomains != null) {
response.globalEquivalentDomains.forEach((global) => {
if (global.domains.length > 0) {
eqDomains.push(global.domains);
}
});
}
return this.settingsService.setEquivalentDomains(eqDomains);
}
private async syncPolicies(response: PolicyResponse[]) {
const policies: { [id: string]: PolicyData } = {};
if (response != null) {
response.forEach((p) => {
policies[p.id] = new PolicyData(p);
});
}
return await this.policyService.replace(policies);
}
}

View File

@@ -0,0 +1,38 @@
import { SyncResponse } from "../models/response/sync.response";
type SyncStatus = "Started" | "Completed";
type SyncEventArgsBase<T extends SyncStatus> = {
status: T;
};
type SyncCompletedEventArgsBase<T extends boolean> = SyncEventArgsBase<"Completed"> & {
successfully: T;
};
type SyncSuccessfullyCompletedEventArgs = SyncCompletedEventArgsBase<true> & {
data: SyncResponse;
};
export type SyncEventArgs =
| SyncSuccessfullyCompletedEventArgs
| SyncCompletedEventArgsBase<false>
| SyncEventArgsBase<"Started">;
/**
* Helper function to filter only on successfully completed syncs
* @returns a function that can be used in a `.pipe(filter(...))` from an observable
* @example
* ```
* of<SyncEventArgs>({ status: "Completed", successfully: true, data: new SyncResponse() })
* .pipe(filter(isSuccessfullyCompleted))
* .subscribe(event => {
* console.log(event.data);
* });
* ```
*/
export function isSuccessfullyCompleted(
syncEvent: SyncEventArgs
): syncEvent is SyncSuccessfullyCompletedEventArgs {
return syncEvent.status === "Completed" && syncEvent.successfully;
}