@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
|
@@ -15,9 +15,10 @@ import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
|
|
|
15
15
|
/** A cryptographic public key used to sign or encrypt messages — safe to expose publicly. Mirrors
|
|
16
16
|
* `@rapidmx/restapi`'s `PublicKey` type exactly. */
|
|
17
17
|
export interface PublicKey {
|
|
18
|
-
/** Base64-encoded DER X.509 certificate. */
|
|
18
|
+
/** Base64-encoded DER X.509 certificate - or, for `type: "openpgp"`, the binary OpenPGP transferable public key. */
|
|
19
19
|
publicKey: string;
|
|
20
|
-
/** The key's type/format
|
|
20
|
+
/** The key's type/format: `x509` (S/MIME), or `openpgp` - an OpenPGP certificate, which both encrypts and signs and is listed
|
|
21
|
+
* once, with `useType: "encrypt"` (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). */
|
|
21
22
|
type: string;
|
|
22
23
|
useType: "sign" | "encrypt";
|
|
23
24
|
/** SHA-256 fingerprint of the certificate, hex encoded. */
|
|
@@ -48,6 +49,23 @@ export interface WrappedPrivateKey {
|
|
|
48
49
|
algorithm: string;
|
|
49
50
|
fingerprint: string;
|
|
50
51
|
useType: KeyUseType;
|
|
52
|
+
/** `"openpgp"` for an OpenPGP secret key (sealed with `OPENPGP_PRIVATE_KEY_AAD_PURPOSE`); absent for a PKCS#8 key. */
|
|
53
|
+
type?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `PublicKey.type` of an OpenPGP certificate. */
|
|
57
|
+
export const OPENPGP_KEY_TYPE = "openpgp";
|
|
58
|
+
|
|
59
|
+
/** Whether `key` is an OpenPGP certificate (or an OpenPGP secret key's wrap). */
|
|
60
|
+
export function isOpenPgpKey(key: { type?: string } | undefined): boolean {
|
|
61
|
+
return key?.type === OPENPGP_KEY_TYPE;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A key's format: `"openpgp"`, or `"x509"` for every other key (S/MIME). */
|
|
65
|
+
export type KeyFormat = "x509" | "openpgp";
|
|
66
|
+
|
|
67
|
+
export function keyFormat(key: { type?: string }): KeyFormat {
|
|
68
|
+
return isOpenPgpKey(key) ? "openpgp" : "x509";
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/** What a mailbox's own key is used for. `sign`/`encrypt` keys are published (`Mailbox.keys`); `zero-access` keys never are.
|
|
@@ -83,8 +101,14 @@ export interface MasterKeyWrap {
|
|
|
83
101
|
export interface EncryptionPreference {
|
|
84
102
|
lastSeen?: number;
|
|
85
103
|
preferEncrypt: "mutual" | "nopreference";
|
|
104
|
+
/** Which end-to-end format the address would rather receive when a sender can use either (restapi
|
|
105
|
+
* `specs/end-to-end_encryption.md` *Choosing the format*). Absent: no preference. */
|
|
106
|
+
preferFormat?: EndToEndFormat;
|
|
86
107
|
}
|
|
87
108
|
|
|
109
|
+
/** The two end-to-end encryption formats. */
|
|
110
|
+
export type EndToEndFormat = "smime" | "openpgp";
|
|
111
|
+
|
|
88
112
|
/** A newly observed key that differs from a contact's pinned key of the same `useType` - restapi records it instead of
|
|
89
113
|
* silently replacing the pin (the spec's Key Conflict Handling) until the user resolves it with `resolveKeyConflict()`.
|
|
90
114
|
* A contact holds at most one per `useType`. Mirrors `@rapidmx/restapi`'s `KeyConflict`. */
|
|
@@ -152,10 +176,10 @@ export function getKeyVault(mailboxUid: string, client?: ApiClient): Promise<Key
|
|
|
152
176
|
* `revocationReason` - a superseded key may still verify old mail (`isTrustedForVerification()`) but is never used for
|
|
153
177
|
* new mail. Shared by `crypto/keySession.ts` (the
|
|
154
178
|
* mailbox's own keys) and `crypto/composeSecurity.ts` (a recipient's discovered keys). */
|
|
155
|
-
export function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt"): PublicKey | undefined {
|
|
179
|
+
export function findActivePublicKey(keys: PublicKey[], useType: "sign" | "encrypt", format: KeyFormat = "x509"): PublicKey | undefined {
|
|
156
180
|
const now = Date.now();
|
|
157
181
|
return keys
|
|
158
|
-
.filter((k) => k.useType === useType && !k.revokedAt && k.notAfter > now)
|
|
182
|
+
.filter((k) => k.useType === useType && keyFormat(k) === format && !k.revokedAt && k.notAfter > now)
|
|
159
183
|
.sort((a, b) => b.notBefore - a.notBefore)[0];
|
|
160
184
|
}
|
|
161
185
|
|
|
@@ -199,7 +223,11 @@ export interface EnrollKeyInput extends ExpectedMasterKeyGeneration {
|
|
|
199
223
|
csr?: string;
|
|
200
224
|
/** An already-issued PEM certificate — required (and only meaningful) for `useType: "sign"`. */
|
|
201
225
|
certificate?: string;
|
|
202
|
-
|
|
226
|
+
/** `"openpgp"` to enroll the mailbox's OpenPGP key (with `useType: "encrypt"`): self-certified, no CA. */
|
|
227
|
+
type?: string;
|
|
228
|
+
/** The OpenPGP public key (armored or base64 binary) - required, and only meaningful, with `type: "openpgp"`. */
|
|
229
|
+
publicKey?: string;
|
|
230
|
+
wrappedKey: Omit<WrappedPrivateKey, "fingerprint" | "useType" | "type">;
|
|
203
231
|
/** Only meaningful the very first time a mailbox enrolls a key at all (bootstraps its master key). */
|
|
204
232
|
masterKeyWraps?: MasterKeyWrap[];
|
|
205
233
|
}
|
|
@@ -655,6 +683,11 @@ export interface KeyLookupResult {
|
|
|
655
683
|
keyConflicts?: KeyConflict[];
|
|
656
684
|
/** Keys that were pinned before, newest first - see `PreviousKey`. */
|
|
657
685
|
previousKeys?: PreviousKey[];
|
|
686
|
+
/** The format to use for a recipient reachable in both (restapi *Choosing the format*): the user's choice for the contact, else what
|
|
687
|
+
* the address advertises, else what it was last seen using. Absent when nothing says - S/MIME then. */
|
|
688
|
+
preferredFormat?: EndToEndFormat;
|
|
689
|
+
/** Where `preferredFormat` came from. */
|
|
690
|
+
preferredFormatSource?: "contact" | "advertised" | "observed";
|
|
658
691
|
}
|
|
659
692
|
|
|
660
693
|
/** Server-side Discovery: the server itself performs the `_rapidmx` DNS lookup and remote key-endpoint
|
|
@@ -669,8 +702,11 @@ export function lookupKeys(mailboxUid: string, addr: string, client?: ApiClient)
|
|
|
669
702
|
/** What `trustSigner()` pins: the sender's address and the certificate that signed their message. */
|
|
670
703
|
export interface TrustSignerInput {
|
|
671
704
|
address: string;
|
|
672
|
-
/** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate
|
|
705
|
+
/** Base64 DER of the signer certificate - `MessageSecurityResult.signerCertificate` - or, with `type: "openpgp"`, an OpenPGP
|
|
706
|
+
* public key (armored or base64 binary): a signer's, a key attached to a message, or one the user pasted. */
|
|
673
707
|
certificate: string;
|
|
708
|
+
/** `"openpgp"` to pin an OpenPGP key (it encrypts and signs); omitted for an X.509 signing certificate. */
|
|
709
|
+
type?: string;
|
|
674
710
|
}
|
|
675
711
|
|
|
676
712
|
/**
|
|
@@ -696,7 +732,7 @@ export async function trustSigner(mailboxUid: string, input: TrustSignerInput, c
|
|
|
696
732
|
try {
|
|
697
733
|
return await withClient<KeyLookupResult>(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/trust`, {
|
|
698
734
|
method: "POST",
|
|
699
|
-
body: JSON.stringify({ address: input.address, certificate: input.certificate }),
|
|
735
|
+
body: JSON.stringify({ address: input.address, certificate: input.certificate, ...(input.type ? { type: input.type } : {}) }),
|
|
700
736
|
});
|
|
701
737
|
} catch (err) {
|
|
702
738
|
if (err instanceof ApiRequestError && err.status === 409) {
|
|
@@ -721,6 +757,8 @@ export interface ResolveKeyConflictInput {
|
|
|
721
757
|
/** For `"accept"`: base64 DER of the certificate to pin (e.g. `MessageSecurityResult.signerCertificate` of a
|
|
722
758
|
* `signer_key_changed` result). Omitted, restapi pins the recorded conflict's `observedKey`. */
|
|
723
759
|
certificate?: string;
|
|
760
|
+
/** `"openpgp"` for the contact's OpenPGP key (always `useType: "encrypt"`); omitted for its S/MIME certificate. */
|
|
761
|
+
type?: string;
|
|
724
762
|
}
|
|
725
763
|
|
|
726
764
|
/** The contact's resulting key state after `resolveKeyConflict()`. */
|
|
@@ -755,6 +793,7 @@ export async function resolveKeyConflict(
|
|
|
755
793
|
action: input.action,
|
|
756
794
|
expectedPinnedFingerprint: input.expectedPinnedFingerprint,
|
|
757
795
|
...(input.certificate !== undefined ? { certificate: input.certificate } : {}),
|
|
796
|
+
...(input.type ? { type: input.type } : {}),
|
|
758
797
|
};
|
|
759
798
|
try {
|
|
760
799
|
return await withClient<ResolveKeyConflictResult>(client, `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/keys/resolve`, {
|
|
@@ -779,6 +818,9 @@ export interface EncryptionPolicy {
|
|
|
779
818
|
* supports zero-access encryption (its stored value or the server default); absent from one that doesn't, which a
|
|
780
819
|
* caller treats as not allowed. */
|
|
781
820
|
allowZeroAccess?: boolean;
|
|
821
|
+
/** "Allow OpenPGP": whether mailboxes may enroll and publish OpenPGP keys, for mail with OpenPGP users. Absent from a server
|
|
822
|
+
* that predates it, which a caller treats as not allowed. */
|
|
823
|
+
allowOpenPgp?: boolean;
|
|
782
824
|
}
|
|
783
825
|
|
|
784
826
|
/** The system-wide encryption policy (readable by any authenticated user, used to decide what encryption
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
import type { DecryptionKey } from "./smime.js";
|
|
16
|
+
|
|
17
|
+
/** The `#` parameter of a link that carries a meeting key. */
|
|
18
|
+
export const MEETING_KEY_FRAGMENT_PARAM = "k";
|
|
19
|
+
|
|
20
|
+
/** HKDF `info` of the live key (`specs/meeting_privacy.md` §3). */
|
|
21
|
+
const LIVE_KEY_INFO = "rapidmx-meeting-live-v1";
|
|
22
|
+
|
|
23
|
+
const EC_PARAMS: EcKeyImportParams = { name: "ECDH", namedCurve: "P-256" };
|
|
24
|
+
|
|
25
|
+
/** A meeting key as its creator holds it. */
|
|
26
|
+
export interface GeneratedMeetingKey {
|
|
27
|
+
/** PKCS#8 DER of the private key - what a link carries and a wrap seals. */
|
|
28
|
+
pkcs8: Uint8Array;
|
|
29
|
+
/** The self-signed certificate's DER - what the server stores and data is sealed to. */
|
|
30
|
+
certificateDer: Uint8Array;
|
|
31
|
+
/** SHA-256 of `certificateDer`, lowercase hex. */
|
|
32
|
+
fingerprint: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadX509(): Promise<typeof import("@peculiar/x509")> {
|
|
36
|
+
await import("reflect-metadata");
|
|
37
|
+
const x509 = await import("@peculiar/x509");
|
|
38
|
+
x509.cryptoProvider.set(crypto);
|
|
39
|
+
return x509;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
43
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as BufferSource));
|
|
44
|
+
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A new meeting key valid until `expiresAt` (its certificate's `notAfter`). */
|
|
48
|
+
export async function generateMeetingKey(expiresAt: Date): Promise<GeneratedMeetingKey> {
|
|
49
|
+
const x509 = await loadX509();
|
|
50
|
+
// ECDSA to self-sign the certificate; the same P-256 key is re-imported as ECDH to open what is sealed to it (`importMeetingKey()`).
|
|
51
|
+
const keys = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]));
|
|
52
|
+
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
53
|
+
serialNumber: toHex(crypto.getRandomValues(new Uint8Array(16))),
|
|
54
|
+
name: "CN=meeting",
|
|
55
|
+
notBefore: new Date(Date.now() - 60_000),
|
|
56
|
+
notAfter: expiresAt,
|
|
57
|
+
signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
|
|
58
|
+
keys,
|
|
59
|
+
});
|
|
60
|
+
const certificateDer = new Uint8Array(cert.rawData);
|
|
61
|
+
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keys.privateKey));
|
|
62
|
+
return { pkcs8, certificateDer, fingerprint: await sha256Hex(certificateDer) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toHex(bytes: Uint8Array): string {
|
|
66
|
+
// A positive serial: the top bit clear.
|
|
67
|
+
const copy = Uint8Array.from(bytes);
|
|
68
|
+
copy[0] &= 0x7f;
|
|
69
|
+
return Array.from(copy, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The `#` part a guest's link carries for `pkcs8`: `k=<base64url>`. */
|
|
73
|
+
export function meetingKeyFragment(pkcs8: Uint8Array): string {
|
|
74
|
+
return `${MEETING_KEY_FRAGMENT_PARAM}=${toBase64Url(pkcs8)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The meeting key in `hash` (a `location.hash`, with or without its `#`), or `undefined` when it carries none or it is malformed. */
|
|
78
|
+
export function readMeetingKeyFragment(hash: string): Uint8Array | undefined {
|
|
79
|
+
const params = new URLSearchParams(hash.replace(/^#/, ""));
|
|
80
|
+
const value = params.get(MEETING_KEY_FRAGMENT_PARAM);
|
|
81
|
+
if (!value || !/^[A-Za-z0-9_-]{16,1024}$/.test(value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return fromBase64Url(value);
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 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. */
|
|
92
|
+
export async function importMeetingKey(
|
|
93
|
+
pkcs8: Uint8Array,
|
|
94
|
+
certificateDer: Uint8Array,
|
|
95
|
+
meetingUid: string,
|
|
96
|
+
): Promise<{ decryptionKey: DecryptionKey; liveKey: CryptoKey }> {
|
|
97
|
+
// Extractable once, only to read its scalar for the live key; the key kept for CMS isn't.
|
|
98
|
+
const readable = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, EC_PARAMS, true, ["deriveBits"]);
|
|
99
|
+
const jwk = await crypto.subtle.exportKey("jwk", readable);
|
|
100
|
+
/* v8 ignore next 3 -- an imported PKCS#8 private key always exports its scalar */
|
|
101
|
+
if (!jwk.d) {
|
|
102
|
+
throw new Error("This is not a meeting key.");
|
|
103
|
+
}
|
|
104
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8 as BufferSource, EC_PARAMS, false, ["deriveBits"]);
|
|
105
|
+
return { decryptionKey: { certDer: certificateDer, privateKey }, liveKey: await deriveLiveKey(fromBase64Url(jwk.d), meetingUid) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The AES-256-GCM live key of meeting `meetingUid` from `ikm`, the meeting key's private scalar. Non-extractable. */
|
|
109
|
+
export async function deriveLiveKey(ikm: Uint8Array, meetingUid: string): Promise<CryptoKey> {
|
|
110
|
+
const base = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveKey"]);
|
|
111
|
+
return crypto.subtle.deriveKey(
|
|
112
|
+
{ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(meetingUid), info: new TextEncoder().encode(LIVE_KEY_INFO) },
|
|
113
|
+
base,
|
|
114
|
+
{ name: "AES-GCM", length: 256 },
|
|
115
|
+
false,
|
|
116
|
+
["encrypt", "decrypt"],
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
MimeAttachment,
|
|
43
43
|
MimeHeaderField,
|
|
44
44
|
bytesToBinaryString,
|
|
45
|
+
decodeBodyText,
|
|
46
|
+
type MimeEntity,
|
|
45
47
|
decodeBase64Text,
|
|
46
48
|
decodeHeaderText,
|
|
47
49
|
extractAddresses,
|
|
@@ -54,7 +56,17 @@ import {
|
|
|
54
56
|
} from "./mime.js";
|
|
55
57
|
import { fromBase64, toBase64 } from "./encoding.js";
|
|
56
58
|
import { type DecryptionKey, computeCertFingerprint, decryptEnvelopedDataWithKeys, extractCertificateEmails } from "./smime.js";
|
|
57
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
ComparableOuterHeaders,
|
|
61
|
+
ProtectedHeaders,
|
|
62
|
+
extractHpOuterHeaders,
|
|
63
|
+
headersToProtectedHeaders,
|
|
64
|
+
outerHeadersMatch,
|
|
65
|
+
parseEncryptedMessageWithKeys,
|
|
66
|
+
parseSignedOnlyMessage,
|
|
67
|
+
} from "./smimeMessage.js";
|
|
68
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
69
|
+
import type { ParsedPgpMessage, PgpSignerCandidate } from "./pgpMessage.js";
|
|
58
70
|
import { type PublicKey, isTrustedForVerification } from "./keyvaultApi.js";
|
|
59
71
|
import { KeysLockedError } from "./masterKey.js";
|
|
60
72
|
import { type SealKeyMaterial, type SealedVerificationState, buildVerificationSeal, openVerificationSeal, rawMimeSha256 } from "./verificationSeal.js";
|
|
@@ -161,6 +173,14 @@ export interface MessageSecurityResult {
|
|
|
161
173
|
signerFingerprint?: string;
|
|
162
174
|
/** The email addresses that signer certificate asserts (lowercased), alongside `signerFingerprint`. */
|
|
163
175
|
signerEmails?: string[];
|
|
176
|
+
/** `"openpgp"` when the signer is an OpenPGP key: `signerCertificate` is then its certificate (base64 binary) and
|
|
177
|
+
* `signerFingerprint` its primary key's fingerprint - pin it with `trustSigner()`'s `type: "openpgp"`. */
|
|
178
|
+
signerKeyType?: "openpgp";
|
|
179
|
+
/** `true` for an OpenPGP message whose signature was made by a key this reader doesn't have (neither pinned for the sender,
|
|
180
|
+
* nor the reader's own, nor attached to the message), so it couldn't be checked at all. A signed-only one is then reported
|
|
181
|
+
* `"unprotected"` (its content is readable anyway), an encrypted one `"encrypted"`; a UI can say the sender's key isn't known
|
|
182
|
+
* yet. */
|
|
183
|
+
signerKeyUnknown?: boolean;
|
|
164
184
|
/** Base64 DER of the certificate that verified the signature - what `keyvaultApi.ts`'s `trustSigner()` pins for
|
|
165
185
|
* a "trust this signer" action. Present alongside `signerFingerprint` for `"signed_verified"`,
|
|
166
186
|
* `"encrypted_verified"`, the `*_unverified_signer` states, and `"signature_failed"` with reason
|
|
@@ -280,8 +300,13 @@ function sameInstant(signed: string | undefined, outer: string | undefined): boo
|
|
|
280
300
|
return Number.isNaN(left) || left === Date.parse(String(outer));
|
|
281
301
|
}
|
|
282
302
|
|
|
283
|
-
function normalizePins(pins:
|
|
284
|
-
|
|
303
|
+
function normalizePins(pins: SignerPinsInput): string[] {
|
|
304
|
+
const list: string[] = Array.isArray(pins) ? pins : pins === undefined ? [] : typeof pins === "string" ? [pins] : pins.fingerprints;
|
|
305
|
+
return list.map((pin) => pin.toLowerCase());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function pinnedOpenPgpKeys(pins: SignerPinsInput): string[] {
|
|
309
|
+
return pins !== undefined && typeof pins === "object" && !Array.isArray(pins) ? pins.openPgpKeys : [];
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
/** Checks 2-4 from this module's doc comment for an already cryptographically verified signature.
|
|
@@ -299,13 +324,14 @@ export async function checkSignerBinding(input: SignerBindingInput): Promise<Sig
|
|
|
299
324
|
if (pins.length > 0 && !pins.includes(await computeCertFingerprint(certDer))) {
|
|
300
325
|
// A certificate that otherwise binds cleanly to the sender is a key change for the user to judge; anything else
|
|
301
326
|
// (no certificate, wrong identity, header tampering) stays a plain untrusted signer.
|
|
302
|
-
return input.signerCertificateDer && checkIdentityAndHeaders(input, certDer) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
327
|
+
return input.signerCertificateDer && checkIdentityAndHeaders(input, extractCertificateEmails(certDer)) === undefined ? "signer_key_changed" : "untrusted_signer";
|
|
303
328
|
}
|
|
304
|
-
return checkIdentityAndHeaders(input, certDer);
|
|
329
|
+
return checkIdentityAndHeaders(input, extractCertificateEmails(certDer));
|
|
305
330
|
}
|
|
306
331
|
|
|
307
|
-
/** Checks 3-4 from this module's doc comment.
|
|
308
|
-
|
|
332
|
+
/** Checks 3-4 from this module's doc comment, for a signer whose key names `signerEmails` (an X.509 certificate's, or an
|
|
333
|
+
* OpenPGP key's user IDs). */
|
|
334
|
+
function checkIdentityAndHeaders(input: Omit<SignerBindingInput, "signerCertificateDer">, signerEmails: string[]): SignatureFailureReason | undefined {
|
|
309
335
|
const { protectedHeaders, outerHeaders } = input;
|
|
310
336
|
if (hasRepeatedAddressField(input.outerFields) || hasRepeatedAddressField(input.protectedFields)) {
|
|
311
337
|
return "header_mismatch";
|
|
@@ -313,7 +339,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
|
|
|
313
339
|
const outerFrom = extractAddresses(outerHeaders["from"]);
|
|
314
340
|
const protectedFrom = extractAddresses(protectedHeaders?.from);
|
|
315
341
|
const senderAddresses = protectedFrom.length > 0 ? protectedFrom : outerFrom;
|
|
316
|
-
if (senderAddresses.length !== 1 || !
|
|
342
|
+
if (senderAddresses.length !== 1 || !signerEmails.includes(senderAddresses[0])) {
|
|
317
343
|
return "signer_identity_mismatch";
|
|
318
344
|
}
|
|
319
345
|
const protectedTo = extractAddresses(protectedHeaders?.to);
|
|
@@ -357,7 +383,7 @@ function checkIdentityAndHeaders(input: SignerBindingInput, certDer: Uint8Array)
|
|
|
357
383
|
export async function evaluateMessageSecurity(
|
|
358
384
|
rawMime: string,
|
|
359
385
|
unlocked: MessageSecurityKeys | undefined,
|
|
360
|
-
pinnedSignerFingerprints?:
|
|
386
|
+
pinnedSignerFingerprints?: SignerPinsInput,
|
|
361
387
|
readerAddress?: string,
|
|
362
388
|
): Promise<MessageSecurityResult> {
|
|
363
389
|
return (await evaluateLive(rawMime, unlocked, pinnedSignerFingerprints, readerAddress)).result;
|
|
@@ -372,8 +398,22 @@ export interface MessageSecurityKeys {
|
|
|
372
398
|
/** The mailbox's zero-access keys (`keySession.ts`'s `UnlockedKeys.zeroAccessKeys`) - what opens a message the server sealed at
|
|
373
399
|
* rest on arrival. */
|
|
374
400
|
zeroAccessKeys?: { certDer: Uint8Array; privateKey: CryptoKey }[];
|
|
401
|
+
/** The mailbox's OpenPGP secret keys (`keySession.ts`'s `UnlockedKeys.openPgpKeys`), the active one first - what opens mail an
|
|
402
|
+
* OpenPGP user encrypted to the mailbox, and verifies mail it signed itself. */
|
|
403
|
+
openPgpKeys?: { fingerprint: string; privateKey: OpenPgpPrivateKey }[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The sender's trusted keys, when they include OpenPGP certificates: `fingerprints` as `pinnedSignerFingerprints`, and
|
|
407
|
+
* `openPgpKeys` the pinned OpenPGP certificates themselves (base64, `PublicKey.publicKey`) - an OpenPGP signature carries only
|
|
408
|
+
* a key ID, so its key has to be supplied to be checked at all (`pinnedSigners.ts`'s `getPinnedSigners()`). */
|
|
409
|
+
export interface SignerPins {
|
|
410
|
+
fingerprints: string[];
|
|
411
|
+
openPgpKeys: string[];
|
|
375
412
|
}
|
|
376
413
|
|
|
414
|
+
/** What `pinnedSignerFingerprints` accepts: one fingerprint, several, or `SignerPins`. */
|
|
415
|
+
export type SignerPinsInput = string | string[] | SignerPins | undefined;
|
|
416
|
+
|
|
377
417
|
/**
|
|
378
418
|
* The subject and preview of a zero-access message (`Message.zeroAccess.sealedSummary`, base64 CMS of JSON `{ subject, preview }`),
|
|
379
419
|
* decrypted with the mailbox's zero-access keys - a few hundred bytes, so a message list shows real subjects without downloading
|
|
@@ -437,16 +477,21 @@ interface LiveEvaluation {
|
|
|
437
477
|
async function evaluateLive(
|
|
438
478
|
rawMime: string,
|
|
439
479
|
unlocked: MessageSecurityKeys | undefined,
|
|
440
|
-
pinnedSignerFingerprints:
|
|
480
|
+
pinnedSignerFingerprints: SignerPinsInput,
|
|
441
481
|
readerAddress: string | undefined,
|
|
442
482
|
sealedAtRest = false,
|
|
443
483
|
): Promise<LiveEvaluation> {
|
|
444
|
-
const
|
|
484
|
+
const outerEntity = parseMimeEntity(rawMime);
|
|
485
|
+
const { headers, body, fields } = outerEntity;
|
|
445
486
|
const rawContentType = headers["content-type"];
|
|
487
|
+
const contentType = parseParameterizedHeader(rawContentType ?? "text/plain");
|
|
488
|
+
const pgp = pgpKind(contentType, outerEntity);
|
|
489
|
+
if (pgp) {
|
|
490
|
+
return await evaluatePgp(pgp, rawContentType ?? "text/plain", outerEntity, unlocked, pinnedSignerFingerprints, readerAddress);
|
|
491
|
+
}
|
|
446
492
|
if (!rawContentType) {
|
|
447
493
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
448
494
|
}
|
|
449
|
-
const contentType = parseParameterizedHeader(rawContentType);
|
|
450
495
|
const callerPins = normalizePins(pinnedSignerFingerprints);
|
|
451
496
|
|
|
452
497
|
// The received message's own *real* outer envelope - what `HP-Outer`'s field copies (written at
|
|
@@ -603,6 +648,122 @@ async function evaluateLive(
|
|
|
603
648
|
return { result: { state: "unprotected" }, kind: "other" };
|
|
604
649
|
}
|
|
605
650
|
|
|
651
|
+
/** Which OpenPGP form a received message is, if any: RFC 3156 PGP/MIME (`multipart/signed` or `multipart/encrypted` with an OpenPGP
|
|
652
|
+
* `protocol`), or an inline message - a `text/plain` body that is one armored `PGP MESSAGE`, or a cleartext signature. */
|
|
653
|
+
type PgpKind = "signed" | "encrypted" | "inline-encrypted" | "inline-signed";
|
|
654
|
+
|
|
655
|
+
const INLINE_PGP_MESSAGE = /^-----BEGIN PGP MESSAGE-----\r?\n[\s\S]*-----END PGP MESSAGE-----$/;
|
|
656
|
+
const INLINE_PGP_SIGNED = /^-----BEGIN PGP SIGNED MESSAGE-----\r?\n[\s\S]*-----END PGP SIGNATURE-----$/;
|
|
657
|
+
|
|
658
|
+
function pgpKind(contentType: { value: string; params: Record<string, string> }, entity: MimeEntity): PgpKind | undefined {
|
|
659
|
+
const protocol = contentType.params["protocol"]?.toLowerCase();
|
|
660
|
+
if (contentType.value === "multipart/signed" && protocol === "application/pgp-signature") {
|
|
661
|
+
return "signed";
|
|
662
|
+
}
|
|
663
|
+
if (contentType.value === "multipart/encrypted" && protocol === "application/pgp-encrypted") {
|
|
664
|
+
return "encrypted";
|
|
665
|
+
}
|
|
666
|
+
if (contentType.value !== "text/plain") {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
const text = decodeBodyText(entity).trim();
|
|
670
|
+
return INLINE_PGP_MESSAGE.test(text) ? "inline-encrypted" : INLINE_PGP_SIGNED.test(text) ? "inline-signed" : undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* `evaluateLive()` for an OpenPGP message (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*), with the same
|
|
675
|
+
* trust rules as S/MIME: a signature by a key pinned for the sender (`SignerPins.openPgpKeys`) or the reader's own is verified;
|
|
676
|
+
* one by a key only attached to the message is `*_unverified_signer` - or `signer_key_changed` when the sender has a different key
|
|
677
|
+
* pinned; and the key must name the sender, with the protected headers matching the outer ones (`checkIdentityAndHeaders()`).
|
|
678
|
+
* A signature nobody's key can check is reported through `signerKeyUnknown`, never as failed. The crypto is `pgpMessage.ts`'s,
|
|
679
|
+
* loaded only here.
|
|
680
|
+
*/
|
|
681
|
+
async function evaluatePgp(
|
|
682
|
+
kind: PgpKind,
|
|
683
|
+
rawContentType: string,
|
|
684
|
+
outer: MimeEntity,
|
|
685
|
+
unlocked: MessageSecurityKeys | undefined,
|
|
686
|
+
pins: SignerPinsInput,
|
|
687
|
+
readerAddress: string | undefined,
|
|
688
|
+
): Promise<LiveEvaluation> {
|
|
689
|
+
const pgp = await import("./pgpMessage.js");
|
|
690
|
+
const pinned = (await pgp.readKeysBase64(pinnedOpenPgpKeys(pins))).map((key) => ({ key, trusted: true }));
|
|
691
|
+
const ownKeys = unlocked?.openPgpKeys ?? [];
|
|
692
|
+
const candidates: PgpSignerCandidate[] = [...pinned, ...ownKeys.map(({ privateKey }) => ({ key: privateKey.toPublic(), trusted: true }))];
|
|
693
|
+
const decryptionKeys = ownKeys.map(({ privateKey }) => privateKey);
|
|
694
|
+
const encrypted = kind === "encrypted" || kind === "inline-encrypted";
|
|
695
|
+
const text = decodeBodyText(outer).trim();
|
|
696
|
+
let parsed: ParsedPgpMessage;
|
|
697
|
+
if (kind === "signed") {
|
|
698
|
+
parsed = await pgp.parsePgpSignedMessage(rawContentType, outer.body, candidates);
|
|
699
|
+
} else if (kind === "encrypted") {
|
|
700
|
+
parsed = await pgp.parsePgpEncryptedMessage(rawContentType, outer.body, decryptionKeys, candidates);
|
|
701
|
+
} else if (kind === "inline-encrypted") {
|
|
702
|
+
parsed = await pgp.parsePgpInlineEncrypted(text, decryptionKeys, candidates);
|
|
703
|
+
} else {
|
|
704
|
+
parsed = await pgp.parsePgpCleartextSigned(text, candidates);
|
|
705
|
+
}
|
|
706
|
+
const resultKind: LiveEvaluation["kind"] = encrypted ? "encrypted" : "signed";
|
|
707
|
+
if (encrypted && !parsed.decrypted) {
|
|
708
|
+
return { result: { state: "encrypted", decryptError: NO_KEY_ERROR }, kind: resultKind };
|
|
709
|
+
}
|
|
710
|
+
if (parsed.entityText === undefined) {
|
|
711
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature" }, kind: resultKind };
|
|
712
|
+
}
|
|
713
|
+
const entity = parseMimeEntity(parsed.entityText);
|
|
714
|
+
const inline = kind === "inline-encrypted" || kind === "inline-signed";
|
|
715
|
+
const protectedHeaders: ProtectedHeaders | undefined = inline ? undefined : headersToProtectedHeaders(entity.headers);
|
|
716
|
+
const hasProtected = !!protectedHeaders && extractAddresses(protectedHeaders.from).length > 0;
|
|
717
|
+
const hpOuter = encrypted && !inline ? extractHpOuterHeaders(entity.fields) : undefined;
|
|
718
|
+
const outerComparable: ComparableOuterHeaders = { from: outer.headers["from"], to: outer.headers["to"], cc: outer.headers["cc"], date: outer.headers["date"], subject: outer.headers["subject"] };
|
|
719
|
+
const recipients = [...extractAddresses(protectedHeaders?.to), ...extractAddresses(protectedHeaders?.cc)];
|
|
720
|
+
const content: Partial<MessageSecurityResult> = {
|
|
721
|
+
...renderDisplayBody(extractDisplayBody(entity)),
|
|
722
|
+
subject: (hasProtected ? protectedHeaders?.subject : undefined) || undefined,
|
|
723
|
+
attachments: extractAttachments(entity),
|
|
724
|
+
...(hpOuter ? { headerTamperDetected: !outerHeadersMatch(hpOuter, outerComparable) } : {}),
|
|
725
|
+
...(readerAddress && recipients.length > 0 ? { notAddressedToReader: !recipients.includes(readerAddress.trim().toLowerCase()) } : {}),
|
|
726
|
+
};
|
|
727
|
+
const signature = parsed.signature;
|
|
728
|
+
if (!signature) {
|
|
729
|
+
// Encrypted without a signature (an unsigned signed-only message can't happen: it is the signature).
|
|
730
|
+
return { result: { state: "encrypted", ...content }, kind: resultKind };
|
|
731
|
+
}
|
|
732
|
+
if (signature.status === "unknown_key") {
|
|
733
|
+
return { result: encrypted ? { state: "encrypted", ...content, signerKeyUnknown: true } : { state: "unprotected", signerKeyUnknown: true }, kind: resultKind };
|
|
734
|
+
}
|
|
735
|
+
if (signature.status === "invalid") {
|
|
736
|
+
return { result: { state: "signature_failed", signatureFailureReason: "invalid_signature", ...(encrypted ? content : {}) }, kind: resultKind };
|
|
737
|
+
}
|
|
738
|
+
const signer = { signerFingerprint: signature.fingerprint, signerEmails: signature.emails, signerKeyType: "openpgp" as const };
|
|
739
|
+
const acceptedSigner = { ...signer, signerCertificate: toBase64(signature.candidate.key.write()) };
|
|
740
|
+
const signedHeaders: Partial<MessageSecurityResult> = hasProtected
|
|
741
|
+
? { protectedHeaders: { from: protectedHeaders.from, to: protectedHeaders.to, ...(protectedHeaders.cc !== undefined ? { cc: protectedHeaders.cc } : {}), subject: protectedHeaders.subject } }
|
|
742
|
+
: {};
|
|
743
|
+
const failure = checkIdentityAndHeaders(
|
|
744
|
+
{
|
|
745
|
+
protectedHeaders: hasProtected ? protectedHeaders : undefined,
|
|
746
|
+
outerHeaders: outer.headers,
|
|
747
|
+
outerFields: outer.fields,
|
|
748
|
+
protectedFields: hasProtected ? entity.fields : undefined,
|
|
749
|
+
compareSubject: !encrypted,
|
|
750
|
+
},
|
|
751
|
+
signature.emails,
|
|
752
|
+
);
|
|
753
|
+
// Signed by a key only attached to the message while the sender has OpenPGP keys pinned: they appear to use a new key.
|
|
754
|
+
const keyChanged = !failure && !signature.candidate.trusted && pinned.length > 0;
|
|
755
|
+
if (failure || keyChanged) {
|
|
756
|
+
const reason: SignatureFailureReason = failure ?? "signer_key_changed";
|
|
757
|
+
return {
|
|
758
|
+
result: { state: "signature_failed", signatureFailureReason: reason, ...(encrypted ? content : {}), ...(reason === "signer_key_changed" ? acceptedSigner : signer) },
|
|
759
|
+
kind: resultKind,
|
|
760
|
+
...(reason === "signer_key_changed" ? { signedContent: { ...content, ...signedHeaders } } : {}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const state: MessageSecurityState = signature.candidate.trusted ? (encrypted ? "encrypted_verified" : "signed_verified") : encrypted ? "encrypted_unverified_signer" : "signed_unverified_signer";
|
|
764
|
+
return { result: { state, ...content, ...signedHeaders, ...acceptedSigner }, kind: resultKind };
|
|
765
|
+
}
|
|
766
|
+
|
|
606
767
|
/** Options for `evaluateMessageSecurityWithSeal()`. */
|
|
607
768
|
export interface VerificationSealOptions {
|
|
608
769
|
mailboxUid: string;
|
|
@@ -669,7 +830,7 @@ function failedOnlyOnKeyStatus(result: MessageSecurityResult): boolean {
|
|
|
669
830
|
export async function evaluateMessageSecurityWithSeal(
|
|
670
831
|
rawMime: string,
|
|
671
832
|
unlocked: (MessageSecurityKeys & SealKeyMaterial) | undefined,
|
|
672
|
-
pinnedSignerFingerprints:
|
|
833
|
+
pinnedSignerFingerprints: SignerPinsInput,
|
|
673
834
|
readerAddress: string | undefined,
|
|
674
835
|
options: VerificationSealOptions,
|
|
675
836
|
): Promise<MessageSecurityResult> {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The mailbox's own OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): generated here, its secret
|
|
7
|
+
* key sealed under the master key like every other private key, its certificate enrolled with restapi - which publishes it on the
|
|
8
|
+
* RapidMX discovery endpoint, the Web Key Directory and in an `Autocrypt` header, so OpenPGP users (Proton Mail and others) can
|
|
9
|
+
* encrypt to the mailbox. S/MIME stays the mailbox's own format; this key is for correspondents who can't receive S/MIME.
|
|
10
|
+
*/
|
|
11
|
+
import type { ApiClient } from "../util/api.js";
|
|
12
|
+
import { type KeyVault, OPENPGP_KEY_TYPE, enrollKey, getEncryptionPolicy, getKeyVault, isOpenPgpKey, recordKeyExport } from "./keyvaultApi.js";
|
|
13
|
+
import { addOpenPgpKey, getUnlockedKeys, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, openMasterKeyWithPassword } from "./keySession.js";
|
|
14
|
+
import type { OpenPgpPrivateKey } from "./openpgp.js";
|
|
15
|
+
import { buildAad, openWithKey, sealWithKey } from "./masterKey.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates an OpenPGP key for `mailboxAddress` (`openpgp.ts`'s `generateOpenPgpKey()`), seals its secret key under `masterKey`
|
|
19
|
+
* and enrolls it; restapi makes it the mailbox's active OpenPGP key, superseding any earlier one. Pass the vault's
|
|
20
|
+
* `masterKeyGeneration` read with `masterKey` as `expectedMasterKeyGeneration` (`409` when another device rotated it meanwhile).
|
|
21
|
+
* Resolves with the vault and the new key, ready to decrypt and sign with.
|
|
22
|
+
*/
|
|
23
|
+
export async function provisionOpenPgpKey(
|
|
24
|
+
mailboxUid: string,
|
|
25
|
+
mailboxAddress: string,
|
|
26
|
+
masterKey: Uint8Array,
|
|
27
|
+
expectedMasterKeyGeneration?: number,
|
|
28
|
+
displayName?: string,
|
|
29
|
+
client?: ApiClient,
|
|
30
|
+
): Promise<{ vault: KeyVault; key: { fingerprint: string; privateKey: OpenPgpPrivateKey } }> {
|
|
31
|
+
const { generateOpenPgpKey, readSecretKey } = await import("./openpgp.js");
|
|
32
|
+
const generated = await generateOpenPgpKey(mailboxAddress, displayName);
|
|
33
|
+
try {
|
|
34
|
+
const sealed = await sealWithKey(masterKey, generated.secretKey, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
35
|
+
const vault = await enrollKey(
|
|
36
|
+
mailboxUid,
|
|
37
|
+
{
|
|
38
|
+
useType: "encrypt",
|
|
39
|
+
type: OPENPGP_KEY_TYPE,
|
|
40
|
+
publicKey: generated.armoredPublicKey,
|
|
41
|
+
wrappedKey: { ciphertext: sealed.ciphertext, nonce: sealed.nonce, algorithm: "AES-256-GCM" },
|
|
42
|
+
...(expectedMasterKeyGeneration !== undefined ? { expectedMasterKeyGeneration } : {}),
|
|
43
|
+
},
|
|
44
|
+
client,
|
|
45
|
+
);
|
|
46
|
+
return { vault, key: { fingerprint: generated.fingerprint, privateKey: await readSecretKey(generated.secretKey) } };
|
|
47
|
+
} finally {
|
|
48
|
+
generated.secretKey.fill(0);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Provisions already in flight, per mailbox - a second caller joins the first. */
|
|
53
|
+
const inFlight = new Map<string, Promise<boolean>>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gives an unlocked mailbox an OpenPGP key when it has none - how every mailbox, including one set up before OpenPGP was
|
|
57
|
+
* supported, gets one on its owner's next unlock. Resolves `true` when it enrolled one, `false` when there was nothing to do:
|
|
58
|
+
* the mailbox isn't unlocked, the server doesn't allow OpenPGP (`EncryptionPolicy.allowOpenPgp`), the vault isn't set up yet, or it
|
|
59
|
+
* already holds an OpenPGP key. Only the mailbox's owner may call this (restapi refuses anybody else's enrollment).
|
|
60
|
+
*/
|
|
61
|
+
export function ensureOpenPgpKey(mailboxUid: string, mailboxAddress: string, displayName?: string, client?: ApiClient): Promise<boolean> {
|
|
62
|
+
const pending = inFlight.get(mailboxUid);
|
|
63
|
+
if (pending) {
|
|
64
|
+
return pending;
|
|
65
|
+
}
|
|
66
|
+
const run = (async (): Promise<boolean> => {
|
|
67
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
68
|
+
if (!unlocked || unlocked.shared || unlocked.openPgpKeys?.length) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const policy = await getEncryptionPolicy(client);
|
|
72
|
+
if (policy.allowOpenPgp !== true) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
76
|
+
if (vault.wrappedKeys.length === 0 || vault.wrappedKeys.some((k) => isOpenPgpKey(k))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const { key } = await provisionOpenPgpKey(mailboxUid, mailboxAddress, unlocked.masterKey, vault.masterKeyGeneration, displayName, client);
|
|
80
|
+
addOpenPgpKey(mailboxUid, unlocked, key);
|
|
81
|
+
return true;
|
|
82
|
+
})().finally(() => inFlight.delete(mailboxUid));
|
|
83
|
+
inFlight.set(mailboxUid, run);
|
|
84
|
+
return run;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The mailbox's active OpenPGP secret key, armored and protected with `passphrase` - for a user who reads this mailbox's OpenPGP
|
|
89
|
+
* mail in another OpenPGP app too (Thunderbird, GnuPG...). Like the PKCS#12 export (`keyExport.ts`) it re-opens the vault with
|
|
90
|
+
* the master password, whatever the session, never touches the session's keys, and is recorded with restapi (kind `encrypt`).
|
|
91
|
+
* Throws when the mailbox has no OpenPGP key.
|
|
92
|
+
*/
|
|
93
|
+
export async function exportOpenPgpSecretKey(
|
|
94
|
+
mailboxUid: string,
|
|
95
|
+
masterPassword: string,
|
|
96
|
+
passphrase: string,
|
|
97
|
+
client?: ApiClient,
|
|
98
|
+
): Promise<{ armored: string; fingerprint: string }> {
|
|
99
|
+
const { readSecretKey, exportProtectedSecretKey } = await import("./openpgp.js");
|
|
100
|
+
const { vault, masterKey } = await openMasterKeyWithPassword(mailboxUid, masterPassword, client);
|
|
101
|
+
try {
|
|
102
|
+
// The newest OpenPGP key enrolled is the active one (restapi appends, and supersedes the earlier ones).
|
|
103
|
+
const wrapped = [...vault.wrappedKeys].reverse().find((k) => isOpenPgpKey(k));
|
|
104
|
+
if (!wrapped) {
|
|
105
|
+
throw new Error("This mailbox has no OpenPGP key to export.");
|
|
106
|
+
}
|
|
107
|
+
const raw = await openWithKey(masterKey, wrapped, buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE));
|
|
108
|
+
try {
|
|
109
|
+
const armored = await exportProtectedSecretKey(await readSecretKey(raw), passphrase);
|
|
110
|
+
await recordKeyExport(mailboxUid, [{ kind: "encrypt", fingerprint: wrapped.fingerprint }], client);
|
|
111
|
+
return { armored, fingerprint: wrapped.fingerprint };
|
|
112
|
+
} finally {
|
|
113
|
+
raw.fill(0);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
masterKey.fill(0);
|
|
117
|
+
}
|
|
118
|
+
}
|