@rapidmx/web-client 0.36.0 → 0.37.1
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
|
@@ -41,9 +41,17 @@ function policyStateForTier(policy, tier) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Per-recipient encryption eligibility, given this device's own encryption preference and the
|
|
43
43
|
* system-wide policy. One entry per (address, lookup result) pair the caller already resolved via
|
|
44
|
-
* `lookupKeys()` - this function does no discovery itself.
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
* `lookupKeys()` - this function does no discovery itself. `ownCanUseOpenPgp` false: the sender has no OpenPGP key to use here
|
|
45
|
+
* (an OpenPGP copy is encrypted to the sender too), so a recipient who prefers OpenPGP but has an S/MIME certificate gets S/MIME - a
|
|
46
|
+
* preference never leaves them unencrypted (restapi `specs/end-to-end_encryption.md` *Choosing the format*). */
|
|
47
|
+
export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy, recipientAddress, lookup, ownCanUseOpenPgp = true) {
|
|
48
|
+
const smimeCert = lookup ? findActivePublicKey(lookup.keys, "encrypt") : undefined;
|
|
49
|
+
const usableOpenPgpKey = lookup && policy.allowOpenPgp === true ? findActivePublicKey(lookup.keys, "encrypt", "openpgp") : undefined;
|
|
50
|
+
// S/MIME, unless the recipient has no certificate or prefers OpenPGP (restapi *Choosing the format*: the user's choice for the
|
|
51
|
+
// contact, what the address advertises, what it was last seen using) - and OpenPGP only where the server allows it.
|
|
52
|
+
const useOpenPgp = !!usableOpenPgpKey && (!smimeCert || (lookup?.preferredFormat === "openpgp" && ownCanUseOpenPgp));
|
|
53
|
+
const encryptCert = useOpenPgp ? undefined : smimeCert;
|
|
54
|
+
const openPgpKey = useOpenPgp ? usableOpenPgpKey : undefined;
|
|
47
55
|
const tier = classifyRecipientTier(ownAddress, recipientAddress, !!lookup && lookup.keys.length > 0);
|
|
48
56
|
const policyState = policyStateForTier(policy, tier);
|
|
49
57
|
if (policyState === "prohibited") {
|
|
@@ -56,7 +64,7 @@ export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy,
|
|
|
56
64
|
prohibitedReason: "Your organization's encryption policy does not allow encrypting messages to this recipient.",
|
|
57
65
|
};
|
|
58
66
|
}
|
|
59
|
-
if (!encryptCert) {
|
|
67
|
+
if (!encryptCert && !openPgpKey) {
|
|
60
68
|
return { address: recipientAddress, tier, canEncrypt: false, autoEncrypt: false };
|
|
61
69
|
}
|
|
62
70
|
const recipientPrefersMutual = lookup?.encryptPreference?.preferEncrypt === "mutual";
|
|
@@ -64,7 +72,7 @@ export function resolveRecipientEncryption(ownAddress, ownPrefersMutual, policy,
|
|
|
64
72
|
return {
|
|
65
73
|
address: recipientAddress,
|
|
66
74
|
tier,
|
|
67
|
-
encryptCert,
|
|
75
|
+
...(encryptCert ? { encryptCert, format: "smime" } : { openPgpKey, format: "openpgp" }),
|
|
68
76
|
canEncrypt: true,
|
|
69
77
|
autoEncrypt: policyState === "automatic" && mutual,
|
|
70
78
|
};
|
|
@@ -14,7 +14,7 @@ import "reflect-metadata";
|
|
|
14
14
|
import * as asn1js from "asn1js";
|
|
15
15
|
import * as pkijs from "pkijs";
|
|
16
16
|
import { fromBase64 } from "./encoding.js";
|
|
17
|
-
import { findActivePublicKey, recordKeyExport } from "./keyvaultApi.js";
|
|
17
|
+
import { findActivePublicKey, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
|
|
18
18
|
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS, openMasterKeyWithPassword, SIGNING_PRIVATE_KEY_AAD_PURPOSE, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
|
|
19
19
|
import { buildAad, openWithKey } from "./masterKey.js";
|
|
20
20
|
/** PBKDF2 iterations for the shrouded key bags and the PKCS#12 MAC. Windows, macOS and iOS all accept this. */
|
|
@@ -90,7 +90,7 @@ async function openExportedKeys(input, vault, masterKey) {
|
|
|
90
90
|
const active = findActivePublicKey(input.mailboxKeys, "encrypt");
|
|
91
91
|
const encryption = [
|
|
92
92
|
...(active ? [active] : []),
|
|
93
|
-
...input.mailboxKeys.filter((k) => k.useType === "encrypt" && k !== active).sort((a, b) => b.notBefore - a.notBefore),
|
|
93
|
+
...input.mailboxKeys.filter((k) => k.useType === "encrypt" && !isOpenPgpKey(k) && k !== active).sort((a, b) => b.notBefore - a.notBefore),
|
|
94
94
|
];
|
|
95
95
|
await take("encrypt", encryption, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
96
96
|
if (input.includeSigningKey) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
|
|
3
|
+
* from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
|
|
4
|
+
* to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
|
|
5
|
+
* server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
|
|
6
|
+
*/
|
|
7
|
+
/** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
|
|
8
|
+
* parse or import is `false`. */
|
|
9
|
+
export declare function certificateHoldsKey(certDer: Uint8Array, pkcs8: Uint8Array): Promise<boolean>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Whether a certificate the server lists is really the one of a private key this device opened. A zero-access key's private half comes
|
|
7
|
+
* from an authenticated seal (or a member's wrap), but its certificate comes from the server as it is: what this device then encrypts
|
|
8
|
+
* to that certificate - the search index segments it builds from end-to-end mail, a protected meeting's key - must not go to a key the
|
|
9
|
+
* server swapped in. Plain WebCrypto and a few lines of DER, so the unlock path loads no certificate library.
|
|
10
|
+
*/
|
|
11
|
+
/** One DER element of `der` at `offset`: where its header starts, where its content starts, where it ends. */
|
|
12
|
+
function element(der, offset) {
|
|
13
|
+
const tag = der[offset];
|
|
14
|
+
let length = der[offset + 1];
|
|
15
|
+
let content = offset + 2;
|
|
16
|
+
if (length & 0x80) {
|
|
17
|
+
const bytes = length & 0x7f;
|
|
18
|
+
if (bytes === 0 || bytes > 4) {
|
|
19
|
+
throw new Error("Unsupported DER length.");
|
|
20
|
+
}
|
|
21
|
+
length = 0;
|
|
22
|
+
for (let i = 0; i < bytes; i++) {
|
|
23
|
+
length = length * 256 + der[content + i];
|
|
24
|
+
}
|
|
25
|
+
content += bytes;
|
|
26
|
+
}
|
|
27
|
+
const end = content + length;
|
|
28
|
+
if (tag === undefined || end > der.length) {
|
|
29
|
+
throw new Error("Truncated DER.");
|
|
30
|
+
}
|
|
31
|
+
return { tag, header: offset, content, end };
|
|
32
|
+
}
|
|
33
|
+
/** The children of the constructed element `parent`. */
|
|
34
|
+
function children(der, parent) {
|
|
35
|
+
const found = [];
|
|
36
|
+
for (let offset = parent.content; offset < parent.end; offset = found[found.length - 1].end) {
|
|
37
|
+
found.push(element(der, offset));
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
/** The SubjectPublicKeyInfo of X.509 certificate `certDer` (RFC 5280: the seventh field of `tbsCertificate`, the sixth when it has no
|
|
42
|
+
* explicit version). */
|
|
43
|
+
function subjectPublicKeyInfo(certDer) {
|
|
44
|
+
const [tbs] = children(certDer, element(certDer, 0));
|
|
45
|
+
const fields = children(certDer, tbs);
|
|
46
|
+
const spki = fields[fields[0].tag === 0xa0 ? 6 : 5];
|
|
47
|
+
if (!spki || spki.tag !== 0x30) {
|
|
48
|
+
throw new Error("No subject public key.");
|
|
49
|
+
}
|
|
50
|
+
return certDer.subarray(spki.header, spki.end);
|
|
51
|
+
}
|
|
52
|
+
const P256_ECDH = { name: "ECDH", namedCurve: "P-256" };
|
|
53
|
+
/** Whether `certDer` (a P-256 certificate) carries the public half of `pkcs8` (a P-256 private key). Never rejects: anything that won't
|
|
54
|
+
* parse or import is `false`. */
|
|
55
|
+
export async function certificateHoldsKey(certDer, pkcs8) {
|
|
56
|
+
try {
|
|
57
|
+
const publicKey = await crypto.subtle.importKey("spki", subjectPublicKeyInfo(certDer), P256_ECDH, true, []);
|
|
58
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, P256_ECDH, true, ["deriveBits"]);
|
|
59
|
+
const [certified, held] = await Promise.all([crypto.subtle.exportKey("jwk", publicKey), crypto.subtle.exportKey("jwk", privateKey)]);
|
|
60
|
+
return !!certified.x && certified.x === held.x && certified.y === held.y;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { type KeyVault, type PublicKey } from "./keyvaultApi.js";
|
|
21
21
|
import type { ApiClient } from "../util/api.js";
|
|
22
22
|
import { KeysLockedError } from "./masterKey.js";
|
|
23
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
23
24
|
/** AAD purpose labels — MUST exactly match what `KeyEnrollmentGate.tsx` used when it originally
|
|
24
25
|
* sealed each of these values, or `openWithKey()` fails (GCM authenticates the AAD, not just the
|
|
25
26
|
* ciphertext). Centralized here as the one place both wrapping and unwrapping refer to. */
|
|
@@ -27,6 +28,7 @@ export declare const MASTER_KEY_AAD_PURPOSE = "master-key";
|
|
|
27
28
|
export declare const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
|
|
28
29
|
export declare const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
|
|
29
30
|
export declare const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
|
|
31
|
+
export declare const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
|
|
30
32
|
export { KeysLockedError };
|
|
31
33
|
/** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
|
|
32
34
|
* `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
|
|
@@ -66,6 +68,13 @@ export interface UnlockedKeys {
|
|
|
66
68
|
* index entries and sealed summaries to - the active one first, then retained ones newest first, at most
|
|
67
69
|
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. Decryption only. Absent when the mailbox has none (or none opened). */
|
|
68
70
|
zeroAccessKeys?: RetainedEncryptionKey[];
|
|
71
|
+
/** The mailbox's OpenPGP secret keys (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*) - the active one
|
|
72
|
+
* first, then retained ones newest first, at most `MAX_RETAINED_ENCRYPTION_KEYS + 1`: what opens mail OpenPGP users encrypt to
|
|
73
|
+
* the mailbox, and signs what it sends them. Absent when the mailbox has none (or none opened). */
|
|
74
|
+
openPgpKeys?: {
|
|
75
|
+
fingerprint: string;
|
|
76
|
+
privateKey: OpenPgpPrivateKey;
|
|
77
|
+
}[];
|
|
69
78
|
/** A shared mailbox opened through this user's member key wraps (`sharedMailboxKeys.ts`), not with its own master key: it holds
|
|
70
79
|
* only `zeroAccessKeys`, and `masterKey` is a per-key secret derived from its zero-access private key (what the local search
|
|
71
80
|
* index's key is derived from), never a key that opens the mailbox's vault. */
|
|
@@ -96,6 +105,14 @@ export declare function subscribeKeySession(listener: KeySessionListener): () =>
|
|
|
96
105
|
* meanwhile; the next unlock opens the key from the vault anyway).
|
|
97
106
|
*/
|
|
98
107
|
export declare function addZeroAccessKey(mailboxUid: string, unlocked: UnlockedKeys, key: RetainedEncryptionKey): void;
|
|
108
|
+
/**
|
|
109
|
+
* Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
|
|
110
|
+
* OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function addOpenPgpKey(mailboxUid: string, unlocked: UnlockedKeys, key: {
|
|
113
|
+
fingerprint: string;
|
|
114
|
+
privateKey: OpenPgpPrivateKey;
|
|
115
|
+
}): void;
|
|
99
116
|
/**
|
|
100
117
|
* Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
|
|
101
118
|
* session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
|
|
@@ -163,8 +180,8 @@ export declare class UnopenableEncryptionKeyError extends Error {
|
|
|
163
180
|
* "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
|
|
164
181
|
* Once the master key has opened, the password is known to be right: an active signing key that then
|
|
165
182
|
* won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
|
|
166
|
-
* encryption key that won't
|
|
167
|
-
*
|
|
183
|
+
* encryption key that won't open, or whose certificate isn't its own,
|
|
184
|
+
* still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
|
|
168
185
|
* `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
|
|
169
186
|
*/
|
|
170
187
|
export declare function unlockWithPassword(mailboxUid: string, mailboxKeys: PublicKey[], password: string): Promise<UnlockResult>;
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* follow-up, not a silent gap: a mailbox enrolled *only* with a passkey has no way to unlock through this
|
|
22
22
|
* module today.
|
|
23
23
|
*/
|
|
24
|
-
import { findActivePublicKey, getKeyVault } from "./keyvaultApi.js";
|
|
24
|
+
import { findActivePublicKey, getKeyVault, isOpenPgpKey } from "./keyvaultApi.js";
|
|
25
25
|
import { fromBase64 } from "./encoding.js";
|
|
26
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
26
27
|
import { importPrivateKeyPkcs8 } from "./keys.js";
|
|
27
28
|
import { KeysLockedError, buildAad, openWithKey } from "./masterKey.js";
|
|
28
29
|
import { deriveFromPassword, parseArgon2idKdfLabel } from "./passwordUnlock.js";
|
|
@@ -34,6 +35,7 @@ export const MASTER_KEY_AAD_PURPOSE = "master-key";
|
|
|
34
35
|
export const SIGNING_PRIVATE_KEY_AAD_PURPOSE = "sign-private-key";
|
|
35
36
|
export const ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE = "encrypt-private-key";
|
|
36
37
|
export const ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE = "zero-access-private-key";
|
|
38
|
+
export const OPENPGP_PRIVATE_KEY_AAD_PURPOSE = "openpgp-private-key";
|
|
37
39
|
export { KeysLockedError };
|
|
38
40
|
/** How many retained (non-active) encryption keys an unlock opens at most: the most recently issued ones by
|
|
39
41
|
* `notBefore`. Each costs an AEAD open and a key import at unlock, and `smime.ts`'s `decryptEnvelopedDataWithKeys()`
|
|
@@ -102,6 +104,16 @@ export function addZeroAccessKey(mailboxUid, unlocked, key) {
|
|
|
102
104
|
}
|
|
103
105
|
unlocked.zeroAccessKeys = [key, ...(unlocked.zeroAccessKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
|
|
104
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Makes an OpenPGP key this device has just enrolled (`openPgpKey.ts`'s `ensureOpenPgpKey()`) the active one of `unlocked`, so
|
|
109
|
+
* OpenPGP mail to it reads without another unlock - the same rules as `addZeroAccessKey()`.
|
|
110
|
+
*/
|
|
111
|
+
export function addOpenPgpKey(mailboxUid, unlocked, key) {
|
|
112
|
+
if (unlocked.destroyed || sessions.get(mailboxUid) !== unlocked) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
unlocked.openPgpKeys = [key, ...(unlocked.openPgpKeys ?? []).filter((k) => k.fingerprint !== key.fingerprint)];
|
|
116
|
+
}
|
|
105
117
|
/**
|
|
106
118
|
* Stores the keys of a shared mailbox opened through member key wraps (`sharedMailboxKeys.ts`'s `unlockSharedMailbox()`) as that mailbox's
|
|
107
119
|
* session, exactly as an unlock would - tracked for `destroyUnlockedKeys()`, and notified - unless the mailbox was locked since `generation`
|
|
@@ -197,6 +209,15 @@ function destroyObject(unlocked) {
|
|
|
197
209
|
unlocked.zeroAccessKeys.length = 0;
|
|
198
210
|
delete unlocked.zeroAccessKeys;
|
|
199
211
|
}
|
|
212
|
+
if (unlocked.openPgpKeys) {
|
|
213
|
+
// OpenPGP.js keeps a decrypted secret key's material in its own objects: wipe it there too.
|
|
214
|
+
for (const { privateKey } of unlocked.openPgpKeys) {
|
|
215
|
+
// Present at runtime on every `PrivateKey`, missing from OpenPGP.js's type declarations.
|
|
216
|
+
privateKey.clearPrivateParams();
|
|
217
|
+
}
|
|
218
|
+
unlocked.openPgpKeys.length = 0;
|
|
219
|
+
delete unlocked.openPgpKeys;
|
|
220
|
+
}
|
|
200
221
|
}
|
|
201
222
|
/** Finds the wrapped private key whose fingerprint matches a given published public key. */
|
|
202
223
|
function findWrappedPrivateKey(vault, fingerprint) {
|
|
@@ -217,10 +238,15 @@ export class UnopenableEncryptionKeyError extends Error {
|
|
|
217
238
|
}
|
|
218
239
|
}
|
|
219
240
|
/** Opens one wrapped PKCS#8 private key under `masterKey` and imports it (non-extractable by default, like
|
|
220
|
-
* `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds.
|
|
221
|
-
|
|
241
|
+
* `importPrivateKeyPkcs8()` itself), zeroing the transient plaintext whether or not the import succeeds. With `certDer` - the server's
|
|
242
|
+
* certificate for it, unauthenticated - it rejects a key that certificate isn't the one of (`certificateHoldsKey()`), so a swapped
|
|
243
|
+
* certificate never pairs with the opened key. */
|
|
244
|
+
async function openPrivateKey(masterKey, wrapped, aad, algorithm, usages, extractable = false, certDer) {
|
|
222
245
|
const raw = await openWithKey(masterKey, wrapped, aad);
|
|
223
246
|
try {
|
|
247
|
+
if (certDer && !(await certificateHoldsKey(certDer, raw))) {
|
|
248
|
+
throw new Error("The certificate isn't the key's.");
|
|
249
|
+
}
|
|
224
250
|
return await importPrivateKeyPkcs8(raw, algorithm, usages, extractable);
|
|
225
251
|
}
|
|
226
252
|
finally {
|
|
@@ -231,7 +257,7 @@ async function openPrivateKey(masterKey, wrapped, aad, algorithm, usages, extrac
|
|
|
231
257
|
* Opens the retained encryption keys onto `unlocked.retainedEncryptionKeys`: every published `encrypt` key in
|
|
232
258
|
* `mailboxKeys` other than the active one (`activeFingerprint`), whatever its revocation or expiry, that has a wrapped
|
|
233
259
|
* private key in the vault - de-duplicated by fingerprint, newest `notBefore` first, at most
|
|
234
|
-
* `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode, is skipped and
|
|
260
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS`. A key that won't open or import, or whose certificate won't decode or isn't the key's, is skipped and
|
|
235
261
|
* added to `unopenableKeys`. Retained keys are imported non-extractable. Each key is attached as it opens, so a failed
|
|
236
262
|
* unlock's `destroyObject()` drops those too.
|
|
237
263
|
*/
|
|
@@ -239,7 +265,7 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
|
|
|
239
265
|
const seen = new Set(activeFingerprint !== undefined ? [activeFingerprint] : []);
|
|
240
266
|
const candidates = [];
|
|
241
267
|
for (const publicKey of [...mailboxKeys].sort((a, b) => b.notBefore - a.notBefore)) {
|
|
242
|
-
const wrapped = publicKey.useType === "encrypt" && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
|
|
268
|
+
const wrapped = publicKey.useType === "encrypt" && !isOpenPgpKey(publicKey) && !seen.has(publicKey.fingerprint) ? findWrappedPrivateKey(vault, publicKey.fingerprint) : undefined;
|
|
243
269
|
if (!wrapped) {
|
|
244
270
|
continue;
|
|
245
271
|
}
|
|
@@ -253,7 +279,7 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
|
|
|
253
279
|
for (const { publicKey, wrapped } of candidates) {
|
|
254
280
|
try {
|
|
255
281
|
const certDer = fromBase64(publicKey.publicKey);
|
|
256
|
-
const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
282
|
+
const privateKey = await openPrivateKey(unlocked.masterKey, wrapped, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, certDer);
|
|
257
283
|
(unlocked.retainedEncryptionKeys ?? (unlocked.retainedEncryptionKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
|
|
258
284
|
}
|
|
259
285
|
catch {
|
|
@@ -265,7 +291,9 @@ async function openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlock
|
|
|
265
291
|
* Opens the mailbox's zero-access private keys into `unlocked.zeroAccessKeys`: the vault's `zeroAccessKeys` that have a
|
|
266
292
|
* wrapped private key, the unrevoked one with the latest `notBefore` first, then the rest newest first, at most
|
|
267
293
|
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. A key that won't open is listed in `unopenableKeys` and skipped - zero-access mail
|
|
268
|
-
* encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it.
|
|
294
|
+
* encrypted to it doesn't decrypt on this device, but nothing else about the unlock depends on it. So is one whose certificate (the
|
|
295
|
+
* server's, unauthenticated) isn't the private key's: this device seals to the active key's certificate - search index segments of
|
|
296
|
+
* end-to-end mail, protected meetings' keys - so a certificate swapped by the server must never become it.
|
|
269
297
|
*/
|
|
270
298
|
async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
|
|
271
299
|
const ordered = [...(vault.zeroAccessKeys ?? [])].sort((a, b) => Number(!!a.revokedAt) - Number(!!b.revokedAt) || b.notBefore - a.notBefore);
|
|
@@ -277,8 +305,18 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
|
|
|
277
305
|
continue;
|
|
278
306
|
}
|
|
279
307
|
try {
|
|
280
|
-
const
|
|
281
|
-
|
|
308
|
+
const certDer = fromBase64(publicKey.publicKey);
|
|
309
|
+
const raw = await openWithKey(unlocked.masterKey, wrapped, aad);
|
|
310
|
+
try {
|
|
311
|
+
if (!(await certificateHoldsKey(certDer, raw))) {
|
|
312
|
+
throw new Error("The zero-access certificate isn't the key's.");
|
|
313
|
+
}
|
|
314
|
+
const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
|
|
315
|
+
(unlocked.zeroAccessKeys ?? (unlocked.zeroAccessKeys = [])).push({ fingerprint: publicKey.fingerprint, certDer, privateKey });
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
raw.fill(0);
|
|
319
|
+
}
|
|
282
320
|
}
|
|
283
321
|
catch {
|
|
284
322
|
unopenableKeys.push(publicKey.fingerprint);
|
|
@@ -288,6 +326,39 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
|
|
|
288
326
|
}
|
|
289
327
|
}
|
|
290
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Opens the mailbox's OpenPGP secret keys into `unlocked.openPgpKeys`: its published OpenPGP certificates (`type: "openpgp"`) that
|
|
331
|
+
* have a wrapped secret key, the active one first (unrevoked, unexpired, latest `notBefore`), then the rest newest first, at most
|
|
332
|
+
* `MAX_RETAINED_ENCRYPTION_KEYS + 1`. OpenPGP.js is loaded only for a mailbox that has one. A key that won't open is listed in
|
|
333
|
+
* `unopenableKeys` and skipped.
|
|
334
|
+
*/
|
|
335
|
+
async function openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys) {
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
const active = (key) => !key.revokedAt && key.notAfter > now;
|
|
338
|
+
const ordered = mailboxKeys
|
|
339
|
+
.filter((key) => isOpenPgpKey(key) && findWrappedPrivateKey(vault, key.fingerprint))
|
|
340
|
+
.sort((a, b) => Number(active(b)) - Number(active(a)) || b.notBefore - a.notBefore)
|
|
341
|
+
.slice(0, MAX_RETAINED_ENCRYPTION_KEYS + 1);
|
|
342
|
+
if (ordered.length === 0) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const { readSecretKey } = await import("./openpgp.js");
|
|
346
|
+
const aad = buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
|
|
347
|
+
for (const publicKey of ordered) {
|
|
348
|
+
try {
|
|
349
|
+
const raw = await openWithKey(unlocked.masterKey, findWrappedPrivateKey(vault, publicKey.fingerprint), aad);
|
|
350
|
+
try {
|
|
351
|
+
(unlocked.openPgpKeys ?? (unlocked.openPgpKeys = [])).push({ fingerprint: publicKey.fingerprint, privateKey: await readSecretKey(raw) });
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
raw.fill(0);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
unopenableKeys.push(publicKey.fingerprint);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
291
362
|
/**
|
|
292
363
|
* Unlocks a mailbox's key vault with its password unlock method: fetches the vault, derives the
|
|
293
364
|
* wrapping key from `password` using the *exact* KDF parameters that wrap was created with, unwraps
|
|
@@ -300,8 +371,8 @@ async function openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys) {
|
|
|
300
371
|
* "incorrect password," not a generic error, but this module doesn't presume a specific UI's error copy.
|
|
301
372
|
* Once the master key has opened, the password is known to be right: an active signing key that then
|
|
302
373
|
* won't open is skipped and listed in `UnlockResult.unopenableKeys` (so is a retained encryption key); an active
|
|
303
|
-
* encryption key that won't
|
|
304
|
-
*
|
|
374
|
+
* encryption key that won't open, or whose certificate isn't its own,
|
|
375
|
+
* still fails the unlock, with `UnopenableEncryptionKeyError`. Throws `KeysLockedError` when
|
|
305
376
|
* `destroyUnlockedKeys()` locked this mailbox (or all mailboxes) while the unlock was in flight.
|
|
306
377
|
*/
|
|
307
378
|
export async function unlockWithPassword(mailboxUid, mailboxKeys, password) {
|
|
@@ -416,18 +487,23 @@ async function openSession(mailboxUid, mailboxKeys, vault, masterKey, generation
|
|
|
416
487
|
const encryptionPublicKey = findActivePublicKey(mailboxKeys, "encrypt");
|
|
417
488
|
const wrappedEncryptionKey = encryptionPublicKey && findWrappedPrivateKey(vault, encryptionPublicKey.fingerprint);
|
|
418
489
|
if (encryptionPublicKey && wrappedEncryptionKey) {
|
|
490
|
+
let encryptionCertDer;
|
|
419
491
|
try {
|
|
492
|
+
// The certificate is the server's: one that isn't this key's - what this device encrypts its own copies to, and what it
|
|
493
|
+
// hands out as its S/MIME certificate - makes the key unopenable, exactly as a wrap that won't open.
|
|
494
|
+
encryptionCertDer = fromBase64(encryptionPublicKey.publicKey);
|
|
420
495
|
const aad = buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
|
|
421
|
-
unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
|
|
496
|
+
unlocked.encryptionPrivateKey = await openPrivateKey(masterKey, wrappedEncryptionKey, aad, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false, encryptionCertDer);
|
|
422
497
|
}
|
|
423
498
|
catch (err) {
|
|
424
499
|
throw new UnopenableEncryptionKeyError(encryptionPublicKey.fingerprint, err);
|
|
425
500
|
}
|
|
426
|
-
unlocked.encryptionCertDer =
|
|
501
|
+
unlocked.encryptionCertDer = encryptionCertDer;
|
|
427
502
|
unlocked.encryptionFingerprint = encryptionPublicKey.fingerprint;
|
|
428
503
|
}
|
|
429
504
|
await openRetainedEncryptionKeys(mailboxUid, mailboxKeys, vault, unlocked, encryptionPublicKey?.fingerprint, unopenableKeys);
|
|
430
505
|
await openZeroAccessKeys(mailboxUid, vault, unlocked, unopenableKeys);
|
|
506
|
+
await openOpenPgpKeys(mailboxUid, mailboxKeys, vault, unlocked, unopenableKeys);
|
|
431
507
|
if (lockGeneration(mailboxUid) !== generation) {
|
|
432
508
|
// Locked (e.g. logout) while this unlock was awaiting - never restore keys after that.
|
|
433
509
|
throw new KeysLockedError();
|
|
@@ -10,9 +10,10 @@ import { ApiClient, ApiRequestError } from "../util/api.js";
|
|
|
10
10
|
/** A cryptographic public key used to sign or encrypt messages — safe to expose publicly. Mirrors
|
|
11
11
|
* `@rapidmx/restapi`'s `PublicKey` type exactly. */
|
|
12
12
|
export interface PublicKey {
|
|
13
|
-
/** Base64-encoded DER X.509 certificate. */
|
|
13
|
+
/** Base64-encoded DER X.509 certificate - or, for `type: "openpgp"`, the binary OpenPGP transferable public key. */
|
|
14
14
|
publicKey: string;
|
|
15
|
-
/** The key's type/format
|
|
15
|
+
/** The key's type/format: `x509` (S/MIME), or `openpgp` - an OpenPGP certificate, which both encrypts and signs and is listed
|
|
16
|
+
* once, with `useType: "encrypt"` (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). */
|
|
16
17
|
type: string;
|
|
17
18
|
useType: "sign" | "encrypt";
|
|
18
19
|
/** SHA-256 fingerprint of the certificate, hex encoded. */
|
|
@@ -42,7 +43,20 @@ export interface WrappedPrivateKey {
|
|
|
42
43
|
algorithm: string;
|
|
43
44
|
fingerprint: string;
|
|
44
45
|
useType: KeyUseType;
|
|
46
|
+
/** `"openpgp"` for an OpenPGP secret key (sealed with `OPENPGP_PRIVATE_KEY_AAD_PURPOSE`); absent for a PKCS#8 key. */
|
|
47
|
+
type?: string;
|
|
45
48
|
}
|
|
49
|
+
/** `PublicKey.type` of an OpenPGP certificate. */
|
|
50
|
+
export declare const OPENPGP_KEY_TYPE = "openpgp";
|
|
51
|
+
/** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
|
|
52
|
+
export declare function isOpenPgpKey(key: {
|
|
53
|
+
type?: string;
|
|
54
|
+
} | undefined): boolean;
|
|
55
|
+
/** A key's format: `"openpgp"`, or `"x509"` for every other key (S/MIME). */
|
|
56
|
+
export type KeyFormat = "x509" | "openpgp";
|
|
57
|
+
export declare function keyFormat(key: {
|
|
58
|
+
type?: string;
|
|
59
|
+
}): KeyFormat;
|
|
46
60
|
/** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
|
|
47
61
|
* Mirrors `@rapidmx/restapi`'s `KeyUseType`. */
|
|
48
62
|
export type KeyUseType = "sign" | "encrypt" | "zero-access";
|
|
@@ -73,7 +87,12 @@ export interface MasterKeyWrap {
|
|
|
73
87
|
export interface EncryptionPreference {
|
|
74
88
|
lastSeen?: number;
|
|
75
89
|
preferEncrypt: "mutual" | "nopreference";
|
|
90
|
+
/** Which end-to-end format the address would rather receive when a sender can use either (restapi
|
|
91
|
+
* `specs/end-to-end_encryption.md` *Choosing the format*). Absent: no preference. */
|
|
92
|
+
preferFormat?: EndToEndFormat;
|
|
76
93
|
}
|
|
94
|
+
/** The two end-to-end encryption formats. */
|
|
95
|
+
export type EndToEndFormat = "smime" | "openpgp";
|
|
77
96
|
/** A newly observed key that differs from a contact's pinned key of the same `useType` - restapi records it instead of
|
|
78
97
|
* silently replacing the pin (the spec's Key Conflict Handling) until the user resolves it with `resolveKeyConflict()`.
|
|
79
98
|
* A contact holds at most one per `useType`. Mirrors `@rapidmx/restapi`'s `KeyConflict`. */
|
|
@@ -133,7 +152,7 @@ export declare function getKeyVault(mailboxUid: string, client?: ApiClient): Pro
|
|
|
133
152
|
* `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
|
|
134
153
|
* new mail. Shared by `crypto/keySession.ts` (the
|
|
135
154
|
* mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
|
|
136
|
-
export declare function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt"): PublicKey | undefined;
|
|
155
|
+
export declare function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt", format?: KeyFormat): PublicKey | undefined;
|
|
137
156
|
/**
|
|
138
157
|
* Whether `key` may still be trusted to *verify* existing signatures - never whether to sign or encrypt with it (that is
|
|
139
158
|
* `findActivePublicKey()`, which skips every revoked key). Expired keys are trusted: mail signed while a key was valid
|
|
@@ -165,7 +184,11 @@ export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
|
|
|
165
184
|
csr?: string;
|
|
166
185
|
/** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
|
|
167
186
|
certificate?: string;
|
|
168
|
-
|
|
187
|
+
/** `"openpgp"` to enroll the mailbox's OpenPGP key (with `useType: "encrypt"`): self-certified, no CA. */
|
|
188
|
+
type?: string;
|
|
189
|
+
/** The OpenPGP public key (armored or base64 binary) - required, and only meaningful, with `type: "openpgp"`. */
|
|
190
|
+
publicKey?: string;
|
|
191
|
+
wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType" | "type">;
|
|
169
192
|
/** Only meaningful the very first time a mailbox enrolls a key at all (bootstraps its master key). */
|
|
170
193
|
masterKeyWraps?: MasterKeyWrap[];
|
|
171
194
|
}
|
|
@@ -401,6 +424,11 @@ export interface KeyLookupResult {
|
|
|
401
424
|
keyConflicts?: KeyConflict[];
|
|
402
425
|
/** Keys that were pinned before, newest first - see `PreviousKey`. */
|
|
403
426
|
previousKeys?: PreviousKey[];
|
|
427
|
+
/** The format to use for a recipient reachable in both (restapi *Choosing the format*): the user's choice for the contact, else what
|
|
428
|
+
* the address advertises, else what it was last seen using. Absent when nothing says - S/MIME then. */
|
|
429
|
+
preferredFormat?: EndToEndFormat;
|
|
430
|
+
/** Where `preferredFormat` came from. */
|
|
431
|
+
preferredFormatSource?: "contact" | "advertised" | "observed";
|
|
404
432
|
}
|
|
405
433
|
/** Server-side Discovery: the server itself performs the `_rapidmx` DNS lookup and remote key-endpoint
|
|
406
434
|
* fetch (browsers can't do DNS TXT lookups, and a direct cross-origin fetch would hit CORS), persisting
|
|
@@ -410,8 +438,11 @@ export declare function lookupKeys(mailboxUid: string, addr: string, client?: Ap
|
|
|
410
438
|
/** What `trustSigner()` pins: the sender's address and the certificate that signed their message. */
|
|
411
439
|
export interface TrustSignerInput {
|
|
412
440
|
address: string;
|
|
413
|
-
/** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate
|
|
441
|
+
/** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate` - or, with `type: "openpgp"`, an OpenPGP
|
|
442
|
+
* public key (armored or base64 binary): a signer's, a key attached to a message, or one the user pasted. */
|
|
414
443
|
certificate: string;
|
|
444
|
+
/** `"openpgp"` to pin an OpenPGP key (it encrypts and signs); omitted for an X.509 signing certificate. */
|
|
445
|
+
type?: string;
|
|
415
446
|
}
|
|
416
447
|
/**
|
|
417
448
|
* Thrown by `trustSigner()` when restapi answers `409`: a different signing key is already pinned for that address, so
|
|
@@ -444,6 +475,8 @@ export interface ResolveKeyConflictInput {
|
|
|
444
475
|
/** For `"accept"`: base64 DER of the certificate to pin (e.g. `MessageSecurityResult.signerCertificate` of a
|
|
445
476
|
* `signer_key_changed` result). Omitted, restapi pins the recorded conflict's `observedKey`. */
|
|
446
477
|
certificate?: string;
|
|
478
|
+
/** `"openpgp"` for the contact's OpenPGP key (always `useType: "encrypt"`); omitted for its S/MIME certificate. */
|
|
479
|
+
type?: string;
|
|
447
480
|
}
|
|
448
481
|
/** The contact's resulting key state after `resolveKeyConflict()`. */
|
|
449
482
|
export type ResolveKeyConflictResult = KeyLookupResult;
|
|
@@ -471,6 +504,9 @@ export interface EncryptionPolicy {
|
|
|
471
504
|
* supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
|
|
472
505
|
* caller treats as not allowed. */
|
|
473
506
|
allowZeroAccess?: boolean;
|
|
507
|
+
/** "Allow OpenPGP": whether mailboxes may enroll and publish OpenPGP keys, for mail with OpenPGP users. Absent from a server
|
|
508
|
+
* that predates it, which a caller treats as not allowed. */
|
|
509
|
+
allowOpenPgp?: boolean;
|
|
474
510
|
}
|
|
475
511
|
/** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
|
|
476
512
|
* controls a compose UI should offer). */
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* and `crypto/keys.ts` for the client-side cryptography that produces the values passed here.
|
|
12
12
|
*/
|
|
13
13
|
import { ApiRequestError, withClient } from "../util/api.js";
|
|
14
|
+
/** `PublicKey.type` of an OpenPGP certificate. */
|
|
15
|
+
export const OPENPGP_KEY_TYPE = "openpgp";
|
|
16
|
+
/** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
|
|
17
|
+
export function isOpenPgpKey(key) {
|
|
18
|
+
return key?.type === OPENPGP_KEY_TYPE;
|
|
19
|
+
}
|
|
20
|
+
export function keyFormat(key) {
|
|
21
|
+
return isOpenPgpKey(key) ? "openpgp" : "x509";
|
|
22
|
+
}
|
|
14
23
|
/** `client`, given by every function below that calls the network, is an explicit `ApiClient` from `createApiClient()`
|
|
15
24
|
* (e.g. one account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
16
25
|
* own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
|
|
@@ -24,10 +33,10 @@ export function getKeyVault(mailboxUid, client) {
|
|
|
24
33
|
* `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
|
|
25
34
|
* new mail. Shared by `crypto/keySession.ts` (the
|
|
26
35
|
* mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
|
|
27
|
-
export function findActivePublicKey(keys, useType) {
|
|
36
|
+
export function findActivePublicKey(keys, useType, format = "x509") {
|
|
28
37
|
const now = Date.now();
|
|
29
38
|
return keys
|
|
30
|
-
.filter((k) => k.useType === useType && !k.revokedAt && k.notAfter > now)
|
|
39
|
+
.filter((k) => k.useType === useType && keyFormat(k) === format && !k.revokedAt && k.notAfter > now)
|
|
31
40
|
.sort((a, b) => b.notBefore - a.notBefore)[0];
|
|
32
41
|
}
|
|
33
42
|
/**
|
|
@@ -320,7 +329,7 @@ export async function trustSigner(mailboxUid, input, client) {
|
|
|
320
329
|
try {
|
|
321
330
|
return await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/trust`, {
|
|
322
331
|
method: "POST",
|
|
323
|
-
body: JSON.stringify({ address: input.address, certificate: input.certificate }),
|
|
332
|
+
body: JSON.stringify({ address: input.address, certificate: input.certificate, ...(input.type ? { type: input.type } : {}) }),
|
|
324
333
|
});
|
|
325
334
|
}
|
|
326
335
|
catch (err) {
|
|
@@ -354,6 +363,7 @@ export async function resolveKeyConflict(mailboxUid, input, client) {
|
|
|
354
363
|
action: input.action,
|
|
355
364
|
expectedPinnedFingerprint: input.expectedPinnedFingerprint,
|
|
356
365
|
...(input.certificate !== undefined ? { certificate: input.certificate } : {}),
|
|
366
|
+
...(input.type ? { type: input.type } : {}),
|
|
357
367
|
};
|
|
358
368
|
try {
|
|
359
369
|
return await withClient(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/resolve`, {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DecryptionKey } from "./smime.js";
|
|
2
|
+
/** The `#` parameter of a link that carries a meeting key. */
|
|
3
|
+
export declare const MEETING_KEY_FRAGMENT_PARAM = "k";
|
|
4
|
+
/** A meeting key as its creator holds it. */
|
|
5
|
+
export interface GeneratedMeetingKey {
|
|
6
|
+
/** PKCS#8 DER of the private key - what a link carries and a wrap seals. */
|
|
7
|
+
pkcs8: Uint8Array;
|
|
8
|
+
/** The self-signed certificate's DER - what the server stores and data is sealed to. */
|
|
9
|
+
certificateDer: Uint8Array;
|
|
10
|
+
/** SHA-256 of `certificateDer`, lowercase hex. */
|
|
11
|
+
fingerprint: string;
|
|
12
|
+
}
|
|
13
|
+
/** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
|
|
14
|
+
export declare function generateMeetingKey(expiresAt: Date): Promise<GeneratedMeetingKey>;
|
|
15
|
+
/** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
|
|
16
|
+
export declare function meetingKeyFragment(pkcs8: Uint8Array): string;
|
|
17
|
+
/** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
|
|
18
|
+
export declare function readMeetingKeyFragment(hash: string): Uint8Array | undefined;
|
|
19
|
+
/** 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. */
|
|
20
|
+
export declare function importMeetingKey(pkcs8: Uint8Array, certificateDer: Uint8Array, meetingUid: string): Promise<{
|
|
21
|
+
decryptionKey: DecryptionKey;
|
|
22
|
+
liveKey: CryptoKey;
|
|
23
|
+
}>;
|
|
24
|
+
/** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
|
|
25
|
+
export declare function deriveLiveKey(ikm: Uint8Array, meetingUid: string): Promise<CryptoKey>;
|