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.
session-desktop/ts/session/apis/snode_api/expireRequest.ts

335 lines
10 KiB
TypeScript

/* eslint-disable no-restricted-syntax */
import { isEmpty, sample } from 'lodash';
import pRetry from 'p-retry';
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 { UpdateExpiryOnNodeSubRequest } from './SnodeRequestTypes';
import { doSnodeBatchRequest } from './batchRequest';
import { GetNetworkTime } from './getNetworkTime';
import { getSwarmFor } from './snodePool';
import { SnodeSignature } from './snodeSignatures';
import { ExpireMessageResultItem, ExpireMessagesResultsContent } from './types';
import { SeedNodeAPI } from '../seed_node_api';
async function verifyExpireMsgsResponseSignature({
pubkey,
snodePubkey,
messageHashes,
expiry,
signature,
updated,
unchanged,
}: ExpireMessageResultItem & {
pubkey: string;
snodePubkey: any;
messageHashes: Array<string>;
}): Promise<boolean> {
if (!expiry || isEmpty(messageHashes) || isEmpty(signature)) {
window.log.warn(
`WIP: [verifyExpireMsgsSignature] missing argument\nexpiry:${expiry}\nmessageHashes:${messageHashes}\nsignature:${signature}`
);
return false;
}
const edKeyPrivBytes = fromHexToArray(snodePubkey);
const hashes = [...messageHashes, ...updated];
if (unchanged && Object.keys(unchanged).length > 0) {
hashes.push(
...Object.entries(unchanged)
.map(([key, value]: [string, number]) => {
return `${key}${value}`;
})
.sort()
);
}
const verificationString = `${pubkey}${expiry}${hashes.join('')}`;
const verificationData = StringUtils.encode(verificationString, 'utf8');
// window.log.debug('WIP: [verifyExpireMsgsSignature] 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('WIP: [verifyExpireMsgsSignature] failed with: ', e.message);
return false;
}
}
type ExpireRequestResponseResults = Record<string, { hashes: Array<string>; expiry: number }>;
async function processExpireRequestResponse(
pubkey: string,
targetNode: Snode,
swarm: ExpireMessagesResultsContent,
messageHashes: Array<string>
): Promise<ExpireRequestResponseResults> {
if (isEmpty(swarm)) {
throw Error(`[processExpireRequestResponse] Swarm is missing! ${messageHashes}`);
}
const results: ExpireRequestResponseResults = {};
// window.log.debug(`WIP: [processExpireRequestResponse] initial results: `, swarm, messageHashes);
for (const nodeKey of Object.keys(swarm)) {
if (!isEmpty(swarm[nodeKey].failed)) {
window.log.warn(
`WIP: [processExpireRequestResponse] Swarm result failure on ${
targetNode.pubkey_ed25519
} for nodeKey ${nodeKey}\n${JSON.stringify(swarm[nodeKey])}`
);
continue;
}
const updatedHashes = swarm[nodeKey].updated;
const unchangedHashes = swarm[nodeKey].unchanged;
const expiry = swarm[nodeKey].expiry;
const signature = swarm[nodeKey].signature;
if (!updatedHashes || !expiry || !signature) {
window.log.warn(
`WIP: [processExpireRequestResponse] Missing arguments on ${
targetNode.pubkey_ed25519
} so we will ignore this result (${nodeKey}) and trust in the force.\n${JSON.stringify(
swarm[nodeKey]
)}`
);
continue;
}
// eslint-disable-next-line no-await-in-loop
const isValid = await verifyExpireMsgsResponseSignature({
pubkey,
snodePubkey: nodeKey,
messageHashes,
expiry,
signature,
updated: updatedHashes,
unchanged: unchangedHashes,
});
if (!isValid) {
window.log.warn(
`WIP: [processExpireRequestResponse] Signature verification failed on ${
targetNode.pubkey_ed25519
}!\n${JSON.stringify(messageHashes)}`
);
continue;
}
results[nodeKey] = { hashes: updatedHashes, expiry };
}
return results;
}
async function expireOnNodes(
targetNode: Snode,
expireRequest: UpdateExpiryOnNodeSubRequest
): Promise<number> {
try {
const result = await doSnodeBatchRequest(
[expireRequest],
targetNode,
4000,
expireRequest.params.pubkey,
'batch'
);
if (!result || result.length !== 1) {
throw Error(
`There was an issue with the results. sessionRpc ${targetNode.ip}:${
targetNode.port
} expireRequest ${JSON.stringify(expireRequest)}`
);
}
// TODOLATER make sure that this code still works once disappearing messages is merged
// do a basic check to know if we have something kind of looking right (status 200 should always be there for a retrieve)
const firstResult = result[0];
if (firstResult.code !== 200) {
throw Error(`result is not 200 but ${firstResult.code}`);
}
const bodyFirstResult = firstResult.body;
const expirationResults = await processExpireRequestResponse(
expireRequest.params.pubkey,
targetNode,
bodyFirstResult.swarm as ExpireMessagesResultsContent,
expireRequest.params.messages
);
const firstExpirationResult = Object.entries(expirationResults).at(0);
if (!firstExpirationResult) {
throw new Error('firstExpirationResult is null');
}
const messageHash = firstExpirationResult[0];
const expiry = firstExpirationResult[1].expiry;
if (!expiry || !messageHash) {
throw new Error(
`Something is wrong with the firstExpirationResult: ${JSON.stringify(
JSON.stringify(firstExpirationResult)
)}`
);
}
// window.log.debug(
// `WIP: [expireOnNodes] Success!\nHere are the results from one of the snodes.\nmessageHash: ${messageHash} \nexpiry: ${expiry} \nexpires at: ${new Date(
// expiry
// ).toUTCString()}\nnow: ${new Date(GetNetworkTime.getNowWithNetworkOffset()).toUTCString()}`
// );
return expiry;
} catch (err) {
window?.log?.warn(
'WIP: [expireOnNodes]',
err.message || err,
`destination ${targetNode.ip}:${targetNode.port}`
);
// NOTE batch requests have their own retry logic which includes abort errors that will break our retry logic so we need to catch them and throw regular errors
if (err instanceof pRetry.AbortError) {
throw Error(err.message);
}
throw err;
}
}
type ExpireMessageOnSnodeProps = {
messageHash: string;
expireTimer: number;
extend?: boolean;
shorten?: boolean;
};
async function buildExpireRequest(
props: ExpireMessageOnSnodeProps
): Promise<UpdateExpiryOnNodeSubRequest | null> {
const { messageHash, expireTimer, extend, shorten } = props;
if (extend && shorten) {
window.log.error(
'WIP: [buildExpireRequest] We cannot extend and shorten a message at the same time',
messageHash
);
return null;
}
// NOTE empty string means we want to hardcode the expiry to a TTL value, otherwise it's a shorten or extension of the TTL
const shortenOrExtend = shorten ? 'shorten' : extend ? 'extend' : ('' as const);
const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
if (!ourPubKey) {
window.log.error('WIP: [buildExpireRequest] No pubkey found', messageHash);
return null;
}
const expiry = GetNetworkTime.getNowWithNetworkOffset() + expireTimer;
// window.log.debug(
// `WIP: [buildExpireRequest]\nmessageHash: ${messageHash} should expire at ${new Date(
// expiry
// ).toUTCString()}`
// );
const signResult = await SnodeSignature.generateUpdateExpirySignature({
shortenOrExtend,
timestamp: expiry,
messageHashes: [messageHash],
});
if (!signResult) {
window.log.error(
`WIP: [buildExpireRequest] SnodeSignature.generateUpdateExpirySignature returned an empty result ${messageHash}`
);
return null;
}
const expireParams: UpdateExpiryOnNodeSubRequest = {
method: 'expire',
params: {
pubkey: ourPubKey,
pubkey_ed25519: signResult.pubkey_ed25519.toUpperCase(),
messages: [messageHash],
expiry,
extend: extend || undefined,
shorten: shorten || undefined,
signature: signResult?.signature,
},
};
// window.log.debug(`WIP: [buildExpireRequest] ${messageHash}\n${JSON.stringify(expireParams)}`);
return expireParams;
}
/**
* Sends an 'expire' request to the user's swarm for a specific message.
* This supports both extending and shortening a message's TTL.
* The returned TTL should be assigned to the message to expire.
* @param messageHash the hash of the message to expire
* @param expireTimer amount of time until we expire the message from now in milliseconds
* @param extend whether to extend the message's TTL
* @param shorten whether to shorten the message's TTL
* @returns the TTL of the message as set by the server
*/
export async function expireMessageOnSnode(
props: ExpireMessageOnSnodeProps
): Promise<number | null> {
const { messageHash } = props;
const ourPubKey = UserUtils.getOurPubKeyStrFromCache();
if (!ourPubKey) {
window.log.error('WIP: [expireMessageOnSnode] No pubkey found', messageHash);
return null;
}
let snode: Snode | undefined;
try {
const expireRequestParams = await buildExpireRequest(props);
if (!expireRequestParams) {
throw new Error(`Failed to build expire request ${JSON.stringify(props)}`);
}
let newTTL = null;
await pRetry(
async () => {
const swarm = await getSwarmFor(ourPubKey);
snode = sample(swarm);
if (!snode) {
throw new EmptySwarmError(ourPubKey, 'Ran out of swarm nodes to query');
}
newTTL = await expireOnNodes(snode, expireRequestParams);
},
{
retries: 3,
factor: 2,
minTimeout: SeedNodeAPI.getMinTimeout(),
onFailedAttempt: e => {
window?.log?.warn(
`WIP: [expireMessageOnSnode] expire message on snode attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left... Error: ${e.message}`
);
},
}
);
return newTTL;
} catch (e) {
const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null';
window?.log?.warn(
`WIP: [expireMessageOnSnode] ${e.code ? `${e.code} ` : ''}${e.message ||
e} by ${ourPubKey} for ${messageHash} via snode:${snodeStr}`
);
throw e;
}
}