@jsxc/jsxc
Version:
Real-time XMPP chat application with video calls, file transfer and encrypted communication
418 lines (335 loc) • 13 kB
text/typescript
import Log from '../../util/Log';
import { IContact } from '@src/Contact.interface';
import Message from '../../Message';
import { DIRECTION } from '../../Message.interface';
import Translation from '../../util/Translation';
import OTR from 'otr/lib/otr';
import DSA from 'otr/lib/dsa';
import { EncryptionState } from '../../plugin/AbstractPlugin';
import PersistentMap from '../../util/PersistentMap';
import OTRPlugin from './Plugin';
import { IConnection } from '@connection/Connection.interface';
import VerificationDialog from '@ui/dialogs/verification';
import IStorage from '@src/Storage.interface';
//@REVIEW
interface IOTR {
off: (name: string, func) => void;
on: (name: string, func) => void;
sendQueryMsg: () => void;
receiveMsg: (message: string, meta: any) => void;
sendMsg: (message: string, meta: any) => void;
endOtr: (func) => void;
smpSecret: (secret: string, question?: string) => void;
init;
_smInit;
ake;
their_priv_pk;
msgstate;
SEND_WHITESPACE_TAG;
WHITESPACE_START_AKE;
trust;
}
export default class Session {
private session: IOTR;
private data: PersistentMap;
private ourPayloadId: number;
private verificationDialog: VerificationDialog;
constructor(private peer: IContact, private key: DSA, private storage: IStorage, private connection: IConnection) {
let options: any = {
priv: key,
debug: true,
//smw: {} //@TODO enable worker for smp
};
this.session = new OTR(options);
if (options.SEND_WHITESPACE_TAG) {
this.session.SEND_WHITESPACE_TAG = true;
}
if (options.WHITESPACE_START_AKE) {
this.session.WHITESPACE_START_AKE = true;
}
this.session.on('status', status => {
this.saveSession();
// need this to get the right context in status handler
this.statusHandler(status);
});
this.session.on('smp', (type, result) => {
//@REVIEW do we need to save the session at this point?
this.smpHandler(type, result);
});
this.session.on('error', err => {
this.errorHandler(err);
});
this.session.on('io', (msg, meta) => {
this.saveSession();
if (typeof msg === 'string' && (typeof meta === 'undefined' || meta === null)) {
// Only process ake and other system messages. User messages will have a
// message object as meta data and are processed with a one-time handler.
this.sendOutgoingMessage(msg);
}
});
this.data = new PersistentMap(this.storage, 'otr', 'session', this.peer.getId());
this.data.registerHook('payload', newPayload => {
if (this.ourPayloadId !== newPayload.id) {
this.restoreSession(newPayload);
}
});
this.restoreSession(this.data.get('payload'));
}
public goEncrypted(): Promise<void> {
this.session.sendQueryMsg();
return Promise.resolve();
}
public goPlain(): Promise<void> {
return this.end();
}
public processMessage(message: Message, type: 'decryptMessage' | 'encryptMessage'): Promise<Message> {
let plaintextBody = message.getPlaintextMessage();
//@TODO test muc
//@TODO check for file upload url after decryption
if (plaintextBody) {
return this[type](message);
}
return Promise.resolve(message);
}
private encryptMessage(message: Message): Promise<Message> {
let self = this;
let messageId = message.getUid();
return new Promise((resolve, reject) => {
// we need this one-time handler for the promise
//@REVIEW maybe it's easier to add promises to the OTR lib
this.session.on('io', function handler(msg, message) {
if (message && message.getUid() === messageId) {
self.session.off('ui', handler);
self.afterEncryptMessage(msg, message, resolve);
}
});
this.session.sendMsg(message.getPlaintextMessage(), message);
});
}
private decryptMessage(message: Message): Promise<Message> {
let self = this;
let messageId = message.getUid();
return new Promise((resolve, reject) => {
this.session.on('ui', function handler(msg, encrypted, message) {
if (message && message.getUid() === messageId) {
self.session.off('ui', handler);
self.afterDecryptMessage(msg, encrypted, message, resolve);
}
});
this.session.receiveMsg(message.getPlaintextMessage(), message);
});
}
public end(): Promise<void> {
return new Promise((resolve, reject) => {
this.session.endOtr(() => {
this.session.init.call(this.session);
this.saveSession();
resolve();
});
});
}
public isEncrypted(): boolean {
return this.session.msgstate === OTR.CONST.MSGSTATE_ENCRYPTED;
}
public isEnded(): boolean {
return this.session.msgstate === OTR.CONST.MSGSTATE_FINISHED;
}
public getOwnFingerprint(): string {
return this.key.fingerprint();
}
public getTheirFingerprint(): string {
return this.session.their_priv_pk && this.session.their_priv_pk.fingerprint();
}
public isVerified(): boolean {
return !!this.session.trust;
}
public setVerified(verified: boolean) {
this.session.trust = verified;
this.saveSession();
if (verified) {
this.peer.setEncryptionState(EncryptionState.VerifiedEncrypted, OTRPlugin.getId());
} else {
this.peer.setEncryptionState(EncryptionState.UnverifiedEncrypted, OTRPlugin.getId());
}
}
public sendSMPRequest(secret: string, question?: string) {
this.session.smpSecret(secret, question);
}
private inform(messageString: string) {
this.peer.addSystemMessage(Translation.t(messageString));
}
private statusHandler = status => {
switch (status) {
case OTR.CONST.STATUS_SEND_QUERY:
this.inform('trying_to_start_private_conversation');
break;
case OTR.CONST.STATUS_AKE_SUCCESS:
let msgState = this.session.trust ? 'Verified' : 'Unverified';
this.inform(msgState + '_private_conversation_started');
this.peer.setEncryptionState(
this.session.trust ? EncryptionState.VerifiedEncrypted : EncryptionState.UnverifiedEncrypted,
OTRPlugin.getId()
);
break;
case OTR.CONST.STATUS_END_OTR:
if (this.session.msgstate === OTR.CONST.MSGSTATE_PLAINTEXT) {
// we aborted the private conversation
this.inform('private_conversation_aborted');
this.peer.setEncryptionState(EncryptionState.Plaintext, OTRPlugin.getId());
} else {
// the buddy abort the private conversation
this.inform('your_buddy_closed_the_private_conversation_you_should_do_the_same');
this.peer.setEncryptionState(EncryptionState.Ended, OTRPlugin.getId());
}
break;
case OTR.CONST.STATUS_SMP_HANDLE:
// jsxc.keepBusyAlive();
break;
}
};
private smpHandler = (type: string, data: boolean | string) => {
switch (type) {
case 'question': // verification request received
this.inform('Authentication_request_received');
//@TODO add link to above message which opens the verification dialog
let dialog = this.openVerificationDialog();
dialog.preFill(typeof data === 'string' ? data : undefined);
break;
case 'trust': // verification completed
this.setVerified(!!data);
if (data) {
this.inform('conversation_is_now_verified');
} else {
this.inform('authentication_failed');
}
break;
case 'abort':
this.closeVerificationDialog();
this.inform('Authentication_aborted');
break;
default:
Log.debug('[OTR] sm callback: Unknown type: ' + type);
}
};
private afterEncryptMessage = function (encryptedMessage: string, message: Message, resolve) {
if (this.session.msgstate === OTR.CONST.MSGSTATE_ENCRYPTED) {
message.setEncryptedPlaintextMessage(encryptedMessage);
message.setEncrypted(true);
} else if (this.peer.isEncrypted()) {
message.setErrorMessage(Translation.t('Message_was_not_encrypted'));
Log.warn('This message had to be encrypted.');
}
resolve(message);
};
private afterDecryptMessage = function (plaintextMessage: string, encrypted: boolean, message: Message, resolve) {
if (this.session.msgstate !== OTR.CONST.MSGSTATE_PLAINTEXT && !encrypted) {
message.setPlaintextMessage(Translation.t('Received_an_unencrypted_message') + '. [' + plaintextMessage + ']');
message.setDirection(DIRECTION.SYS);
} else {
message.setPlaintextMessage(plaintextMessage);
}
message.setEncrypted(encrypted);
resolve(message);
};
private sendOutgoingMessage(messageString: string) {
let message = new Message({
peer: this.peer.getJid(),
direction: Message.DIRECTION.OUT,
plaintextMessage: messageString,
});
this.connection.sendMessage(message);
}
private errorHandler = function (err) {
// Handle this case in jsxc.otr.receiveMessage
if (err !== 'Received an unencrypted message.') {
this.inform(err);
}
Log.error('[OTR] ' + err);
};
//@REVIEW optimization possible? Update only altered values. Maybe split this in two payloads (permanent/ephemeral)
private restoreSession = payload => {
if (this.session !== null || payload !== null) {
let key;
for (key in payload) {
if (payload.hasOwnProperty(key)) {
let val = JSON.parse(payload[key]);
if (key === 'their_priv_pk' && val !== null) {
val = DSA.parsePublic(val);
}
if (key === 'otr_version' && val !== null) {
this.session.ake.otr_version = val;
} else {
this.session[key] = val;
}
}
}
if (this.session.msgstate === 1 && this.session.their_priv_pk !== null) {
this.session._smInit.call(this.session);
}
}
let encryptionState = this.peer.getEncryptionState();
//@REVIEW can this be simplified?
if (
(encryptionState === EncryptionState.Plaintext && this.session.msgstate !== OTR.CONST.MSGSTATE_PLAINTEXT) ||
(encryptionState === EncryptionState.UnverifiedEncrypted &&
(this.session.msgstate !== OTR.CONST.MSGSTATE_ENCRYPTED || this.session.trust)) ||
(encryptionState === EncryptionState.VerifiedEncrypted &&
(this.session.msgstate !== OTR.CONST.MSGSTATE_ENCRYPTED || !this.session.trust)) ||
(encryptionState === EncryptionState.Ended && this.session.msgstate !== OTR.CONST.MSGSTATE_FINISHED)
) {
Log.warn('Expected encryption state does not match real message state');
}
};
private saveSession() {
let payload: any = {}; // return value
if (this.session === null) {
return;
}
// all variables which should be saved
let exportableKeys = [
'jid',
'our_instance_tag',
'msgstate',
'authstate',
'fragment',
'their_y',
'their_old_y',
'their_keyid',
'their_instance_tag',
'our_dh',
'our_old_dh',
'our_keyid',
'sessKeys',
'storedMgs',
'oldMacKeys',
'trust',
'transmittedRS',
'ssid',
'receivedPlaintext',
'authstate',
'send_interval',
];
for (let key of exportableKeys) {
payload[key] = JSON.stringify(this.session[key]);
}
if (this.session.their_priv_pk !== null) {
payload.their_priv_pk = JSON.stringify(this.session.their_priv_pk.packPublic());
}
if (this.session.ake.otr_version && this.session.ake.otr_version !== '') {
payload.otr_version = JSON.stringify(this.session.ake.otr_version);
}
this.ourPayloadId = payload.id = Math.random();
this.data.set('payload', payload);
}
private openVerificationDialog() {
this.closeVerificationDialog();
this.verificationDialog = new VerificationDialog(this.peer, this);
return this.verificationDialog;
}
private closeVerificationDialog() {
if (this.verificationDialog) {
this.verificationDialog.close();
this.verificationDialog = undefined;
}
}
}