mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: hide the pgp/mime control parts of an encrypted message from its attachments
This commit is contained in:
@@ -31,6 +31,8 @@ interface Props {
|
||||
tnefUnpacked?: boolean;
|
||||
/** Parts extracted client-side (TNEF / embedded message). */
|
||||
extracted?: ExtractedAttachment[];
|
||||
/** Server-listed parts to leave out (a decrypted PGP/MIME message's control parts). */
|
||||
hidePart?: (att: Attachment) => boolean;
|
||||
}
|
||||
|
||||
type Item =
|
||||
@@ -45,7 +47,7 @@ const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
|
||||
* save), long-press opens Preview / Open / Save / Share, and "Download all"
|
||||
* bundles every part into a zip named by the export template.
|
||||
*/
|
||||
export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted }: Props) {
|
||||
export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted, hidePart }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
@@ -76,6 +78,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
|
||||
.filter((att) => !hidePart?.(att))
|
||||
.map<Item>((att, i) => ({
|
||||
key: att.blobId || `att-${i}`,
|
||||
kind: 'blob',
|
||||
@@ -93,7 +96,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
size: part.size,
|
||||
}));
|
||||
return [...blobs, ...extra];
|
||||
}, [email, hideInlineImageAttachments, calendarBannerShown, tnefUnpacked, extracted]);
|
||||
}, [email, hideInlineImageAttachments, calendarBannerShown, tnefUnpacked, extracted, hidePart]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSecureMessage } from './use-secure-message';
|
||||
import { SecureMessageBanner } from './SecureMessageBanner';
|
||||
import { deriveHeaderInfo } from '../../lib/email-headers';
|
||||
import { calendarBannerShownFor } from '../../lib/attachment-display';
|
||||
import { hidesPgpMimeControlParts, pgpMimeControlParts } from '../../lib/pgp-mime-parts';
|
||||
|
||||
export interface MessageContentProps {
|
||||
email: Email;
|
||||
@@ -82,6 +83,11 @@ export function MessageContent({
|
||||
() => (secure.result?.status.encrypted && secure.result.attachments.length ? [...secure.result.attachments, ...unwrap.extracted] : unwrap.extracted),
|
||||
[secure.result, unwrap.extracted],
|
||||
);
|
||||
// A PGP/MIME message's version part and encrypted payload are no
|
||||
// attachments of the decrypted message (nor of a locked one).
|
||||
const hideControlParts = hidesPgpMimeControlParts({ loading: secure.loading, status: secure.result?.status ?? null });
|
||||
const controlPart = React.useMemo(() => pgpMimeControlParts(email), [email]);
|
||||
const hidePart = hideControlParts ? controlPart : undefined;
|
||||
const calendarBannerShown = calendarBannerShownFor(email, calendarParsing);
|
||||
const from = email.from?.[0];
|
||||
|
||||
@@ -92,6 +98,7 @@ export function MessageContent({
|
||||
calendarBannerShown={calendarBannerShown}
|
||||
tnefUnpacked={unwrap.tnefUnpacked}
|
||||
extracted={extracted}
|
||||
hidePart={hidePart}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// A decrypted PGP/MIME message still listed its control parts as attachments:
|
||||
// "Attachment.pgp-encrypted" (the version part) and "encrypted.asc".
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Email } from '../../api/types';
|
||||
import type { SecureStatus } from '../crypto/secure-message';
|
||||
import { hidesPgpMimeControlParts, pgpMimeControlParts } from '../pgp-mime-parts';
|
||||
|
||||
const version = { partId: '1', blobId: 'b-version', type: 'application/pgp-encrypted', size: 12 };
|
||||
const payload = { partId: '2', blobId: 'b-payload', type: 'application/octet-stream', name: 'encrypted.asc', size: 900 };
|
||||
const pgpMime = {
|
||||
bodyStructure: { type: 'multipart/encrypted', subParts: [version, payload] },
|
||||
attachments: [version, payload],
|
||||
} as unknown as Email;
|
||||
|
||||
const plainWithPdf = {
|
||||
bodyStructure: {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [{ partId: '1', type: 'text/plain' }, { partId: '2', blobId: 'b-pdf', type: 'application/pdf' }],
|
||||
},
|
||||
attachments: [{ partId: '2', blobId: 'b-pdf', type: 'application/pdf' }],
|
||||
} as unknown as Email;
|
||||
|
||||
const status = (s: Partial<SecureStatus>): SecureStatus => ({
|
||||
kind: 'pgp', encrypted: true, decrypted: false, signature: 'none', ...s,
|
||||
});
|
||||
|
||||
describe('PGP/MIME control parts', () => {
|
||||
it('are the parts of the multipart/encrypted', () => {
|
||||
const isControl = pgpMimeControlParts(pgpMime);
|
||||
expect(pgpMime.attachments!.filter((a) => !isControl(a))).toEqual([]);
|
||||
});
|
||||
|
||||
it('are found by type when the structure is missing', () => {
|
||||
const isControl = pgpMimeControlParts({ attachments: [version, payload] } as unknown as Email);
|
||||
expect(isControl(version) && isControl(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('leave other messages alone', () => {
|
||||
const isControl = pgpMimeControlParts(plainWithPdf);
|
||||
expect(plainWithPdf.attachments!.filter((a) => !isControl(a))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('are hidden once decrypted, while opening and while a key is locked', () => {
|
||||
expect(hidesPgpMimeControlParts({ loading: false, status: status({ decrypted: true }) })).toBe(true);
|
||||
expect(hidesPgpMimeControlParts({ loading: true, status: null })).toBe(true);
|
||||
expect(hidesPgpMimeControlParts({ loading: false, status: status({ lockedKeyIds: ['k1'] }) })).toBe(true);
|
||||
});
|
||||
|
||||
it('stay when the message cannot be decrypted, so the payload can be saved', () => {
|
||||
expect(hidesPgpMimeControlParts({ loading: false, status: status({ noKey: true }) })).toBe(false);
|
||||
expect(hidesPgpMimeControlParts({ loading: false, status: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('are filtered out of the reader chips', () => {
|
||||
const content = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageContent.tsx'), 'utf8');
|
||||
expect(content).toMatch(/hidePart=\{hidePart\}/);
|
||||
const chips = fs.readFileSync(path.resolve(__dirname, '../../components/email/AttachmentChips.tsx'), 'utf8');
|
||||
expect(chips).toMatch(/\.filter\(\(att\) => !hidePart\?\.\(att\)\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// A PGP/MIME message (RFC 3156) is a multipart/encrypted with two parts: an
|
||||
// application/pgp-encrypted version part and the encrypted payload, usually
|
||||
// named encrypted.asc. The server lists both as attachments, so a decrypted
|
||||
// message still showed "Attachment.pgp-encrypted" and "encrypted.asc" chips
|
||||
// beside the decrypted body's own attachments. As in the webmail OpenPGP
|
||||
// plugin (repos/plugins/bulwark-gpg/src/index.js onRenderEmailBody, which
|
||||
// replaces the attachments with the decrypted ones), they are left out once
|
||||
// the message is decrypted, and while it waits for a key to be unlocked (the
|
||||
// banner offers Unlock). When it can't be decrypted at all they stay, so the
|
||||
// payload can still be saved and opened elsewhere.
|
||||
|
||||
import type { BodyPart, Email } from '../api/types';
|
||||
import type { SecureStatus } from './crypto/secure-message';
|
||||
|
||||
type PartLike = Pick<BodyPart, 'type'> & { partId?: string | null; blobId?: string | null; subParts?: PartLike[] };
|
||||
|
||||
/** An attachment as the server lists it (partId is there, though not in the type). */
|
||||
export interface AttachmentRef {
|
||||
partId?: string | null;
|
||||
blobId?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
const base = (type?: string | null) => (type || '').split(';')[0].trim().toLowerCase();
|
||||
|
||||
/** The attachments that are a PGP/MIME message's control parts, by blob id or part id. */
|
||||
export function pgpMimeControlParts(
|
||||
email: Pick<Email, 'bodyStructure' | 'attachments'>,
|
||||
): (att: AttachmentRef) => boolean {
|
||||
const ids = new Set<string>();
|
||||
const collect = (part: PartLike) => {
|
||||
if (part.partId) ids.add(`p:${part.partId}`);
|
||||
if (part.blobId) ids.add(`b:${part.blobId}`);
|
||||
part.subParts?.forEach(collect);
|
||||
};
|
||||
const walk = (part: PartLike | undefined) => {
|
||||
if (!part) return;
|
||||
if (base(part.type) === 'multipart/encrypted') {
|
||||
part.subParts?.forEach(collect);
|
||||
return;
|
||||
}
|
||||
part.subParts?.forEach(walk);
|
||||
};
|
||||
walk(email.bodyStructure as PartLike | undefined);
|
||||
if (ids.size > 0) {
|
||||
return (att) => (!!att.partId && ids.has(`p:${att.partId}`)) || (!!att.blobId && ids.has(`b:${att.blobId}`));
|
||||
}
|
||||
// Without the structure: the version part and the payload beside it.
|
||||
const all = email.attachments ?? [];
|
||||
if (!all.some((a) => base(a.type) === 'application/pgp-encrypted')) return () => false;
|
||||
return (att) => base(att.type) === 'application/pgp-encrypted' || base(att.type) === 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Whether the reader leaves the control parts out: decrypted, being opened, or waiting for an unlock. */
|
||||
export function hidesPgpMimeControlParts(secure: { loading: boolean; status: SecureStatus | null }): boolean {
|
||||
if (secure.loading) return true;
|
||||
const status = secure.status;
|
||||
if (!status || status.kind !== 'pgp' || !status.encrypted) return false;
|
||||
return status.decrypted || (status.lockedKeyIds?.length ?? 0) > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user