Skip to content

Commit 2d56f2f

Browse files
author
MargeBot
committed
Merge branch 'B2B-add-group-member-keys' into 'main'
Add group member keys api See merge request web/clients!25568
2 parents 29f38f2 + f733501 commit 2d56f2f

2 files changed

Lines changed: 142 additions & 2 deletions

File tree

packages/account/groups/addGroupMember.ts

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { CryptoProxy, type PrivateKeyReference, type PublicKeyReference } from '@protontech/crypto';
12
import type { ThunkAction, UnknownAction } from '@reduxjs/toolkit';
23

3-
import { CryptoProxy, type PrivateKeyReference, type PublicKeyReference } from '@protontech/crypto';
44
import type { ProtonThunkArguments } from '@proton/redux-shared-store-types';
55
import { CacheType } from '@proton/redux-utilities/interface';
6-
import { addGroupMember as addGroupMemberApi } from '@proton/shared/lib/api/groups';
6+
import {
7+
addGroupMember as addGroupMemberApi,
8+
addGroupMemberKeys as addGroupMemberKeysApi,
9+
} from '@proton/shared/lib/api/groups';
710
import { getSilentApi } from '@proton/shared/lib/api/helpers/customConfig';
811
import { MEMBER_PRIVATE, RECIPIENT_TYPES } from '@proton/shared/lib/constants';
912
import { getIsEncryptionDisabled } from '@proton/shared/lib/helpers/address';
@@ -16,6 +19,8 @@ import type {
1619
EnhancedMember,
1720
} from '@proton/shared/lib/interfaces';
1821
import { GroupMemberType } from '@proton/shared/lib/interfaces';
22+
import { GROUP_MEMBER_TYPE } from '@proton/shared/lib/interfaces/GroupMember';
23+
import type { GroupMember } from '@proton/shared/lib/interfaces/GroupMember';
1924
import { getAddressKeyToken, getDecryptedUserKeys, getEmailFromKey, splitKeys } from '@proton/shared/lib/keys';
2025
import { getInternalParameters, getInternalParametersPrivate } from '@proton/shared/lib/keys/forward/forward';
2126
import { getGroupMemberPublicKeys } from '@proton/shared/lib/keys/groupKeys';
@@ -296,3 +301,107 @@ export const addGroupMembersThunk = ({
296301
}
297302
};
298303
};
304+
305+
export const addGroupMemberKeysThunk = ({
306+
groupMember,
307+
groupAddress,
308+
getMemberPublicKeys,
309+
}: {
310+
groupMember: GroupMember;
311+
groupAddress: Address;
312+
getMemberPublicKeys: (email: string) => Promise<ApiKeysConfig>;
313+
}): ThunkAction<Promise<void>, RequiredState, ProtonThunkArguments, UnknownAction> => {
314+
return async (dispatch, _, extra) => {
315+
const email = groupMember.Email;
316+
if (!email) {
317+
throw new Error('Group member has no email');
318+
}
319+
320+
const api = getSilentApi(extra.api);
321+
const forwarderKey = await dispatch(getGroupKey({ groupAddress }));
322+
const { forwardeeKeysConfig, forwardeeArmoredPrimaryPublicKey } = await getGroupMemberPublicKeys({
323+
api,
324+
memberEmail: email,
325+
getMemberPublicKeys,
326+
});
327+
328+
// External recipients can't keep E2EE, so disable it on the group address (skip if already off).
329+
// NOTE: getIsEncryptionDisabled reads cached state and is therefore not safe under concurrency.
330+
// Once the key addition is parallelized across multiple members, do this once up front (as addGroupMembersThunk does) instead of per member.
331+
if (isExternalForMail(forwardeeKeysConfig) && !getIsEncryptionDisabled(groupAddress)) {
332+
await dispatch(disableGroupAddressEncryption({ groupAddress, forwarderKey }));
333+
}
334+
335+
const [members, organizationKey] = await Promise.all([
336+
dispatch(membersThunk()),
337+
dispatch(organizationKeyThunk()),
338+
]);
339+
340+
const canonicalEmail = canonicalizeInternalEmail(email);
341+
const member = getMemberByEmail(members, canonicalEmail);
342+
343+
const AddressSignaturePacket = await signMemberEmail(canonicalEmail, forwarderKey.privateKey);
344+
345+
// Case 1 — External: no key to forward to, send email + signature only.
346+
if (groupMember.Type === GROUP_MEMBER_TYPE.EXTERNAL) {
347+
return api(addGroupMemberKeysApi(groupMember.ID, { Email: email, AddressSignaturePacket }));
348+
}
349+
350+
// Case 2 — Internal we can't decrypt for (other org / private / no org key): build the key
351+
// from their public key and send an ActivationToken for them to activate it themselves.
352+
if (!member || isPrivate(member) || !organizationKey.privateKey) {
353+
const forwardeePublicKey = await getForwardeePublicKey(forwardeeArmoredPrimaryPublicKey);
354+
const Email = getEmailFromKey(forwardeePublicKey) ?? email;
355+
const userIDsForForwardeeKey = [{ email: Email, name: Email }];
356+
const { activationToken, forwardeeKey, proxyInstances } = await getInternalParametersPrivate(
357+
forwarderKey.privateKey,
358+
userIDsForForwardeeKey,
359+
forwardeePublicKey
360+
);
361+
return api(
362+
addGroupMemberKeysApi(groupMember.ID, {
363+
Email: canonicalEmail,
364+
AddressSignaturePacket,
365+
ActivationToken: activationToken,
366+
GroupMemberAddressPrivateKey: forwardeeKey,
367+
ProxyInstances: proxyInstances.map(mapProxyInstance),
368+
})
369+
);
370+
}
371+
372+
// Case 3 — Managed internal member: we hold the org key, so we decrypt their address
373+
// token ourselves and forward a ready-to-use key (no activation step needed).
374+
await dispatch(replaceMemberAddressTokensIfNeeded({ member }));
375+
376+
const memberAddresses = await dispatch(getMemberAddresses({ member, retry: true }));
377+
const forwardeeAddress = memberAddresses.find(
378+
({ Email }) => canonicalizeInternalEmail(Email) === canonicalEmail
379+
);
380+
if (!forwardeeAddress) {
381+
throw new Error('Member without matching address');
382+
}
383+
384+
const { decryptedToken, Token, Signature } = await getPrimaryMemberTokenAndSignature({
385+
member,
386+
forwardeeAddress,
387+
organizationKey,
388+
});
389+
390+
const userIDsForForwardeeKey = [{ email: canonicalEmail, name: canonicalEmail }];
391+
const { forwardeeKey, proxyInstances } = await getInternalParameters(
392+
forwarderKey.privateKey,
393+
userIDsForForwardeeKey,
394+
decryptedToken
395+
);
396+
return api(
397+
addGroupMemberKeysApi(groupMember.ID, {
398+
Email: canonicalEmail,
399+
AddressSignaturePacket,
400+
GroupMemberAddressPrivateKey: forwardeeKey,
401+
ProxyInstances: proxyInstances.map(mapProxyInstance),
402+
Token,
403+
Signature,
404+
})
405+
);
406+
};
407+
};

