diff --git a/extension/chrome/elements/pgp_block.ts b/extension/chrome/elements/pgp_block.ts index 41d0e40a006..6aeac1f16b8 100644 --- a/extension/chrome/elements/pgp_block.ts +++ b/extension/chrome/elements/pgp_block.ts @@ -110,7 +110,8 @@ export class PgpBlockView extends View { if (data?.separateQuotedContentAndRenderText) { this.quoteModule.separateQuotedContentAndRenderText( data.separateQuotedContentAndRenderText.decryptedContent, - data.separateQuotedContentAndRenderText.isHtml + data.separateQuotedContentAndRenderText.isHtml, + data.separateQuotedContentAndRenderText.isChecksumInvalid ); } if (data?.setFrameColor) { diff --git a/extension/chrome/elements/pgp_block_modules/pgp-block-quote-module.ts b/extension/chrome/elements/pgp_block_modules/pgp-block-quote-module.ts index e41a5088eb5..c8c6ad78766 100644 --- a/extension/chrome/elements/pgp_block_modules/pgp-block-quote-module.ts +++ b/extension/chrome/elements/pgp_block_modules/pgp-block-quote-module.ts @@ -9,7 +9,7 @@ import { Xss } from '../../../js/common/platform/xss.js'; export class PgpBlockViewQuoteModule { public constructor(private view: PgpBlockView) {} - public separateQuotedContentAndRenderText = (decryptedContent: string, isHtml: boolean) => { + public separateQuotedContentAndRenderText = (decryptedContent: string, isHtml: boolean, isChecksumInvalid: boolean) => { if (isHtml) { const message = $('
').html(Xss.htmlSanitizeKeepBasicTags(decryptedContent)); // xss-sanitized let htmlBlockQuoteExists = false; @@ -32,10 +32,10 @@ export class PgpBlockViewQuoteModule { message[0].removeChild(shouldBeQuoted[i]); quotedHtml += shouldBeQuoted[i].outerHTML; } - this.view.renderModule.renderContent(message.html(), false); + this.view.renderModule.renderContent(message.html(), false, isChecksumInvalid); this.appendCollapsedQuotedContentButton(quotedHtml, true); } else { - this.view.renderModule.renderContent(decryptedContent, false); + this.view.renderModule.renderContent(decryptedContent, false, isChecksumInvalid); } } else { const lines = decryptedContent.split(/\r?\n/); @@ -62,7 +62,7 @@ export class PgpBlockViewQuoteModule { // only got quoted part, no real text -> show everything as real text, without quoting lines.push(...linesQuotedPart.splice(0, linesQuotedPart.length)); } - this.view.renderModule.renderContent(Str.escapeTextAsRenderableHtml(lines.join('\n')), false); + this.view.renderModule.renderContent(Str.escapeTextAsRenderableHtml(lines.join('\n')), false, isChecksumInvalid); if (linesQuotedPart.join('').trim()) { this.appendCollapsedQuotedContentButton(linesQuotedPart.join('\n')); } diff --git a/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts b/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts index 066d40e1f57..cc30f4c0506 100644 --- a/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts +++ b/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts @@ -46,9 +46,11 @@ export class PgpBlockViewRenderModule { }); }; - public renderContent = (htmlContent: string, isErr: boolean) => { + public renderContent = (htmlContent: string, isErr: boolean, isChecksumInvalid = false) => { let contentWithLink = linkifyHtml(htmlContent); - + if (isChecksumInvalid) { + contentWithLink = `
${Lang.pgpBlock.invalidCheckSum}
${contentWithLink}}`; + } // Temporary workaround for an issue where 'cryptup_reply' divs are not being hidden when replying to all // messages from the FES. The root cause is that FES currently returns the body of // password message replies as 'text/plain', which does not hide the divs as intended. diff --git a/extension/css/cryptup.css b/extension/css/cryptup.css index cea22762c56..c82a5a89a0b 100644 --- a/extension/css/cryptup.css +++ b/extension/css/cryptup.css @@ -1592,6 +1592,13 @@ td { align-items: center; } +.pgp-invalid-checksum { + border-left: 3px solid #d18826; + color: #d18826; + margin: 10px 0; + padding-left: 5px; +} + .pgp_badge { opacity: 1; margin-bottom: 1em; diff --git a/extension/js/common/core/crypto/key.ts b/extension/js/common/core/crypto/key.ts index 0a0fe629e99..b856789671b 100644 --- a/extension/js/common/core/crypto/key.ts +++ b/extension/js/common/core/crypto/key.ts @@ -489,6 +489,60 @@ export class KeyUtil { return keys.map(k => ({ id: k.id, emails: k.emails, armored: KeyUtil.armor(k), family: k.family })); } + public static validateChecksum = (armoredText: string): boolean => { + const lines = armoredText.split('\n').map(l => l.trim()); + + // Filter out known non-data lines + const dataCandidates = lines.filter(line => line.length > 0 && !line.startsWith('-----') && !line.startsWith('Version:') && !line.startsWith('Comment:')); + + // Find checksum line + const checksumIndex = dataCandidates.findIndex(line => line.startsWith('=')); + if (checksumIndex === -1) return false; + + const checksumLine = dataCandidates[checksumIndex].slice(1); + const dataLines = dataCandidates.slice(0, checksumIndex); + + // Decode checksum + let providedBytes: string; + try { + providedBytes = atob(checksumLine); + } catch { + return false; + } + if (providedBytes.length !== 3) return false; + + const providedCRC = (providedBytes.charCodeAt(0) << 16) | (providedBytes.charCodeAt(1) << 8) | providedBytes.charCodeAt(2); + + // Attempt to decode all data lines (some may not be base64) + const decodedChunks: string[] = []; + for (const line of dataLines) { + try { + decodedChunks.push(atob(line)); + } catch { + // Not a valid base64 line, skip it + } + } + + if (decodedChunks.length === 0) return false; + + const rawData = decodedChunks.join(''); + const dataBytes = new Uint8Array([...rawData].map(c => c.charCodeAt(0))); + + return KeyUtil.crc24(dataBytes) === providedCRC; + }; + + private static crc24 = (dataBytes: Uint8Array): number => { + let crc = 0xb704ce; + for (const dataByte of dataBytes) { + crc ^= dataByte << 16; + for (let j = 0; j < 8; j++) { + crc <<= 1; + if (crc & 0x1000000) crc ^= 0x1864cfb; + } + } + return crc & 0xffffff; + }; + private static getSortValue(pubinfo: PubkeyInfo): number { const expirationSortValue = typeof pubinfo.pubkey.expiration === 'undefined' ? Infinity : pubinfo.pubkey.expiration; // sort non-revoked first, then non-expired diff --git a/extension/js/common/lang.ts b/extension/js/common/lang.ts index d376adcba17..e7a52fd2ba9 100644 --- a/extension/js/common/lang.ts +++ b/extension/js/common/lang.ts @@ -93,6 +93,8 @@ export const Lang = { cannotLocate: 'Could not locate this message.', brokenLink: 'It seems it contains a broken link.', pwdMsgAskSenderUsePubkey: 'This appears to be a password-protected message. Please ask the sender to encrypt messages for your Public Key instead.', + invalidCheckSum: + 'Warning: Checksum mismatch detected.\nThis indicates the message may have been altered or corrupted during transmission.\nDecryption may still succeed, but verify the message source and integrity if possible.', }, compose: { abortSending: 'A message is currently being sent. Closing the compose window may abort sending the message.\nAbort sending?', diff --git a/extension/js/common/message-renderer.ts b/extension/js/common/message-renderer.ts index 7fc34704869..f4744fb61b1 100644 --- a/extension/js/common/message-renderer.ts +++ b/extension/js/common/message-renderer.ts @@ -521,7 +521,8 @@ export class MessageRenderer { sigResult: VerifyRes | undefined, renderModule: RenderInterface, retryVerification: (() => Promise) | undefined, - plainSubject: string | undefined + plainSubject: string | undefined, + isChecksumInvalid = false ): Promise<{ publicKeys?: string[] }> => { if (isEncrypted) { renderModule.renderEncryptionStatus('encrypted'); @@ -603,7 +604,7 @@ export class MessageRenderer { ); } decryptedContent = this.clipMessageIfLimitExceeds(decryptedContent); - renderModule.separateQuotedContentAndRenderText(decryptedContent, isHtml); + renderModule.separateQuotedContentAndRenderText(decryptedContent, isHtml, isChecksumInvalid); await MessageRenderer.renderPgpSignatureCheckResult(renderModule, sigResult, Boolean(signerEmail), retryVerification); if (renderableAttachments.length) { renderModule.renderInnerAttachments(renderableAttachments, isEncrypted); @@ -743,7 +744,8 @@ export class MessageRenderer { result.signature, renderModule, this.getRetryVerification(signerEmail, verificationPubs => MessageRenderer.decryptFunctionToVerifyRes(() => decrypt(verificationPubs))), - plainSubject + plainSubject, + !KeyUtil.validateChecksum(encryptedData.toString()) ); } else if (result.error.type === DecryptErrTypes.format) { if (fallbackToPlainText) { diff --git a/extension/js/common/render-interface.ts b/extension/js/common/render-interface.ts index 49de8eed2c4..b5ba6b9d405 100644 --- a/extension/js/common/render-interface.ts +++ b/extension/js/common/render-interface.ts @@ -24,7 +24,7 @@ export interface RenderInterface extends RenderInterfaceBase { renderPassphraseNeeded(longids: string[]): void; renderErr(errBoxContent: string, renderRawMsg: string | undefined, errMsg?: string): void; renderInnerAttachments(attachments: TransferableAttachment[], isEncrypted: boolean): void; - separateQuotedContentAndRenderText(decryptedContent: string, isHtml: boolean): void; + separateQuotedContentAndRenderText(decryptedContent: string, isHtml: boolean, isChecksumInvalid: boolean): void; renderVerificationInProgress(): void; renderSignatureOffline(retry: () => void): void; } diff --git a/extension/js/common/render-message.ts b/extension/js/common/render-message.ts index 07c46c593af..9d54e2d4b4b 100644 --- a/extension/js/common/render-message.ts +++ b/extension/js/common/render-message.ts @@ -23,7 +23,7 @@ export type MessageInfo = { export type RenderMessage = { done?: true; resizePgpBlockFrame?: true; - separateQuotedContentAndRenderText?: { decryptedContent: string; isHtml: boolean }; + separateQuotedContentAndRenderText?: { decryptedContent: string; isHtml: boolean; isChecksumInvalid: boolean }; renderText?: string; progressOperation?: { operationId: string; text: string; perc?: number; init?: boolean }; setFrameColor?: 'green' | 'gray' | 'red'; diff --git a/extension/js/common/render-relay.ts b/extension/js/common/render-relay.ts index 2d3c0cb6ed1..d2892a36ace 100644 --- a/extension/js/common/render-relay.ts +++ b/extension/js/common/render-relay.ts @@ -78,8 +78,8 @@ export class RenderRelay implements RenderInterface { this.relay({ resizePgpBlockFrame: true }); }; - public separateQuotedContentAndRenderText = (decryptedContent: string, isHtml: boolean) => { - this.relay({ separateQuotedContentAndRenderText: { decryptedContent, isHtml } }); + public separateQuotedContentAndRenderText = (decryptedContent: string, isHtml: boolean, isChecksumInvalid: boolean) => { + this.relay({ separateQuotedContentAndRenderText: { decryptedContent, isHtml, isChecksumInvalid } }); }; public renderText = (text: string) => { diff --git a/test/source/tests/decrypt.ts b/test/source/tests/decrypt.ts index c7d3862dcb5..c0ceffba7c4 100644 --- a/test/source/tests/decrypt.ts +++ b/test/source/tests/decrypt.ts @@ -1882,7 +1882,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== browser, threadId, { - content: [expectedContent], + content: [expectedContent, 'Warning: Checksum mismatch detected'], encryption: 'encrypted', }, authHdr