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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ComposeView } from '../compose.js';
import { KeyStore } from '../../../js/common/platform/store/key-store.js';
import { AcctStore } from '../../../js/common/platform/store/acct-store.js';
import { GlobalStore } from '../../../js/common/platform/store/global-store.js';
import { ContactStore } from '../../../js/common/platform/store/contact-store.js';
import { ContactStore, ContactUpdate } from '../../../js/common/platform/store/contact-store.js';
import { PassphraseStore } from '../../../js/common/platform/store/passphrase-store.js';
import { Settings } from '../../../js/common/settings.js';
import { Ui } from '../../../js/common/browser/ui.js';
Expand Down Expand Up @@ -102,20 +102,36 @@ export class ComposeStorageModule extends ViewModule<ComposeView> {
try {
const lookupResult = await this.view.pubLookup.lookupEmail(email);
if (lookupResult && email) {
if (lookupResult.pubkey) {
const key = await KeyUtil.parse(lookupResult.pubkey);
if (!key.usableForEncryption && !KeyUtil.expired(key)) { // Not to skip expired keys
const pubkeys: Key[] = [];
for (const pubkey of lookupResult.pubkeys) {
const key = await KeyUtil.parse(pubkey);
if (!key.usableForEncryption && !key.revoked && !KeyUtil.expired(key)) { // Not to skip expired and revoked keys
console.info('Dropping found+parsed key because getEncryptionKeyPacket===null', { for: email, fingerprint: key.id });
Ui.toast(`Public Key retrieved for email ${email} with id ${key.id} was ignored because it's not usable for encryption.`, 5);
lookupResult.pubkey = null; // tslint:disable-line:no-null-keyword
} else {
pubkeys.push(key);
}
}
const ksContact = await ContactStore.obj({ email, name, pubkey: lookupResult.pubkey, lastCheck: Date.now() });
if (ksContact.pubkey) {
this.ksLookupsByEmail[email] = ksContact.pubkey;
// save multiple pubkeys as separate operations
// todo: add a convenient method to storage?
const updates: ContactUpdate[] = [];
if (!pubkeys.length) {
if (name) {
// update just name
updates.push({ name } as ContactUpdate);
} else {
// No public key found. Returning early, nothing to update in local store below.
return await ContactStore.obj({ email });
}
}
for (const pubkey of pubkeys) {
updates.push({ name, pubkey, pubkeyLastCheck: Date.now() });
}
if (updates.length) {
await Promise.all(updates.map(async (update) => await ContactStore.update(undefined, email, update)));
}
await ContactStore.save(undefined, ksContact);
return ksContact;
const [preferred] = await ContactStore.get(undefined, [email]);
return preferred ?? PUBKEY_LOOKUP_RESULT_FAIL;
} else {
return PUBKEY_LOOKUP_RESULT_FAIL;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { PgpBlockView } from '../pgp_block';
import { Ui } from '../../../js/common/browser/ui.js';
import { VerifyRes } from '../../../js/common/core/crypto/pgp/msg-util.js';
import { ContactStore } from '../../../js/common/platform/store/contact-store.js';
import { OpenPGPKey } from '../../../js/common/core/crypto/pgp/openpgp-key.js';
import { Str } from '../../../js/common/core/common.js';

export class PgpBlockViewSignatureModule {
Expand Down Expand Up @@ -64,19 +63,19 @@ export class PgpBlockViewSignatureModule {
return;
}
// ---> and user doesn't have pubkey for that email addr
const { pubkey } = await this.view.pubLookup.lookupEmail(senderEmail);
if (!pubkey) {
const { pubkeys } = await this.view.pubLookup.lookupEmail(senderEmail);
if (!pubkeys.length) {
render(`Missing pubkey ${signerLongid}`, () => undefined);
return;
}
// ---> and pubkey found on keyserver by sender email
const { key } = await BrowserMsg.send.bg.await.keyParse({ armored: pubkey });
if (!key.allIds.map(id => OpenPGPKey.fingerprintToLongid(id)).includes(signerLongid)) {
render(`Fetched sender's pubkey ${OpenPGPKey.fingerprintToLongid(key.id)} but message was signed with a different key: ${signerLongid}, will not verify.`, () => undefined);
const { key: pubkey } = await BrowserMsg.send.bg.await.keyMatch({ pubkeys, longid: signerLongid });
if (!pubkey) {
render(`Fetched ${pubkeys.length} sender's pubkeys but message was signed with a different key: ${signerLongid}, will not verify.`, () => undefined);
return;
}
// ---> and longid it matches signature
await ContactStore.save(undefined, await ContactStore.obj({ email: senderEmail, pubkey })); // <= TOFU auto-import
await ContactStore.update(undefined, senderEmail, { pubkey }); // <= TOFU auto-import
render('Fetched pubkey, click to verify', () => window.location.reload());
} else { // don't know who sent it
render('Cannot verify: missing pubkey, missing sender info', () => undefined);
Expand Down
1 change: 0 additions & 1 deletion extension/chrome/settings/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export class SetupView extends View {
public pubLookup!: PubLookup;
public keyManager: KeyManager | undefined; // not set if no url in org rules

public acctEmailAttesterPubId: string | undefined;
public fetchedKeyBackups: KeyInfo[] = [];
public fetchedKeyBackupsUniqueLongids: string[] = [];
public importedKeysUniqueLongids: string[] = [];
Expand Down
5 changes: 1 addition & 4 deletions extension/chrome/settings/setup/setup-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Settings } from '../../../js/common/settings.js';
import { SetupView } from '../setup.js';
import { AcctStore } from '../../../js/common/platform/store/acct-store.js';
import { KeyStore } from '../../../js/common/platform/store/key-store.js';
import { KeyUtil } from '../../../js/common/core/crypto/key.js';

export class SetupRenderModule {

Expand Down Expand Up @@ -97,9 +96,7 @@ export class SetupRenderModule {
} catch (e) {
return await Settings.promptToRetry(e, Lang.setup.failedToCheckIfAcctUsesEncryption, () => this.renderSetupDialog());
}
if (keyserverRes.pubkey) {
const pub = await KeyUtil.parse(keyserverRes.pubkey);
this.view.acctEmailAttesterPubId = pub.id;
if (keyserverRes.pubkeys.length) {
if (!this.view.orgRules.canBackupKeys()) {
// they already have a key recorded on attester, but no backups allowed on the domain. They should enter their prv manually
this.displayBlock('step_2b_manual_enter');
Expand Down
18 changes: 8 additions & 10 deletions extension/js/common/api/key-server/wkd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { Api } from './../shared/api.js';
import { ApiErr } from '../shared/api-error.js';
import { opgp } from '../../core/crypto/pgp/openpgpjs-custom.js';
import { Buf } from '../../core/buf.js';
import { PubkeySearchResult } from './../pub-lookup.js';
import { PubkeysSearchResult } from './../pub-lookup.js';
import { Key, KeyUtil } from '../../core/crypto/key.js';

// tslint:disable:no-null-keyword
// tslint:disable:no-direct-ajax

export class Wkd extends Api {
Expand Down Expand Up @@ -59,20 +58,19 @@ export class Wkd extends Api {
return await KeyUtil.readMany(response.buf);
}

public lookupEmail = async (email: string): Promise<PubkeySearchResult> => {
public lookupEmail = async (email: string): Promise<PubkeysSearchResult> => {
const { keys, errs } = await this.rawLookupEmail(email);
if (errs.length) {
return { pubkey: null };
return { pubkeys: [] };
}
const key = keys.find(key => key.usableForEncryption && key.emails.some(x => x.toLowerCase() === email.toLowerCase()));
if (!key) {
return { pubkey: null };
const pubkeys = keys.filter(key => key.emails.some(x => x.toLowerCase() === email.toLowerCase()));
if (!pubkeys.length) {
return { pubkeys: [] };
}
try {
const pubkey = KeyUtil.armor(key);
return { pubkey };
return { pubkeys: pubkeys.map(pubkey => KeyUtil.armor(pubkey)) };
} catch (e) {
return { pubkey: null };
return { pubkeys: [] };
}
}

Expand Down
15 changes: 10 additions & 5 deletions extension/js/common/api/pub-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Wkd } from './key-server/wkd.js';
import { OrgRules } from '../org-rules.js';

export type PubkeySearchResult = { pubkey: string | null };
export type PubkeysSearchResult = { pubkeys: string[] };

/**
* Look up public keys.
Expand Down Expand Up @@ -38,24 +39,28 @@ export class PubLookup {
}
}

public lookupEmail = async (email: string): Promise<PubkeySearchResult> => {
public lookupEmail = async (email: string): Promise<PubkeysSearchResult> => {
if (this.keyManager) {
const res = await this.keyManager.lookupPublicKey(email);
if (res.publicKeys.length) {
return { pubkey: res.publicKeys[0].publicKey };
return { pubkeys: res.publicKeys.map(x => x.publicKey) };
}
}
const wkdRes = await this.wkd.lookupEmail(email);
if (wkdRes.pubkey) {
if (wkdRes.pubkeys.length) {
return wkdRes;
}
if (this.internalSks) {
const res = await this.internalSks.lookupEmail(email);
if (res.pubkey) {
return res;
return { pubkeys: [res.pubkey] };
}
}
return await this.attester.lookupEmail(email);
const attRes = await this.attester.lookupEmail(email);
if (attRes.pubkey) {
return { pubkeys: [attRes.pubkey] };
}
return { pubkeys: [] };
}

public lookupFingerprint = async (fingerprintOrLongid: string): Promise<PubkeySearchResult> => {
Expand Down
13 changes: 11 additions & 2 deletions extension/js/common/browser/browser-msg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Ui } from './ui.js';
import { GlobalStoreDict, GlobalIndex } from '../platform/store/global-store.js';
import { AcctStoreDict, AccountIndex } from '../platform/store/acct-store.js';
import { Contact, Key, KeyUtil } from '../core/crypto/key.js';
import { OpenPGPKey } from '../core/crypto/pgp/openpgp-key.js';

export type GoogleAuthWindowResult$result = 'Success' | 'Denied' | 'Error' | 'Closed';

Expand Down Expand Up @@ -62,6 +63,7 @@ export namespace Bm {
export type PgpHashChallengeAnswer = { answer: string };
export type PgpMsgType = PgpMsgMethod.Arg.Type;
export type KeyParse = { armored: string };
export type KeyMatch = { pubkeys: string[], longid: string };
export type Ajax = { req: JQueryAjaxSettings, stack: string };
export type AjaxGmailAttachmentGetChunk = { acctEmail: string, msgId: string, attachmentId: string };
export type ShowAttachmentPreview = { iframeUrl: string };
Expand All @@ -82,6 +84,7 @@ export namespace Bm {
export type PgpMsgType = PgpMsgTypeResult;
export type PgpHashChallengeAnswer = { hashed: string };
export type KeyParse = { key: Key };
export type KeyMatch = { key: Key | undefined };
export type AjaxGmailAttachmentGetChunk = { chunk: Buf };
export type _tab_ = { tabId: string | null | undefined };
export type Db = any; // not included in Any below
Expand All @@ -90,7 +93,7 @@ export namespace Bm {
export type Any = GetActiveTabInfo | _tab_ | ReconnectAcctAuthPopup
| PgpMsgDecrypt | PgpMsgDiagnoseMsgPubkeys | PgpMsgVerify | PgpHashChallengeAnswer | PgpMsgType | KeyParse
| StoreSessionGet | StoreSessionSet | StoreAcctGet | StoreAcctSet | StoreGlobalGet | StoreGlobalSet
| AjaxGmailAttachmentGetChunk;
| AjaxGmailAttachmentGetChunk | KeyMatch;
}

export type AnyRequest = PassphraseEntry | StripeResult | OpenPage | OpenGoogleAuthDialog | Redirect | Reload |
Expand All @@ -99,7 +102,7 @@ export namespace Bm {
NotificationShow | PassphraseDialog | PassphraseDialog | Settings | SetCss | AddOrRemoveClass | ReconnectAcctAuthPopup |
Db | StoreSessionSet | StoreSessionGet | StoreGlobalGet | StoreGlobalSet | StoreAcctGet | StoreAcctSet | KeyParse |
PgpMsgDecrypt | PgpMsgDiagnoseMsgPubkeys | PgpMsgVerifyDetached | PgpHashChallengeAnswer | PgpMsgType | Ajax | FocusFrame |
ShowAttachmentPreview | ReRenderRecipient;
ShowAttachmentPreview | ReRenderRecipient | KeyMatch;

// export type RawResponselessHandler = (req: AnyRequest) => Promise<void>;
// export type RawRespoHandler = (req: AnyRequest) => Promise<void>;
Expand Down Expand Up @@ -146,6 +149,7 @@ export class BrowserMsg {
pgpMsgDecrypt: (bm: Bm.PgpMsgDecrypt) => BrowserMsg.sendAwait(undefined, 'pgpMsgDecrypt', bm, true) as Promise<Bm.Res.PgpMsgDecrypt>,
pgpMsgVerifyDetached: (bm: Bm.PgpMsgVerifyDetached) => BrowserMsg.sendAwait(undefined, 'pgpMsgVerifyDetached', bm, true) as Promise<Bm.Res.PgpMsgVerify>,
keyParse: (bm: Bm.KeyParse) => BrowserMsg.sendAwait(undefined, 'keyParse', bm, true) as Promise<Bm.Res.KeyParse>,
keyMatch: (bm: Bm.KeyMatch) => BrowserMsg.sendAwait(undefined, 'keyMatch', bm, true) as Promise<Bm.Res.KeyMatch>,
pgpMsgType: (bm: Bm.PgpMsgType) => BrowserMsg.sendAwait(undefined, 'pgpMsgType', bm, true) as Promise<Bm.Res.PgpMsgType>,
},
},
Expand Down Expand Up @@ -244,6 +248,11 @@ export class BrowserMsg {
BrowserMsg.bgAddListener('pgpMsgVerifyDetached', MsgUtil.verifyDetached);
BrowserMsg.bgAddListener('pgpMsgType', MsgUtil.type);
BrowserMsg.bgAddListener('keyParse', async (r: Bm.KeyParse) => ({ key: await KeyUtil.parse(r.armored) }));
BrowserMsg.bgAddListener('keyMatch', async (r: Bm.KeyMatch) => ({
key:
(await Promise.all(r.pubkeys.map(async (pub) => await KeyUtil.parse(pub)))).
find(k => k.allIds.map(id => OpenPGPKey.fingerprintToLongid(id).includes(r.longid)))
}));
}

public static addListener = (name: string, handler: Handler) => {
Expand Down
1 change: 1 addition & 0 deletions extension/js/common/core/crypto/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface Key {
id: string; // a fingerprint of the primary key in OpenPGP, and similarly a fingerprint of the actual cryptographic key (eg RSA fingerprint) in S/MIME
allIds: string[]; // a list of fingerprints, including those for subkeys
created: number;
revoked: boolean;
lastModified: number | undefined; // date of last signature, or undefined if never had valid signature
expiration: number | undefined; // number of millis of expiration or undefined if never expires
usableForEncryption: boolean;
Expand Down
1 change: 1 addition & 0 deletions extension/js/common/core/crypto/pgp/openpgp-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export class OpenPGPKey {
curve: (algoInfo as any).curve as string | undefined,
algorithmId: opgp.enums.publicKey[algoInfo.algorithm]
},
revoked: keyWithoutWeakPackets.revocationSignatures.length > 0
Copy link
Contributor Author

@rrrooommmaaa rrrooommmaaa Apr 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is the flag I was talking about. It's easily calculated when creating the Key structure.
So we don't really need to keep it in the database, do we?
The main question is: how should we prevent an update operation which replaces a revoked key with its unrevoked (outdated) version?

Copy link
Collaborator

@tomholub tomholub Apr 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is the flag I was talking about. It's easily calculated when creating the Key structure.
So we don't really need to keep it in the database, do we?

Ah. My intuition was to update the schema to add this flag to storage. Then filter based on that when pulling keys out of storage.

What you say is that you pull all keys for that user, then parse them and choose the appropriate key to use based on the parsed information. Your approach is simpler, and therefore better.

The main question is: how should we prevent an update operation which replaces a revoked key with its unrevoked (outdated) version?

I tend to do this in two ways - once in high level code and once more - a last resort hard stop - in low level code:

  1. a sensible logic preventing unwanted behavior in high-level code, in particular:
  • when pulling from pubkey lookup (compose)
  • when refreshing public keys from remote sources (compose)
  • when verifying messages (does that also update keys? I know it TOFU fetches a key but not sure if it would attempt to update existing key?)
  • when importing pubkey sent by another user (pgp_pubkey.htm)
  • when importing pubkeys in settings (contacts.htm)

All of the above places would have an if to handle this situation and not update key that's already revoked. Often this will result in a ui modal or toast message to the user if it was user-initiated action. In situations when this was an automatic action (like compose window lookups after user enters recipient), the UI interaction could be skipped and the update would be skipped silently (still in high level code).

  1. throw an error in low level function, in case we missed any edge cases. In this case, save and update function would check if existing stored key is revoked. If it is, it will throw new Error("Wrongly attempted to replace a local revoked pubkey with non-revoked version")

If the error ever throws, it will get reported to the backend, and I'll get to know about it. Then we'll have the stack trace to know which case we missed to handle in high level code.


I don't know if I'm over doing it. We could just silently skip updating in low level code and don't tell the user. But that could lead to some behavior that may appear buggy to the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. My intuition was to update the schema to add this flag to storage. Then filter based on that when pulling keys out of storage.

Actually, to make a unified solution for both OpenPGP and S/MIME revocation (we do want to support CRL later on, don't we?), the easiest would be to add a flag, or keep a table of revoked keys' fingerprints.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll let you choose an approach that you deem appropriate. I'd be ok to prevent updating revoked keys in a followup PR - the situation now is already better then before (it used to not be even able to import revoked keys, I think).

} as Key);
(key as any)[internal] = keyWithoutWeakPackets;
(key as any).rawKey = opgpKey;
Expand Down
1 change: 1 addition & 0 deletions extension/js/common/core/crypto/smime/smime-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class SmimeKey {
fullyEncrypted: false,
isPublic: !certificate.privateKey,
isPrivate: !!certificate.privateKey,
revoked: false, // todo:
issuerAndSerialNumber: forge.asn1.toDer(issuerAndSerialNumberAsn1).getBytes()
} as Key;
(key as unknown as { rawArmored: string }).rawArmored = pem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ export class GmailElementReplacer implements WebmailElementReplacer {
const [contact] = await ContactStore.get(undefined, [email]);
if (contact && contact.pubkey) {
this.recipientHasPgpCache[email] = true;
} else if ((await this.pubLookup.lookupEmail(email)).pubkey) {
} else if ((await this.pubLookup.lookupEmail(email)).pubkeys.length) {
this.recipientHasPgpCache[email] = true;
} else {
this.recipientHasPgpCache[email] = false;
Expand Down
28 changes: 15 additions & 13 deletions test/source/tests/browser-unit-tests/unit-Wkd.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ BROWSER_UNIT_TEST_NAME(`Wkd direct method`);
wkd.port = 8001;
let email;
email = 'john.doe@localhost';
if (!(await wkd.lookupEmail(email)).pubkey) {
if (!(await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
email = 'John.Doe@localhost';
if (!(await wkd.lookupEmail(email)).pubkey) {
if (!(await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
return 'pass';
Expand All @@ -42,30 +42,32 @@ BROWSER_UNIT_TEST_NAME(`Wkd advanced method`);
wkd.port = 8001;
let email;
email = 'john.doe@localhost';
if (!(await wkd.lookupEmail(email)).pubkey) {
if (!(await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
email = 'John.Doe@localHOST';
if (!(await wkd.lookupEmail(email)).pubkey) {
if (!(await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
return 'pass';
})();

BROWSER_UNIT_TEST_NAME(`Wkd client picks valid key among revoked keys`);
BROWSER_UNIT_TEST_NAME(`Wkd client returns all keys`);
(async () => {
const wkd = new Wkd();
wkd.port = 8001;
const email = 'some.revoked@localhost';
const pubkey = (await wkd.lookupEmail(email)).pubkey;
if (!pubkey) {
const pubkeys = (await wkd.lookupEmail(email)).pubkeys;
if (!pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
const key = await KeyUtil.parse(pubkey);
if (key && key.id.toUpperCase() === 'D6662C5FB9BDE9DA01F3994AAA1EF832D8CCA4F2' && key.usableForEncryption) {
const ids = (await Promise.all(pubkeys.map(async(pubkey) => await KeyUtil.parse(pubkey)))).map(key => key.id.toUpperCase());
if (ids.length === 3 && ids.includes('D6662C5FB9BDE9DA01F3994AAA1EF832D8CCA4F2') &&
ids.includes('A5CFC8E8EA4AE69989FE2631097EEBF354259A5E') &&
ids.includes('3930752556D57C46A1C56B63DE8538DDA1648C76')) {
return 'pass';
} else {
return `Expected key with id=D6662C5FB9BDE9DA01F3994AAA1EF832D8CCA4F2 wasn't received`;
return "Expected keys weren't received";
}
})();

Expand All @@ -74,7 +76,7 @@ BROWSER_UNIT_TEST_NAME(`Wkd advanced shouldn't fall back on direct if advanced p
const wkd = new Wkd();
wkd.port = 8001;
const email = 'jack.advanced@localhost';
if ((await wkd.lookupEmail(email)).pubkey) {
if ((await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't expect a pubkey`);
}
return 'pass';
Expand All @@ -85,7 +87,7 @@ BROWSER_UNIT_TEST_NAME(`Wkd incorrect UID should fail`);
const wkd = new Wkd();
wkd.port = 8001;
const email = 'incorrect@localhost';
if ((await wkd.lookupEmail(email)).pubkey) {
if ((await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't expect a pubkey`);
}
return 'pass';
Expand All @@ -95,7 +97,7 @@ BROWSER_UNIT_TEST_NAME(`Wkd should extract key for human@flowcrypt.com`);
(async () => {
const wkd = new Wkd();
const email = 'human@flowcrypt.com';
if (!(await wkd.lookupEmail(email)).pubkey) {
if (!(await wkd.lookupEmail(email)).pubkeys.length) {
throw Error(`Wkd for ${email} didn't return a pubkey`);
}
return 'pass';
Expand Down