@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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The temporary per-meeting key of a meeting with external guests (meet-plugin `specs/meeting_privacy.md` §3-4): a P-256 keypair made
|
|
7
|
+
* in the browser of whoever creates the meeting, with a self-signed certificate so the CMS code in `smime.ts` seals to it and opens with
|
|
8
|
+
* it unchanged. Every participant receives the private key in their link's `#k=` part; the organizer also keeps it sealed to their own
|
|
9
|
+
* zero-access certificate. A live AES-256-GCM key derived from it encrypts the meeting's signaling and relayed media. Shared by the
|
|
10
|
+
* organizer's calendar editor, meet-plugin and booking-plugin.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here talks to the server: the private key never leaves the browser except inside a link or a CMS wrap.
|
|
13
|
+
*/
|
|
14
|
+
import { fromBase64Url, toBase64Url } from "./encoding.js";
|
|
15
|
+
/** The `#` parameter of a link that carries a meeting key. */
|
|
16
|
+
export const MEETING_KEY_FRAGMENT_PARAM = "k";
|
|
17
|
+
/** HKDF `info` of the live key (`specs/meeting_privacy.md` §3). */
|
|
18
|
+
const LIVE_KEY_INFO = "rapidmx-meeting-live-v1";
|
|
19
|
+
const EC_PARAMS = { name: "ECDH", namedCurve: "P-256" };
|
|
20
|
+
async function loadX509() {
|
|
21
|
+
await import("reflect-metadata");
|
|
22
|
+
const x509 = await import("@peculiar/x509");
|
|
23
|
+
x509.cryptoProvider.set(crypto);
|
|
24
|
+
return x509;
|
|
25
|
+
}
|
|
26
|
+
async function sha256Hex(bytes) {
|
|
27
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
28
|
+
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
29
|
+
}
|
|
30
|
+
/** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
|
|
31
|
+
export async function generateMeetingKey(expiresAt) {
|
|
32
|
+
const x509 = await loadX509();
|
|
33
|
+
// ECDSA to self-sign the certificate; the same P-256 key is re-imported as ECDH to open what is sealed to it (`importMeetingKey()`).
|
|
34
|
+
const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
|
|
35
|
+
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
36
|
+
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(16))),
|
|
37
|
+
name: "CN=meeting",
|
|
38
|
+
notBefore: new Date(Date.now() - 60000),
|
|
39
|
+
notAfter: expiresAt,
|
|
40
|
+
signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
|
|
41
|
+
keys,
|
|
42
|
+
});
|
|
43
|
+
const certificateDer = new Uint8Array(cert.rawData);
|
|
44
|
+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey));
|
|
45
|
+
return { pkcs8, certificateDer, fingerprint: await sha256Hex(certificateDer) };
|
|
46
|
+
}
|
|
47
|
+
function toHex(bytes) {
|
|
48
|
+
// A positive serial: the top bit clear.
|
|
49
|
+
const copy = Uint8Array.from(bytes);
|
|
50
|
+
copy[0] &= 0x7f;
|
|
51
|
+
return Array.from(copy, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
52
|
+
}
|
|
53
|
+
/** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
|
|
54
|
+
export function meetingKeyFragment(pkcs8) {
|
|
55
|
+
return `${MEETING_KEY_FRAGMENT_PARAM}=${toBase64Url(pkcs8)}`;
|
|
56
|
+
}
|
|
57
|
+
/** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
|
|
58
|
+
export function readMeetingKeyFragment(hash) {
|
|
59
|
+
const params = new URLSearchParams(hash.replace(/^#/, ""));
|
|
60
|
+
const value = params.get(MEETING_KEY_FRAGMENT_PARAM);
|
|
61
|
+
if (!value || !/^[A-Za-z0-9_-]{16,1024}$/.test(value)) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return fromBase64Url(value);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** The meeting key `pkcs8` as the CMS code opens with it, and its live key for meeting `meetingUid`. Rejects a key that isn't P-256. */
|
|
72
|
+
export async function importMeetingKey(pkcs8, certificateDer, meetingUid) {
|
|
73
|
+
// Extractable once, only to read its scalar for the live key; the key kept for CMS isn't.
|
|
74
|
+
const readable = await crypto.subtle.importKey("pkcs8", pkcs8, EC_PARAMS, true, ["deriveBits"]);
|
|
75
|
+
const jwk = await crypto.subtle.exportKey("jwk", readable);
|
|
76
|
+
/* v8 ignore next 3 -- an imported PKCS#8 private key always exports its scalar */
|
|
77
|
+
if (!jwk.d) {
|
|
78
|
+
throw new Error("This is not a meeting key.");
|
|
79
|
+
}
|
|
80
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, EC_PARAMS, false, ["deriveBits"]);
|
|
81
|
+
return { decryptionKey: { certDer: certificateDer, privateKey }, liveKey: await deriveLiveKey(fromBase64Url(jwk.d), meetingUid) };
|
|
82
|
+
}
|
|
83
|
+
/** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
|
|
84
|
+
export async function deriveLiveKey(ikm, meetingUid) {
|
|
85
|
+
const base = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
|
|
86
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(meetingUid), info: new TextEncoder().encode(LIVE_KEY_INFO) }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
87
|
+
}
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
* accepted or automatic rotation still verifies.
|
|
35
35
|
*/
|
|
36
36
|
import { MimeAttachment, MimeHeaderField } from "./mime.js";
|
|
37
|
+
import { type DecryptionKey } from "./smime.js";
|
|
37
38
|
import { ProtectedHeaders } from "./smimeMessage.js";
|
|
39
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
38
40
|
import { type PublicKey } from "./keyvaultApi.js";
|
|
39
41
|
import { type SealKeyMaterial, type SealedVerificationState } from "./verificationSeal.js";
|
|
40
42
|
export { signingKeyFingerprints } from "./keyvaultApi.js";
|
|
@@ -49,7 +51,7 @@ export { signingKeyFingerprints } from "./keyvaultApi.js";
|
|
|
49
51
|
* `sealedState`, `signerFingerprint`, the recovered content, `liveState`/`liveSignatureFailureReason`, and
|
|
50
52
|
* `laterCompromised` when the signer key has since been revoked as compromised. A UI should show it as "verified when
|
|
51
53
|
* first opened" - distinct from the live verified states, and with a warning when `laterCompromised` is set. */
|
|
52
|
-
export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "unprotected";
|
|
54
|
+
export type MessageSecurityState = "encrypted" | "signed_verified" | "encrypted_verified" | "signed_unverified_signer" | "encrypted_unverified_signer" | "signature_failed" | "verified_at_first_open" | "protected_at_rest" | "unprotected";
|
|
53
55
|
/** Why a message is `"signature_failed"`. `invalid_signature`: the CMS signature itself is malformed or
|
|
54
56
|
* doesn't verify over the content (or a `multipart/signed` body doesn't hold exactly two parts).
|
|
55
57
|
* `signer_key_changed`: pinned fingerprints were supplied and the signer certificate matches none of them, but it
|
|
@@ -92,6 +94,13 @@ export interface MessageSecurityResult {
|
|
|
92
94
|
* since-rotated key, non-AEAD content encryption, or corrupt/foreign data) - the caller should keep
|
|
93
95
|
* showing whatever `GET /:id/content` already produced alongside this explanation. */
|
|
94
96
|
decryptError?: string;
|
|
97
|
+
/** Set when the stored message was sealed at rest by the server (restapi `specs/search.md` §3) and opened here with the mailbox's
|
|
98
|
+
* zero-access key. `state` then describes the original message inside: `protected_at_rest` when it arrived unprotected, or its own
|
|
99
|
+
* signed/encrypted state otherwise. */
|
|
100
|
+
zeroAccess?: boolean;
|
|
101
|
+
/** With `zeroAccess`: the original's calendar file (`extractCalendarText()`), which the server can't read from the sealed copy - the
|
|
102
|
+
* client sends it with the invitation endpoints. Absent when the message carries none. */
|
|
103
|
+
calendar?: string;
|
|
95
104
|
/** See `ParsedEncryptedMessage.headerTamperDetected`'s own doc comment - a deliberately separate,
|
|
96
105
|
* orthogonal signal from `state`, since RFC 9788's `HP-Outer` is written on every encrypted message
|
|
97
106
|
* regardless of whether it's also signed. `undefined` for anything that isn't an encrypted message
|
|
@@ -118,6 +127,14 @@ export interface MessageSecurityResult {
|
|
|
118
127
|
signerFingerprint?: string;
|
|
119
128
|
/** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
|
|
120
129
|
signerEmails?: string[];
|
|
130
|
+
/** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
|
|
131
|
+
* `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
|
|
132
|
+
signerKeyType?: "openpgp";
|
|
133
|
+
/** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
|
|
134
|
+
* 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
|
|
135
|
+
* `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
|
|
136
|
+
* yet. */
|
|
137
|
+
signerKeyUnknown?: boolean;
|
|
121
138
|
/** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
|
|
122
139
|
* a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
|
|
123
140
|
* `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
|
|
@@ -190,7 +207,7 @@ export declare function checkSignerBinding(input: SignerBindingInput): Promise<S
|
|
|
190
207
|
* An encrypted message is decrypted with `unlocked`'s active encryption key or any of its `retainedEncryptionKeys` (the
|
|
191
208
|
* older keys a rotation replaced - see `keySession.ts`), matched by recipient identifier first.
|
|
192
209
|
*/
|
|
193
|
-
export declare function evaluateMessageSecurity(rawMime: string, unlocked: MessageSecurityKeys | undefined, pinnedSignerFingerprints?:
|
|
210
|
+
export declare function evaluateMessageSecurity(rawMime: string, unlocked: MessageSecurityKeys | undefined, pinnedSignerFingerprints?: SignerPinsInput, readerAddress?: string): Promise<MessageSecurityResult>;
|
|
194
211
|
/** The keys `evaluateMessageSecurity()` reads from an unlocked session (`keySession.ts`'s `UnlockedKeys`). */
|
|
195
212
|
export interface MessageSecurityKeys {
|
|
196
213
|
encryptionPrivateKey?: CryptoKey;
|
|
@@ -200,7 +217,43 @@ export interface MessageSecurityKeys {
|
|
|
200
217
|
privateKey: CryptoKey;
|
|
201
218
|
}[];
|
|
202
219
|
signingFingerprint?: string;
|
|
220
|
+
/** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
|
|
221
|
+
* rest on arrival. */
|
|
222
|
+
zeroAccessKeys?: {
|
|
223
|
+
certDer: Uint8Array;
|
|
224
|
+
privateKey: CryptoKey;
|
|
225
|
+
}[];
|
|
226
|
+
/** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
|
|
227
|
+
* OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
|
|
228
|
+
openPgpKeys?: {
|
|
229
|
+
fingerprint: string;
|
|
230
|
+
privateKey: OpenPgpPrivateKey;
|
|
231
|
+
}[];
|
|
232
|
+
}
|
|
233
|
+
/** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
|
|
234
|
+
* `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
|
|
235
|
+
* a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
|
|
236
|
+
export interface SignerPins {
|
|
237
|
+
fingerprints: string[];
|
|
238
|
+
openPgpKeys: string[];
|
|
203
239
|
}
|
|
240
|
+
/** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
|
|
241
|
+
export type SignerPinsInput = string | string[] | SignerPins | undefined;
|
|
242
|
+
/**
|
|
243
|
+
* The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
|
|
244
|
+
* decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
|
|
245
|
+
* each message. `undefined` when no key opens it or it isn't a summary.
|
|
246
|
+
*/
|
|
247
|
+
export declare function openZeroAccessSummary(sealedSummary: string, keys: DecryptionKey[] | undefined): Promise<{
|
|
248
|
+
subject: string;
|
|
249
|
+
preview: string;
|
|
250
|
+
} | undefined>;
|
|
251
|
+
/**
|
|
252
|
+
* The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
|
|
253
|
+
* `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
|
|
254
|
+
* filter (`reportMessage()`'s `rawMessage`).
|
|
255
|
+
*/
|
|
256
|
+
export declare function openZeroAccessMessage(rawMime: string, unlocked: MessageSecurityKeys | undefined): Promise<string | undefined>;
|
|
204
257
|
/** Options for `evaluateMessageSecurityWithSeal()`. */
|
|
205
258
|
export interface VerificationSealOptions {
|
|
206
259
|
mailboxUid: string;
|
|
@@ -248,4 +301,4 @@ export interface VerificationSealOptions {
|
|
|
248
301
|
* `unlocked` was destroyed (`keySession.ts`), before evaluating anything, or if it is destroyed while sealing; otherwise
|
|
249
302
|
* never throws - a seal that can't be built (e.g. uids too long for the size limit) just leaves `sealToWrite` unset.
|
|
250
303
|
*/
|
|
251
|
-
export declare function evaluateMessageSecurityWithSeal(rawMime: string, unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined, pinnedSignerFingerprints:
|
|
304
|
+
export declare function evaluateMessageSecurityWithSeal(rawMime: string, unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined, pinnedSignerFingerprints: SignerPinsInput, readerAddress: string | undefined, options: VerificationSealOptions): Promise<MessageSecurityResult>;
|
|
@@ -37,10 +37,10 @@
|
|
|
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 { decodeHeaderText, extractAddresses, parseMimeEntity, parseParameterizedHeader, plainTextToHtml } from "./mime.js";
|
|
41
|
-
import { toBase64 } from "./encoding.js";
|
|
42
|
-
import { computeCertFingerprint, extractCertificateEmails } from "./smime.js";
|
|
43
|
-
import { parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.js";
|
|
40
|
+
import { bytesToBinaryString, decodeBodyText, decodeBase64Text, decodeHeaderText, extractAddresses, extractAttachments, extractCalendarText, extractDisplayBody, parseMimeEntity, parseParameterizedHeader, plainTextToHtml, } from "./mime.js";
|
|
41
|
+
import { fromBase64, toBase64 } from "./encoding.js";
|
|
42
|
+
import { computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
|
|
43
|
+
import { extractHpOuterHeaders, headersToProtectedHeaders, outerHeadersMatch, parseEncryptedMessageWithKeys, parseSignedOnlyMessage, } from "./smimeMessage.js";
|
|
44
44
|
import { isTrustedForVerification } from "./keyvaultApi.js";
|
|
45
45
|
import { KeysLockedError } from "./masterKey.js";
|
|
46
46
|
import { buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
|
|
@@ -103,7 +103,11 @@ function sameInstant(signed, outer) {
|
|
|
103
103
|
return Number.isNaN(left) || left === Date.parse(String(outer));
|
|
104
104
|
}
|
|
105
105
|
function normalizePins(pins) {
|
|
106
|
-
|
|
106
|
+
const list = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
|
|
107
|
+
return list.map((pin) => pin.toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
function pinnedOpenPgpKeys(pins) {
|
|
110
|
+
return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
|
|
107
111
|
}
|
|
108
112
|
/** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
|
|
109
113
|
* Returns `undefined` when the signer is accepted, otherwise the failure reason. A pin mismatch is
|
|
@@ -120,12 +124,13 @@ export async function checkSignerBinding(input) {
|
|
|
120
124
|
if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
|
|
121
125
|
// A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
|
|
122
126
|
// (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
|
|
123
|
-
return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
127
|
+
return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
124
128
|
}
|
|
125
|
-
return checkIdentityAndHeaders(input, certDer);
|
|
129
|
+
return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
|
|
126
130
|
}
|
|
127
|
-
/** Checks 3-4 from this module's doc comment.
|
|
128
|
-
|
|
131
|
+
/** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
|
|
132
|
+
* OpenPGP key's user IDs). */
|
|
133
|
+
function checkIdentityAndHeaders(input, signerEmails) {
|
|
129
134
|
const { protectedHeaders, outerHeaders } = input;
|
|
130
135
|
if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
|
|
131
136
|
return "header_mismatch";
|
|
@@ -133,7 +138,7 @@ function checkIdentityAndHeaders(input, certDer) {
|
|
|
133
138
|
const outerFrom = extractAddresses(outerHeaders["from"]);
|
|
134
139
|
const protectedFrom = extractAddresses(protectedHeaders?.from);
|
|
135
140
|
const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
|
|
136
|
-
if (senderAddresses.length !== 1 || !
|
|
141
|
+
if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
|
|
137
142
|
return "signer_identity_mismatch";
|
|
138
143
|
}
|
|
139
144
|
const protectedTo = extractAddresses(protectedHeaders?.to);
|
|
@@ -174,13 +179,66 @@ function checkIdentityAndHeaders(input, certDer) {
|
|
|
174
179
|
export async function evaluateMessageSecurity(rawMime, unlocked, pinnedSignerFingerprints, readerAddress) {
|
|
175
180
|
return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
|
|
176
181
|
}
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
/**
|
|
183
|
+
* The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
|
|
184
|
+
* decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
|
|
185
|
+
* each message. `undefined` when no key opens it or it isn't a summary.
|
|
186
|
+
*/
|
|
187
|
+
export async function openZeroAccessSummary(sealedSummary, keys) {
|
|
188
|
+
if (!keys?.length) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const json = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealedSummary), keys)));
|
|
193
|
+
const { subject, preview } = (json ?? {});
|
|
194
|
+
return typeof subject === "string" && typeof preview === "string" ? { subject, preview } : undefined;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The original MIME (as a binary string) of a stored message the server sealed at rest, opened with `unlocked.zeroAccessKeys` -
|
|
202
|
+
* `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
|
|
203
|
+
* filter (`reportMessage()`'s `rawMessage`).
|
|
204
|
+
*/
|
|
205
|
+
export async function openZeroAccessMessage(rawMime, unlocked) {
|
|
206
|
+
const { headers, body } = parseMimeEntity(rawMime);
|
|
207
|
+
const rawContentType = headers["content-type"];
|
|
208
|
+
if (!unlocked?.zeroAccessKeys?.length || !rawContentType || !isEncryptedContentType(parseParameterizedHeader(rawContentType))) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
return openZeroAccess(body, unlocked.zeroAccessKeys);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The original MIME of a message the server sealed at rest (restapi `specs/search.md` §3) - the stored S/MIME `enveloped-data`
|
|
215
|
+
* message's content, decrypted with one of `keys` - or `undefined` when none of them opens it. Only the server ever encrypts to a
|
|
216
|
+
* zero-access key (it is never published), so a message that opens with one was sealed at rest, never sent end-to-end.
|
|
217
|
+
*/
|
|
218
|
+
async function openZeroAccess(body, keys) {
|
|
219
|
+
const der = decodeBase64Text(body);
|
|
220
|
+
if (!der) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return bytesToBinaryString(await decryptEnvelopedDataWithKeys(der, keys));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress, sealedAtRest = false) {
|
|
231
|
+
const outerEntity = parseMimeEntity(rawMime);
|
|
232
|
+
const { headers, body, fields } = outerEntity;
|
|
179
233
|
const rawContentType = headers["content-type"];
|
|
234
|
+
const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
|
|
235
|
+
const pgp = pgpKind(contentType, outerEntity);
|
|
236
|
+
if (pgp) {
|
|
237
|
+
return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
|
|
238
|
+
}
|
|
180
239
|
if (!rawContentType) {
|
|
181
240
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
182
241
|
}
|
|
183
|
-
const contentType = parseParameterizedHeader(rawContentType);
|
|
184
242
|
const callerPins = normalizePins(pinnedSignerFingerprints);
|
|
185
243
|
// The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
|
|
186
244
|
// send time, inside the encrypted content) are compared against to detect post-send tampering.
|
|
@@ -248,6 +306,34 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
|
|
|
248
306
|
kind: "signed",
|
|
249
307
|
};
|
|
250
308
|
}
|
|
309
|
+
// A message the server sealed at rest: evaluate the original it holds - which may itself be signed or end-to-end encrypted -
|
|
310
|
+
// and report one that was unprotected when it arrived as `protected_at_rest`, with its content.
|
|
311
|
+
if (isEncryptedContentType(contentType) && !sealedAtRest && unlocked?.zeroAccessKeys?.length) {
|
|
312
|
+
const original = await openZeroAccess(body, unlocked.zeroAccessKeys);
|
|
313
|
+
if (original !== undefined) {
|
|
314
|
+
const inner = await evaluateLive(original, unlocked, pinnedSignerFingerprints, readerAddress, true);
|
|
315
|
+
const entity = parseMimeEntity(original);
|
|
316
|
+
const subject = entity.headers["subject"];
|
|
317
|
+
const calendarText = extractCalendarText(entity);
|
|
318
|
+
const calendar = calendarText !== undefined ? { calendar: calendarText } : {};
|
|
319
|
+
if (inner.result.state !== "unprotected") {
|
|
320
|
+
// The stored message's own Subject is the placeholder: the original's stands in when the inner result has none.
|
|
321
|
+
const fallbackSubject = inner.result.subject === undefined && subject !== undefined ? { subject: decodeHeaderText(subject) } : {};
|
|
322
|
+
return { ...inner, result: { ...inner.result, ...fallbackSubject, ...calendar, zeroAccess: true } };
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
result: {
|
|
326
|
+
state: "protected_at_rest",
|
|
327
|
+
zeroAccess: true,
|
|
328
|
+
...calendar,
|
|
329
|
+
...renderDisplayBody(extractDisplayBody(entity)),
|
|
330
|
+
...(subject !== undefined ? { subject: decodeHeaderText(subject) } : {}),
|
|
331
|
+
attachments: extractAttachments(entity),
|
|
332
|
+
},
|
|
333
|
+
kind: "other",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
251
337
|
if (isEncryptedContentType(contentType)) {
|
|
252
338
|
// The active key first, then the retained older ones (see `keySession.ts`'s `retainedEncryptionKeys`), so mail
|
|
253
339
|
// encrypted to a key since replaced still opens. Only ever used to decrypt here - never to encrypt or sign.
|
|
@@ -290,6 +376,108 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
|
|
|
290
376
|
}
|
|
291
377
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
292
378
|
}
|
|
379
|
+
const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
|
|
380
|
+
const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
|
|
381
|
+
function pgpKind(contentType, entity) {
|
|
382
|
+
const protocol = contentType.params["protocol"]?.toLowerCase();
|
|
383
|
+
if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
|
|
384
|
+
return "signed";
|
|
385
|
+
}
|
|
386
|
+
if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
|
|
387
|
+
return "encrypted";
|
|
388
|
+
}
|
|
389
|
+
if (contentType.value !== "text/plain") {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
const text = decodeBodyText(entity).trim();
|
|
393
|
+
return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
|
|
397
|
+
* trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
|
|
398
|
+
* one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
|
|
399
|
+
* pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
|
|
400
|
+
* A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
|
|
401
|
+
* loaded only here.
|
|
402
|
+
*/
|
|
403
|
+
async function evaluatePgp(kind, rawContentType, outer, unlocked, pins, readerAddress) {
|
|
404
|
+
const pgp = await import("./pgpMessage.js");
|
|
405
|
+
const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
|
|
406
|
+
const ownKeys = unlocked?.openPgpKeys ?? [];
|
|
407
|
+
const candidates = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
|
|
408
|
+
const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
|
|
409
|
+
const encrypted = kind === "encrypted" || kind === "inline-encrypted";
|
|
410
|
+
const text = decodeBodyText(outer).trim();
|
|
411
|
+
let parsed;
|
|
412
|
+
if (kind === "signed") {
|
|
413
|
+
parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
|
|
414
|
+
}
|
|
415
|
+
else if (kind === "encrypted") {
|
|
416
|
+
parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
|
|
417
|
+
}
|
|
418
|
+
else if (kind === "inline-encrypted") {
|
|
419
|
+
parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
parsed = await pgp.parsePgpCleartextSigned(text, candidates);
|
|
423
|
+
}
|
|
424
|
+
const resultKind = encrypted ? "encrypted" : "signed";
|
|
425
|
+
if (encrypted && !parsed.decrypted) {
|
|
426
|
+
return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
|
|
427
|
+
}
|
|
428
|
+
if (parsed.entityText === undefined) {
|
|
429
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
|
|
430
|
+
}
|
|
431
|
+
const entity = parseMimeEntity(parsed.entityText);
|
|
432
|
+
const inline = kind === "inline-encrypted" || kind === "inline-signed";
|
|
433
|
+
const protectedHeaders = inline ? undefined : headersToProtectedHeaders(entity.headers);
|
|
434
|
+
const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
|
|
435
|
+
const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
|
|
436
|
+
const outerComparable = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
|
|
437
|
+
const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
|
|
438
|
+
const content = {
|
|
439
|
+
...renderDisplayBody(extractDisplayBody(entity)),
|
|
440
|
+
subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
|
|
441
|
+
attachments: extractAttachments(entity),
|
|
442
|
+
...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
|
|
443
|
+
...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
|
|
444
|
+
};
|
|
445
|
+
const signature = parsed.signature;
|
|
446
|
+
if (!signature) {
|
|
447
|
+
// Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
|
|
448
|
+
return { result: { state: "encrypted", ...content }, kind: resultKind };
|
|
449
|
+
}
|
|
450
|
+
if (signature.status === "unknown_key") {
|
|
451
|
+
return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
|
|
452
|
+
}
|
|
453
|
+
if (signature.status === "invalid") {
|
|
454
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
|
|
455
|
+
}
|
|
456
|
+
const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" };
|
|
457
|
+
const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
|
|
458
|
+
const signedHeaders = hasProtected
|
|
459
|
+
? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
|
|
460
|
+
: {};
|
|
461
|
+
const failure = checkIdentityAndHeaders({
|
|
462
|
+
protectedHeaders: hasProtected ? protectedHeaders : undefined,
|
|
463
|
+
outerHeaders: outer.headers,
|
|
464
|
+
outerFields: outer.fields,
|
|
465
|
+
protectedFields: hasProtected ? entity.fields : undefined,
|
|
466
|
+
compareSubject: !encrypted,
|
|
467
|
+
}, signature.emails);
|
|
468
|
+
// Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
|
|
469
|
+
const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
|
|
470
|
+
if (failure || keyChanged) {
|
|
471
|
+
const reason = failure ?? "signer_key_changed";
|
|
472
|
+
return {
|
|
473
|
+
result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
|
|
474
|
+
kind: resultKind,
|
|
475
|
+
...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const state = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
|
|
479
|
+
return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
|
|
480
|
+
}
|
|
293
481
|
const LIVE_VERIFIED_STATES = ["signed_verified", "encrypted_verified"];
|
|
294
482
|
/** Whether a live result failed only because of the signer key's status - the cases a seal may override. Every other
|
|
295
483
|
* failure (an invalid signature, identity or header mismatch, `untrusted_signer`, HP-Outer tampering) never is. */
|
|
@@ -135,6 +135,11 @@ export interface MimeAttachment {
|
|
|
135
135
|
* reported as one attachment, never expanded.
|
|
136
136
|
*/
|
|
137
137
|
export declare function extractAttachments(entity: MimeEntity, depth?: number): MimeAttachment[];
|
|
138
|
+
/**
|
|
139
|
+
* The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
|
|
140
|
+
* `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
|
|
141
|
+
*/
|
|
142
|
+
export declare function extractCalendarText(entity: MimeEntity, depth?: number): string | undefined;
|
|
138
143
|
/** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
|
|
139
144
|
* rendered through the same HTML path without ever being interpreted as markup. */
|
|
140
145
|
export declare function plainTextToHtml(text: string): string;
|
package/dist/lib/crypto/mime.js
CHANGED
|
@@ -478,6 +478,31 @@ export function extractAttachments(entity, depth = 0) {
|
|
|
478
478
|
},
|
|
479
479
|
];
|
|
480
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* The text of the first calendar file in `entity` (descending into multipart children, bounded depth): a `text/calendar` or
|
|
483
|
+
* `application/ics` leaf, whether a body alternative (as Outlook sends an invitation) or an attached `.ics`. `undefined` when there is none.
|
|
484
|
+
*/
|
|
485
|
+
export function extractCalendarText(entity, depth = 0) {
|
|
486
|
+
const contentType = parseParameterizedHeader(entity.headers["content-type"] ?? "text/plain");
|
|
487
|
+
if (contentType.value.startsWith("multipart/")) {
|
|
488
|
+
const boundary = contentType.params["boundary"];
|
|
489
|
+
if (!boundary || depth >= MAX_MULTIPART_DEPTH) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
for (const part of splitMultipart(entity.body, boundary)) {
|
|
493
|
+
const found = extractCalendarText(parseMimeEntity(part), depth + 1);
|
|
494
|
+
if (found !== undefined) {
|
|
495
|
+
return found;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
if (contentType.value !== "text/calendar" && contentType.value !== "application/ics") {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
const bytes = decodeBodyBytes(entity);
|
|
504
|
+
return bytes ? new TextDecoder().decode(bytes) : undefined;
|
|
505
|
+
}
|
|
481
506
|
/** HTML-escapes plain text and wraps it in a whitespace-preserving `<pre>`, so a `text/plain` body can be
|
|
482
507
|
* rendered through the same HTML path without ever being interpreted as markup. */
|
|
483
508
|
export function plainTextToHtml(text) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
|
|
3
|
+
* key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
|
|
4
|
+
* RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
|
|
5
|
+
* encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
|
|
6
|
+
*/
|
|
7
|
+
import type { ApiClient } from "../util/api.js";
|
|
8
|
+
import { type KeyVault } from "./keyvaultApi.js";
|
|
9
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
10
|
+
/**
|
|
11
|
+
* Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
|
|
12
|
+
* and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
|
|
13
|
+
* `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
|
|
14
|
+
* Resolves with the vault and the new key, ready to decrypt and sign with.
|
|
15
|
+
*/
|
|
16
|
+
export declare function provisionOpenPgpKey(mailboxUid: string, mailboxAddress: string, masterKey: Uint8Array, expectedMasterKeyGeneration?: number, displayName?: string, client?: ApiClient): Promise<{
|
|
17
|
+
vault: KeyVault;
|
|
18
|
+
key: {
|
|
19
|
+
fingerprint: string;
|
|
20
|
+
privateKey: OpenPgpPrivateKey;
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
|
|
25
|
+
* supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
|
|
26
|
+
* the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
|
|
27
|
+
* already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
|
|
32
|
+
* mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
|
|
33
|
+
* the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
|
|
34
|
+
* Throws when the mailbox has no OpenPGP key.
|
|
35
|
+
*/
|
|
36
|
+
export declare function exportOpenPgpSecretKey(mailboxUid: string, masterPassword: string, passphrase: string, client?: ApiClient): Promise<{
|
|
37
|
+
armored: string;
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
|
|
2
|
+
import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
|
|
3
|
+
import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
|
|
4
|
+
/**
|
|
5
|
+
* Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
|
|
6
|
+
* and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
|
|
7
|
+
* `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
|
|
8
|
+
* Resolves with the vault and the new key, ready to decrypt and sign with.
|
|
9
|
+
*/
|
|
10
|
+
export async function provisionOpenPgpKey(mailboxUid, mailboxAddress, masterKey, expectedMasterKeyGeneration, displayName, client) {
|
|
11
|
+
const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
|
|
12
|
+
const generated = await generateOpenPgpKey(mailboxAddress, displayName);
|
|
13
|
+
try {
|
|
14
|
+
const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
15
|
+
const vault = await enrollKey(mailboxUid, {
|
|
16
|
+
useType: "encrypt",
|
|
17
|
+
type: OPENPGP_KEY_TYPE,
|
|
18
|
+
publicKey: generated.armoredPublicKey,
|
|
19
|
+
wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
|
|
20
|
+
...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
|
|
21
|
+
}, client);
|
|
22
|
+
return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
generated.secretKey.fill(0);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Provisions already in flight, per mailbox - a second caller joins the first. */
|
|
29
|
+
const inFlight = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
|
|
32
|
+
* supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
|
|
33
|
+
* the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
|
|
34
|
+
* already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
|
|
35
|
+
*/
|
|
36
|
+
export function ensureOpenPgpKey(mailboxUid, mailboxAddress, displayName, client) {
|
|
37
|
+
const pending = inFlight.get(mailboxUid);
|
|
38
|
+
if (pending) {
|
|
39
|
+
return pending;
|
|
40
|
+
}
|
|
41
|
+
const run = (async () => {
|
|
42
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
43
|
+
if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const policy = await getEncryptionPolicy(client);
|
|
47
|
+
if (policy.allowOpenPgp !== true) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
51
|
+
if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
|
|
55
|
+
addOpenPgpKey(mailboxUid, unlocked, key);
|
|
56
|
+
return true;
|
|
57
|
+
})().finally(() => inFlight.delete(mailboxUid));
|
|
58
|
+
inFlight.set(mailboxUid, run);
|
|
59
|
+
return run;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
|
|
63
|
+
* mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
|
|
64
|
+
* the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
|
|
65
|
+
* Throws when the mailbox has no OpenPGP key.
|
|
66
|
+
*/
|
|
67
|
+
export async function exportOpenPgpSecretKey(mailboxUid, masterPassword, passphrase, client) {
|
|
68
|
+
const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
|
|
69
|
+
const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
|
|
70
|
+
try {
|
|
71
|
+
// The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
|
|
72
|
+
const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
|
|
73
|
+
if (!wrapped) {
|
|
74
|
+
throw new Error("This mailbox has no OpenPGP key to export.");
|
|
75
|
+
}
|
|
76
|
+
const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
77
|
+
try {
|
|
78
|
+
const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
|
|
79
|
+
await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
|
|
80
|
+
return { armored, fingerprint: wrapped.fingerprint };
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
raw.fill(0);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
masterKey.fill(0);
|
|
88
|
+
}
|
|
89
|
+
}
|