@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type OpenPgpPrivateKey, type OpenPgpPublicKey } from "./openpgp.js";
|
|
2
|
+
import { type MimePart, type ProtectedHeaders } from "./smimeMessage.js";
|
|
3
|
+
/**
|
|
4
|
+
* An RFC 3156 `multipart/encrypted` message: the inner entity (protected headers, the body and - with `attachKey` - our public
|
|
5
|
+
* key) signed with `signingKey` when given and encrypted to every key in `recipientKeys` in one OpenPGP message. Per the spec's
|
|
6
|
+
* "Encrypt to Self", `recipientKeys` MUST include the sender's own key.
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildPgpEncryptedMessage(bodyContentType: string, bodyText: string, protectedHeaders: ProtectedHeaders, outerHeaders: ProtectedHeaders, recipientKeys: OpenPgpPublicKey[], signingKey?: OpenPgpPrivateKey, attachKey?: string): Promise<MimePart>;
|
|
9
|
+
/** An RFC 3156 detached `multipart/signed` message: the inner entity (protected headers, the body and - with `attachKey` - our
|
|
10
|
+
* public key), signed with `signingKey`. */
|
|
11
|
+
export declare function buildPgpSignedOnlyMessage(bodyContentType: string, bodyText: string, protectedHeaders: ProtectedHeaders, signingKey: OpenPgpPrivateKey, attachKey?: string): Promise<MimePart>;
|
|
12
|
+
/** A key that may have made a signature: a pinned one is trusted, one only attached to the message is not. */
|
|
13
|
+
export interface PgpSignerCandidate {
|
|
14
|
+
key: OpenPgpPublicKey;
|
|
15
|
+
trusted: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** What a signature proved. `"unknown_key"`: no candidate key has the signing key's ID, so it can't be checked at all. */
|
|
18
|
+
export type PgpSignatureOutcome = {
|
|
19
|
+
status: "verified";
|
|
20
|
+
candidate: PgpSignerCandidate;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
emails: string[];
|
|
23
|
+
} | {
|
|
24
|
+
status: "invalid";
|
|
25
|
+
} | {
|
|
26
|
+
status: "unknown_key";
|
|
27
|
+
};
|
|
28
|
+
/** A recovered OpenPGP message: the inner entity (a binary string, as `mime.ts` parses it) and what its signature proved. */
|
|
29
|
+
export interface ParsedPgpMessage {
|
|
30
|
+
/** For an encrypted message: whether one of the keys opened it. Always `true` for a signed-only one. */
|
|
31
|
+
decrypted: boolean;
|
|
32
|
+
/** The recovered MIME entity (PGP/MIME), or for an inline message a `text/plain` entity built around its text. */
|
|
33
|
+
entityText?: string;
|
|
34
|
+
/** `undefined` when the message carried no signature. */
|
|
35
|
+
signature?: PgpSignatureOutcome;
|
|
36
|
+
}
|
|
37
|
+
/** Verifies an RFC 3156 `multipart/signed` message (`protocol="application/pgp-signature"`). Never throws. */
|
|
38
|
+
export declare function parsePgpSignedMessage(contentType: string, body: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
|
|
39
|
+
/**
|
|
40
|
+
* Decrypts an RFC 3156 `multipart/encrypted` message with `decryptionKeys`, verifying the signature it carries (a combined signed
|
|
41
|
+
* and encrypted message, RFC 3156 §6.2, or an inner `multipart/signed`, §6.1) against `candidates` and the keys attached inside.
|
|
42
|
+
* Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parsePgpEncryptedMessage(contentType: string, body: string, decryptionKeys: OpenPgpPrivateKey[], candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
|
|
45
|
+
/** Decrypts an inline OpenPGP message (an armored `PGP MESSAGE` as the whole text body), verifying a signature it carries. Never
|
|
46
|
+
* throws. */
|
|
47
|
+
export declare function parsePgpInlineEncrypted(armored: string, decryptionKeys: OpenPgpPrivateKey[], candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
|
|
48
|
+
/** Verifies an inline cleartext-signed message (`BEGIN PGP SIGNED MESSAGE`). Never throws. */
|
|
49
|
+
export declare function parsePgpCleartextSigned(text: string, candidates: PgpSignerCandidate[]): Promise<ParsedPgpMessage>;
|
|
50
|
+
/** The certificates in a list of base64 keys (`PublicKey.publicKey` of OpenPGP entries) - unreadable ones dropped. */
|
|
51
|
+
export declare function readKeysBase64(keys: string[]): Promise<OpenPgpPublicKey[]>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* OpenPGP messages (RFC 3156 PGP/MIME, and the inline forms some clients still send) - the OpenPGP counterpart of
|
|
7
|
+
* `smimeMessage.ts`, for mail with OpenPGP users (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*). Only ever
|
|
8
|
+
* imported dynamically: OpenPGP.js is loaded when an OpenPGP message is read or sent.
|
|
9
|
+
*
|
|
10
|
+
* Sending writes PGP/MIME only: `multipart/encrypted` (signed and encrypted in one OpenPGP message, RFC 3156 §6.2) and
|
|
11
|
+
* detached `multipart/signed`. The inner entity carries the protected headers exactly as an S/MIME message does
|
|
12
|
+
* (`smimeMessage.ts`'s `protectedHeaderLines()`, RFC 9788), marked both `hp=` and `protected-headers="v1"` (the form OpenPGP
|
|
13
|
+
* mail clients recognise), and its body is base64 so the signed bytes survive transport.
|
|
14
|
+
*
|
|
15
|
+
* Reading recovers the inner entity and reports what the signature proved; the trust decision (pins, identity binding,
|
|
16
|
+
* header checks) is `messageSecurity.ts`'s, the same as for S/MIME.
|
|
17
|
+
*/
|
|
18
|
+
import * as openpgp from "openpgp";
|
|
19
|
+
import { fromBase64 } from "./encoding.js";
|
|
20
|
+
import { bytesToBinaryString, binaryStringToBytes, decodeBodyText, parseMimeEntity, parseParameterizedHeader, splitMultipart } from "./mime.js";
|
|
21
|
+
import { keyEmails, readPublicKeys } from "./openpgp.js";
|
|
22
|
+
import { base64Wrapped, flattenHeaderValue, generateBoundary, protectedHeaderLines, } from "./smimeMessage.js";
|
|
23
|
+
const CRLF = "\r\n";
|
|
24
|
+
/** Builds the inner entity: protected headers, then the body (base64) - wrapped with `attachKey` (an armored public key) in a
|
|
25
|
+
* `multipart/mixed` when one is given, so a correspondent without our key can import it. */
|
|
26
|
+
function innerEntity(bodyContentType, bodyText, headerBlock, hp, attachKey) {
|
|
27
|
+
const marks = `hp="${hp}"; protected-headers="v1"`;
|
|
28
|
+
const bodyPart = [
|
|
29
|
+
`Content-Type: ${flattenHeaderValue(bodyContentType)}`,
|
|
30
|
+
`Content-Transfer-Encoding: base64`,
|
|
31
|
+
"",
|
|
32
|
+
base64Wrapped(new TextEncoder().encode(bodyText)),
|
|
33
|
+
].join(CRLF);
|
|
34
|
+
if (!attachKey) {
|
|
35
|
+
return [headerBlock, bodyPart.replace(/^Content-Type: ([^\r\n]*)/, `Content-Type: $1; ${marks}`)].join(CRLF);
|
|
36
|
+
}
|
|
37
|
+
const boundary = generateBoundary();
|
|
38
|
+
return [
|
|
39
|
+
headerBlock,
|
|
40
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"; ${marks}`,
|
|
41
|
+
"",
|
|
42
|
+
`--${boundary}`,
|
|
43
|
+
bodyPart,
|
|
44
|
+
`--${boundary}`,
|
|
45
|
+
`Content-Type: application/pgp-keys; name="OpenPGP_public_key.asc"`,
|
|
46
|
+
`Content-Disposition: attachment; filename="OpenPGP_public_key.asc"`,
|
|
47
|
+
"",
|
|
48
|
+
attachKey.replace(/\r?\n/g, CRLF).trim(),
|
|
49
|
+
`--${boundary}--`,
|
|
50
|
+
].join(CRLF);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* An RFC 3156 `multipart/encrypted` message: the inner entity (protected headers, the body and - with `attachKey` - our public
|
|
54
|
+
* key) signed with `signingKey` when given and encrypted to every key in `recipientKeys` in one OpenPGP message. Per the spec's
|
|
55
|
+
* "Encrypt to Self", `recipientKeys` MUST include the sender's own key.
|
|
56
|
+
*/
|
|
57
|
+
export async function buildPgpEncryptedMessage(bodyContentType, bodyText, protectedHeaders, outerHeaders, recipientKeys, signingKey, attachKey) {
|
|
58
|
+
const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders, outerHeaders), "cipher", attachKey);
|
|
59
|
+
const armored = await openpgp.encrypt({
|
|
60
|
+
message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
|
|
61
|
+
encryptionKeys: recipientKeys,
|
|
62
|
+
...(signingKey ? { signingKeys: signingKey } : {}),
|
|
63
|
+
format: "armored",
|
|
64
|
+
});
|
|
65
|
+
const boundary = generateBoundary();
|
|
66
|
+
const body = [
|
|
67
|
+
`--${boundary}`,
|
|
68
|
+
`Content-Type: application/pgp-encrypted`,
|
|
69
|
+
`Content-Description: PGP/MIME version identification`,
|
|
70
|
+
"",
|
|
71
|
+
"Version: 1",
|
|
72
|
+
"",
|
|
73
|
+
`--${boundary}`,
|
|
74
|
+
`Content-Type: application/octet-stream; name="encrypted.asc"`,
|
|
75
|
+
`Content-Description: OpenPGP encrypted message`,
|
|
76
|
+
`Content-Disposition: inline; filename="encrypted.asc"`,
|
|
77
|
+
"",
|
|
78
|
+
armored.replace(/\r?\n/g, CRLF).trim(),
|
|
79
|
+
`--${boundary}--`,
|
|
80
|
+
].join(CRLF);
|
|
81
|
+
return { contentType: `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="${boundary}"`, body };
|
|
82
|
+
}
|
|
83
|
+
/** The `micalg` of a detached signature: `pgp-` and its hash algorithm's name (RFC 3156 §5). */
|
|
84
|
+
async function micalg(armoredSignature) {
|
|
85
|
+
const signature = await openpgp.readSignature({ armoredSignature });
|
|
86
|
+
const id = signature.packets[0]?.hashAlgorithm;
|
|
87
|
+
const name = id !== undefined && id !== null ? openpgp.enums.read(openpgp.enums.hash, id) : "sha512";
|
|
88
|
+
return `pgp-${String(name).toLowerCase()}`;
|
|
89
|
+
}
|
|
90
|
+
/** An RFC 3156 detached `multipart/signed` message: the inner entity (protected headers, the body and - with `attachKey` - our
|
|
91
|
+
* public key), signed with `signingKey`. */
|
|
92
|
+
export async function buildPgpSignedOnlyMessage(bodyContentType, bodyText, protectedHeaders, signingKey, attachKey) {
|
|
93
|
+
const entity = innerEntity(bodyContentType, bodyText, protectedHeaderLines(protectedHeaders), "clear", attachKey);
|
|
94
|
+
const signature = await openpgp.sign({
|
|
95
|
+
message: await openpgp.createMessage({ binary: new TextEncoder().encode(entity) }),
|
|
96
|
+
signingKeys: signingKey,
|
|
97
|
+
detached: true,
|
|
98
|
+
format: "armored",
|
|
99
|
+
});
|
|
100
|
+
const boundary = generateBoundary();
|
|
101
|
+
const body = [
|
|
102
|
+
`--${boundary}`,
|
|
103
|
+
entity,
|
|
104
|
+
`--${boundary}`,
|
|
105
|
+
`Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"`,
|
|
106
|
+
`Content-Description: OpenPGP digital signature`,
|
|
107
|
+
`Content-Disposition: attachment; filename="OpenPGP_signature.asc"`,
|
|
108
|
+
"",
|
|
109
|
+
signature.replace(/\r?\n/g, CRLF).trim(),
|
|
110
|
+
`--${boundary}--`,
|
|
111
|
+
].join(CRLF);
|
|
112
|
+
return {
|
|
113
|
+
contentType: `multipart/signed; protocol="application/pgp-signature"; micalg=${await micalg(signature)}; boundary="${boundary}"`,
|
|
114
|
+
body,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function signatureOutcome(signatures, candidates) {
|
|
118
|
+
let known = false;
|
|
119
|
+
for (const signature of signatures) {
|
|
120
|
+
const candidate = candidates.find((c) => c.key.getKeyIDs().some((id) => id.equals(signature.keyID)));
|
|
121
|
+
if (!candidate) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
known = true;
|
|
125
|
+
const valid = await signature.verified.then(() => true, () => false);
|
|
126
|
+
if (valid) {
|
|
127
|
+
return { status: "verified", candidate, fingerprint: candidate.key.getFingerprint().toLowerCase(), emails: await keyEmails(candidate.key) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return known ? { status: "invalid" } : { status: "unknown_key" };
|
|
131
|
+
}
|
|
132
|
+
/** The certificates attached to `entityText` (`application/pgp-keys` parts) - untrusted signer candidates. */
|
|
133
|
+
async function attachedKeys(entityText) {
|
|
134
|
+
const keys = [];
|
|
135
|
+
const visit = async (text, depth) => {
|
|
136
|
+
if (depth > 4) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const entity = parseMimeEntity(text);
|
|
140
|
+
const type = parseParameterizedHeader(entity.headers["content-type"]);
|
|
141
|
+
if (type.value === "application/pgp-keys") {
|
|
142
|
+
keys.push(...(await readPublicKeys(decodeBodyText(entity))));
|
|
143
|
+
}
|
|
144
|
+
else if (type.value.startsWith("multipart/") && type.params["boundary"]) {
|
|
145
|
+
for (const part of splitMultipart(entity.body, type.params["boundary"]).slice(0, 16)) {
|
|
146
|
+
await visit(part, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
await visit(entityText, 0);
|
|
151
|
+
return keys;
|
|
152
|
+
}
|
|
153
|
+
/** Candidates for `entityText`: the given ones, then any key attached to the message (untrusted). */
|
|
154
|
+
async function withAttached(candidates, entityText) {
|
|
155
|
+
return [...candidates, ...(await attachedKeys(entityText)).map((key) => ({ key, trusted: false }))];
|
|
156
|
+
}
|
|
157
|
+
/** Verifies an RFC 3156 `multipart/signed` message (`protocol="application/pgp-signature"`). Never throws. */
|
|
158
|
+
export async function parsePgpSignedMessage(contentType, body, candidates) {
|
|
159
|
+
const boundary = parseParameterizedHeader(contentType).params["boundary"];
|
|
160
|
+
const parts = boundary ? splitMultipart(body, boundary) : [];
|
|
161
|
+
if (parts.length !== 2) {
|
|
162
|
+
return { decrypted: true, signature: { status: "invalid" } };
|
|
163
|
+
}
|
|
164
|
+
const [signed, signaturePart] = parts;
|
|
165
|
+
try {
|
|
166
|
+
const signature = await openpgp.readSignature({ armoredSignature: decodeBodyText(parseMimeEntity(signaturePart)) });
|
|
167
|
+
const all = await withAttached(candidates, signed);
|
|
168
|
+
// What was signed is the part in canonical CRLF form (RFC 3156 §5).
|
|
169
|
+
const canonical = signed.replace(/\r?\n/g, CRLF);
|
|
170
|
+
const verification = await openpgp.verify({
|
|
171
|
+
message: await openpgp.createMessage({ binary: binaryStringToBytes(canonical) }),
|
|
172
|
+
signature,
|
|
173
|
+
verificationKeys: all.map((c) => c.key),
|
|
174
|
+
expectSigned: false,
|
|
175
|
+
});
|
|
176
|
+
return { decrypted: true, entityText: signed, signature: await signatureOutcome(verification.signatures, all) };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { decrypted: true, signature: { status: "invalid" } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** The armored OpenPGP message in an RFC 3156 `multipart/encrypted` body (its second part), or `undefined`. */
|
|
183
|
+
function encryptedPayload(contentType, body) {
|
|
184
|
+
const boundary = parseParameterizedHeader(contentType).params["boundary"];
|
|
185
|
+
const parts = boundary ? splitMultipart(body, boundary) : [];
|
|
186
|
+
return parts.length === 2 ? decodeBodyText(parseMimeEntity(parts[1])) : undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Decrypts an RFC 3156 `multipart/encrypted` message with `decryptionKeys`, verifying the signature it carries (a combined signed
|
|
190
|
+
* and encrypted message, RFC 3156 §6.2, or an inner `multipart/signed`, §6.1) against `candidates` and the keys attached inside.
|
|
191
|
+
* Never throws.
|
|
192
|
+
*/
|
|
193
|
+
export async function parsePgpEncryptedMessage(contentType, body, decryptionKeys, candidates) {
|
|
194
|
+
const payload = encryptedPayload(contentType, body);
|
|
195
|
+
if (!payload || decryptionKeys.length === 0) {
|
|
196
|
+
return { decrypted: false };
|
|
197
|
+
}
|
|
198
|
+
let data;
|
|
199
|
+
let signed;
|
|
200
|
+
try {
|
|
201
|
+
const result = await openpgp.decrypt({ message: await openpgp.readMessage({ armoredMessage: payload }), decryptionKeys, format: "binary", expectSigned: false });
|
|
202
|
+
data = result.data;
|
|
203
|
+
signed = result.signatures.length > 0;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { decrypted: false };
|
|
207
|
+
}
|
|
208
|
+
const entityText = bytesToBinaryString(data);
|
|
209
|
+
const inner = parseMimeEntity(entityText);
|
|
210
|
+
const innerType = parseParameterizedHeader(inner.headers["content-type"]);
|
|
211
|
+
if (!signed && innerType.value === "multipart/signed" && innerType.params["protocol"]?.toLowerCase() === "application/pgp-signature") {
|
|
212
|
+
const innerSigned = await parsePgpSignedMessage(inner.headers["content-type"], inner.body, candidates);
|
|
213
|
+
return { decrypted: true, entityText: innerSigned.entityText ?? entityText, signature: innerSigned.signature };
|
|
214
|
+
}
|
|
215
|
+
if (!signed) {
|
|
216
|
+
return { decrypted: true, entityText };
|
|
217
|
+
}
|
|
218
|
+
// The keys attached inside are only known now: verify the signature against them and the given ones together.
|
|
219
|
+
const all = await withAttached(candidates, entityText);
|
|
220
|
+
if (all.length === 0) {
|
|
221
|
+
return { decrypted: true, entityText, signature: { status: "unknown_key" } };
|
|
222
|
+
}
|
|
223
|
+
const verified = await openpgp.decrypt({
|
|
224
|
+
message: await openpgp.readMessage({ armoredMessage: payload }),
|
|
225
|
+
decryptionKeys,
|
|
226
|
+
verificationKeys: all.map((c) => c.key),
|
|
227
|
+
format: "binary",
|
|
228
|
+
expectSigned: false,
|
|
229
|
+
});
|
|
230
|
+
return { decrypted: true, entityText, signature: await signatureOutcome(verified.signatures, all) };
|
|
231
|
+
}
|
|
232
|
+
/** A `text/plain` entity for an inline message's recovered text. */
|
|
233
|
+
function plainEntity(text) {
|
|
234
|
+
return bytesToBinaryString(new TextEncoder().encode(`Content-Type: text/plain; charset=utf-8${CRLF}${CRLF}${text}`));
|
|
235
|
+
}
|
|
236
|
+
/** Decrypts an inline OpenPGP message (an armored `PGP MESSAGE` as the whole text body), verifying a signature it carries. Never
|
|
237
|
+
* throws. */
|
|
238
|
+
export async function parsePgpInlineEncrypted(armored, decryptionKeys, candidates) {
|
|
239
|
+
if (decryptionKeys.length === 0) {
|
|
240
|
+
return { decrypted: false };
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const result = await openpgp.decrypt({
|
|
244
|
+
message: await openpgp.readMessage({ armoredMessage: armored }),
|
|
245
|
+
decryptionKeys,
|
|
246
|
+
verificationKeys: candidates.map((c) => c.key),
|
|
247
|
+
expectSigned: false,
|
|
248
|
+
});
|
|
249
|
+
const signature = result.signatures.length > 0 ? await signatureOutcome(result.signatures, candidates) : undefined;
|
|
250
|
+
return { decrypted: true, entityText: plainEntity(String(result.data)), ...(signature ? { signature } : {}) };
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { decrypted: false };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Verifies an inline cleartext-signed message (`BEGIN PGP SIGNED MESSAGE`). Never throws. */
|
|
257
|
+
export async function parsePgpCleartextSigned(text, candidates) {
|
|
258
|
+
try {
|
|
259
|
+
const message = await openpgp.readCleartextMessage({ cleartextMessage: text });
|
|
260
|
+
const verification = await openpgp.verify({ message, verificationKeys: candidates.map((c) => c.key), expectSigned: false });
|
|
261
|
+
return { decrypted: true, entityText: plainEntity(message.getText()), signature: await signatureOutcome(verification.signatures, candidates) };
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return { decrypted: true, signature: { status: "invalid" } };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** The certificates in a list of base64 keys (`PublicKey.publicKey` of OpenPGP entries) - unreadable ones dropped. */
|
|
268
|
+
export async function readKeysBase64(keys) {
|
|
269
|
+
const read = await Promise.all(keys.map((key) => readPublicKeys(fromBase64(key))));
|
|
270
|
+
return read.flat();
|
|
271
|
+
}
|
|
@@ -24,18 +24,28 @@ export type SharedUnlockOutcome =
|
|
|
24
24
|
/**
|
|
25
25
|
* Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
|
|
26
26
|
* own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
|
|
27
|
+
*
|
|
28
|
+
* `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
|
|
29
|
+
* after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
|
|
27
30
|
*/
|
|
28
|
-
export declare function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome>;
|
|
31
|
+
export declare function unlockSharedMailbox(mailboxUid: string, client?: ApiClient, refresh?: boolean): Promise<SharedUnlockOutcome>;
|
|
29
32
|
/** What `maintainSharedMailbox()` did. */
|
|
30
33
|
export interface SharedMaintenance {
|
|
31
34
|
provisioned: boolean;
|
|
32
35
|
rotated: boolean;
|
|
33
36
|
/** Wraps added for waiting members. */
|
|
34
37
|
wrapped: number;
|
|
38
|
+
/** Wraps of older keys the server refused for waiting members (tried again on the next pass). */
|
|
39
|
+
refused: number;
|
|
35
40
|
/** Departed users whose wraps were removed. */
|
|
36
41
|
removed: number;
|
|
37
42
|
/** Keys wrapped for the mailbox's escrow scope. */
|
|
38
43
|
escrowed: number;
|
|
44
|
+
/**
|
|
45
|
+
* The server's reason when it refused (400) the new key of a pending rotation - e.g. the vault couldn't hold the new key's member wraps
|
|
46
|
+
* next to those it holds - so the current key stays active and members keep reading; the rotation is tried again on the next pass.
|
|
47
|
+
*/
|
|
48
|
+
rotationRefused?: string;
|
|
39
49
|
}
|
|
40
50
|
/**
|
|
41
51
|
* The background care of a shared mailbox, run by the client of its owner or of a member with full access (`status.manager`); nothing for
|
|
@@ -44,7 +54,9 @@ export interface SharedMaintenance {
|
|
|
44
54
|
* A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
|
|
45
55
|
* certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
|
|
46
56
|
* master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
|
|
47
|
-
* are removed. Members still waiting (`pending`) get wraps of every key this client can open,
|
|
48
|
-
* owner gets copies of the keys it is
|
|
57
|
+
* are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
|
|
58
|
+
* first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
|
|
59
|
+
* missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
|
|
60
|
+
* and the rest of the pass goes ahead.
|
|
49
61
|
*/
|
|
50
62
|
export declare function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance>;
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
import { ApiRequestError } from "../util/api.js";
|
|
18
18
|
import { fromBase64, toBase64 } from "./encoding.js";
|
|
19
19
|
import { enrollSharedZeroAccessKey, getEncryptionPolicy, getEscrowInfo, getKeyVault, getMyMemberKeyWraps, getSharedKeyStatus, putMemberKeyWraps, putZeroAccessEscrowWraps, removeMemberKeyWraps, } from "./keyvaultApi.js";
|
|
20
|
+
import { certificateHoldsKey } from "./keyPairMatch.js";
|
|
20
21
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
|
|
21
|
-
import { getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
|
|
22
|
+
import { addZeroAccessKey, getUnlockedKeys, installSharedSession, personalSessions, sharedUnlockGeneration, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, } from "./keySession.js";
|
|
22
23
|
import { buildAad, openWithKey } from "./masterKey.js";
|
|
23
24
|
import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "./smime.js";
|
|
24
25
|
import { provisionZeroAccessKey } from "./zeroAccessKey.js";
|
|
@@ -63,9 +64,13 @@ function keyOrder(status) {
|
|
|
63
64
|
/**
|
|
64
65
|
* Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
|
|
65
66
|
* own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
|
|
67
|
+
*
|
|
68
|
+
* `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
|
|
69
|
+
* after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
|
|
66
70
|
*/
|
|
67
|
-
export async function unlockSharedMailbox(mailboxUid, client) {
|
|
68
|
-
|
|
71
|
+
export async function unlockSharedMailbox(mailboxUid, client, refresh = false) {
|
|
72
|
+
const existing = getUnlockedKeys(mailboxUid);
|
|
73
|
+
if (existing && !(refresh && existing.shared)) {
|
|
69
74
|
return "unlocked";
|
|
70
75
|
}
|
|
71
76
|
const generation = sharedUnlockGeneration(mailboxUid);
|
|
@@ -75,14 +80,22 @@ export async function unlockSharedMailbox(mailboxUid, client) {
|
|
|
75
80
|
}
|
|
76
81
|
catch (err) {
|
|
77
82
|
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
|
|
78
|
-
return "not_shared";
|
|
83
|
+
return existing ? "unlocked" : "not_shared";
|
|
79
84
|
}
|
|
80
85
|
throw err;
|
|
81
86
|
}
|
|
87
|
+
const holdsActive = (keys) => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
|
|
88
|
+
if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
|
|
89
|
+
return "unlocked";
|
|
90
|
+
}
|
|
82
91
|
if (status.role !== "member") {
|
|
83
|
-
return "not_shared";
|
|
92
|
+
return existing ? "unlocked" : "not_shared";
|
|
84
93
|
}
|
|
85
94
|
const wraps = await getMyMemberKeyWraps(mailboxUid, client);
|
|
95
|
+
// Nothing new to open yet: the active key's wrap is still on its way.
|
|
96
|
+
if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
|
|
97
|
+
return "unlocked";
|
|
98
|
+
}
|
|
86
99
|
if (wraps.length === 0) {
|
|
87
100
|
return "pending";
|
|
88
101
|
}
|
|
@@ -99,29 +112,48 @@ export async function unlockSharedMailbox(mailboxUid, client) {
|
|
|
99
112
|
}
|
|
100
113
|
const pkcs8 = unwrapped.pkcs8;
|
|
101
114
|
try {
|
|
115
|
+
// The certificate is the server's: one that isn't this key's must never be what this session seals to.
|
|
116
|
+
if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
102
119
|
secret ?? (secret = await sessionSecret(pkcs8));
|
|
103
120
|
const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
|
|
104
121
|
opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
|
|
105
122
|
openedWith.add(unwrapped.openedWith);
|
|
106
123
|
}
|
|
107
124
|
catch {
|
|
108
|
-
// A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
|
|
125
|
+
// A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
|
|
109
126
|
}
|
|
110
127
|
finally {
|
|
111
128
|
pkcs8.fill(0);
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
if (!secret || opened.length === 0) {
|
|
115
|
-
return "own_keys_locked";
|
|
132
|
+
return existing ? "unlocked" : "own_keys_locked";
|
|
133
|
+
}
|
|
134
|
+
if (existing) {
|
|
135
|
+
// Opened again: the secret the local index key is derived from stays the one the session started with.
|
|
136
|
+
secret.fill(0);
|
|
137
|
+
secret = new Uint8Array(existing.masterKey);
|
|
116
138
|
}
|
|
117
139
|
const unlocked = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
|
|
118
140
|
installSharedSession(mailboxUid, unlocked, generation);
|
|
119
141
|
return "unlocked";
|
|
120
142
|
}
|
|
121
143
|
/** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
|
|
122
|
-
* key, or from the caller's own member wraps.
|
|
144
|
+
* key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
|
|
145
|
+
* for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
|
|
123
146
|
async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
|
|
124
147
|
const keys = new Map();
|
|
148
|
+
const keep = async (fingerprint, pkcs8) => {
|
|
149
|
+
const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
|
|
150
|
+
if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
|
|
151
|
+
keys.set(fingerprint, pkcs8);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
pkcs8.fill(0);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
125
157
|
if (ownerUnlocked) {
|
|
126
158
|
const vault = await getKeyVault(mailboxUid, client);
|
|
127
159
|
const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
|
|
@@ -129,7 +161,7 @@ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
|
|
|
129
161
|
const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
|
|
130
162
|
if (wrapped) {
|
|
131
163
|
try {
|
|
132
|
-
|
|
164
|
+
await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
|
|
133
165
|
}
|
|
134
166
|
catch {
|
|
135
167
|
// Sealed under a master key since replaced: not this device's to share.
|
|
@@ -142,7 +174,7 @@ async function openPrivateKeys(mailboxUid, status, ownerUnlocked, client) {
|
|
|
142
174
|
for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
|
|
143
175
|
const unwrapped = await openWrap(wrap, own);
|
|
144
176
|
if (unwrapped) {
|
|
145
|
-
|
|
177
|
+
await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
|
|
146
178
|
}
|
|
147
179
|
}
|
|
148
180
|
return keys;
|
|
@@ -154,9 +186,21 @@ async function wrapFor(member, pkcs8) {
|
|
|
154
186
|
cms: toBase64(await encryptForRecipients(pkcs8, [fromBase64(member.certificate.publicKey)])),
|
|
155
187
|
};
|
|
156
188
|
}
|
|
157
|
-
/** The members with a certificate of their own - the ones a key can be wrapped for.
|
|
189
|
+
/** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
|
|
190
|
+
* this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
|
|
191
|
+
* swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
|
|
158
192
|
function wrappable(status) {
|
|
159
|
-
|
|
193
|
+
const own = ownZeroAccessKeys();
|
|
194
|
+
return status.members.filter((member) => {
|
|
195
|
+
if (!member.certificate) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const mine = own.find((key) => key.fingerprint === member.certificate.fingerprint);
|
|
199
|
+
return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function sameBytes(a, b) {
|
|
203
|
+
return a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
160
204
|
}
|
|
161
205
|
/** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
|
|
162
206
|
async function escrowWrapFor(escrow, pkcs8) {
|
|
@@ -184,11 +228,13 @@ async function enrollNewSharedKey(mailboxUid, mailboxAddress, status, client) {
|
|
|
184
228
|
* A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
|
|
185
229
|
* certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
|
|
186
230
|
* master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
|
|
187
|
-
* are removed. Members still waiting (`pending`) get wraps of every key this client can open,
|
|
188
|
-
* owner gets copies of the keys it is
|
|
231
|
+
* are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
|
|
232
|
+
* first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
|
|
233
|
+
* missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
|
|
234
|
+
* and the rest of the pass goes ahead.
|
|
189
235
|
*/
|
|
190
236
|
export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client) {
|
|
191
|
-
const result = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
|
|
237
|
+
const result = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
|
|
192
238
|
let status = await getSharedKeyStatus(mailboxUid, client);
|
|
193
239
|
if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
|
|
194
240
|
return result;
|
|
@@ -206,15 +252,27 @@ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client)
|
|
|
206
252
|
result.provisioned = true;
|
|
207
253
|
}
|
|
208
254
|
else if (status.rotationPending) {
|
|
209
|
-
|
|
210
|
-
|
|
255
|
+
try {
|
|
256
|
+
if (status.ownerless) {
|
|
257
|
+
status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const vault = await getKeyVault(mailboxUid, client);
|
|
261
|
+
const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked.masterKey, vault.masterKeyGeneration, client);
|
|
262
|
+
// The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
|
|
263
|
+
addZeroAccessKey(mailboxUid, ownerUnlocked, key);
|
|
264
|
+
status = await getSharedKeyStatus(mailboxUid, client);
|
|
265
|
+
}
|
|
266
|
+
result.rotated = true;
|
|
211
267
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
268
|
+
catch (err) {
|
|
269
|
+
// Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
|
|
270
|
+
// current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
|
|
271
|
+
if (!(err instanceof ApiRequestError) || err.status !== 400) {
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
result.rotationRefused = err.message;
|
|
216
275
|
}
|
|
217
|
-
result.rotated = true;
|
|
218
276
|
}
|
|
219
277
|
// The departed hold no wrap of the active key any more (or never did): their old wraps go.
|
|
220
278
|
if (!status.rotationPending) {
|
|
@@ -228,17 +286,32 @@ export async function maintainSharedMailbox(mailboxUid, mailboxAddress, client)
|
|
|
228
286
|
if (missing.length > 0 || escrowPending.length > 0) {
|
|
229
287
|
const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
|
|
230
288
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
289
|
+
// One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
|
|
290
|
+
// each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
|
|
291
|
+
const order = keyOrder(status);
|
|
292
|
+
for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
|
|
293
|
+
const wraps = [];
|
|
294
|
+
for (const member of missing) {
|
|
234
295
|
if (!member.wrapped.includes(fingerprint)) {
|
|
235
296
|
wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
|
|
236
297
|
}
|
|
237
298
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
299
|
+
if (wraps.length === 0) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (fingerprint === status.activeKeyFingerprint) {
|
|
303
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
304
|
+
result.wrapped += wraps.length;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
try {
|
|
308
|
+
await putMemberKeyWraps(mailboxUid, wraps, client);
|
|
309
|
+
result.wrapped += wraps.length;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
result.refused += wraps.length;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
242
315
|
}
|
|
243
316
|
const held = escrowPending.filter((fingerprint) => keys.has(fingerprint));
|
|
244
317
|
if (held.length > 0) {
|
|
@@ -13,6 +13,14 @@ export interface ProtectedHeaders {
|
|
|
13
13
|
* (Section 3.3). Applied to produce the OUTER, unprotected envelope's headers from the real
|
|
14
14
|
* (protected) ones; `From`/`To`/`Cc`/`Date` pass through unchanged, `Subject` is obscured. */
|
|
15
15
|
export declare function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedHeaders;
|
|
16
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
17
|
+
export declare function base64Wrapped(bytes: Uint8Array): string;
|
|
18
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
19
|
+
export declare function generateBoundary(): string;
|
|
20
|
+
/** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
|
|
21
|
+
export declare function flattenHeaderValue(value: string): string;
|
|
22
|
+
/** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
|
|
23
|
+
export declare function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string;
|
|
16
24
|
/** One MIME entity's worth of headers-beyond-Content-Type plus body, ready for a caller (the actual
|
|
17
25
|
* outbound-message compose step, built on top of this module) to combine with the outer envelope's
|
|
18
26
|
* own `From`/`To`/`Subject`/`Date`/`Message-ID`. */
|
|
@@ -106,6 +114,17 @@ export interface ParsedEncryptedMessage {
|
|
|
106
114
|
* (`messageId` has no `HP-Outer` counterpart; only the fields the outer envelope itself carries do).
|
|
107
115
|
* Exported so `messageSecurity.ts` can build one from a received message's real outer envelope. */
|
|
108
116
|
export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to" | "cc" | "date" | "subject">>;
|
|
117
|
+
/** Extracts the outer-envelope header values RFC 9788's `HP-Outer: <Field>: <value>` mechanism embeds
|
|
118
|
+
* inside an encrypted message's protected content - one line per outer header, all sharing the literal
|
|
119
|
+
* field name `HP-Outer`, which `splitHeadersAndBody()`'s own flat header map can hold only the last of
|
|
120
|
+
* (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
|
|
121
|
+
* text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
|
|
122
|
+
* can distinguish "nothing to compare" from "compared and everything matched". */
|
|
123
|
+
export declare function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined;
|
|
124
|
+
/** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
|
|
125
|
+
* field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
|
|
126
|
+
* 9788 never claims coverage for it either way. */
|
|
127
|
+
export declare function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean;
|
|
109
128
|
/** Decrypts (and, if present, verifies the inner signature of) a message built by
|
|
110
129
|
* `buildEncryptedMessage()`. Returns `{ decrypted: false }` (never throws) for anything malformed or
|
|
111
130
|
* undecryptable with the given key — the caller's own concern is distinguishing "wrong key" from
|
|
@@ -130,3 +149,5 @@ export declare function splitHeadersAndBody(entity: string): {
|
|
|
130
149
|
body: string;
|
|
131
150
|
rawHeaderBlock: string;
|
|
132
151
|
};
|
|
152
|
+
/** @internal Shared with `pgpMessage.ts`. */
|
|
153
|
+
export declare function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders;
|