You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			423 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			423 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
| import { from_hex, to_hex } from 'libsodium-wrappers-sumo';
 | |
| import _, { cloneDeep, flatten, isEmpty, isEqual, isString, uniqBy } from 'lodash';
 | |
| import { getConversationController } from '../../../conversations';
 | |
| import { LibSodiumWrappers } from '../../../crypto';
 | |
| import { KeyPrefixType, PubKey } from '../../../types';
 | |
| import { crypto_sign_curve25519_pk_to_ed25519 } from 'curve25519-js';
 | |
| import { Data } from '../../../../data/data';
 | |
| import { combineKeys, generateBlindingFactor } from '../../../utils/SodiumUtils';
 | |
| import { OpenGroupData } from '../../../../data/opengroups';
 | |
| import { ConversationModel } from '../../../../models/conversation';
 | |
| import { UserUtils } from '../../../utils';
 | |
| import { SogsBlinding } from './sogsBlinding';
 | |
| import { fromHexToArray } from '../../../utils/String';
 | |
| import { roomHasBlindEnabled } from './sogsV3Capabilities';
 | |
| 
 | |
| export type BlindedIdMapping = {
 | |
|   blindedId: string;
 | |
|   serverPublicKey: string;
 | |
|   realSessionId: string;
 | |
| };
 | |
| 
 | |
| export const KNOWN_BLINDED_KEYS_ITEM = 'KNOWN_BLINDED_KEYS_ITEM';
 | |
| 
 | |
| // for now, we assume we won't find a lot of blinded keys.
 | |
| // So we can store all of those in a single JSON string in the db.
 | |
| let cachedKnownMapping: Array<BlindedIdMapping> | null = null;
 | |
| 
 | |
| /**
 | |
|  * This function must only be used for testing
 | |
|  */
 | |
