@rapidmx/web-client 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +86 -7
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +14 -5
|
@@ -37,10 +37,36 @@
|
|
|
37
37
|
* `untrusted_signer`. Previously pinned keys count as pins (see `signingKeyFingerprints()`), so mail signed before an
|
|
38
38
|
* accepted or automatic rotation still verifies.
|
|
39
39
|
*/
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
import {
|
|
41
|
+
DisplayBody,
|
|
42
|
+
MimeAttachment,
|
|
43
|
+
MimeHeaderField,
|
|
44
|
+
bytesToBinaryString,
|
|
45
|
+
decodeBodyText,
|
|
46
|
+
type MimeEntity,
|
|
47
|
+
decodeBase64Text,
|
|
48
|
+
decodeHeaderText,
|
|
49
|
+
extractAddresses,
|
|
50
|
+
extractAttachments,
|
|
51
|
+
extractCalendarText,
|
|
52
|
+
extractDisplayBody,
|
|
53
|
+
parseMimeEntity,
|
|
54
|
+
parseParameterizedHeader,
|
|
55
|
+
plainTextToHtml,
|
|
56
|
+
} from "./mime.js";
|
|
57
|
+
import { fromBase64, toBase64 } from "./encoding.js";
|
|
58
|
+
import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
|
|
59
|
+
import {
|
|
60
|
+
ComparableOuterHeaders,
|
|
61
|
+
ProtectedHeaders,
|
|
62
|
+
extractHpOuterHeaders,
|
|
63
|
+
headersToProtectedHeaders,
|
|
64
|
+
outerHeadersMatch,
|
|
65
|
+
parseEncryptedMessageWithKeys,
|
|
66
|
+
parseSignedOnlyMessage,
|
|
67
|
+
} from "./smimeMessage.js";
|
|
68
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
69
|
+
import type { ParsedPgpMessage, PgpSignerCandidate } from "./pgpMessage.js";
|
|
44
70
|
import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
|
|
45
71
|
import { KeysLockedError } from "./masterKey.js";
|
|
46
72
|
import { type SealKeyMaterial, type SealedVerificationState, buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
|
|
@@ -67,6 +93,7 @@ export type MessageSecurityState =
|
|
|
67
93
|
| "encrypted_unverified_signer"
|
|
68
94
|
| "signature_failed"
|
|
69
95
|
| "verified_at_first_open"
|
|
96
|
+
| "protected_at_rest"
|
|
70
97
|
| "unprotected";
|
|
71
98
|
|
|
72
99
|
/** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
|
|
@@ -113,6 +140,13 @@ export interface MessageSecurityResult {
|
|
|
113
140
|
* since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
|
|
114
141
|
* showing whatever `GET /:id/content` already produced alongside this explanation. */
|
|
115
142
|
decryptError?: string;
|
|
143
|
+
/** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
|
|
144
|
+
* zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
|
|
145
|
+
* signed/encrypted state otherwise. */
|
|
146
|
+
zeroAccess?: boolean;
|
|
147
|
+
/** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
|
|
148
|
+
* client sends it with the invitation endpoints. Absent when the message carries none. */
|
|
149
|
+
calendar?: string;
|
|
116
150
|
/** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
|
|
117
151
|
* orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
|
|
118
152
|
* regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
|
|
@@ -139,6 +173,14 @@ export interface MessageSecurityResult {
|
|
|
139
173
|
signerFingerprint?: string;
|
|
140
174
|
/** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
|
|
141
175
|
signerEmails?: string[];
|
|
176
|
+
/** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
|
|
177
|
+
* `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
|
|
178
|
+
signerKeyType?: "openpgp";
|
|
179
|
+
/** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
|
|
180
|
+
* nor the reader's own, nor attached to the message), so it couldn't be checked at all. A signed-only one is then reported
|
|
181
|
+
* `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
|
|
182
|
+
* yet. */
|
|
183
|
+
signerKeyUnknown?: boolean;
|
|
142
184
|
/** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
|
|
143
185
|
* a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
|
|
144
186
|
* `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
|
|
@@ -258,8 +300,13 @@ function sameInstant(signed: string | undefined, outer: string | undefined): boo
|
|
|
258
300
|
return Number.isNaN(left) || left === Date.parse(String(outer));
|
|
259
301
|
}
|
|
260
302
|
|
|
261
|
-
function normalizePins(pins:
|
|
262
|
-
|
|
303
|
+
function normalizePins(pins: SignerPinsInput): string[] {
|
|
304
|
+
const list: string[] = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
|
|
305
|
+
return list.map((pin) => pin.toLowerCase());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function pinnedOpenPgpKeys(pins: SignerPinsInput): string[] {
|
|
309
|
+
return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
|
|
263
310
|
}
|
|
264
311
|
|
|
265
312
|
/** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
|
|
@@ -277,13 +324,14 @@ export async function checkSignerBinding(input: SignerBindingInput): Promise<Sig
|
|
|
277
324
|
if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
|
|
278
325
|
// A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
|
|
279
326
|
// (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
|
|
280
|
-
return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
327
|
+
return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
281
328
|
}
|
|
282
|
-
return checkIdentityAndHeaders(input, certDer);
|
|
329
|
+
return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
|
|
283
330
|
}
|
|
284
331
|
|
|
285
|
-
/** Checks 3-4 from this module's doc comment.
|
|
286
|
-
|
|
332
|
+
/** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
|
|
333
|
+
* OpenPGP key's user IDs). */
|
|
334
|
+
function checkIdentityAndHeaders(input: Omit<SignerBindingInput, "signerCertificateDer">, signerEmails: string[]): SignatureFailureReason | undefined {
|
|
287
335
|
const { protectedHeaders, outerHeaders } = input;
|
|
288
336
|
if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
|
|
289
337
|
return "header_mismatch";
|
|
@@ -291,7 +339,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
|
|
|
291
339
|
const outerFrom = extractAddresses(outerHeaders["from"]);
|
|
292
340
|
const protectedFrom = extractAddresses(protectedHeaders?.from);
|
|
293
341
|
const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
|
|
294
|
-
if (senderAddresses.length !== 1 || !
|
|
342
|
+
if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
|
|
295
343
|
return "signer_identity_mismatch";
|
|
296
344
|
}
|
|
297
345
|
const protectedTo = extractAddresses(protectedHeaders?.to);
|
|
@@ -335,7 +383,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
|
|
|
335
383
|
export async function evaluateMessageSecurity(
|
|
336
384
|
rawMime: string,
|
|
337
385
|
unlocked: MessageSecurityKeys | undefined,
|
|
338
|
-
pinnedSignerFingerprints?:
|
|
386
|
+
pinnedSignerFingerprints?: SignerPinsInput,
|
|
339
387
|
readerAddress?: string,
|
|
340
388
|
): Promise<MessageSecurityResult> {
|
|
341
389
|
return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
|
|
@@ -347,6 +395,75 @@ export interface MessageSecurityKeys {
|
|
|
347
395
|
encryptionCertDer?: Uint8Array;
|
|
348
396
|
retainedEncryptionKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
|
|
349
397
|
signingFingerprint?: string;
|
|
398
|
+
/** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
|
|
399
|
+
* rest on arrival. */
|
|
400
|
+
zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
|
|
401
|
+
/** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
|
|
402
|
+
* OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
|
|
403
|
+
openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
|
|
407
|
+
* `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
|
|
408
|
+
* a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
|
|
409
|
+
export interface SignerPins {
|
|
410
|
+
fingerprints: string[];
|
|
411
|
+
openPgpKeys: string[];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
|
|
415
|
+
export type SignerPinsInput = string | string[] | SignerPins | undefined;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
|
|
419
|
+
* decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
|
|
420
|
+
* each message. `undefined` when no key opens it or it isn't a summary.
|
|
421
|
+
*/
|
|
422
|
+
export async function openZeroAccessSummary(
|
|
423
|
+
sealedSummary: string,
|
|
424
|
+
keys: DecryptionKey[] | undefined,
|
|
425
|
+
): Promise<{ subject: string; preview: string } | undefined> {
|
|
426
|
+
if (!keys?.length) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const json: unknown = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
|
|
431
|
+
const { subject, preview } = (json ?? {}) as Record<string, unknown>;
|
|
432
|
+
return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
|
|
433
|
+
} catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
|
|
440
|
+
* `undefined` when it isn't a sealed message or no key opens it. For what needs the original itself, e.g. reporting it to the spam
|
|
441
|
+
* filter (`reportMessage()`'s `rawMessage`).
|
|
442
|
+
*/
|
|
443
|
+
export async function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined> {
|
|
444
|
+
const { headers, body } = parseMimeEntity(rawMime);
|
|
445
|
+
const rawContentType = headers["content-type"];
|
|
446
|
+
if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
return openZeroAccess(body, unlocked.zeroAccessKeys);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
|
|
454
|
+
* message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
|
|
455
|
+
* zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
|
|
456
|
+
*/
|
|
457
|
+
async function openZeroAccess(body: string, keys: DecryptionKey[]): Promise<string | undefined> {
|
|
458
|
+
const der = decodeBase64Text(body);
|
|
459
|
+
if (!der) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
|
|
464
|
+
} catch {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
350
467
|
}
|
|
351
468
|
|
|
352
469
|
/** `evaluateMessageSecurity()`'s result plus what the seal-aware wrapper needs: which kind of protected message it was,
|
|
@@ -360,15 +477,21 @@ interface LiveEvaluation {
|
|
|
360
477
|
async function evaluateLive(
|
|
361
478
|
rawMime: string,
|
|
362
479
|
unlocked: MessageSecurityKeys | undefined,
|
|
363
|
-
pinnedSignerFingerprints:
|
|
480
|
+
pinnedSignerFingerprints: SignerPinsInput,
|
|
364
481
|
readerAddress: string | undefined,
|
|
482
|
+
sealedAtRest = false,
|
|
365
483
|
): Promise<LiveEvaluation> {
|
|
366
|
-
const
|
|
484
|
+
const outerEntity = parseMimeEntity(rawMime);
|
|
485
|
+
const { headers, body, fields } = outerEntity;
|
|
367
486
|
const rawContentType = headers["content-type"];
|
|
487
|
+
const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
|
|
488
|
+
const pgp = pgpKind(contentType, outerEntity);
|
|
489
|
+
if (pgp) {
|
|
490
|
+
return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
|
|
491
|
+
}
|
|
368
492
|
if (!rawContentType) {
|
|
369
493
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
370
494
|
}
|
|
371
|
-
const contentType = parseParameterizedHeader(rawContentType);
|
|
372
495
|
const callerPins = normalizePins(pinnedSignerFingerprints);
|
|
373
496
|
|
|
374
497
|
// The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
|
|
@@ -452,6 +575,35 @@ async function evaluateLive(
|
|
|
452
575
|
};
|
|
453
576
|
}
|
|
454
577
|
|
|
578
|
+
// A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
|
|
579
|
+
// and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
|
|
580
|
+
if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
|
|
581
|
+
const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
|
|
582
|
+
if (original !== undefined) {
|
|
583
|
+
const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
|
|
584
|
+
const entity = parseMimeEntity(original);
|
|
585
|
+
const subject = entity.headers["subject"];
|
|
586
|
+
const calendarText = extractCalendarText(entity);
|
|
587
|
+
const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
|
|
588
|
+
if (inner.result.state !== "unprotected") {
|
|
589
|
+
// The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
|
|
590
|
+
const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
|
|
591
|
+
return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
result: {
|
|
595
|
+
state: "protected_at_rest",
|
|
596
|
+
zeroAccess: true,
|
|
597
|
+
...calendar,
|
|
598
|
+
...renderDisplayBody(extractDisplayBody(entity)),
|
|
599
|
+
...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
|
|
600
|
+
attachments: extractAttachments(entity),
|
|
601
|
+
},
|
|
602
|
+
kind: "other",
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
455
607
|
if (isEncryptedContentType(contentType)) {
|
|
456
608
|
// The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
|
|
457
609
|
// encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
|
|
@@ -496,6 +648,122 @@ async function evaluateLive(
|
|
|
496
648
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
497
649
|
}
|
|
498
650
|
|
|
651
|
+
/** Which OpenPGP form a received message is, if any: RFC 3156 PGP/MIME (`multipart/signed` or `multipart/encrypted` with an OpenPGP
|
|
652
|
+
* `protocol`), or an inline message - a `text/plain` body that is one armored `PGP MESSAGE`, or a cleartext signature. */
|
|
653
|
+
type PgpKind = "signed" | "encrypted" | "inline-encrypted" | "inline-signed";
|
|
654
|
+
|
|
655
|
+
const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
|
|
656
|
+
const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
|
|
657
|
+
|
|
658
|
+
function pgpKind(contentType: { value: string; params: Record<string, string> }, entity: MimeEntity): PgpKind | undefined {
|
|
659
|
+
const protocol = contentType.params["protocol"]?.toLowerCase();
|
|
660
|
+
if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
|
|
661
|
+
return "signed";
|
|
662
|
+
}
|
|
663
|
+
if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
|
|
664
|
+
return "encrypted";
|
|
665
|
+
}
|
|
666
|
+
if (contentType.value !== "text/plain") {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
const text = decodeBodyText(entity).trim();
|
|
670
|
+
return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
|
|
675
|
+
* trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
|
|
676
|
+
* one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
|
|
677
|
+
* pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
|
|
678
|
+
* A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
|
|
679
|
+
* loaded only here.
|
|
680
|
+
*/
|
|
681
|
+
async function evaluatePgp(
|
|
682
|
+
kind: PgpKind,
|
|
683
|
+
rawContentType: string,
|
|
684
|
+
outer: MimeEntity,
|
|
685
|
+
unlocked: MessageSecurityKeys | undefined,
|
|
686
|
+
pins: SignerPinsInput,
|
|
687
|
+
readerAddress: string | undefined,
|
|
688
|
+
): Promise<LiveEvaluation> {
|
|
689
|
+
const pgp = await import("./pgpMessage.js");
|
|
690
|
+
const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
|
|
691
|
+
const ownKeys = unlocked?.openPgpKeys ?? [];
|
|
692
|
+
const candidates: PgpSignerCandidate[] = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
|
|
693
|
+
const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
|
|
694
|
+
const encrypted = kind === "encrypted" || kind === "inline-encrypted";
|
|
695
|
+
const text = decodeBodyText(outer).trim();
|
|
696
|
+
let parsed: ParsedPgpMessage;
|
|
697
|
+
if (kind === "signed") {
|
|
698
|
+
parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
|
|
699
|
+
} else if (kind === "encrypted") {
|
|
700
|
+
parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
|
|
701
|
+
} else if (kind === "inline-encrypted") {
|
|
702
|
+
parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
|
|
703
|
+
} else {
|
|
704
|
+
parsed = await pgp.parsePgpCleartextSigned(text, candidates);
|
|
705
|
+
}
|
|
706
|
+
const resultKind: LiveEvaluation["kind"] = encrypted ? "encrypted" : "signed";
|
|
707
|
+
if (encrypted && !parsed.decrypted) {
|
|
708
|
+
return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
|
|
709
|
+
}
|
|
710
|
+
if (parsed.entityText === undefined) {
|
|
711
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
|
|
712
|
+
}
|
|
713
|
+
const entity = parseMimeEntity(parsed.entityText);
|
|
714
|
+
const inline = kind === "inline-encrypted" || kind === "inline-signed";
|
|
715
|
+
const protectedHeaders: ProtectedHeaders | undefined = inline ? undefined : headersToProtectedHeaders(entity.headers);
|
|
716
|
+
const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
|
|
717
|
+
const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
|
|
718
|
+
const outerComparable: ComparableOuterHeaders = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
|
|
719
|
+
const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
|
|
720
|
+
const content: Partial<MessageSecurityResult> = {
|
|
721
|
+
...renderDisplayBody(extractDisplayBody(entity)),
|
|
722
|
+
subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
|
|
723
|
+
attachments: extractAttachments(entity),
|
|
724
|
+
...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
|
|
725
|
+
...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
|
|
726
|
+
};
|
|
727
|
+
const signature = parsed.signature;
|
|
728
|
+
if (!signature) {
|
|
729
|
+
// Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
|
|
730
|
+
return { result: { state: "encrypted", ...content }, kind: resultKind };
|
|
731
|
+
}
|
|
732
|
+
if (signature.status === "unknown_key") {
|
|
733
|
+
return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
|
|
734
|
+
}
|
|
735
|
+
if (signature.status === "invalid") {
|
|
736
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
|
|
737
|
+
}
|
|
738
|
+
const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" as const };
|
|
739
|
+
const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
|
|
740
|
+
const signedHeaders: Partial<MessageSecurityResult> = hasProtected
|
|
741
|
+
? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
|
|
742
|
+
: {};
|
|
743
|
+
const failure = checkIdentityAndHeaders(
|
|
744
|
+
{
|
|
745
|
+
protectedHeaders: hasProtected ? protectedHeaders : undefined,
|
|
746
|
+
outerHeaders: outer.headers,
|
|
747
|
+
outerFields: outer.fields,
|
|
748
|
+
protectedFields: hasProtected ? entity.fields : undefined,
|
|
749
|
+
compareSubject: !encrypted,
|
|
750
|
+
},
|
|
751
|
+
signature.emails,
|
|
752
|
+
);
|
|
753
|
+
// Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
|
|
754
|
+
const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
|
|
755
|
+
if (failure || keyChanged) {
|
|
756
|
+
const reason: SignatureFailureReason = failure ?? "signer_key_changed";
|
|
757
|
+
return {
|
|
758
|
+
result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
|
|
759
|
+
kind: resultKind,
|
|
760
|
+
...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const state: MessageSecurityState = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
|
|
764
|
+
return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
|
|
765
|
+
}
|
|
766
|
+
|
|
499
767
|
/** Options for `evaluateMessageSecurityWithSeal()`. */
|
|
500
768
|
export interface VerificationSealOptions {
|
|
501
769
|
mailboxUid: string;
|
|
@@ -562,7 +830,7 @@ function failedOnlyOnKeyStatus(result: MessageSecurityResult): boolean {
|
|
|
562
830
|
export async function evaluateMessageSecurityWithSeal(
|
|
563
831
|
rawMime: string,
|
|
564
832
|
unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined,
|
|
565
|
-
pinnedSignerFingerprints:
|
|
833
|
+
pinnedSignerFingerprints: SignerPinsInput,
|
|
566
834
|
readerAddress: string | undefined,
|
|
567
835
|
options: VerificationSealOptions,
|
|
568
836
|
): Promise<MessageSecurityResult> {
|
package/lib/crypto/mime.ts
CHANGED
|
@@ -541,6 +541,32 @@ export function extractAttachments(entity: MimeEntity, depth = 0): MimeAttachmen
|
|
|
541
541
|
];
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
/**
|
|
545
|
+
* The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
|
|
546
|
+
* `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
|
|
547
|
+
*/
|
|
548
|
+
export function extractCalendarText(entity: MimeEntity, depth = 0): string | undefined {
|
|
549
|
+
const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
|
|
550
|
+
if (contentType.value.startsWith("multipart/")) {
|
|
551
|
+
const boundary = contentType.params["boundary"];
|
|
552
|
+
if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
for (const part of splitMultipart(entity.body, boundary)) {
|
|
556
|
+
const found = extractCalendarText(parseMimeEntity(part), depth + 1);
|
|
557
|
+
if (found !== undefined) {
|
|
558
|
+
return found;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
const bytes = decodeBodyBytes(entity);
|
|
567
|
+
return bytes ? new TextDecoder().decode(bytes) : undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
544
570
|
/** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
|
|
545
571
|
* rendered through the same HTML path without ever being interpreted as markup. */
|
|
546
572
|
export function plainTextToHtml(text: string): string {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
|
|
7
|
+
* key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
|
|
8
|
+
* RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
|
|
9
|
+
* encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
|
|
10
|
+
*/
|
|
11
|
+
import type { ApiClient } from "../util/api.js";
|
|
12
|
+
import { type KeyVault, OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
|
|
13
|
+
import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
|
|
14
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
15
|
+
import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
|
|
19
|
+
* and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
|
|
20
|
+
* `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
|
|
21
|
+
* Resolves with the vault and the new key, ready to decrypt and sign with.
|
|
22
|
+
*/
|
|
23
|
+
export async function provisionOpenPgpKey(
|
|
24
|
+
mailboxUid: string,
|
|
25
|
+
mailboxAddress: string,
|
|
26
|
+
masterKey: Uint8Array,
|
|
27
|
+
expectedMasterKeyGeneration?: number,
|
|
28
|
+
displayName?: string,
|
|
29
|
+
client?: ApiClient,
|
|
30
|
+
): Promise<{ vault: KeyVault; key: { fingerprint: string; privateKey: OpenPgpPrivateKey } }> {
|
|
31
|
+
const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
|
|
32
|
+
const generated = await generateOpenPgpKey(mailboxAddress, displayName);
|
|
33
|
+
try {
|
|
34
|
+
const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
35
|
+
const vault = await enrollKey(
|
|
36
|
+
mailboxUid,
|
|
37
|
+
{
|
|
38
|
+
useType: "encrypt",
|
|
39
|
+
type: OPENPGP_KEY_TYPE,
|
|
40
|
+
publicKey: generated.armoredPublicKey,
|
|
41
|
+
wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
|
|
42
|
+
...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
|
|
43
|
+
},
|
|
44
|
+
client,
|
|
45
|
+
);
|
|
46
|
+
return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
|
|
47
|
+
} finally {
|
|
48
|
+
generated.secretKey.fill(0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Provisions already in flight, per mailbox - a second caller joins the first. */
|
|
53
|
+
const inFlight = new Map<string, Promise<boolean>>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
|
|
57
|
+
* supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
|
|
58
|
+
* the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
|
|
59
|
+
* already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
|
|
60
|
+
*/
|
|
61
|
+
export function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean> {
|
|
62
|
+
const pending = inFlight.get(mailboxUid);
|
|
63
|
+
if (pending) {
|
|
64
|
+
return pending;
|
|
65
|
+
}
|
|
66
|
+
const run = (async (): Promise<boolean> => {
|
|
67
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
68
|
+
if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const policy = await getEncryptionPolicy(client);
|
|
72
|
+
if (policy.allowOpenPgp !== true) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
76
|
+
if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
|
|
80
|
+
addOpenPgpKey(mailboxUid, unlocked, key);
|
|
81
|
+
return true;
|
|
82
|
+
})().finally(() => inFlight.delete(mailboxUid));
|
|
83
|
+
inFlight.set(mailboxUid, run);
|
|
84
|
+
return run;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
|
|
89
|
+
* mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
|
|
90
|
+
* the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
|
|
91
|
+
* Throws when the mailbox has no OpenPGP key.
|
|
92
|
+
*/
|
|
93
|
+
export async function exportOpenPgpSecretKey(
|
|
94
|
+
mailboxUid: string,
|
|
95
|
+
masterPassword: string,
|
|
96
|
+
passphrase: string,
|
|
97
|
+
client?: ApiClient,
|
|
98
|
+
): Promise<{ armored: string; fingerprint: string }> {
|
|
99
|
+
const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
|
|
100
|
+
const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
|
|
101
|
+
try {
|
|
102
|
+
// The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
|
|
103
|
+
const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
|
|
104
|
+
if (!wrapped) {
|
|
105
|
+
throw new Error("This mailbox has no OpenPGP key to export.");
|
|
106
|
+
}
|
|
107
|
+
const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
108
|
+
try {
|
|
109
|
+
const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
|
|
110
|
+
await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
|
|
111
|
+
return { armored, fingerprint: wrapped.fingerprint };
|
|
112
|
+
} finally {
|
|
113
|
+
raw.fill(0);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
masterKey.fill(0);
|
|
117
|
+
}
|
|
118
|
+
}
|