packages/shared/lib/api/groups.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,37 @@ export const addGroupMember = (
9898
data,
9999
});
100100

101+
interface AddGroupMemberKeysBaseParameters {
102+
Email: string;
103+
AddressSignaturePacket: string;
104+
}
105+
106+
interface AddInternalGroupMemberKeysBaseParameters extends AddGroupMemberKeysBaseParameters {
107+
GroupMemberAddressPrivateKey: string;
108+
ProxyInstances: ProxyInstances[];
109+
}
110+
111+
interface AddInternalNonPrivateGroupMemberKeysParameters extends AddInternalGroupMemberKeysBaseParameters {
112+
Token: string;
113+
Signature: string;
114+
}
115+
116+
interface AddInternalGroupMemberKeysParameters extends AddInternalGroupMemberKeysBaseParameters {
117+
ActivationToken: string;
118+
}
119+
120+
export const addGroupMemberKeys = (
121+
groupMemberID: string,
122+
data:
123+
| AddGroupMemberKeysBaseParameters
124+
| AddInternalNonPrivateGroupMemberKeysParameters
125+
| AddInternalGroupMemberKeysParameters
126+
) => ({
127+
method: 'post',
128+
url: `core/v4/groups/members/${groupMemberID}/keys`,
129+
data,
130+
});
131+
101132
export const updateGroupMember = (
102133
groupMemberID: string,
103134
groupMemberParams: { GroupID: string; Permissions: GroupPermissions }

0 commit comments

Comments
 (0)