fix: add currentHashes and update expiry on every poll

pull/2620/head
Audric Ackermann 2 years ago
parent 44483b7d23
commit 3cd52d5b1a

@ -98,7 +98,7 @@
"glob": "7.1.2",
"image-type": "^4.1.0",
"ip2country": "1.0.1",
"libsession_util_nodejs": "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.5/libsession_util_nodejs-v0.1.5.tar.gz",
"libsession_util_nodejs": "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.8/libsession_util_nodejs-v0.1.8.tar.gz",
"libsodium-wrappers-sumo": "^0.7.9",
"linkify-it": "3.0.2",
"lodash": "^4.17.20",

@ -192,7 +192,11 @@ export class OpenGroupManagerV2 {
);
// here, the convo does not exist. Make sure the db & wrappers are clean too
await OpenGroupData.removeV2OpenGroupRoom(conversationId);
await SessionUtilUserGroups.removeCommunityFromWrapper(conversationId, fullUrl);
try {
await SessionUtilUserGroups.removeCommunityFromWrapper(conversationId, fullUrl);
} catch (e) {
window.log.warn('failed to removeCommunityFromWrapper', conversationId);
}
const room: OpenGroupV2Room = {
serverUrl,

@ -50,7 +50,8 @@ export type RetrieveSubKeySubRequestType = {
export type RetrieveSubRequestType =
| RetrieveLegacyClosedGroupSubRequestType
| RetrievePubkeySubRequestType
| RetrieveSubKeySubRequestType;
| RetrieveSubKeySubRequestType
| UpdateExpiryOnNodeSubRequest;
/**
* OXEND_REQUESTS
@ -129,6 +130,21 @@ export type DeleteFromNodeSubRequest = {
params: DeleteByHashesFromNodeParams;
};
export type UpdateExpireNodeParams = {
pubkey: string;
pubkey_ed25519: string;
messages: Array<string>;
expiry: number;
signature: string;
extend?: boolean;
shorten?: boolean;
};
export type UpdateExpiryOnNodeSubRequest = {
method: 'expire';
params: UpdateExpireNodeParams;
};
export type OxendSubRequest = OnsResolveSubRequest | GetServiceNodesSubRequest;
export type SnodeApiSubRequests =
@ -138,7 +154,8 @@ export type SnodeApiSubRequests =
| StoreOnNodeSubRequest
| NetworkTimeSubRequest
| DeleteFromNodeSubRequest
| DeleteAllFromNodeSubRequest;
| DeleteAllFromNodeSubRequest
| UpdateExpiryOnNodeSubRequest;
// tslint:disable: array-type
export type NonEmptyArray<T> = [T, ...T[]];

@ -0,0 +1,237 @@
import { isEmpty, sample } from 'lodash';
import { Snode } from '../../../data/data';
import { getSodiumRenderer } from '../../crypto';
import { StringUtils, UserUtils } from '../../utils';
import { fromBase64ToArray, fromHexToArray } from '../../utils/String';
import { EmptySwarmError } from '../../utils/errors';
import { UpdateExpireNodeParams } from './SnodeRequestTypes';
import { doSnodeBatchRequest } from './batchRequest';
import { GetNetworkTime } from './getNetworkTime';
import { getSwarmFor } from './snodePool';
import { SnodeSignature } from './snodeSignatures';
async function verifySignature({
pubkey,
snodePubkey,
expiryApplied,
signature,
messageHashes,
updatedHashes,
unchangedHashes,
}: {
pubkey: string;
snodePubkey: any;
expiryApplied: number;
signature: string;
messageHashes: Array<string>;
updatedHashes: Array<string>;
// only used when shorten or extend is in the request
unchangedHashes?: Record<string, string>;
}): Promise<boolean> {
if (!expiryApplied || isEmpty(messageHashes) || isEmpty(signature)) {
window.log.warn('verifySignature missing argument');
return false;
}
const edKeyPrivBytes = fromHexToArray(snodePubkey);
/* PUBKEY_HEX || EXPIRY || RMSGs... || UMSGs... || CMSG_EXPs...
where RMSGs are the requested expiry hashes,
UMSGs are the actual updated hashes, and
CMSG_EXPs are (HASH || EXPIRY) values, ascii-sorted by hash, for the unchanged message hashes included in the "unchanged" field.
*/
const hashes = [...messageHashes, ...updatedHashes];
if (unchangedHashes && Object.keys(unchangedHashes).length > 0) {
hashes.push(
...Object.entries(unchangedHashes)
.map(([key, value]: [string, string]) => {
return `${key}${value}`;
})
.sort()
);
}
const verificationString = `${pubkey}${expiryApplied}${hashes.join('')}`;
const verificationData = StringUtils.encode(verificationString, 'utf8');
window.log.debug('verifySignature verificationString', verificationString);
const sodium = await getSodiumRenderer();
try {
const isValid = sodium.crypto_sign_verify_detached(
fromBase64ToArray(signature),
new Uint8Array(verificationData),
edKeyPrivBytes
);
return isValid;
} catch (e) {
window.log.warn('verifySignature failed with: ', e.message);
return false;
}
}
async function processExpirationResults(
pubkey: string,
targetNode: Snode,
swarm: Record<string, any>,
messageHashes: Array<string>
) {
if (isEmpty(swarm)) {
throw Error(`expireOnNodes failed! ${messageHashes}`);
}
// TODO need proper typing for swarm and results
const results: Record<string, { hashes: Array<string>; expiry: number }> = {};
// window.log.debug(`processExpirationResults start`, swarm, messageHashes);
for (const nodeKey of Object.keys(swarm)) {
if (!isEmpty(swarm[nodeKey].failed)) {
const reason = 'Unknown';
const statusCode = '404';
window?.log?.warn(
`loki_message:::expireMessage - Couldn't delete data from: ${
targetNode.pubkey_ed25519
}${reason && statusCode && ` due to an error ${reason} (${statusCode})`}`
);
// TODO This might be a redundant step
results[nodeKey] = { hashes: [], expiry: 0 };
}
const updatedHashes = swarm[nodeKey].updated;
const unchangedHashes = swarm[nodeKey].unchanged;
const expiryApplied = swarm[nodeKey].expiry;
const signature = swarm[nodeKey].signature;
const isValid = await verifySignature({
pubkey,
snodePubkey: nodeKey,
expiryApplied,
signature,
messageHashes,
updatedHashes,
unchangedHashes,
});
if (!isValid) {
window.log.warn(
'loki_message:::expireMessage - Signature verification failed!',
messageHashes
);
}
results[nodeKey] = { hashes: updatedHashes, expiry: expiryApplied };
}
return results;
}
async function expireOnNodes(targetNode: Snode, params: UpdateExpireNodeParams) {
try {
const result = await doSnodeBatchRequest(
[
{
method: 'expire',
params,
},
],
targetNode,
4000,
params.pubkey,
'batch'
);
if (!result || result.length !== 1 || result[0]?.code !== 200 || !result[0]?.body) {
return false;
}
try {
// TODOLATER make sure that this code still works once disappearing messages is merged
const parsed = result[0].body;
const expirationResults = await processExpirationResults(
params.pubkey,
targetNode,
parsed.swarm,
params.messages
);
window.log.debug('expireOnNodes attempt complete. Here are the results', expirationResults);
return true;
} catch (e) {
window?.log?.warn('expireOnNodes Failed to parse "swarm" result: ', e.msg);
}
return false;
} catch (e) {
window?.log?.warn('expire - send error:', e, `destination ${targetNode.ip}:${targetNode.port}`);
throw e;
}
}
type ExpireMessageOnSnodeProps = {
messageHash: string;
expireTimer: number;
extend?: boolean;
shorten?: boolean;
};
// TODO make this retry in case of updated swarm
export async function expireMessageOnSnode(props: ExpireMessageOnSnodeProps) {
const { messageHash, expireTimer, extend, shorten } = props;
if (extend && shorten) {
window.log.error(
'[expireMessageOnSnode] We cannot extend and shorten a message at the same time',
messageHash
);
return;
}
const shortenOrExtend = shorten ? 'shorten' : extend ? 'extend' : ('' as const);
const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
if (!ourPubKey) {
window.log.eror('[expireMessageOnSnode] No pubkey found', messageHash);
return;
}
const swarm = await getSwarmFor(ourPubKey);
const expiry = GetNetworkTime.getNowWithNetworkOffset() + expireTimer;
const signResult = await SnodeSignature.generateUpdateExpirySignature({
shortenOrExtend,
timestamp: expiry,
messageHashes: [messageHash],
});
if (!signResult) {
window.log.error('[expireMessageOnSnode] Signing message expiry on swarm failed', messageHash);
return;
}
const params: UpdateExpireNodeParams = {
pubkey: ourPubKey,
pubkey_ed25519: signResult.pubkey_ed25519.toUpperCase(),
// TODO better testing for failed case
messages: [messageHash],
expiry,
extend: extend || undefined,
shorten: shorten || undefined,
signature: signResult?.signature,
};
const snode = sample(swarm);
if (!snode) {
throw new EmptySwarmError(ourPubKey, 'Ran out of swarm nodes to query');
}
try {
// TODO make this whole function `expireMessageOnSnode` retry
const successfulSend = await expireOnNodes(snode, params);
} catch (e) {
const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null';
window?.log?.warn(
`loki_message:::expireMessage - ${e.code ? `${e.code} ` : ''}${
e.message
} by ${ourPubKey} for ${messageHash} via snode:${snodeStr}`
);
throw e;
}
}

@ -67,7 +67,12 @@ function isUserConfigNamespace(namespace: SnodeNamespaces) {
return false;
default:
assertUnreachable(namespace, `isUserConfigNamespace case not handled: ${namespace}`);
try {
assertUnreachable(namespace, `isUserConfigNamespace case not handled: ${namespace}`);
} catch (e) {
window.log.warn(`isUserConfigNamespace case not handled: ${namespace}: ${e.message}`);
return false;
}
}
}

