Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion extension/js/background_page/background_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Catch } from '../common/platform/catch.js';
import { GoogleAuth } from '../common/api/email-provider/gmail/google-auth.js';
import { VERSION } from '../common/core/const.js';
import { injectFcIntoWebmail } from './inject.js';
import { migrateGlobal, moveContactsToEmailsAndPubkeys, updateX509FingerprintsAndLongids } from './migrations.js';
import { migrateGlobal, moveContactsToEmailsAndPubkeys, updateOpgpRevocations, updateX509FingerprintsAndLongids } from './migrations.js';
import { opgp } from '../common/core/crypto/pgp/openpgpjs-custom.js';
import { GlobalStoreDict, GlobalStore } from '../common/platform/store/global-store.js';
import { ContactStore } from '../common/platform/store/contact-store.js';
Expand Down Expand Up @@ -41,6 +41,7 @@ opgp.initWorker({ path: '/lib/openpgp.worker.js' });

try {
db = await ContactStore.dbOpen(); // takes 4-10 ms first time
await updateOpgpRevocations(db);
await updateX509FingerprintsAndLongids(db);
await moveContactsToEmailsAndPubkeys(db);
} catch (e) {
Expand Down
28 changes: 27 additions & 1 deletion extension/js/background_page/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ export const updateX509FingerprintsAndLongids = async (db: IDBDatabase): Promise
console.info('done updating');
};

export const updateOpgpRevocations = async (db: IDBDatabase): Promise<void> => {
const globalStore = await GlobalStore.get(['contact_store_opgp_revoked_flags_updated']);
if (globalStore.contact_store_opgp_revoked_flags_updated) {
return;
}
console.info('updating ContactStorage to revoked flags of OpenPGP keys...');
const tx = db.transaction(['pubkeys'], 'readonly');
const pubkeys: Pubkey[] = await new Promise((resolve, reject) => {
const search = tx.objectStore('pubkeys').getAll();
ContactStore.setReqPipe(search, resolve, reject);
});
const revokedKeys = (await Promise.all(pubkeys.filter(entity => KeyUtil.getKeyType(entity.armoredKey) === 'openpgp').
map(async (entity) => await KeyUtil.parse(entity.armoredKey)))).
filter(k => k.revoked);
const txUpdate = db.transaction(['revocations'], 'readwrite');
await new Promise((resolve, reject) => {
ContactStore.setTxHandlers(txUpdate, resolve, reject);
const revocationsStore = txUpdate.objectStore('revocations');
for (const revokedKey of revokedKeys) {
revocationsStore.put(ContactStore.revocationObj(revokedKey));
}
});
await GlobalStore.set({ contact_store_opgp_revoked_flags_updated: true });
console.info('done updating');
};

