@rapidmx/web-client 0.36.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/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- 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 +29 -1
- 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 +6 -3
- 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 +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- 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/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- 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 +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- 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 +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- 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 +9 -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 +6 -3
- 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 +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- 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/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -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/settings/KeyExportSetting.js +3 -1
- 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/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- 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/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/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -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 +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- 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 +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -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 +61 -4
- 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 +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- 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.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -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 +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- 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 +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -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/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- 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 +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- 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 +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -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 +12 -3
|
@@ -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
|
+
}
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { MimeAttachment, MimeHeaderField } from "./mime.js";
|
|
37
37
|
import { type DecryptionKey } from "./smime.js";
|
|
38
38
|
import { ProtectedHeaders } from "./smimeMessage.js";
|
|
39
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
39
40
|
import { type PublicKey } from "./keyvaultApi.js";
|
|
40
41
|
import { type SealKeyMaterial, type SealedVerificationState } from "./verificationSeal.js";
|
|
41
42
|
export { signingKeyFingerprints } from "./keyvaultApi.js";
|
|
@@ -126,6 +127,14 @@ export interface MessageSecurityResult {
|
|
|
126
127
|
signerFingerprint?: string;
|
|
127
128
|
/** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
|
|
128
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;
|
|
129
138
|
/** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
|
|
130
139
|
* a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
|
|
131
140
|
* `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
|
|
@@ -198,7 +207,7 @@ export declare function checkSignerBinding(input: SignerBindingInput): Promise<S
|
|
|
198
207
|
* An encrypted message is decrypted with `unlocked`'s active encryption key or any of its `retainedEncryptionKeys` (the
|
|
199
208
|
* older keys a rotation replaced - see `keySession.ts`), matched by recipient identifier first.
|
|
200
209
|
*/
|
|
201
|
-
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>;
|
|
202
211
|
/** The keys `evaluateMessageSecurity()` reads from an unlocked session (`keySession.ts`'s `UnlockedKeys`). */
|
|
203
212
|
export interface MessageSecurityKeys {
|
|
204
213
|
encryptionPrivateKey?: CryptoKey;
|
|
@@ -214,7 +223,22 @@ export interface MessageSecurityKeys {
|
|
|
214
223
|
certDer: Uint8Array;
|
|
215
224
|
privateKey: CryptoKey;
|
|
216
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[];
|
|
217
239
|
}
|
|
240
|
+
/** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
|
|
241
|
+
export type SignerPinsInput = string | string[] | SignerPins | undefined;
|
|
218
242
|
/**
|
|
219
243
|
* The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
|
|
220
244
|
* decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
|
|
@@ -277,4 +301,4 @@ export interface VerificationSealOptions {
|
|
|
277
301
|
* `unlocked` was destroyed (`keySession.ts`), before evaluating anything, or if it is destroyed while sealing; otherwise
|
|
278
302
|
* never throws - a seal that can't be built (e.g. uids too long for the size limit) just leaves `sealToWrite` unset.
|
|
279
303
|
*/
|
|
280
|
-
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 { bytesToBinaryString, decodeBase64Text, decodeHeaderText, extractAddresses, extractAttachments, extractCalendarText, extractDisplayBody, parseMimeEntity, parseParameterizedHeader, plainTextToHtml, } from "./mime.js";
|
|
40
|
+
import { bytesToBinaryString, decodeBodyText, decodeBase64Text, decodeHeaderText, extractAddresses, extractAttachments, extractCalendarText, extractDisplayBody, parseMimeEntity, parseParameterizedHeader, plainTextToHtml, } from "./mime.js";
|
|
41
41
|
import { fromBase64, toBase64 } from "./encoding.js";
|
|
42
42
|
import { computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
|
|
43
|
-
import { parseEncryptedMessageWithKeys, parseSignedOnlyMessage } from "./smimeMessage.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);
|
|
@@ -223,12 +228,17 @@ async function openZeroAccess(body, keys) {
|
|
|
223
228
|
}
|
|
224
229
|
}
|
|
225
230
|
async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress, sealedAtRest = false) {
|
|
226
|
-
const
|
|
231
|
+
const outerEntity = parseMimeEntity(rawMime);
|
|
232
|
+
const { headers, body, fields } = outerEntity;
|
|
227
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
|
+
}
|
|
228
239
|
if (!rawContentType) {
|
|
229
240
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
230
241
|
}
|
|
231
|
-
const contentType = parseParameterizedHeader(rawContentType);
|
|
232
242
|
const callerPins = normalizePins(pinnedSignerFingerprints);
|
|
233
243
|
// The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
|
|
234
244
|
// send time, inside the encrypted content) are compared against to detect post-send tampering.
|
|
@@ -366,6 +376,108 @@ async function evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerA
|
|
|
366
376
|
}
|
|
367
377
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
368
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
|
+
}
|
|
369
481
|
const LIVE_VERIFIED_STATES = ["signed_verified", "encrypted_verified"];
|
|
370
482
|
/** Whether a live result failed only because of the signer key's status - the cases a seal may override. Every other
|
|
371
483
|
* failure (an invalid signature, identity or header mismatch, `untrusted_signer`, HP-Outer tampering) never is. */
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenPGP keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): the mailbox's own key, generated here, and the
|
|
3
|
+
* keys of correspondents who use OpenPGP (Proton Mail, Thunderbird, GnuPG). Built on OpenPGP.js - which runs on WebCrypto and audited
|
|
4
|
+
* curve code, never OpenSSL - and loaded only when OpenPGP is used: this module is only ever imported dynamically.
|
|
5
|
+
*
|
|
6
|
+
* S/MIME stays RapidMX's own format; an OpenPGP key exists so OpenPGP users can reach RapidMX users, and RapidMX users them.
|
|
7
|
+
*/
|
|
8
|
+
import * as openpgp from "openpgp";
|
|
9
|
+
export type OpenPgpPrivateKey = openpgp.PrivateKey;
|
|
10
|
+
export type OpenPgpPublicKey = openpgp.PublicKey;
|
|
11
|
+
/** A freshly generated key: the public certificate to publish and the secret key to seal under the master key. */
|
|
12
|
+
export interface GeneratedOpenPgpKey {
|
|
13
|
+
/** Base64 of the binary transferable public key (`PublicKey.publicKey`). */
|
|
14
|
+
publicKey: string;
|
|
15
|
+
/** The same certificate, ASCII armored (what enrollment sends and the user can export). */
|
|
16
|
+
armoredPublicKey: string;
|
|
17
|
+
/** The binary transferable secret key, unencrypted - the caller seals it under the master key and zeroes this copy. */
|
|
18
|
+
secretKey: Uint8Array;
|
|
19
|
+
/** The primary key's fingerprint (40 hex characters for a v4 key, 64 for v6). */
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
}
|
|
22
|
+
/** Which OpenPGP key version to generate: 4 (RFC 4880, the default - what every OpenPGP app reads, GnuPG included) or 6 (RFC 9580). */
|
|
23
|
+
export type OpenPgpKeyVersion = 4 | 6;
|
|
24
|
+
/**
|
|
25
|
+
* Generates the mailbox's OpenPGP key: an Ed25519 primary key with a Curve25519 encryption subkey and one user ID,
|
|
26
|
+
* `displayName <address>`, never expiring (rotation replaces it). A v4 key uses the long-established EdDSA/ECDH Curve25519
|
|
27
|
+
* algorithms; a v6 key RFC 9580's Ed25519/X25519. It carries no passphrase: like every other private key, its secret half is
|
|
28
|
+
* sealed under the master key before it leaves this device.
|
|
29
|
+
*/
|
|
30
|
+
export declare function generateOpenPgpKey(address: string, displayName?: string, version?: OpenPgpKeyVersion): Promise<GeneratedOpenPgpKey>;
|
|
31
|
+
/** A rotation's new key with its user ID certified by the key it replaces, so correspondents who pinned the old one accept the
|
|
32
|
+
* new one without asking (restapi `OpenPgpKeyUtils.canReplaceOpenPgpKeyAutomatically()`). */
|
|
33
|
+
export declare function certifyWithPredecessor(successor: GeneratedOpenPgpKey, predecessor: OpenPgpPrivateKey): Promise<GeneratedOpenPgpKey>;
|
|
34
|
+
/** The secret key in `bytes` (a sealed `WrappedPrivateKey`'s plaintext), ready to decrypt and sign with. */
|
|
35
|
+
export declare function readSecretKey(bytes: Uint8Array): Promise<OpenPgpPrivateKey>;
|
|
36
|
+
/** The certificate in `base64` (`PublicKey.publicKey`), or `undefined` when it isn't one. */
|
|
37
|
+
export declare function readPublicKey(base64: string): Promise<OpenPgpPublicKey | undefined>;
|
|
38
|
+
/** Every certificate in an armored or binary key block (e.g. an attached `application/pgp-keys` part, or a pasted one). */
|
|
39
|
+
export declare function readPublicKeys(block: string | Uint8Array): Promise<OpenPgpPublicKey[]>;
|
|
40
|
+
/** The email addresses a certificate's user IDs name with a verifying self-signature, lowercased. */
|
|
41
|
+
export declare function keyEmails(key: OpenPgpPublicKey): Promise<string[]>;
|
|
42
|
+
/** A certificate's armored form, for export or for attaching to a message. */
|
|
43
|
+
export declare function armorPublicKey(key: OpenPgpPublicKey): string;
|
|
44
|
+
/** A secret key armored and protected with `passphrase` - the export a user imports into another OpenPGP app. */
|
|
45
|
+
export declare function exportProtectedSecretKey(key: OpenPgpPrivateKey, passphrase: string): Promise<string>;
|
|
46
|
+
/** `key`'s public certificate as base64 (`PublicKey.publicKey`). */
|
|
47
|
+
export declare function publicKeyBase64(key: OpenPgpPrivateKey | OpenPgpPublicKey): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* OpenPGP keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): the mailbox's own key, generated here, and the
|
|
7
|
+
* keys of correspondents who use OpenPGP (Proton Mail, Thunderbird, GnuPG). Built on OpenPGP.js - which runs on WebCrypto and audited
|
|
8
|
+
* curve code, never OpenSSL - and loaded only when OpenPGP is used: this module is only ever imported dynamically.
|
|
9
|
+
*
|
|
10
|
+
* S/MIME stays RapidMX's own format; an OpenPGP key exists so OpenPGP users can reach RapidMX users, and RapidMX users them.
|
|
11
|
+
*/
|
|
12
|
+
import * as openpgp from "openpgp";
|
|
13
|
+
import { fromBase64, toBase64 } from "./encoding.js";
|
|
14
|
+
/**
|
|
15
|
+
* Generates the mailbox's OpenPGP key: an Ed25519 primary key with a Curve25519 encryption subkey and one user ID,
|
|
16
|
+
* `displayName <address>`, never expiring (rotation replaces it). A v4 key uses the long-established EdDSA/ECDH Curve25519
|
|
17
|
+
* algorithms; a v6 key RFC 9580's Ed25519/X25519. It carries no passphrase: like every other private key, its secret half is
|
|
18
|
+
* sealed under the master key before it leaves this device.
|
|
19
|
+
*/
|
|
20
|
+
export async function generateOpenPgpKey(address, displayName, version = 4) {
|
|
21
|
+
const userIDs = [{ ...(displayName ? { name: displayName } : {}), email: address }];
|
|
22
|
+
const { privateKey, publicKey } = version === 6
|
|
23
|
+
? await openpgp.generateKey({ type: "curve25519", userIDs, format: "object", config: { v6Keys: true } })
|
|
24
|
+
: await openpgp.generateKey({ type: "ecc", curve: "curve25519Legacy", userIDs, format: "object" });
|
|
25
|
+
return {
|
|
26
|
+
publicKey: toBase64(publicKey.write()),
|
|
27
|
+
armoredPublicKey: publicKey.armor(),
|
|
28
|
+
secretKey: privateKey.write(),
|
|
29
|
+
fingerprint: publicKey.getFingerprint(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** A rotation's new key with its user ID certified by the key it replaces, so correspondents who pinned the old one accept the
|
|
33
|
+
* new one without asking (restapi `OpenPgpKeyUtils.canReplaceOpenPgpKeyAutomatically()`). */
|
|
34
|
+
export async function certifyWithPredecessor(successor, predecessor) {
|
|
35
|
+
const secret = await openpgp.readPrivateKey({ binaryKey: successor.secretKey });
|
|
36
|
+
const certified = await secret.signPrimaryUser([predecessor]);
|
|
37
|
+
const publicKey = certified.toPublic();
|
|
38
|
+
return { ...successor, publicKey: toBase64(publicKey.write()), armoredPublicKey: publicKey.armor(), secretKey: certified.write() };
|
|
39
|
+
}
|
|
40
|
+
/** The secret key in `bytes` (a sealed `WrappedPrivateKey`'s plaintext), ready to decrypt and sign with. */
|
|
41
|
+
export async function readSecretKey(bytes) {
|
|
42
|
+
return await openpgp.readPrivateKey({ binaryKey: bytes });
|
|
43
|
+
}
|
|
44
|
+
/** The certificate in `base64` (`PublicKey.publicKey`), or `undefined` when it isn't one. */
|
|
45
|
+
export async function readPublicKey(base64) {
|
|
46
|
+
try {
|
|
47
|
+
return await openpgp.readKey({ binaryKey: fromBase64(base64) });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Every certificate in an armored or binary key block (e.g. an attached `application/pgp-keys` part, or a pasted one). */
|
|
54
|
+
export async function readPublicKeys(block) {
|
|
55
|
+
try {
|
|
56
|
+
const keys = typeof block === "string" && block.includes("-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
57
|
+
? await openpgp.readKeys({ armoredKeys: block })
|
|
58
|
+
: await openpgp.readKeys({ binaryKeys: typeof block === "string" ? fromBase64(block.replace(/\s+/g, "")) : block });
|
|
59
|
+
return keys.filter((key) => !key.isPrivate());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The email addresses a certificate's user IDs name with a verifying self-signature, lowercased. */
|
|
66
|
+
export async function keyEmails(key) {
|
|
67
|
+
const emails = [];
|
|
68
|
+
for (const user of key.users) {
|
|
69
|
+
const email = user.userID?.email;
|
|
70
|
+
if (email && (await user.verify().then(() => true, () => false))) {
|
|
71
|
+
emails.push(email.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return emails;
|
|
75
|
+
}
|
|
76
|
+
/** A certificate's armored form, for export or for attaching to a message. */
|
|
77
|
+
export function armorPublicKey(key) {
|
|
78
|
+
return key.armor();
|
|
79
|
+
}
|
|
80
|
+
/** A secret key armored and protected with `passphrase` - the export a user imports into another OpenPGP app. */
|
|
81
|
+
export async function exportProtectedSecretKey(key, passphrase) {
|
|
82
|
+
const copy = await openpgp.readPrivateKey({ binaryKey: key.write() });
|
|
83
|
+
return (await openpgp.encryptKey({ privateKey: copy, passphrase })).armor();
|
|
84
|
+
}
|
|
85
|
+
/** `key`'s public certificate as base64 (`PublicKey.publicKey`). */
|
|
86
|
+
export function publicKeyBase64(key) {
|
|
87
|
+
return toBase64(key.toPublic().write());
|
|
88
|
+
}
|