@ -5,9 +5,12 @@ import { doSnodeBatchRequest } from './batchRequest';
import { GetNetworkTime } from './getNetworkTime';
import { SnodeNamespace, SnodeNamespaces } from './namespaces';
import { DURATION } from '../../constants';
import { UserUtils } from '../../utils';
import {
RetrieveLegacyClosedGroupSubRequestType,
RetrieveSubRequestType,
UpdateExpiryOnNodeSubRequest,
} from './SnodeRequestTypes';
import { SnodeSignature } from './snodeSignatures';
import { RetrieveMessagesResultsBatched, RetrieveMessagesResultsContent } from './types';
@ -16,9 +19,10 @@ async function buildRetrieveRequest(
lastHashes: Array<string>,
pubkey: string,
namespaces: Array<SnodeNamespaces>,
ourPubkey: string
ourPubkey: string,
configHashesToBump: Array<string> | null
): Promise<Array<RetrieveSubRequestType>> {
const retrieveRequestsParams = await Promise.all(
const retrieveRequestsParams: Array<RetrieveSubRequestType> = await Promise.all(
namespaces.map(async (namespace, index) => {
const retrieveParam = {
pubkey,
@ -65,6 +69,33 @@ async function buildRetrieveRequest(
return retrieve;
})
);
if (configHashesToBump?.length) {
const expiry = GetNetworkTime.getNowWithNetworkOffset() + DURATION.DAYS * 30;
const signResult = await SnodeSignature.generateUpdateExpirySignature({
shortenOrExtend: '',
timestamp: expiry,
messageHashes: configHashesToBump,
});
if (!signResult) {
window.log.warn(
`SnodeSignature.generateUpdateExpirySignature returned result empty for hashes ${configHashesToBump}`
);
} else {
const expireParams: UpdateExpiryOnNodeSubRequest = {
method: 'expire',
params: {
messages: configHashesToBump,
pubkey: UserUtils.getOurPubKeyStrFromCache(),
expiry,
signature: signResult.signature,
pubkey_ed25519: signResult.pubkey_ed25519,
},
};
retrieveRequestsParams.push(expireParams);
}
}
return retrieveRequestsParams;
}
@ -73,7 +104,8 @@ async function retrieveNextMessages(
lastHashes: Array<string>,
associatedWith: string,
namespaces: Array<SnodeNamespaces>,
ourPubkey: string
ourPubkey: string,
configHashesToBump: Array<string> | null
): Promise<RetrieveMessagesResultsBatched> {
if (namespaces.length !== lastHashes.length) {
throw new Error('namespaces and lasthashes does not match');
@ -83,7 +115,8 @@ async function retrieveNextMessages(
lastHashes,
associatedWith,
namespaces,
ourPubkey
ourPubkey,
configHashesToBump
);
// let exceptions bubble up
// no retry for this one as this a call we do every few seconds while polling for messages
@ -99,7 +132,8 @@ async function retrieveNextMessages(
);
}
if (results.length !== namespaces.length) {
// the +1 is to take care of the extra `expire` method added once user config is released
if (results.length !== namespaces.length && results.length !== namespaces.length + 1) {
throw new Error(
`We asked for updates about ${namespaces.length} messages but got results of length ${results.length}`
);

@ -94,4 +94,47 @@ async function getSnodeSignatureParams(params: {
}
}
export const SnodeSignature = { getSnodeSignatureParams, getSnodeSignatureByHashesParams };
async function generateUpdateExpirySignature({
shortenOrExtend,
timestamp,
messageHashes,
}: {
shortenOrExtend: 'extend' | 'shorten' | '';
timestamp: number;
messageHashes: Array<string>;
}): Promise<{ signature: string; pubkey_ed25519: string } | null> {
const ourEd25519Key = await UserUtils.getUserED25519KeyPair();
if (!ourEd25519Key) {
const err = `getSnodeSignatureParams "expiry": User has no getUserED25519KeyPair()`;
window.log.warn(err);
throw new Error(err);
}
const edKeyPrivBytes = fromHexToArray(ourEd25519Key?.privKey);
// "expire" || ShortenOrExtend || expiry || messages[0] || ... || messages[N]
const verificationString = `expire${shortenOrExtend}${timestamp}${messageHashes.join('')}`;
const verificationData = StringUtils.encode(verificationString, 'utf8');
const message = new Uint8Array(verificationData);
const sodium = await getSodiumRenderer();
try {
const signature = sodium.crypto_sign_detached(message, edKeyPrivBytes);
const signatureBase64 = fromUInt8ArrayToBase64(signature);
return {
signature: signatureBase64,
pubkey_ed25519: ourEd25519Key.pubKey,
};
} catch (e) {
window.log.warn('generateSignature failed with: ', e.message);
return null;
}
}
export const SnodeSignature = {
getSnodeSignatureParams,
getSnodeSignatureByHashesParams,
generateUpdateExpirySignature,
};

@ -22,6 +22,8 @@ import { SnodeNamespace, SnodeNamespaces } from './namespaces';
import { SnodeAPIRetrieve } from './retrieveRequest';
import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types';
import { ReleasedFeatures } from '../../../util/releaseFeature';
import { LibSessionUtil } from '../../utils/libsession/libsession_utils';
import { GenericWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface';
export function extractWebSocketContent(
message: string,
@ -215,7 +217,12 @@ export class SwarmPolling {
let resultsFromAllNamespaces: RetrieveMessagesResultsBatched | null;
try {
resultsFromAllNamespaces = await this.pollNodeForKey(toPollFrom, pubkey, namespaces);
resultsFromAllNamespaces = await this.pollNodeForKey(
toPollFrom,
pubkey,
namespaces,
!isGroup
);
} catch (e) {
window.log.warn(
`pollNodeForKey of ${pubkey} namespaces: ${namespaces} failed with: ${e.message}`
@ -371,39 +378,69 @@ export class SwarmPolling {
private async pollNodeForKey(
node: Snode,
pubkey: PubKey,
namespaces: Array<SnodeNamespaces>
namespaces: Array<SnodeNamespaces>,
isUs: boolean
): Promise<RetrieveMessagesResultsBatched | null> {
const namespaceLength = namespaces.length;
if (namespaceLength <= 0) {
throw new Error(`invalid number of retrieve namespace provided: ${namespaceLength}`);
}
const edkey = node.pubkey_ed25519;
const snodeEdkey = node.pubkey_ed25519;
const pkStr = pubkey.key;
try {
return await pRetry(
async () => {
const prevHashes = await Promise.all(
namespaces.map(namespace => this.getLastHash(edkey, pkStr, namespace))
namespaces.map(namespace => this.getLastHash(snodeEdkey, pkStr, namespace))
);
const results = await SnodeAPIRetrieve.retrieveNextMessages(
const configHashesToBump: Array<string> = [];
if (await ReleasedFeatures.checkIsUserConfigFeatureReleased()) {
// TODOLATER add the logic to take care of the closed groups too once we have a way to do it with the wrappers
if (isUs) {
for (let index = 0; index < LibSessionUtil.requiredUserVariants.length; index++) {
const variant = LibSessionUtil.requiredUserVariants[index];
try {
const toBump = await GenericWrapperActions.currentHashes(variant);
if (toBump?.length) {
configHashesToBump.push(...toBump);
}
} catch (e) {
window.log.warn(`failed to get currentHashes for user variant ${variant}`);
}
}
window.log.debug(`configHashesToBump: ${configHashesToBump}`);
}
}
let results = await SnodeAPIRetrieve.retrieveNextMessages(
node,
prevHashes,
pkStr,
namespaces,
UserUtils.getOurPubKeyStrFromCache()
UserUtils.getOurPubKeyStrFromCache(),
configHashesToBump
);
if (!results.length) {
return [];
}
if (results.length !== namespaceLength) {
window.log.error(
`pollNodeForKey asked for ${namespaceLength} namespaces but received only messages about ${results.length} namespaces`
);
throw new Error(
`pollNodeForKey asked for ${namespaceLength} namespaces but received only messages about ${results.length} namespaces`
);
// when we asked to extend the expiry of the config messages, exclude it from the list of results as we do not want to mess up the last hash tracking logic
if (configHashesToBump.length) {
try {
const lastResult = results[results.length - 1];
if (lastResult?.code !== 200) {
// the update expiry of our config messages didn't work.
window.log.warn(
`the update expiry of our tracked config hashes didn't work: ${JSON.stringify(
lastResult
)}`
);
}
} catch (e) {}
results = results.slice(0, results.length - 1);
}
const lastMessages = results.map(r => {
@ -416,7 +453,7 @@ export class SwarmPolling {
return;
}
return this.updateLastHash({
edkey: edkey,
edkey: snodeEdkey,
pubkey,
namespace: namespaces[index],
hash: lastMessage.hash,

@ -238,13 +238,17 @@ export class ConversationController {
try {
const fromWrapper = await UserGroupsWrapperActions.getCommunityByFullUrl(conversation.id);
await SessionUtilConvoInfoVolatile.removeCommunityFromWrapper(
conversation.id,
fromWrapper?.fullUrl || ''
);
if (fromWrapper?.fullUrl) {
await SessionUtilConvoInfoVolatile.removeCommunityFromWrapper(
conversation.id,
fromWrapper.fullUrl
);
}
} catch (e) {
window?.log?.info('SessionUtilConvoInfoVolatile.removeCommunityFromWrapper failed:', e);
window?.log?.info(
'SessionUtilConvoInfoVolatile.removeCommunityFromWrapper failed:',
e.message
);
}
// remove from the wrapper the entries before we remove the roomInfos, as we won't have the required community pubkey afterwards

@ -84,9 +84,9 @@ async function checkIsFeatureReleased(featureName: FeatureNameTracked): Promise<
}
const isReleased = Boolean(getIsFeatureReleasedCached(featureName));
window.log.debug(
`[releaseFeature]: "${featureName}" ${isReleased ? 'is released' : 'has not been released yet'}`
);
// window.log.debug(
// `[releaseFeature]: "${featureName}" ${isReleased ? 'is released' : 'has not been released yet'}`
// );
return isReleased;
}

@ -75,6 +75,10 @@ export const GenericWrapperActions = {
callLibSessionWorker([wrapperId, 'storageNamespace']) as Promise<
ReturnType<BaseWrapperActionsCalls['storageNamespace']>
>,
currentHashes: async (wrapperId: ConfigWrapperObjectTypes) =>
callLibSessionWorker([wrapperId, 'currentHashes']) as Promise<
ReturnType<BaseWrapperActionsCalls['currentHashes']>
>,
};
export const UserConfigWrapperActions: UserConfigWrapperActionsCalls = {
@ -90,6 +94,7 @@ export const UserConfigWrapperActions: UserConfigWrapperActionsCalls = {
needsPush: async () => GenericWrapperActions.needsPush('UserConfig'),
push: async () => GenericWrapperActions.push('UserConfig'),
storageNamespace: async () => GenericWrapperActions.storageNamespace('UserConfig'),
currentHashes: async () => GenericWrapperActions.currentHashes('UserConfig'),
/** UserConfig wrapper specific actions */
getUserInfo: async () =>
@ -119,6 +124,7 @@ export const ContactsWrapperActions: ContactsWrapperActionsCalls = {
needsPush: async () => GenericWrapperActions.needsPush('ContactsConfig'),
push: async () => GenericWrapperActions.push('ContactsConfig'),
storageNamespace: async () => GenericWrapperActions.storageNamespace('ContactsConfig'),
currentHashes: async () => GenericWrapperActions.currentHashes('ContactsConfig'),
/** ContactsConfig wrapper specific actions */
get: async (pubkeyHex: string) =>
@ -154,6 +160,7 @@ export const UserGroupsWrapperActions: UserGroupsWrapperActionsCalls = {
needsPush: async () => GenericWrapperActions.needsPush('UserGroupsConfig'),
push: async () => GenericWrapperActions.push('UserGroupsConfig'),
storageNamespace: async () => GenericWrapperActions.storageNamespace('UserGroupsConfig'),
currentHashes: async () => GenericWrapperActions.currentHashes('UserGroupsConfig'),
/** UserGroups wrapper specific actions */
@ -226,6 +233,7 @@ export const ConvoInfoVolatileWrapperActions: ConvoInfoVolatileWrapperActionsCal
needsPush: async () => GenericWrapperActions.needsPush('ConvoInfoVolatileConfig'),
push: async () => GenericWrapperActions.push('ConvoInfoVolatileConfig'),
storageNamespace: async () => GenericWrapperActions.storageNamespace('ConvoInfoVolatileConfig'),
currentHashes: async () => GenericWrapperActions.currentHashes('ConvoInfoVolatileConfig'),
/** ConvoInfoVolatile wrapper specific actions */
// 1o1

@ -5148,9 +5148,9 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
"libsession_util_nodejs@https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.5/libsession_util_nodejs-v0.1.5.tar.gz":
version "0.1.5"
resolved "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.5/libsession_util_nodejs-v0.1.5.tar.gz#4f9dbac0c3011fc3edd29ce0625cba1044db6990"
"libsession_util_nodejs@https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.8/libsession_util_nodejs-v0.1.8.tar.gz":
version "0.1.8"
resolved "https://github.com/oxen-io/libsession-util-nodejs/releases/download/v0.1.8/libsession_util_nodejs-v0.1.8.tar.gz#49a296dc1d81db5ec8104ef1e6ce5ed2cecff6e9"
dependencies:
cmake-js "^7.2.1"
node-addon-api "^6.1.0"

Loading…
Cancel
Save