export const moveContactsToEmailsAndPubkeys = async (db: IDBDatabase): Promise<void> => {
if (!db.objectStoreNames.contains('contacts')) {
return;
Expand Down Expand Up @@ -160,7 +186,7 @@ const moveContactsBatchToEmailsAndPubkeys = async (db: IDBDatabase, count?: numb
};
}));
{
const tx = db.transaction(['contacts', 'emails', 'pubkeys'], 'readwrite');
const tx = db.transaction(['contacts', 'emails', 'pubkeys', 'revocations'], 'readwrite');
await new Promise((resolve, reject) => {
ContactStore.setTxHandlers(tx, resolve, reject);
for (const item of converted) {
Expand Down
133 changes: 107 additions & 26 deletions extension/js/common/platform/store/contact-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ export type Pubkey = {
expiresOn: number | null;
};

type Revocation = {
fingerprint: string;
};

type PubkeyAttributes = {
fingerprint: string | null;
expiresOn: number | null;
};

export type ContactV4 = {
info: Email,
pubkeys: Pubkey[]
pubkeys: Pubkey[],
revocations: Revocation[]
}

export type ContactPreview = {
Expand All @@ -59,6 +64,8 @@ export type ContactUpdate = {

type DbContactFilter = { hasPgp?: boolean, substring?: string, limit?: number };

const x509postfix = "-X509";

/**
* Store of contacts and their public keys
* This includes an index of email and name substrings for easier search when user is typing
Expand All @@ -72,7 +79,7 @@ export class ContactStore extends AbstractStore {

public static dbOpen = async (): Promise<IDBDatabase> => {
return await new Promise((resolve, reject) => {
const openDbReq = indexedDB.open('cryptup', 4);
const openDbReq = indexedDB.open('cryptup', 5);
openDbReq.onupgradeneeded = (event) => {
const db = openDbReq.result;
if (event.oldVersion < 4) {
Expand All @@ -82,6 +89,9 @@ export class ContactStore extends AbstractStore {
emails.createIndex('index_fingerprints', 'fingerprints', { multiEntry: true }); // fingerprints of all connected pubkeys
pubkeys.createIndex('index_longids', 'longids', { multiEntry: true }); // longids of all public key packets in armored pubkey
}
if (event.oldVersion < 5) {
db.createObjectStore('revocations', { keyPath: 'fingerprint' });
}
if (db.objectStoreNames.contains('contacts')) {
const countRequest = openDbReq.transaction!.objectStore('contacts').count();
ContactStore.setReqPipe(countRequest, (count: number) => {
Expand Down Expand Up @@ -199,7 +209,7 @@ export class ContactStore extends AbstractStore {
Catch.report(`Wrongly updating prv ${update.pubkey.id} as contact - converting to pubkey`);
update.pubkey = await KeyUtil.asPublicKey(update.pubkey);
}
const tx = db.transaction(['emails', 'pubkeys'], 'readwrite');
const tx = db.transaction(['emails', 'pubkeys', 'revocations'], 'readwrite');
await new Promise((resolve, reject) => {
ContactStore.setTxHandlers(tx, resolve, reject);
ContactStore.updateTx(tx, validEmail, update);
Expand Down Expand Up @@ -233,8 +243,9 @@ export class ContactStore extends AbstractStore {
}

public static getOneWithAllPubkeys = async (db: IDBDatabase, email: string): Promise<ContactV4 | undefined> => {
const tx = db.transaction(['emails', 'pubkeys'], 'readonly');
const tx = db.transaction(['emails', 'pubkeys', 'revocations'], 'readonly');
const pubkeys: Pubkey[] = [];
const revocations: Revocation[] = [];
const emailEntity: Email | undefined = await new Promise((resolve, reject) => {
const req = tx.objectStore('emails').get(email);
ContactStore.setReqPipe(req,
Expand All @@ -247,7 +258,10 @@ export class ContactStore extends AbstractStore {
resolve(email);
return;
}
let countdown = email.fingerprints.length;
const uniqueAndStrippedFingerprints = email.fingerprints.
map(ContactStore.stripFingerprint).
filter((value, index, self) => !self.slice(0, index).find((el) => el === value));
let countdown = email.fingerprints.length + uniqueAndStrippedFingerprints.length;
// request all pubkeys by fingerprints
for (const fp of email.fingerprints) {
const req2 = tx.objectStore('pubkeys').get(fp);
Expand All @@ -262,18 +276,36 @@ export class ContactStore extends AbstractStore {
},
reject);
}
for (const fp of uniqueAndStrippedFingerprints) {
const range = ContactStore.createFingerprintRange(fp);
const req3 = tx.objectStore('revocations').getAll(range);
ContactStore.setReqPipe(req3,
(revocation: Revocation[]) => {
revocations.push(...revocation);
if (!--countdown) {
resolve(email);
}
},
reject);
}
},
reject);
});
return emailEntity ? { info: emailEntity, pubkeys } : undefined;
return emailEntity ? { info: emailEntity, pubkeys, revocations } : undefined;
}

public static updateTx = (tx: IDBTransaction, email: string, update: ContactUpdate) => {
if (update.pubkey && !update.pubkeyLastCheck) {
const req = tx.objectStore('pubkeys').get(ContactStore.getPubkeyId(update.pubkey));
ContactStore.setReqPipe(req, (pubkey: Pubkey) => ContactStore.updateTxPhase2(tx, email, update, pubkey));
ContactStore.setReqPipe(req, (pubkey: Pubkey) => {
const range = ContactStore.createFingerprintRange(update.pubkey!.id);
const req2 = tx.objectStore('revocations').getAll(range);
ContactStore.setReqPipe(req2, (revocations: Revocation[]) => {
ContactStore.updateTxPhase2(tx, email, update, pubkey, revocations);
});
});
} else {
ContactStore.updateTxPhase2(tx, email, update, undefined);
ContactStore.updateTxPhase2(tx, email, update, undefined, []);
}
}

Expand Down Expand Up @@ -305,14 +337,44 @@ export class ContactStore extends AbstractStore {
};
}

public static revocationObj = (pubkey: Key): { fingerprint: string, armoredKey: string } => {
return { fingerprint: ContactStore.getPubkeyId(pubkey), armoredKey: KeyUtil.armor(pubkey) };
// todo: we can add a timestamp here and/or some other info
}

private static getPubkeyId = (pubkey: Key): string => {
return (pubkey.type === 'x509') ? (pubkey.id + '-X509') : pubkey.id;
return (pubkey.type === 'x509') ? (pubkey.id + x509postfix) : pubkey.id;
}

private static updateTxPhase2 = (tx: IDBTransaction, email: string, update: ContactUpdate, existingPubkey: Pubkey | undefined) => {
private static stripFingerprint = (fp: string): string => {
return fp.endsWith(x509postfix) ? fp.slice(0, -x509postfix.length) : fp;
}

private static equalFingerprints = (fp1: string, fp2: string): boolean => {
return (fp1.endsWith(x509postfix) ? fp1 : (fp1 + x509postfix))
=== (fp2.endsWith(x509postfix) ? fp2 : (fp2 + x509postfix));
}

private static createFingerprintRange = (fp: string): IDBKeyRange => {
const strippedFp = ContactStore.stripFingerprint(fp);
return IDBKeyRange.bound(strippedFp, strippedFp + x509postfix, false, false);
}

private static updateTxPhase2 = (tx: IDBTransaction, email: string, update: ContactUpdate,
existingPubkey: Pubkey | undefined, revocations: Revocation[]) => {
let pubkeyEntity: Pubkey | undefined;
if (update.pubkey) {
pubkeyEntity = ContactStore.pubkeyObj(update.pubkey, update.pubkeyLastCheck ?? existingPubkey?.lastCheck);
const internalFingerprint = ContactStore.getPubkeyId(update.pubkey!);
if (update.pubkey.type === 'openpgp' && !update.pubkey.revoked && revocations.some(r => r.fingerprint === internalFingerprint)) {
// we have this fingerprint revoked but the supplied key isn't
// so let's not save it
// pubkeyEntity = undefined
} else {
pubkeyEntity = ContactStore.pubkeyObj(update.pubkey, update.pubkeyLastCheck ?? existingPubkey?.lastCheck);
}
if (update.pubkey.revoked && !revocations.some(r => r.fingerprint === internalFingerprint)) {
tx.objectStore('revocations').put(ContactStore.revocationObj(update.pubkey));
}
// todo: will we benefit anything when not saving pubkey if it isn't modified?
} else if (update.pubkeyLastCheck) {
Catch.report(`Wrongly updating pubkeyLastCheck without specifying pubkey for ${email} - ignoring`);
Expand Down Expand Up @@ -467,22 +529,30 @@ export class ContactStore extends AbstractStore {
if (!contactWithAllPubkeys) {
return contactWithAllPubkeys;
}
if (!contactWithAllPubkeys.pubkeys.length) {
return await ContactStore.toContact(contactWithAllPubkeys.info, undefined);
}
// parse the keys
const parsed = await Promise.all(contactWithAllPubkeys.pubkeys.map(async (pubkey) => { return { lastCheck: pubkey.lastCheck, pubkey: await KeyUtil.parse(pubkey.armoredKey) }; }));
// sort non-expired first, pick first usableForEncryption
const sorted = parsed.sort((a, b) => (typeof b.pubkey.expiration === 'undefined') ? Infinity : b.pubkey.expiration!
- ((typeof a.pubkey.expiration === 'undefined') ? Infinity : a.pubkey.expiration!));
let selected = sorted.find(entry => entry.pubkey.usableForEncryption);
const parsed = await Promise.all(contactWithAllPubkeys.pubkeys.map(async (pubkey) => {
const pk = await KeyUtil.parse(pubkey.armoredKey);
const revoked = pk.revoked || contactWithAllPubkeys.revocations.some(r => ContactStore.equalFingerprints(pk.id, r.fingerprint));
const expirationSortValue = (typeof pk.expiration === 'undefined') ? Infinity : pk.expiration!;
return {
lastCheck: pubkey.lastCheck,
pubkey: pk,
revoked,
// sort non-revoked first, then non-expired
sortValue: revoked ? -Infinity : expirationSortValue
};
}));
const sorted = parsed.sort((a, b) => b.sortValue - a.sortValue);
// pick first usableForEncryption
let selected = sorted.find(entry => !entry.revoked && entry.pubkey.usableForEncryption);
if (!selected) {
selected = sorted.find(entry => entry.pubkey.usableForEncryptionButExpired);
selected = sorted.find(entry => !entry.revoked && entry.pubkey.usableForEncryptionButExpired);
}
if (!selected) {
selected = sorted[0];
}
return ContactStore.toContactFromKey(contactWithAllPubkeys.info, selected.pubkey, selected.lastCheck);
const safeKey = (selected?.revoked && !selected.pubkey.revoked) ? undefined : selected;
return ContactStore.toContactFromKey(contactWithAllPubkeys.info, safeKey?.pubkey, safeKey?.lastCheck);
}
// search all longids
const tx = db.transaction(['emails', 'pubkeys'], 'readonly');
Expand All @@ -500,7 +570,7 @@ export class ContactStore extends AbstractStore {
if (!email) {
resolve(undefined);
} else {
resolve(ContactStore.toContact(email, pubkey));
resolve(ContactStore.toContact(db, email, pubkey));
}
},
reject);
Expand All @@ -513,15 +583,26 @@ export class ContactStore extends AbstractStore {
return { fingerprint: key?.id ?? null, expiresOn: DateUtility.asNumber(key?.expiration) };
}

private static toContact = async (email: Email, pubkey: Pubkey | undefined): Promise<Contact | undefined> => {
private static toContact = async (db: IDBDatabase, email: Email, pubkey: Pubkey | undefined): Promise<Contact | undefined> => {
if (!email) {
return;
}
const parsed = pubkey ? await KeyUtil.parse(pubkey.armoredKey) : undefined;
let parsed = pubkey ? await KeyUtil.parse(pubkey.armoredKey) : undefined;
if (parsed && !parsed.revoked) {
const revocations: Revocation[] = await new Promise((resolve, reject) => {
const tx = db.transaction(['revocations'], 'readonly');
const range = ContactStore.createFingerprintRange(parsed!.id);
const req = tx.objectStore('revocations').getAll(range);
ContactStore.setReqPipe(req, resolve, reject);
});
if (revocations.length) {
parsed = undefined;
}
}
return ContactStore.toContactFromKey(email, parsed, parsed ? pubkey!.lastCheck : null);
}

private static toContactFromKey = (email: Email, key: Key | undefined, lastCheck: number | null): Contact | undefined => {
private static toContactFromKey = (email: Email, key: Key | undefined, lastCheck: number | undefined | null): Contact | undefined => {
if (!email) {
return;
}
Expand All @@ -531,7 +612,7 @@ export class ContactStore extends AbstractStore {
pubkey: key,
hasPgp: key ? 1 : 0,
lastUse: email.lastUse,
pubkeyLastCheck: lastCheck,
pubkeyLastCheck: lastCheck ?? null,
...ContactStore.getKeyAttributes(key)
};
}
Expand Down
3 changes: 2 additions & 1 deletion extension/js/common/platform/store/global-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ export type GlobalStoreDict = {
install_mobile_app_notification_dismissed?: boolean;
key_info_store_fingerprints_added?: boolean;
contact_store_x509_fingerprints_and_longids_updated?: boolean;
contact_store_opgp_revoked_flags_updated?: boolean;
};

export type GlobalIndex = 'version' | 'account_emails' | 'settings_seen' | 'hide_pass_phrases' |
'dev_outlook_allow' | 'admin_codes' | 'install_mobile_app_notification_dismissed' | 'key_info_store_fingerprints_added' |
'contact_store_x509_fingerprints_and_longids_updated';
'contact_store_x509_fingerprints_and_longids_updated' | 'contact_store_opgp_revoked_flags_updated';

/**
* Locally stored data that is not associated with any email account
Expand Down
40 changes: 9 additions & 31 deletions test/source/mock/backend/backend-endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */

import * as request from 'fc-node-requests';

import { HttpAuthErr, HttpClientErr } from '../lib/api';

import { BackendData } from './backend-data';
Expand All @@ -14,30 +12,6 @@ import { expect } from 'chai';

export const mockBackendData = new BackendData(oauth);

const fwdToRealBackend = async (parsed: any, req: IncomingMessage): Promise<string> => {
// we are forwarding this request to backend, but we are not properly authenticated with real backend
// better remove authentication: requests that we currently forward during tests don't actually require it
delete req.headers.host;
delete req.headers['content-length'];
const forwarding: any = { headers: req.headers, url: `https://flowcrypt.com${req.url}` };
if (req.url!.includes('message/upload')) {
// Removing mock auth when forwarding request to real backend at ${req.url}
// here a bit more difficult, because the request was already encoded as form-data
parsed.body = (parsed.body as string).replace(/(-----END PGP MESSAGE-----\r\n\r\n------[A-Za-z0-9]+)(.|\r\n)*$/gm, (_, found) => `${found}--\r\n`);
forwarding.body = parsed.body; // FORM-DATA
const r = await request.post(forwarding);
return r.body; // already json-stringified for this call, maybe because backend doesn't return proper content-type
}
if (parsed.body && typeof parsed.body === 'object' && parsed.body.account && parsed.body.uuid) {
// Removing mock auth when forwarding request to real backend at ${req.url}
delete parsed.body.account;
delete parsed.body.uuid;
}
forwarding.json = parsed.body; // JSON
const r = await request.post(forwarding);
return JSON.stringify(r.body);
};

export const mockBackendEndpoints: HandlersDefinition = {
'/api/account/login': async ({ body }, req) => {
const parsed = throwIfNotPostWithAuth(body, req);
Expand Down Expand Up @@ -78,11 +52,15 @@ export const mockBackendEndpoints: HandlersDefinition = {
expect((body as any).email).to.equal('flowcrypt.compatibility@gmail.com');
return { sent: true, text: 'Feedback sent' };
},
'/api/message/presign_files': fwdToRealBackend,
'/api/message/confirm_files': fwdToRealBackend,
'/api/message/upload': fwdToRealBackend,
'/api/link/message': fwdToRealBackend,
'/api/link/me': fwdToRealBackend,
'/api/message/upload': async ({ }) => {
return { short: '0000000000', url: 'https://example.com/msg-123', admin_code: 'mocked-admin-code' };
},
'/api/link/message': async ({ }) => {
return { "url": "https://example.com/msg-123", "repliable": "False", "expire": "2100-05-18 16:31:28", "expired": "False", "deleted": "False" };
},
'/api/link/me': async ({ }, req) => {
throw new Error(`${req.url} mock not implemented`);
},
};

const throwIfNotPostWithAuth = (body: unknown, req: IncomingMessage) => {
Expand Down
Loading