working get and post request with opengroup api v2

pull/1576/head
Audric Ackermann 4 years ago
parent c07271109f
commit b68338e26c
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -122,16 +122,15 @@
window.lokiMessageAPI = new window.LokiMessageAPI();
// singleton to relay events to libtextsecure/message_receiver
window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey);
//FIXME audric
// singleton to interface the File server
// If already exists we registered as a secondary device
// if (!window.lokiFileServerAPI) {
// window.lokiFileServerAPIFactory = new window.LokiFileServerAPI(ourKey);
// window.lokiFileServerAPI = window.lokiFileServerAPIFactory.establishHomeConnection(
// window.getDefaultFileServer()
// );
// }
if (!window.lokiFileServerAPI) {
window.lokiFileServerAPIFactory = new window.LokiFileServerAPI(ourKey);
window.lokiFileServerAPI = window.lokiFileServerAPIFactory.establishHomeConnection(
window.getDefaultFileServer()
);
}
window.initialisedAPI = true;
};

@ -1,9 +1,4 @@
export async function sleepFor(ms: number);
export async function allowOnlyOneAtATime(
name: any,
process: any,
timeout?: any
);
export async function abortableIterator(
array: Array<any>,

@ -18,62 +18,6 @@ const firstTrue = ps => {
return Promise.race(newPs);
};
// one action resolves all
const snodeGlobalLocks = {};
async function allowOnlyOneAtATime(name, process, timeout) {
// if currently not in progress
if (snodeGlobalLocks[name] === undefined) {
// set lock
snodeGlobalLocks[name] = new Promise(async (resolve, reject) => {
// set up timeout feature
let timeoutTimer = null;
if (timeout) {
timeoutTimer = setTimeout(() => {
log.warn(
`loki_primitives:::allowOnlyOneAtATime - TIMEDOUT after ${timeout}s`
);
delete snodeGlobalLocks[name]; // clear lock
reject();
}, timeout);
}
// do actual work
let innerRetVal;
try {
innerRetVal = await process();
} catch (e) {
if (typeof e === 'string') {
log.error(`loki_primitives:::allowOnlyOneAtATime - error ${e}`);
} else {
log.error(
`loki_primitives:::allowOnlyOneAtATime - error ${e.code} ${e.message}`
);
}
// clear timeout timer
if (timeout) {
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
}
delete snodeGlobalLocks[name]; // clear lock
throw e;
}
// clear timeout timer
if (timeout) {
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
}
delete snodeGlobalLocks[name]; // clear lock
// release the kraken
resolve(innerRetVal);
});
}
return snodeGlobalLocks[name];
}
function abortableIterator(array, iterator) {
let abortIteration = false;
@ -114,7 +58,6 @@ function abortableIterator(array, iterator) {
module.exports = {
sleepFor,
allowOnlyOneAtATime,
abortableIterator,
firstTrue,
};

@ -11,7 +11,6 @@ class LokiPublicChatFactoryAPI extends EventEmitter {
this.allMembers = [];
this.openGroupPubKeys = {};
// Multidevice states
this.primaryUserProfileName = {};
}
// MessageReceiver.connect calls this

@ -1,14 +1,15 @@
declare enum PairingTypeEnum {
REQUEST = 1,
GRANT,
}
export interface CryptoInterface {
DHDecrypt: any;
DHEncrypt: any;
DecryptGCM: any; // AES-GCM
EncryptGCM: any; // AES-GCM
PairingType: PairingTypeEnum;
DecryptAESGCM: (
symmetricKey: ArrayBuffer,
ivAndCiphertext: ArrayBuffer
) => Promise<ArrayBuffer>; // AES-GCM
deriveSymmetricKey: (
pubkey: ArrayBuffer,
seckey: ArrayBuffer
) => Promise<ArrayBuffer>;
EncryptAESGCM: any; // AES-GCM
_decodeSnodeAddressToPubKey: any;
decryptToken: any;
encryptForPubkey: any;

@ -32,10 +32,10 @@
return ivAndCiphertext;
}
async function deriveSymmetricKey(pubkey, seckey) {
async function deriveSymmetricKey(x25519PublicKey, x25519PrivateKey) {
const ephemeralSecret = await libsignal.Curve.async.calculateAgreement(
pubkey,
seckey
x25519PublicKey,
x25519PrivateKey
);
const salt = window.Signal.Crypto.bytesFromString('LOKI');
@ -63,12 +63,12 @@
const symmetricKey = await deriveSymmetricKey(snPubkey, ephemeral.privKey);
const ciphertext = await EncryptGCM(symmetricKey, payloadBytes);
const ciphertext = await EncryptAESGCM(symmetricKey, payloadBytes);
return { ciphertext, symmetricKey, ephemeralKey: ephemeral.pubKey };
}
async function EncryptGCM(symmetricKey, plaintext) {
async function EncryptAESGCM(symmetricKey, plaintext) {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const key = await crypto.subtle.importKey(
@ -95,7 +95,7 @@
return ivAndCiphertext;
}
async function DecryptGCM(symmetricKey, ivAndCiphertext) {
async function DecryptAESGCM(symmetricKey, ivAndCiphertext) {
const nonce = ivAndCiphertext.slice(0, NONCE_LENGTH);
const ciphertext = ivAndCiphertext.slice(NONCE_LENGTH);
@ -165,18 +165,13 @@
const sha512 = data => crypto.subtle.digest('SHA-512', data);
const PairingType = Object.freeze({
REQUEST: 1,
GRANT: 2,
});
window.libloki.crypto = {
DHEncrypt,
EncryptGCM, // AES-GCM
EncryptAESGCM, // AES-GCM
DHDecrypt,
DecryptGCM, // AES-GCM
DecryptAESGCM, // AES-GCM
decryptToken,
PairingType,
deriveSymmetricKey,
generateEphemeralKeyPair,
encryptForPubkey,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,

@ -33,6 +33,10 @@ import {
joinOpenGroupV2,
parseOpenGroupV2,
} from '../../opengroup/opengroupV2/JoinOpenGroupV2';
import {
getAuthToken,
getModerators,
} from '../../opengroup/opengroupV2/OpenGroupAPIV2';
// tslint:disable-next-line: no-import-side-effect no-submodule-imports
export enum SectionType {
@ -180,13 +184,20 @@ export const ActionsPanel = () => {
// trigger a sync message if needed for our other devices
// 'http://sessionopengroup.com/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231b'
// 'https://sog.ibolpap.finance/main?public_key=b464aa186530c97d6bcf663a3a3b7465a5f782beaa67c83bee99468824b4aa10'
// 'https://opengroup.bilb.us/main?public_key=1352534ba73d4265973280431dbc72e097a3e43275d1ada984f9805b4943047d'
void syncConfiguration();
const parsedRoom = parseOpenGroupV2(
'https://sog.ibolpap.finance/main?public_key=b464aa186530c97d6bcf663a3a3b7465a5f782beaa67c83bee99468824b4aa10'
'https://opengroup.bilb.us/main?public_key=1352534ba73d4265973280431dbc72e097a3e43275d1ada984f9805b4943047d'
);
if (parsedRoom) {
setTimeout(() => void joinOpenGroupV2(parsedRoom), 10000);
setTimeout(async () => {
await joinOpenGroupV2(parsedRoom);
await getModerators({
serverUrl: parsedRoom.serverUrl,
roomId: parsedRoom.roomId,
});
}, 6000);
}
}, []);

@ -47,11 +47,15 @@ export async function getV2OpenGroupRoom(
return opengroupv2Rooms;
}
export async function getV2OpenGroupRoomByRoomId(
serverUrl: string,
roomId: string
): Promise<OpenGroupV2Room | undefined> {
const room = await channels.getV2OpenGroupRoomByRoomId(serverUrl, roomId);
export async function getV2OpenGroupRoomByRoomId(roomInfos: {
serverUrl: string;
roomId: string;
}): Promise<OpenGroupV2Room | undefined> {
console.warn('getting roomInfo', roomInfos);
const room = await channels.getV2OpenGroupRoomByRoomId(
roomInfos.serverUrl,
roomInfos.roomId
);
if (!room) {
return undefined;
@ -71,6 +75,7 @@ export async function saveV2OpenGroupRoom(
) {
throw new Error('Cannot save v2 room, invalid data');
}
console.warn('saving roomInfo', opengroupsv2Room);
await channels.saveV2OpenGroupRoom(opengroupsv2Room);
}
@ -78,7 +83,8 @@ export async function saveV2OpenGroupRoom(
export async function removeV2OpenGroupRoom(
conversationId: string
): Promise<void> {
// TODO sql
console.warn('removing roomInfo', conversationId);
await channels.removeV2OpenGroupRoom(conversationId);
}

@ -200,6 +200,9 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public isPublic() {
return !!(this.id && this.id.match(/^publicChat:/));
}
public isOpenGroupV2() {
return this.get('type') === ConversationType.OPEN_GROUP;
}
public isClosedGroup() {
return this.get('type') === ConversationType.GROUP && !this.isPublic();
}

@ -1,7 +1,7 @@
import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import { ConversationModel, ConversationType } from '../../models/conversation';
import { ConversationController } from '../../session/conversations';
import { PromiseUtils } from '../../session/utils';
import { allowOnlyOneAtATime } from '../../session/utils/Promise';
import { forceSyncConfigurationNowIfNeeded } from '../../session/utils/syncUtils';
import { prefixify } from '../utils/OpenGroupUtils';
@ -226,24 +226,24 @@ export class OpenGroup {
* To avoid this issue, we allow only a single join of a specific opengroup at a time.
*/
private static async attemptConnectionOneAtATime(
serverURL: string,
serverUrl: string,
channelId: number = 1
): Promise<ConversationModel> {
if (!serverURL) {
if (!serverUrl) {
throw new Error('Cannot join open group with empty URL');
}
const oneAtaTimeStr = `oneAtaTimeOpenGroupJoin:${serverURL}${channelId}`;
const oneAtaTimeStr = `oneAtaTimeOpenGroupJoin:${serverUrl}${channelId}`;
return allowOnlyOneAtATime(oneAtaTimeStr, async () => {
return OpenGroup.attemptConnection(serverURL, channelId);
return OpenGroup.attemptConnection(serverUrl, channelId);
});
}
// Attempts a connection to an open group server
private static async attemptConnection(
serverURL: string,
serverUrl: string,
channelId: number
): Promise<ConversationModel> {
let completeServerURL = serverURL.toLowerCase();
let completeServerURL = serverUrl.toLowerCase();
const valid = OpenGroup.validate(completeServerURL);
if (!valid) {
return new Promise((_resolve, reject) => {
@ -254,7 +254,7 @@ export class OpenGroup {
// Add http or https prefix to server
completeServerURL = prefixify(completeServerURL);
const rawServerURL = serverURL
const rawServerURL = serverUrl
.replace(/^https?:\/\//i, '')
.replace(/[/\\]+$/i, '');

@ -0,0 +1,72 @@
export const defaultServer = 'https://sessionopengroup.com';
export const defaultServerPublicKey =
'658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231b';
export type OpenGroupV2Request = {
method: 'GET' | 'POST' | 'DELETE' | 'PUT';
room: string;
server: string;
endpoint: string;
// queryParams are used for post or get, but not the same way
queryParams?: Record<string, string>;
headers?: Record<string, string>;
isAuthRequired: boolean;
// Always `true` under normal circumstances. You might want to disable this when running over Lokinet.
useOnionRouting?: boolean;
};
export type OpenGroupV2Info = {
id: string;
name: string;
imageId?: string;
};
/**
* Try to build an full url and check it for validity.
* @returns null if the check failed. the built URL otherwise
*/
export const buildUrl = (request: OpenGroupV2Request): URL | null => {
let rawURL = `${request.server}/${request.endpoint}`;
if (request.method === 'GET') {
const entries = Object.entries(request.queryParams || {});
if (entries.length) {
const queryString = entries
.map(([key, value]) => `${key}=${value}`)
.join('&');
rawURL += `?${queryString}`;
}
}
// this just check that the URL is valid
try {
return new URL(`${rawURL}`);
} catch (error) {
return null;
}
};
/**
* Map of serverUrl to roomId to list of moderators as a Set
*/
export const cachedModerators: Map<
string,
Map<string, Set<string>>
> = new Map();
export const setCachedModerators = (
serverUrl: string,
roomId: string,
newModerators: Array<string>
) => {
const allRoomsMods = cachedModerators.get(serverUrl);
if (!allRoomsMods) {
cachedModerators.set(serverUrl, new Map());
}
// tslint:disable: no-non-null-assertion
if (!allRoomsMods!.get(roomId)) {
allRoomsMods!.set(roomId, new Set());
}
newModerators.forEach(m => {
allRoomsMods!.get(roomId)?.add(m);
});
};

@ -1,13 +1,15 @@
import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import {
getV2OpenGroupRoomByRoomId,
OpenGroupV2Room,
removeV2OpenGroupRoom,
} from '../../data/opengroups';
import { ConversationModel } from '../../models/conversation';
import { ConversationController } from '../../session/conversations';
import { PromiseUtils } from '../../session/utils';
import { forceSyncConfigurationNowIfNeeded } from '../../session/utils/syncUtils';
import { prefixify } from '../utils/OpenGroupUtils';
import {
getOpenGroupV2ConversationId,
prefixify,
} from '../utils/OpenGroupUtils';
import { attemptConnectionV2OneAtATime } from './OpenGroupManagerV2';
const protocolRegex = '(https?://)?';
@ -89,13 +91,19 @@ export async function joinOpenGroupV2(
const publicKey = room.serverPublicKey.toLowerCase();
const prefixedServer = prefixify(serverUrl);
const alreadyExist = await getV2OpenGroupRoomByRoomId(serverUrl, roomId);
const alreadyExist = await getV2OpenGroupRoomByRoomId({ serverUrl, roomId });
const conversationId = getOpenGroupV2ConversationId(serverUrl, roomId);
const existingConvo = ConversationController.getInstance().get(
conversationId
);
//FIXME audric
// if (alreadyExist) {
// window.log.warn('Skipping join opengroupv2: already exists');
// return;
// }
if (alreadyExist && existingConvo) {
window.log.warn('Skipping join opengroupv2: already exists');
return;
} else if (alreadyExist) {
// we don't have a convo associated with it. Remove the room in db
await removeV2OpenGroupRoom(conversationId);
}
// Try to connect to server
try {

@ -1,71 +1,21 @@
import { Headers } from 'node-fetch';
import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import { getV2OpenGroupRoomByRoomId } from '../../data/opengroups';
import {
getV2OpenGroupRoomByRoomId,
saveV2OpenGroupRoom,
} from '../../data/opengroups';
import { sendViaOnion } from '../../session/onions/onionSend';
import { fromBase64ToArray } from '../../session/utils/String';
import { allowOnlyOneAtATime } from '../../session/utils/Promise';
import { fromBase64ToArrayBuffer, toHex } from '../../session/utils/String';
import {
getIdentityKeyPair,
getOurPubKeyStrFromCache,
} from '../../session/utils/User';
// HTTP HEADER FOR OPEN GROUP V2
const HEADER_ROOM = 'Room';
const HEADER_AUTHORIZATION = 'Authorization';
const PARAMETER_PUBLIC_KEY = 'public_key';
export const openGroupV2PubKeys: Record<string, string> = {};
export const defaultServer = 'https://sessionopengroup.com';
export const defaultServerPublicKey =
'658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231b';
type OpenGroupV2Request = {
method: 'GET' | 'POST' | 'DELETE' | 'PUT';
room: string;
server: string;
endpoint: string;
// queryParams are used for post or get, but not the same way
queryParams?: Map<string, string>;
headers?: Headers;
isAuthRequired: boolean;
// Always `true` under normal circumstances. You might want to disable this when running over Lokinet.
useOnionRouting?: boolean;
};
type OpenGroupV2Info = {
id: string;
name: string;
imageId?: string;
};
/**
* Try to build an full url and check it for validity.
* @returns null if the check failed. the built URL otherwise
*/
const buildUrl = (request: OpenGroupV2Request): URL | null => {
let rawURL = `${request.server}/${request.endpoint}`;
if (request.method === 'GET') {
if (!!request.queryParams?.size) {
const entries = [...request.queryParams.entries()];
const queryString = entries
.map(([key, value]) => `${key}=${value}`)
.join('&');
rawURL += `/?${queryString}`;
}
}
// this just check that the URL is valid
try {
return new URL(`${rawURL}`);
} catch (error) {
return null;
}
};
/**
* Map of serverUrl to roomId to list of moderators as a Set
*/
export const moderators: Map<string, Map<string, Set<string>>> = new Map();
import {
buildUrl,
cachedModerators,
OpenGroupV2Info,
OpenGroupV2Request,
setCachedModerators,
} from './ApiUtil';
// This function might throw
async function sendOpenGroupV2Request(
@ -78,33 +28,51 @@ async function sendOpenGroupV2Request(
}
// set the headers sent by the caller, and the roomId.
const headersWithRoom = request.headers || new Headers();
headersWithRoom.append(HEADER_ROOM, request.room);
console.warn(`request: ${builtUrl}`);
const headers = request.headers || {};
headers.Room = request.room;
console.warn(`sending request: ${builtUrl}`);
let body = '';
if (request.method !== 'GET') {
body = JSON.stringify(request.queryParams);
}
// request.useOnionRouting === undefined defaults to true
if (request.useOnionRouting || request.useOnionRouting === undefined) {
const roomDetails = await getV2OpenGroupRoomByRoomId(
request.server,
request.room
);
const roomDetails = await getV2OpenGroupRoomByRoomId({
serverUrl: request.server,
roomId: request.room,
});
if (!roomDetails?.serverPublicKey) {
throw new Error('PublicKey not found for this server.');
}
// Because auth happens on a per-room basis, we need both to make an authenticated request
if (request.isAuthRequired && request.room) {
const token = await getAuthToken(request.room, request.server);
// this call will either return the token on the db,
// or the promise currently fetching a new token for that same room
// or fetch a new token for that room if no other request are currently being made.
const token = await getAuthToken({
roomId: request.room,
serverUrl: request.server,
});
if (!token) {
throw new Error('Failed to get token for open group v2');
}
headersWithRoom.append(HEADER_AUTHORIZATION, token);
// FIXME use headersWithRoom
headers.Authorization = token;
const res = await sendViaOnion(roomDetails.serverPublicKey, builtUrl, {
method: request.method,
headers,
body,
});
// A 401 means that we didn't provide a (valid) auth token for a route that required one. We use this as an
// indication that the token we're using has expired. Note that a 403 has a different meaning; it means that
// we provided a valid token but it doesn't have a high enough permission level for the route in question.
return res;
} else {
// no need for auth, just do the onion request
const res = await sendViaOnion(roomDetails.serverPublicKey, builtUrl, {
method: request.method,
headers: { ...headersWithRoom.entries() },
headers,
body,
});
return res;
}
@ -118,53 +86,78 @@ async function sendOpenGroupV2Request(
}
// tslint:disable: member-ordering
export async function requestNewAuthToken(
serverUrl: string,
roomid: string
): Promise<void> {
export async function requestNewAuthToken({
serverUrl,
roomId,
}: {
serverUrl: string;
roomId: string;
}): Promise<string> {
const userKeyPair = await getIdentityKeyPair();
if (!userKeyPair) {
throw new Error('Failed to fetch user keypair');
}
const ourPubkey = getOurPubKeyStrFromCache();
const parameters = [PARAMETER_PUBLIC_KEY, ourPubkey] as [string, string];
const parameters = {} as Record<string, string>;
parameters.public_key = ourPubkey;
const request: OpenGroupV2Request = {
method: 'GET',
room: roomid,
room: roomId,
server: serverUrl,
queryParams: new Map([parameters]),
queryParams: parameters,
isAuthRequired: false,
endpoint: 'auth_token_challenge',
};
const json = (await sendOpenGroupV2Request(request)) as any;
// parse the json
const { challenge } = json;
if (!challenge) {
if (!json || !json?.result?.challenge) {
throw new Error('Parsing failed');
}
const {
ciphertext: base64EncodedCiphertext,
ephemeral_public_key: base64EncodedEphemeralPublicKey,
} = challenge;
} = json?.result?.challenge;
if (!base64EncodedCiphertext || !base64EncodedEphemeralPublicKey) {
throw new Error('Parsing failed');
}
const ciphertext = fromBase64ToArray(base64EncodedCiphertext);
const ephemeralPublicKey = fromBase64ToArray(base64EncodedEphemeralPublicKey);
console.warn('ciphertext', ciphertext);
console.warn('ephemeralPublicKey', ephemeralPublicKey);
const ciphertext = fromBase64ToArrayBuffer(base64EncodedCiphertext);
const ephemeralPublicKey = fromBase64ToArrayBuffer(
base64EncodedEphemeralPublicKey
);
try {
const symmetricKey = await window.libloki.crypto.deriveSymmetricKey(
ephemeralPublicKey,
userKeyPair.privKey
);
const plaintextBuffer = await window.libloki.crypto.DecryptAESGCM(
symmetricKey,
ciphertext
);
const token = toHex(plaintextBuffer);
console.warn('token', token);
return token;
} catch (e) {
window.log.error('Failed to decrypt token open group v2');
throw e;
}
}
/**
* This function might throw
*
*/
export async function openGroupV2GetRoomInfo(
roomId: string,
serverUrl: string
): Promise<OpenGroupV2Info> {
export async function openGroupV2GetRoomInfo({
serverUrl,
roomId,
}: {
roomId: string;
serverUrl: string;
}): Promise<OpenGroupV2Info> {
const request: OpenGroupV2Request = {
method: 'GET',
server: serverUrl,
@ -193,40 +186,133 @@ export async function openGroupV2GetRoomInfo(
async function claimAuthToken(
authToken: string,
serverUrl: string,
roomid: string
): Promise<void> {
const ourPubkey = getOurPubKeyStrFromCache();
const parameters = [PARAMETER_PUBLIC_KEY, ourPubkey] as [string, string];
roomId: string
): Promise<string> {
// Set explicitly here because is isn't in the database yet at this point
const headers = new Headers({ HEADER_AUTHORIZATION: authToken });
const headers = { Authorization: authToken };
const request: OpenGroupV2Request = {
method: 'POST',
headers,
room: roomid,
room: roomId,
server: serverUrl,
queryParams: new Map([parameters]),
queryParams: { public_key: getOurPubKeyStrFromCache() },
isAuthRequired: false,
endpoint: 'claim_auth_token',
};
await sendOpenGroupV2Request(request);
const result = (await sendOpenGroupV2Request(request)) as any;
if (result?.result?.status_code !== 200) {
throw new Error(
`Could not claim token, status code: ${result?.result?.status_code}`
);
}
return authToken;
}
async function getAuthToken(
serverUrl: string,
roomId: string
): Promise<string> {
export async function getAuthToken({
serverUrl,
roomId,
}: {
serverUrl: string;
roomId: string;
}): Promise<string> {
// first try to fetch from db a saved token.
const roomDetails = await getV2OpenGroupRoomByRoomId(serverUrl, roomId);
const roomDetails = await getV2OpenGroupRoomByRoomId({ serverUrl, roomId });
if (!roomDetails) {
throw new Error('getAuthToken Room does not exist.');
}
if (roomDetails?.token) {
return roomDetails?.token;
}
// const token = await allowOnlyOneAtATime(
// `getAuthTokenV2${serverUrl}:${roomId}`,
// async () => {
// requestNewAuthToken
// }
// );
await allowOnlyOneAtATime(
`getAuthTokenV2${serverUrl}:${roomId}`,
async () => {
try {
const token = await requestNewAuthToken({ serverUrl, roomId });
// claimAuthToken throws if the status code is not valid
const claimedToken = await claimAuthToken(token, serverUrl, roomId);
roomDetails.token = token;
await saveV2OpenGroupRoom(roomDetails);
} catch (e) {
window.log.error('Failed to getAuthToken', e);
throw e;
}
}
);
return 'token';
}
export const getModerators = async ({
serverUrl,
roomId,
}: {
serverUrl: string;
roomId: string;
}): Promise<Array<string>> => {
const request: OpenGroupV2Request = {
method: 'GET',
room: roomId,
server: serverUrl,
isAuthRequired: true,
endpoint: 'moderators',
};
const result = (await sendOpenGroupV2Request(request)) as any;
if (result?.result?.status_code !== 200) {
throw new Error(
`Could not getModerators, status code: ${result?.result?.status_code}`
);
}
const moderatorsGot = result?.result?.moderators;
if (moderatorsGot === undefined) {
throw new Error(
'Could not getModerators, got no moderatorsGot at all in json.'
);
}
setCachedModerators(serverUrl, roomId, moderatorsGot || []);
return moderatorsGot || [];
};
export const deleteAuthToken = async ({
serverUrl,
roomId,
}: {
serverUrl: string;
roomId: string;
}) => {
const request: OpenGroupV2Request = {
method: 'DELETE',
room: roomId,
server: serverUrl,
isAuthRequired: false,
endpoint: 'auth_token',
};
const result = (await sendOpenGroupV2Request(request)) as any;
if (result?.result?.status_code !== 200) {
throw new Error(
`Could not deleteAuthToken, status code: ${result?.result?.status_code}`
);
}
};
export const getMessages = async ({
serverUrl,
roomId,
}: {
serverUrl: string;
roomId: string;
}) => {
const request: OpenGroupV2Request = {
method: 'GET',
room: roomId,
server: serverUrl,
isAuthRequired: false,
endpoint: 'auth_token',
};
const result = (await sendOpenGroupV2Request(request)) as any;
if (result?.result?.status_code !== 200) {
throw new Error(
`Could not deleteAuthToken, status code: ${result?.result?.status_code}`
);
}
};

@ -1,4 +1,3 @@
import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import {
OpenGroupV2Room,
removeV2OpenGroupRoom,
@ -6,6 +5,7 @@ import {
} from '../../data/opengroups';
import { ConversationModel, ConversationType } from '../../models/conversation';
import { ConversationController } from '../../session/conversations';
import { allowOnlyOneAtATime } from '../../session/utils/Promise';
import { getOpenGroupV2ConversationId } from '../utils/OpenGroupUtils';
import { openGroupV2GetRoomInfo } from './OpenGroupAPIV2';
import { OpenGroupPollerV2 } from './OpenGroupPollerV2';
@ -19,19 +19,19 @@ import { OpenGroupPollerV2 } from './OpenGroupPollerV2';
* To avoid this issue, we allow only a single join of a specific opengroup at a time.
*/
export async function attemptConnectionV2OneAtATime(
serverURL: string,
serverUrl: string,
roomId: string,
publicKey: string
): Promise<ConversationModel> {
const oneAtaTimeStr = `oneAtaTimeOpenGroupV2Join:${serverURL}${roomId}`;
const oneAtaTimeStr = `oneAtaTimeOpenGroupV2Join:${serverUrl}${roomId}`;
return allowOnlyOneAtATime(oneAtaTimeStr, async () => {
return attemptConnectionV2(serverURL, roomId, publicKey);
return attemptConnectionV2(serverUrl, roomId, publicKey);
});
}
/**
*
* @param serverURL with protocol, hostname and port included
* @param serverUrl with protocol, hostname and port included
*/
async function attemptConnectionV2(
serverUrl: string,
@ -42,9 +42,7 @@ async function attemptConnectionV2(
if (ConversationController.getInstance().get(conversationId)) {
// Url incorrect or server not compatible
return new Promise((_resolve, reject) => {
reject(window.i18n('publicChatExists'));
});
throw new Error(window.i18n('publicChatExists'));
}
// here, the convo does not exist. Make sure the db is clean too
@ -58,47 +56,31 @@ async function attemptConnectionV2(
};
try {
// save the pubkey to the db, the request for room Info will need it and access it from the db
// save the pubkey to the db right now, the request for room Info
// will need it and access it from the db
await saveV2OpenGroupRoom(room);
const info = await openGroupV2GetRoomInfo(roomId, serverUrl);
const roomInfos = await openGroupV2GetRoomInfo({ roomId, serverUrl });
const conversation = await ConversationController.getInstance().getOrCreateAndWait(
conversationId,
ConversationType.OPEN_GROUP
);
conversation.isPublic();
room.imageID = roomInfos.imageId || undefined;
room.roomName = roomInfos.name || undefined;
await saveV2OpenGroupRoom(room);
console.warn('openGroupRoom info', roomInfos);
// mark active so it's not in the contacts list but in the conversation list
conversation.set({
active_at: Date.now(),
});
await conversation.commit();
console.warn('openGroupRoom info', info);
return conversation;
} catch (e) {
window.log.warn('Failed to join open group v2', e);
await removeV2OpenGroupRoom(conversationId);
throw new Error(window.i18n('connectToServerFail'));
}
// Get server
// const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer(
// completeServerURL
// );
// SSL certificate failure or offline
// if (!serverAPI) {
// // Url incorrect or server not compatible
// return new Promise((_resolve, reject) => {
// reject(window.i18n('connectToServerFail'));
// });
// }
// // Create conversation
// const conversation = await ConversationController.getInstance().getOrCreateAndWait(
// conversationId,
// 'group'
// );
// // Convert conversation to a public one
// await conversation.setPublicSource(completeServerURL, channelId);
// // and finally activate it
// void conversation.getPublicSendData(); // may want "await" if you want to use the API
// return conversation;
return undefined;
}
export class OpenGroupManagerV2 {

@ -0,0 +1,100 @@
import { getSodium } from '../../session/crypto';
import { UserUtils } from '../../session/utils';
import {
fromBase64ToArray,
fromHex,
fromHexToArray,
toHex,
} from '../../session/utils/String';
export class OpenGroupMessageV2 {
public serverId?: number;
public sender?: string;
public sentTimestamp: number;
public base64EncodedData: string;
public base64EncodedSignature?: string;
constructor(messageData: {
serverId?: number;
sender?: string;
sentTimestamp: number;
base64EncodedData: string;
base64EncodedSignature?: string;
}) {
const {
base64EncodedData,
sentTimestamp,
base64EncodedSignature,
sender,
serverId,
} = messageData;
this.base64EncodedData = base64EncodedData;
this.sentTimestamp = sentTimestamp;
this.base64EncodedSignature = base64EncodedSignature;
this.sender = sender;
this.serverId = serverId;
}
public async sign() {
const ourKeyPair = await UserUtils.getUserED25519KeyPair();
if (!ourKeyPair) {
window.log.warn("Couldn't find user X25519 key pair.");
return null;
}
const data = fromBase64ToArray(this.base64EncodedData);
const sodium = await getSodium();
const signature = sodium.crypto_sign_detached(
data,
fromHexToArray(ourKeyPair.privKey)
);
if (!signature || signature.length === 0) {
throw new Error("Couldn't sign message");
}
return new OpenGroupMessageV2({
base64EncodedData: this.base64EncodedData,
sentTimestamp: this.sentTimestamp,
base64EncodedSignature: toHex(signature),
sender: this.sender,
serverId: this.serverId,
});
}
public toJson() {
const json = {
data: this.base64EncodedData,
timestamp: this.sentTimestamp,
} as Record<string, any>;
if (this.serverId) {
json.server_id = this.serverId;
}
if (this.sender) {
json.public_key = this.sender;
}
if (this.base64EncodedSignature) {
json.signature = this.base64EncodedSignature;
}
}
public fromJson(json: Record<string, any>) {
const {
data: base64EncodedData,
timestamp: sentTimestamp,
server_id: serverId,
public_key: sender,
signature: base64EncodedSignature,
} = json;
if (!base64EncodedData || !sentTimestamp) {
window.log.info('invalid json to build OpenGroupMessageV2');
return null;
}
return new OpenGroupMessageV2({
base64EncodedData,
base64EncodedSignature,
sentTimestamp,
serverId,
sender,
});
}
}

@ -2,20 +2,16 @@ import { AbortController } from 'abort-controller';
import { OpenGroupV2Room } from '../../data/opengroups';
export class OpenGroupPollerV2 {
private static readonly pollForEverythingInterval = 4 * 1000;
private readonly openGroupRoom: OpenGroupV2Room;
private pollForNewMessagesTimer?: NodeJS.Timeout;
private pollForDeletedMessagesTimer?: NodeJS.Timeout;
private pollForModeratorsTimer?: NodeJS.Timeout;
private pollForEverythingTimer?: NodeJS.Timeout;
private abortController?: AbortController;
private hasStarted = false;
private isPollingForMessages = false;
private readonly pollForNewMessagesInterval = 4 * 1000;
private readonly pollForDeletedMessagesInterval = 30 * 1000;
private readonly pollForModeratorsInterval = 10 * 60 * 1000;
private isPolling = false;
constructor(openGroupRoom: OpenGroupV2Room) {
this.openGroupRoom = openGroupRoom;
@ -27,56 +23,30 @@ export class OpenGroupPollerV2 {
}
this.hasStarted = true;
this.pollForNewMessagesTimer = global.setInterval(
this.pollForNewMessages,
this.pollForNewMessagesInterval
);
this.pollForDeletedMessagesTimer = global.setInterval(
this.pollForDeletedMessages,
this.pollForDeletedMessagesInterval
);
this.pollForModeratorsTimer = global.setInterval(
this.pollForModerators,
this.pollForModeratorsInterval
this.abortController = new AbortController();
this.pollForEverythingTimer = global.setInterval(
this.compactPoll,
OpenGroupPollerV2.pollForEverythingInterval
);
}
public stop() {
if (this.pollForNewMessagesTimer) {
global.clearInterval(this.pollForNewMessagesTimer);
this.pollForNewMessagesTimer = undefined;
}
if (this.pollForDeletedMessagesTimer) {
global.clearInterval(this.pollForDeletedMessagesTimer);
this.pollForDeletedMessagesTimer = undefined;
}
if (this.pollForModeratorsTimer) {
global.clearInterval(this.pollForModeratorsTimer);
this.pollForModeratorsTimer = undefined;
if (this.pollForEverythingTimer) {
global.clearInterval(this.pollForEverythingTimer);
this.abortController?.abort();
this.abortController = undefined;
this.pollForEverythingTimer = undefined;
}
}
private async pollForNewMessages() {
private async compactPoll() {
// return early if a poll is already in progress
if (this.isPollingForMessages) {
if (this.isPolling) {
return;
}
this.isPollingForMessages = true;
this.isPolling = true;
window.log.warn('pollForNewMessages TODO');
// use abortController and do not trigger new messages if it was canceled
this.isPollingForMessages = false;
}
// tslint:disable: no-async-without-await
private async pollForModerators() {
window.log.warn('pollForModerators TODO');
// use abortController
}
private async pollForDeletedMessages() {
window.log.warn('pollForDeletedMessages TODO');
// use abortController
this.isPolling = false;
}
}

@ -102,14 +102,14 @@ export function prefixify(server: string, hasSSL: boolean = true): string {
/**
* No sql access. Just how our open groupv2 url looks like
* @returns `publicChat:${roomId}@${serverURL}`
* @returns `publicChat:${roomId}@${serverUrl}`
*/
export function getOpenGroupV2ConversationId(
serverURL: string,
serverUrl: string,
roomId: string
) {
if (roomId.length < 2) {
throw new Error('Invalid roomId: too short');
}
return `publicChat:${roomId}@${serverURL}`;
return `publicChat:${roomId}@${serverUrl}`;
}

@ -8,6 +8,12 @@ export const TTL_DEFAULT = {
CONFIGURATION_MESSAGE: 4 * DAYS,
};
export const PROTOCOLS = {
// tslint:disable-next-line: no-http-string
HTTP: 'http:',
HTTPS: 'https:',
};
// User Interface
export const CONVERSATION = {
DEFAULT_MEDIA_FETCH_COUNT: 50,

@ -17,7 +17,7 @@ import { actions as conversationActions } from '../../state/ducks/conversations'
export class ConversationController {
private static instance: ConversationController | null;
private readonly conversations: any;
private readonly conversations: ConversationCollection;
private _initialFetchComplete: boolean = false;
private _initialPromise?: Promise<any>;
@ -159,7 +159,7 @@ export class ConversationController {
public isMediumGroup(hexEncodedGroupPublicKey: string): boolean {
const convo = this.conversations.get(hexEncodedGroupPublicKey);
if (convo) {
return convo.isMediumGroup();
return !!convo.isMediumGroup();
}
return false;
}
@ -215,13 +215,15 @@ export class ConversationController {
// Close group leaving
if (conversation.isClosedGroup()) {
await conversation.leaveGroup();
} else if (conversation.isPublic()) {
} else if (conversation.isPublic() && !conversation.isOpenGroupV2()) {
const channelAPI = await conversation.getPublicSendData();
if (channelAPI === null) {
window.log.warn(`Could not get API for public conversation ${id}`);
} else {
channelAPI.serverAPI.partChannel(channelAPI.channelId);
channelAPI.serverAPI.partChannel((channelAPI as any).channelId);
}
} else if (conversation.isOpenGroupV2()) {
window.log.warn('leave open group v2 todo');
}
await conversation.destroyMessages();

@ -1,10 +1,10 @@
import { allowOnlyOneAtATime } from '../../../js/modules/loki_primitives';
import { getGuardNodes } from '../../../ts/data/data';
import * as SnodePool from '../snode_api/snodePool';
import _ from 'lodash';
import { default as insecureNodeFetch } from 'node-fetch';
import { UserUtils } from '../utils';
import { snodeHttpsAgent } from '../snode_api/onions';
import { allowOnlyOneAtATime } from '../utils/Promise';
export type Snode = SnodePool.Snode;

@ -25,7 +25,7 @@ const MAX_SEND_ONION_RETRIES = 3;
type OnionFetchOptions = {
method: string;
body?: string;
headers?: Object;
headers?: Record<string, string>;
};
type OnionFetchBasicOptions = {
@ -55,13 +55,14 @@ export const sendViaOnion = async (
options.requestNumber = OnionPaths.getInstance().assignOnionRequestNumber();
}
let tempHeaders = fetchOptions.headers || {};
const payloadObj = {
method: fetchOptions.method || 'GET',
body: fetchOptions.body || ('' as any),
// safety issue with file server, just safer to have this
headers: fetchOptions.headers || {},
// no initial /
endpoint: url.pathname.replace(/^\//, ''),
headers: {},
};
if (url.search) {
payloadObj.endpoint += url.search;
@ -75,8 +76,8 @@ export const sendViaOnion = async (
) {
const fData = payloadObj.body.getBuffer();
const fHeaders = payloadObj.body.getHeaders();
tempHeaders = { ...tempHeaders, fHeaders };
// update headers for boundary
payloadObj.headers = { ...payloadObj.headers, ...fHeaders };
// update body with base64 chunk
payloadObj.body = {
fileUpload: fData.toString('base64'),
@ -108,6 +109,8 @@ export const sendViaOnion = async (
// protocol: url.protocol,
// port: url.port,
};
payloadObj.headers = tempHeaders;
console.warn('sendViaOnion payloadObj ==> ', payloadObj);
result = await sendOnionRequestLsrpcDest(
0,

@ -250,10 +250,9 @@ async function makeOnionRequest(
// http errors and attempting to decrypt the body with `sharedKey`
// May return false BAD_PATH, indicating that we should try a new path.
const processOnionResponse = async (
reqIdx: any,
reqIdx: number,
response: any,
sharedKey: any,
useAesGcm: boolean,
sharedKey: ArrayBuffer,
debug: boolean
): Promise<SnodeResponse | RequestError> => {
const { log, libloki, dcodeIO, StringView } = window;
@ -318,21 +317,14 @@ const processOnionResponse = async (
if (debug) {
log.debug(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
StringView.arrayBufferToHex(ciphertextBuffer),
'useAesGcm',
useAesGcm
StringView.arrayBufferToHex(ciphertextBuffer)
);
}
window.log.warn(
'attempting decrypt with',
StringView.arrayBufferToHex(sharedKey)
);
const decryptFn = useAesGcm
? libloki.crypto.DecryptGCM
: libloki.crypto.DHDecrypt;
const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer, debug);
const plaintextBuffer = await libloki.crypto.DecryptAESGCM(
sharedKey,
ciphertextBuffer
);
if (debug) {
log.debug(
'lokiRpc::processOnionResponse - plaintextBuffer',
@ -401,19 +393,32 @@ export type DestinationContext = {
export type FinalDestinationOptions = {
destination_ed25519_hex?: string;
headers?: string;
headers?: Record<string, string>;
body?: string;
};
// finalDestOptions is an object
// FIXME: internally track reqIdx, not externally
/**
*
* Onion request looks like this
* Sender -> 1 -> 2 -> 3 -> Receiver
* 1, 2, 3 = onion Snodes
*
*
* @param reqIdx
* @param nodePath the onion path to use to send the request
* @param destX25519Any
* @param finalDestOptions those are the options for the request from 3 to R. It contains for instance the payload and headers.
* @param finalRelayOptions those are the options 3 will use to make a request to R. It contains for instance the host to make the request to
* @param lsrpcIdx
* @returns
*/
const sendOnionRequest = async (
reqIdx: any,
reqIdx: number,
nodePath: Array<Snode>,
destX25519Any: string,
finalDestOptions: {
destination_ed25519_hex?: string;
headers?: string;
headers?: Record<string, string>;
body?: string;
},
finalRelayOptions?: FinalRelayOptions,
@ -449,7 +454,7 @@ const sendOnionRequest = async (
const options = finalDestOptions; // lint
// do we need this?
if (options.headers === undefined) {
options.headers = '';
options.headers = {};
}
const useV2 = window.lokiFeatureFlags.useOnionRequestsV2;
@ -512,13 +517,7 @@ const sendOnionRequest = async (
const response = await insecureNodeFetch(guardUrl, guardFetchOptions);
return processOnionResponse(
reqIdx,
response,
destCtx.symmetricKey,
true,
false
);
return processOnionResponse(reqIdx, response, destCtx.symmetricKey, false);
};
async function sendOnionRequestSnodeDest(
@ -543,11 +542,11 @@ async function sendOnionRequestSnodeDest(
// need relay node's pubkey_x25519_hex
// always the same target: /loki/v1/lsrpc
export async function sendOnionRequestLsrpcDest(
reqIdx: any,
reqIdx: number,
nodePath: Array<Snode>,
destX25519Any: any,
destX25519Any: string,
finalRelayOptions: FinalRelayOptions,
payloadObj: any,
payloadObj: FinalDestinationOptions,
lsrpcIdx: number
): Promise<SnodeResponse | RequestError> {
return sendOnionRequest(

@ -27,6 +27,7 @@ import {
Snode,
updateSnodesFor,
} from './snodePool';
import { Constants } from '..';
/**
* Currently unused. If we need it again, be sure to update it to onion routing rather
@ -94,10 +95,13 @@ const sha256 = (s: string) => {
.digest('base64');
};
const getSslAgentForSeedNode = (seedNodeHost: string) => {
const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => {
let filePrefix = '';
let pubkey256 = '';
let cert256 = '';
if (!isSsl) {
return undefined;
}
switch (seedNodeHost) {
case 'storage.seed1.loki.network':
@ -199,8 +203,11 @@ export async function getSnodesFromSeedUrl(urlObj: URL): Promise<Array<any>> {
method: 'get_n_service_nodes',
params,
};
//FIXME audric
const sslAgent = undefined; //getSslAgentForSeedNode(urlObj.hostname);
const sslAgent = getSslAgentForSeedNode(
urlObj.hostname,
urlObj.protocol !== Constants.PROTOCOLS.HTTP
);
const fetchOptions = {
method: 'POST',

@ -1,10 +1,7 @@
import semver from 'semver';
import _ from 'lodash';
import {
abortableIterator,
allowOnlyOneAtATime,
} from '../../../js/modules/loki_primitives';
import { abortableIterator } from '../../../js/modules/loki_primitives';
import { getSnodesFromSeedUrl, requestSnodesForPubkey } from './serviceNodeAPI';
@ -14,6 +11,7 @@ import {
} from '../../../ts/data/data';
export type SnodeEdKey = string;
import { allowOnlyOneAtATime } from '../utils/Promise';
const MIN_NODES = 3;

@ -13,6 +13,71 @@ export class TaskTimedOutError extends Error {
}
}
// one action resolves all
const snodeGlobalLocks: any = {};
export async function allowOnlyOneAtATime(
name: string,
process: any,
timeoutMs?: number
) {
// if currently not in progress
if (snodeGlobalLocks[name] === undefined) {
// set lock
snodeGlobalLocks[name] = new Promise(async (resolve, reject) => {
// set up timeout feature
let timeoutTimer = null;
if (timeoutMs) {
timeoutTimer = setTimeout(() => {
window.log.warn(
`loki_primitives:::allowOnlyOneAtATime - TIMEDOUT after ${timeoutMs}s`
);
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
reject();
}, timeoutMs);
}
// do actual work
let innerRetVal;
try {
innerRetVal = await process();
} catch (e) {
if (typeof e === 'string') {
window.log.error(
`loki_primitives:::allowOnlyOneAtATime - error ${e}`
);
} else {
window.log.error(
`loki_primitives:::allowOnlyOneAtATime - error ${e.code} ${e.message}`
);
}
// clear timeout timer
if (timeoutMs) {
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
}
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
throw e;
}
// clear timeout timer
if (timeoutMs) {
if (timeoutTimer !== null) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
}
// tslint:disable-next-line: no-dynamic-delete
delete snodeGlobalLocks[name]; // clear lock
// release the kraken
resolve(innerRetVal);
});
}
return snodeGlobalLocks[name];
}
/**
* Create a promise which waits until `done` is called or until `timeout` period is reached.
* If `timeout` is reached then this will throw an Error.

@ -18,7 +18,6 @@ chai.should();
chai.use(chaiAsPromised as any);
chai.config.includeStack = true;
// FIXME audric
// From https://github.com/chaijs/chai/issues/200
chai.use((_chai, _) => {
_chai.Assertion.addMethod('withMessage', (msg: string) => {
@ -148,9 +147,6 @@ export class Common {
)}`,
],
});
// FIXME audric
// chaiAsPromised.transferPromiseness = app1.transferPromiseness;
await app1.start();
await app1.client.waitUntilWindowLoaded();

@ -9,7 +9,10 @@ import { ClosedGroupVisibleMessage } from '../../../../session/messages/outgoing
import { MockConversation } from '../../../test-utils/utils';
import { ConfigurationMessage } from '../../../../session/messages/outgoing/controlMessage/ConfigurationMessage';
import { ConversationModel } from '../../../../models/conversation';
import {
ConversationModel,
ConversationType,
} from '../../../../models/conversation';
import chaiAsPromised from 'chai-as-promised';
chai.use(chaiAsPromised as any);
@ -227,17 +230,17 @@ describe('Message Utils', () => {
let convos: Array<ConversationModel>;
const mockValidOpenGroup = new MockConversation({
type: 'public',
type: ConversationType.OPEN_GROUP,
id: 'publicChat:1@chat-dev.lokinet.org',
});
const mockValidOpenGroup2 = new MockConversation({
type: 'public',
type: ConversationType.OPEN_GROUP,
id: 'publicChat:1@chat-dev2.lokinet.org',
});
const mockValidClosedGroup = new MockConversation({
type: 'group',
type: ConversationType.OPEN_GROUP,
});
const mockValidPrivate = {

Loading…
Cancel
Save