| export function TEST_resetCachedBlindedKeys() {
 | |
|   cachedKnownMapping = null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function must only be used for testing
 | |
|  */
 | |
| export function TEST_getCachedBlindedKeys() {
 | |
|   return cloneDeep(cachedKnownMapping);
 | |
| }
 | |
| 
 | |
| export async function loadKnownBlindedKeys() {
 | |
|   if (cachedKnownMapping !== null) {
 | |
|     throw new Error('loadKnownBlindedKeys must only be called once');
 | |
|   }
 | |
|   const fromDb = await Data.getItemById(KNOWN_BLINDED_KEYS_ITEM);
 | |
| 
 | |
|   if (fromDb && fromDb.value && !isEmpty(fromDb.value)) {
 | |
|     try {
 | |
|       const read = JSON.parse(fromDb.value);
 | |
|       cachedKnownMapping = cachedKnownMapping || [];
 | |
|       read.forEach((elem: any) => {
 | |
|         cachedKnownMapping?.push(elem);
 | |
|       });
 | |
|     } catch (e) {
 | |
|       window.log.error(e.message);
 | |
|       cachedKnownMapping = [];
 | |
|     }
 | |
|   } else {
 | |
|     cachedKnownMapping = [];
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * only exported for testing
 | |
|  */
 | |
| export async function writeKnownBlindedKeys() {
 | |
|   if (cachedKnownMapping && cachedKnownMapping.length) {
 | |
|     await Data.createOrUpdateItem({
 | |
|       id: KNOWN_BLINDED_KEYS_ITEM,
 | |
|       value: JSON.stringify(cachedKnownMapping),
 | |
|     });
 | |
|   }
 | |
| }
 | |
| 
 | |
| function assertLoaded(): Array<BlindedIdMapping> {
 | |
|   if (cachedKnownMapping === null) {
 | |
|     throw new Error('loadKnownBlindedKeys must be called on app start');
 | |
|   }
 | |
|   return cachedKnownMapping;
 | |
| }
 | |
| 
 | |
| export function isNonBlindedKey(blindedId: string) {
 | |
|   if (
 | |
|     blindedId.startsWith(KeyPrefixType.unblinded) ||
 | |
|     blindedId.startsWith(KeyPrefixType.standard)
 | |
|   ) {
 | |
|     return true;
 | |
|   }
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| export function getCachedNakedKeyFromBlinded(
 | |
|   blindedId: string,
 | |
|   serverPublicKey: string
 | |
| ): string | undefined {
 | |
|   if (isNonBlindedKey(blindedId)) {
 | |
|     return blindedId;
 | |
|   }
 | |
|   const found = assertLoaded().find(
 | |
|     m => m.serverPublicKey === serverPublicKey && m.blindedId === blindedId
 | |
|   );
 | |
|   return found?.realSessionId || undefined;
 | |
| }
 | |
| 
 | |
| export async function addCachedBlindedKey({
 | |
|   blindedId,
 | |
|   serverPublicKey,
 | |
|   realSessionId,
 | |
| }: BlindedIdMapping) {
 | |
|   if (isNonBlindedKey(blindedId)) {
 | |
|     throw new Error('blindedId is not a blinded key');
 | |
|   }
 | |
|   if (!isNonBlindedKey(realSessionId)) {
 | |
|     throw new Error('realSessionId must not be blinded');
 | |
|   }
 | |
|   const assertLoadedCache = assertLoaded();
 | |
|   const foundIndex = assertLoadedCache.findIndex(
 | |
|     m => m.blindedId === blindedId && serverPublicKey === m.serverPublicKey
 | |
|   );
 | |
| 
 | |
|   if (foundIndex >= 0) {
 | |
|     if (assertLoadedCache[foundIndex].realSessionId !== realSessionId) {
 | |
|       window.log.warn(
 | |
|         `overriding cached blinded mapping for ${assertLoadedCache[foundIndex].realSessionId} with ${realSessionId} on ${serverPublicKey}`
 | |
|       );
 | |
|       assertLoadedCache[foundIndex].realSessionId = realSessionId;
 | |
|       await writeKnownBlindedKeys();
 | |
|     }
 | |
| 
 | |
|     return;
 | |
|   }
 | |
|   assertLoadedCache.push({ blindedId, serverPublicKey, realSessionId });
 | |
|   await writeKnownBlindedKeys();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Only exported for testing
 | |
|  * Try to match a blindedId with a standardSessionID. This is the only way we have to find that a standard and a blindedID are in fact, the same person.
 | |
|  */
 | |
| export function tryMatchBlindWithStandardKey(
 | |
|   standardSessionId: string,
 | |
|   blindedSessionId: string,
 | |
|   serverPubKey: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): boolean {
 | |
|   if (!standardSessionId.startsWith(KeyPrefixType.standard)) {
 | |
|     throw new Error('standardKey must be a standard key (starting with 05)');
 | |
|   }
 | |
| 
 | |
|   if (!blindedSessionId.startsWith(KeyPrefixType.blinded)) {
 | |
|     throw new Error('blindedKey must be a blinded key (starting with 15)');
 | |
|   }
 | |
| 
 | |
|   // We don't want to stop iterating even if an error happens while looking for a blind/standard match.
 | |
|   // That's why we catch any errors and return false if it happens.
 | |
|   try {
 | |
|     // tslint:disable: no-bitwise
 | |
| 
 | |
|     const sessionIdNoPrefix = PubKey.removePrefixIfNeeded(PubKey.cast(standardSessionId).key);
 | |
|     const blindedIdNoPrefix = PubKey.removePrefixIfNeeded(PubKey.cast(blindedSessionId).key);
 | |
|     const kBytes = generateBlindingFactor(serverPubKey, sodium);
 | |
| 
 | |
|     // From the session id (ignoring 05 prefix) we have two possible ed25519 pubkeys; the first is
 | |
|     // the positive(which is what Signal's XEd25519 conversion always uses)
 | |
| 
 | |
|     const inbin = from_hex(sessionIdNoPrefix);
 | |
|     // Note: The below method is code we have exposed from the method within the Curve25519-js library
 | |
|     // rather than custom code we have written
 | |
|     const xEd25519Key = crypto_sign_curve25519_pk_to_ed25519(inbin);
 | |
| 
 | |
|     // Blind it:
 | |
|     const pk1 = combineKeys(kBytes, xEd25519Key, sodium);
 | |
|     //  For the negative, what we're going to get out of the above is simply the negative of pk1, so
 | |
|     // flip the sign bit to get pk2:
 | |
|     const pk2 = cloneDeep(pk1);
 | |
|     pk2[31] = pk1[31] ^ 0b1000_0000;
 | |
| 
 | |
|     const match =
 | |
|       isEqual(blindedIdNoPrefix, to_hex(pk1)) || isEqual(blindedIdNoPrefix, to_hex(pk2));
 | |
| 
 | |
|     if (!match) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
|   } catch (e) {
 | |
|     window.log.warn('Failed to do crypto tryMatchBlindWithStandardKey with ', e.message);
 | |
|     return false;
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function can be called to trigger a build of the cache.
 | |
|  * This function is expensive depending on the contacts list length of the user
 | |
|  * We only consider the private & approved conversations for mapping.
 | |
|  */
 | |
| function findNotCachedBlindingMatch(
 | |
|   blindedId: string,
 | |
|   serverPublicKey: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): string | undefined {
 | |
|   if (isNonBlindedKey(blindedId)) {
 | |
|     throw new Error('findNotCachedBlindingMatch blindedId is supposed to be blinded');
 | |
|   }
 | |
| 
 | |
|   // we iterate only over the convos private, approved, and which have an unblinded id.
 | |
|   const foundConvoMatchingBlindedPubkey = getConversationController()
 | |
|     .getConversations()
 | |
|     .filter(m => m.isPrivate() && m.isApproved() && !PubKey.hasBlindedPrefix(m.id))
 | |
|     .find(m => {
 | |
|       return tryMatchBlindWithStandardKey(m.id, blindedId, serverPublicKey, sodium);
 | |
|     });
 | |
| 
 | |
|   return foundConvoMatchingBlindedPubkey?.get('id') || undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function returns true if the given blindedId matches our own blinded id on any pysogs.
 | |
|  * If the given pubkey  is not blinded, it returns true if it is our naked SessionID.
 | |
|  * It can be used to replace mentions with the @You syntax and for the quotes too
 | |
|  */
 | |
| export function isUsAnySogsFromCache(blindedOrNakedId: string): boolean {
 | |
|   const usUnblinded = UserUtils.getOurPubKeyStrFromCache();
 | |
| 
 | |
|   if (!PubKey.hasBlindedPrefix(blindedOrNakedId)) {
 | |
|     return blindedOrNakedId === usUnblinded;
 | |
|   }
 | |
|   const found = assertLoaded().find(
 | |
|     m => m.blindedId === blindedOrNakedId && m.realSessionId === usUnblinded
 | |
|   );
 | |
|   return Boolean(found);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function returns the cached blindedId for us, given a public conversation.
 | |
|  */
 | |
| export function getUsBlindedInThatServer(convo: ConversationModel | string): string | undefined {
 | |
|   if (!convo) {
 | |
|     return undefined;
 | |
|   }
 | |
|   const convoId = isString(convo) ? convo : convo.id;
 | |
| 
 | |
|   if (
 | |
|     !getConversationController()
 | |
|       .get(convoId)
 | |
|       ?.isOpenGroupV2()
 | |
|   ) {
 | |
|     return undefined;
 | |
|   }
 | |
|   const room = OpenGroupData.getV2OpenGroupRoom(isString(convo) ? convo : convo.id);
 | |
|   if (!room || !roomHasBlindEnabled(room) || !room.serverPublicKey) {
 | |
|     return undefined;
 | |
|   }
 | |
|   const usNaked = UserUtils.getOurPubKeyStrFromCache();
 | |
| 
 | |
|   const found = assertLoaded().find(
 | |
|     m => m.serverPublicKey === room.serverPublicKey && m.realSessionId === usNaked
 | |
|   );
 | |
|   return found?.blindedId;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function can be called to find all blinded conversations we have with a user given its real sessionID.
 | |
|  * It should be used when we get a message request response, to merge all convos into one.
 | |
|  *
 | |
|  * This function is quite resource intensive, so do not call it everywhere
 | |
|  */
 | |
| function findNotCachedBlindedConvoFromUnblindedKey(
 | |
|   unblindedID: string,
 | |
|   serverPublicKey: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): Array<ConversationModel> {
 | |
|   if (PubKey.hasBlindedPrefix(unblindedID)) {
 | |
|     throw new Error(
 | |
|       'findNotCachedBlindedConvoFromUnblindedKey unblindedID is supposed to be unblinded!'
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   // we iterate only over the convos private, with a blindedId, and active,
 | |
|   // so the one to which we sent a message already or received one from outside sogs.
 | |
|   const foundConvosForThisServerPk =
 | |
|     getConversationController()
 | |
|       .getConversations()
 | |
|       .filter(m => m.isPrivate() && PubKey.hasBlindedPrefix(m.id) && m.isActive())
 | |
|       .filter(m => {
 | |
|         return tryMatchBlindWithStandardKey(unblindedID, m.id, serverPublicKey, sodium);
 | |
|       }) || [];
 | |
| 
 | |
|   // we should have only one per server, as we gave the serverpubkey and a blindedId is uniq for a serverPk
 | |
| 
 | |
|   return foundConvosForThisServerPk;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Look for a cached match of that blindedId and that serverPubkey.
 | |
|  * This function is expensive and should only be run on some very specific case. You shouldn't need to add any other calls to this function in the app
 | |
|  * @param blindedId the blindedId to look for
 | |
|  * @param serverPubKey the serverPubkey on which this blindedId is found
 | |
|  * @param sodium the sodium instance
 | |
|  * @returns the conversationId of the naked private convo found, matching that blindedId on that serverPubkey
 | |
|  */
 | |
| export async function findCachedBlindedMatchOrLookItUp(
 | |
|   blindedId: string,
 | |
|   serverPubKey: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): Promise<string | undefined> {
 | |
|   if (!PubKey.hasBlindedPrefix(blindedId)) {
 | |
|     return blindedId;
 | |
|   }
 | |
|   const found = getCachedNakedKeyFromBlinded(blindedId, serverPubKey);
 | |
| 
 | |
|   if (found) {
 | |
|     return found;
 | |
|   }
 | |
| 
 | |
|   const realSessionIdFound = findNotCachedBlindingMatch(blindedId, serverPubKey, sodium);
 | |
| 
 | |
|   if (realSessionIdFound) {
 | |
|     await addCachedBlindedKey({
 | |
|       blindedId,
 | |
|       realSessionId: realSessionIdFound,
 | |
|       serverPublicKey: serverPubKey,
 | |
|     });
 | |
|     return realSessionIdFound;
 | |
|   }
 | |
|   return undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * When we sent a message to a sogs with blinded enable, we need to store the message with the sender being our blinded pubkey.
 | |
|  * We store that mapping <ourKey, serverPk, blindedKey> in the same cache, so we can map our own messages synced easily.
 | |
|  * This function just find if there is such a mapping already cached, but won't try to update the cache to find one.
 | |
|  */
 | |
| export function findCachedBlindedIdFromUnblinded(
 | |
|   unblindedId: string,
 | |
|   serverPubKey: string
 | |
| ): string | undefined {
 | |
|   if (PubKey.hasBlindedPrefix(unblindedId)) {
 | |
|     throw new Error('findCachedBlindedIdFromUnblinded needs an unblindedID');
 | |
|   }
 | |
|   const found = assertLoaded().find(
 | |
|     m => m.serverPublicKey === serverPubKey && m.realSessionId === unblindedId
 | |
|   );
 | |
|   return found?.blindedId || undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function can be used to generate our blindedId for a sogs requiring it, and cache it.
 | |
|  */
 | |
| export async function findCachedOurBlindedPubkeyOrLookItUp(
 | |
|   serverPubKey: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): Promise<string> {
 | |
|   const ourNakedSessionID = UserUtils.getOurPubKeyStrFromCache();
 | |
| 
 | |
|   if (PubKey.hasBlindedPrefix(ourNakedSessionID)) {
 | |
|     throw new Error('findCachedBlindedIdFromUnblindedOrLookItUp needs a unblindedID');
 | |
|   }
 | |
|   let found = findCachedBlindedIdFromUnblinded(ourNakedSessionID, serverPubKey);
 | |
| 
 | |
|   if (found) {
 | |
|     return found;
 | |
|   }
 | |
|   const signingKeys = await UserUtils.getUserED25519KeyPairBytes();
 | |
| 
 | |
|   // just to make sure the mapping was not added during last line call
 | |
| 
 | |
|   if (!signingKeys) {
 | |
|     throw new Error('addSingleOutgoingMessage: getUserED25519KeyPairBytes returned nothing');
 | |
|   }
 | |
| 
 | |
|   const blindedPubkeyForThisSogs = SogsBlinding.getBlindedPubKey(
 | |
|     fromHexToArray(serverPubKey),
 | |
|     signingKeys,
 | |
|     sodium
 | |
|   );
 | |
|   found = findCachedBlindedIdFromUnblinded(ourNakedSessionID, serverPubKey);
 | |
| 
 | |
|   if (found) {
 | |
|     return found;
 | |
|   }
 | |
| 
 | |
|   await addCachedBlindedKey({
 | |
|     blindedId: blindedPubkeyForThisSogs,
 | |
|     serverPublicKey: serverPubKey,
 | |
|     realSessionId: ourNakedSessionID,
 | |
|   });
 | |
|   return blindedPubkeyForThisSogs;
 | |
| }
 | |
| export function getCachedNakedKeyFromBlindedNoServerPubkey(blindedId: string): string | undefined {
 | |
|   if (isNonBlindedKey(blindedId)) {
 | |
|     return blindedId;
 | |
|   }
 | |
|   const found = assertLoaded().find(m => m.blindedId === blindedId);
 | |
|   return found?.realSessionId || undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Can be used when we get an unblinded message to check if this is actually a reply to any of the conversation we were having with a blinded id, on any sogs
 | |
|  * @param unblindedId the blindedId of that user
 | |
|  * @param sodium passed so we can make this function not async
 | |
|  */
 | |
| export function findCachedBlindedMatchOrLookupOnAllServers(
 | |
|   unblindedId: string,
 | |
|   sodium: LibSodiumWrappers
 | |
| ): Array<ConversationModel> {
 | |
|   if (PubKey.hasBlindedPrefix(unblindedId)) {
 | |
|     throw new Error('findCachedBlindedMatchOrLookupOnAllServers needs an unblindedId');
 | |
|   }
 | |
| 
 | |
|   const allServerPubkeys = OpenGroupData.getAllOpengroupsServerPubkeys();
 | |
|   let matchingServerPubkeyWithThatBlindedId = flatten(
 | |
|     allServerPubkeys.map(serverPk => {
 | |
|       return findNotCachedBlindedConvoFromUnblindedKey(unblindedId, serverPk, sodium);
 | |
|     })
 | |
|   );
 | |
|   matchingServerPubkeyWithThatBlindedId =
 | |
|     uniqBy(matchingServerPubkeyWithThatBlindedId, m => m.id) || [];
 | |
| 
 | |
|   return matchingServerPubkeyWithThatBlindedId;
 | |
| }
 |