mirror of
https://github.com/oxen-io/session-desktop.git
synced 2023-12-14 02:12:57 +01:00
496afa85cb
Remove LokiSnodeChannel as we weren't using it.
477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
/* global
|
|
window,
|
|
libsignal,
|
|
textsecure,
|
|
StringView,
|
|
Multibase,
|
|
TextEncoder,
|
|
TextDecoder,
|
|
crypto,
|
|
dcodeIO
|
|
*/
|
|
|
|
// eslint-disable-next-line func-names
|
|
(function() {
|
|
window.libloki = window.libloki || {};
|
|
|
|
class FallBackDecryptionError extends Error {}
|
|
|
|
const IV_LENGTH = 16;
|
|
const NONCE_LENGTH = 12;
|
|
|
|
async function DHEncrypt(symmetricKey, plainText) {
|
|
const iv = libsignal.crypto.getRandomBytes(IV_LENGTH);
|
|
const ciphertext = await libsignal.crypto.encrypt(
|
|
symmetricKey,
|
|
plainText,
|
|
iv
|
|
);
|
|
const ivAndCiphertext = new Uint8Array(
|
|
iv.byteLength + ciphertext.byteLength
|
|
);
|
|
ivAndCiphertext.set(new Uint8Array(iv));
|
|
ivAndCiphertext.set(new Uint8Array(ciphertext), iv.byteLength);
|
|
return ivAndCiphertext;
|
|
}
|
|
|
|
async function EncryptGCM(symmetricKey, plaintext) {
|
|
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
symmetricKey,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['encrypt']
|
|
);
|
|
|
|
const ciphertext = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
|
|
key,
|
|
plaintext
|
|
);
|
|
|
|
const ivAndCiphertext = new Uint8Array(
|
|
NONCE_LENGTH + ciphertext.byteLength
|
|
);
|
|
|
|
ivAndCiphertext.set(nonce);
|
|
ivAndCiphertext.set(new Uint8Array(ciphertext), nonce.byteLength);
|
|
|
|
return ivAndCiphertext;
|
|
}
|
|
|
|
async function DecryptGCM(symmetricKey, ivAndCiphertext) {
|
|
const nonce = ivAndCiphertext.slice(0, NONCE_LENGTH);
|
|
const ciphertext = ivAndCiphertext.slice(NONCE_LENGTH);
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
symmetricKey,
|
|
{ name: 'AES-GCM' },
|
|
false,
|
|
['decrypt']
|
|
);
|
|
|
|
return crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: nonce },
|
|
key,
|
|
ciphertext
|
|
);
|
|
}
|
|
|
|
async function DHDecrypt(symmetricKey, ivAndCiphertext) {
|
|
const iv = ivAndCiphertext.slice(0, IV_LENGTH);
|
|
const ciphertext = ivAndCiphertext.slice(IV_LENGTH);
|
|
return libsignal.crypto.decrypt(symmetricKey, ciphertext, iv);
|
|
}
|
|
|
|
class FallBackSessionCipher {
|
|
constructor(address) {
|
|
this.identityKeyString = address.getName();
|
|
this.pubKey = StringView.hexToArrayBuffer(address.getName());
|
|
}
|
|
|
|
// Should we use ephemeral key pairs here rather than long term keys on each side?
|
|
async encrypt(plaintext) {
|
|
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
|
|
if (!myKeyPair) {
|
|
throw new Error('Failed to get keypair for encryption');
|
|
}
|
|
const myPrivateKey = myKeyPair.privKey;
|
|
const symmetricKey = libsignal.Curve.calculateAgreement(
|
|
this.pubKey,
|
|
myPrivateKey
|
|
);
|
|
const ivAndCiphertext = await DHEncrypt(symmetricKey, plaintext);
|
|
return {
|
|
type: textsecure.protobuf.Envelope.Type.FRIEND_REQUEST,
|
|
body: ivAndCiphertext,
|
|
registrationId: null,
|
|
};
|
|
}
|
|
|
|
async decrypt(ivAndCiphertext) {
|
|
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
|
|
if (!myKeyPair) {
|
|
throw new Error('Failed to get keypair for decryption');
|
|
}
|
|
const myPrivateKey = myKeyPair.privKey;
|
|
const symmetricKey = libsignal.Curve.calculateAgreement(
|
|
this.pubKey,
|
|
myPrivateKey
|
|
);
|
|
try {
|
|
return await DHDecrypt(symmetricKey, ivAndCiphertext);
|
|
} catch (e) {
|
|
throw new FallBackDecryptionError(
|
|
`Could not decrypt message from ${
|
|
this.identityKeyString
|
|
} using FallBack encryption.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const base32zIndex = Multibase.names.indexOf('base32z');
|
|
const base32zCode = Multibase.codes[base32zIndex];
|
|
|
|
function decodeSnodeAddressToPubKey(snodeAddress) {
|
|
const snodeAddressClean = snodeAddress
|
|
.replace('.snode', '')
|
|
.replace('https://', '')
|
|
.replace('http://', '');
|
|
return Multibase.decode(`${base32zCode}${snodeAddressClean}`);
|
|
}
|
|
|
|
function generateEphemeralKeyPair() {
|
|
const keys = libsignal.Curve.generateKeyPair();
|
|
// Signal protocol prepends with "0x05"
|
|
keys.pubKey = keys.pubKey.slice(1);
|
|
return keys;
|
|
}
|
|
|
|
async function generateSignatureForPairing(secondaryPubKey, type) {
|
|
const pubKeyArrayBuffer = StringView.hexToArrayBuffer(secondaryPubKey);
|
|
// Make sure the signature includes the pairing action (pairing or unpairing)
|
|
const len = pubKeyArrayBuffer.byteLength;
|
|
const data = new Uint8Array(len + 1);
|
|
data.set(new Uint8Array(pubKeyArrayBuffer), 0);
|
|
data[len] = type;
|
|
|
|
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
|
|
if (!myKeyPair) {
|
|
throw new Error('Failed to get keypair for pairing signature generation');
|
|
}
|
|
const signature = await libsignal.Curve.async.calculateSignature(
|
|
myKeyPair.privKey,
|
|
data.buffer
|
|
);
|
|
return signature;
|
|
}
|
|
|
|
async function verifyAuthorisation(authorisation) {
|
|
const {
|
|
primaryDevicePubKey,
|
|
secondaryDevicePubKey,
|
|
requestSignature,
|
|
grantSignature,
|
|
} = authorisation;
|
|
const isGrant = !!grantSignature;
|
|
if (!primaryDevicePubKey || !secondaryDevicePubKey) {
|
|
window.log.warn(
|
|
'Received a pairing request with missing pubkeys. Ignored.'
|
|
);
|
|
return false;
|
|
} else if (!requestSignature) {
|
|
window.log.warn(
|
|
'Received a pairing request with missing request signature. Ignored.'
|
|
);
|
|
return false;
|
|
}
|
|
const verify = async (signature, signatureType) => {
|
|
const encoding = typeof signature === 'string' ? 'base64' : undefined;
|
|
await this.verifyPairingSignature(
|
|
primaryDevicePubKey,
|
|
secondaryDevicePubKey,
|
|
dcodeIO.ByteBuffer.wrap(signature, encoding).toArrayBuffer(),
|
|
signatureType
|
|
);
|
|
};
|
|
try {
|
|
await verify(requestSignature, PairingType.REQUEST);
|
|
} catch (e) {
|
|
window.log.warn(
|
|
'Could not verify pairing request authorisation signature. Ignoring message.'
|
|
);
|
|
window.log.error(e);
|
|
return false;
|
|
}
|
|
// can't have grant without requestSignature?
|
|
if (isGrant) {
|
|
try {
|
|
await verify(grantSignature, PairingType.GRANT);
|
|
} catch (e) {
|
|
window.log.warn(
|
|
'Could not verify pairing grant authorisation signature. Ignoring message.'
|
|
);
|
|
window.log.error(e);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// FIXME: rename to include the fact it's relative to YOUR device
|
|
async function validateAuthorisation(authorisation) {
|
|
const {
|
|
primaryDevicePubKey,
|
|
secondaryDevicePubKey,
|
|
grantSignature,
|
|
} = authorisation;
|
|
const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice');
|
|
const ourPubKey = textsecure.storage.user.getNumber();
|
|
const isRequest = !grantSignature;
|
|
if (isRequest && alreadySecondaryDevice) {
|
|
window.log.warn(
|
|
'Received a pairing request while being a secondary device. Ignored.'
|
|
);
|
|
return false;
|
|
} else if (isRequest && primaryDevicePubKey !== ourPubKey) {
|
|
window.log.warn(
|
|
'Received a pairing request addressed to another pubkey. Ignored.'
|
|
);
|
|
return false;
|
|
} else if (isRequest && secondaryDevicePubKey === ourPubKey) {
|
|
window.log.warn('Received a pairing request from ourselves. Ignored.');
|
|
return false;
|
|
}
|
|
return this.verifyAuthorisation(authorisation);
|
|
}
|
|
|
|
async function verifyPairingSignature(
|
|
primaryDevicePubKey,
|
|
secondaryPubKey,
|
|
signature,
|
|
type
|
|
) {
|
|
const secondaryPubKeyArrayBuffer = StringView.hexToArrayBuffer(
|
|
secondaryPubKey
|
|
);
|
|
const primaryDevicePubKeyArrayBuffer = StringView.hexToArrayBuffer(
|
|
primaryDevicePubKey
|
|
);
|
|
const len = secondaryPubKeyArrayBuffer.byteLength;
|
|
const data = new Uint8Array(len + 1);
|
|
// For REQUEST type message, the secondary device signs the primary device pubkey
|
|
// For GRANT type message, the primary device signs the secondary device pubkey
|
|
let issuer;
|
|
if (type === PairingType.GRANT) {
|
|
data.set(new Uint8Array(secondaryPubKeyArrayBuffer));
|
|
issuer = primaryDevicePubKeyArrayBuffer;
|
|
} else if (type === PairingType.REQUEST) {
|
|
data.set(new Uint8Array(primaryDevicePubKeyArrayBuffer));
|
|
issuer = secondaryPubKeyArrayBuffer;
|
|
}
|
|
data[len] = type;
|
|
// Throws for invalid signature
|
|
await libsignal.Curve.async.verifySignature(issuer, data.buffer, signature);
|
|
}
|
|
async function decryptToken({ cipherText64, serverPubKey64 }) {
|
|
const ivAndCiphertext = new Uint8Array(
|
|
dcodeIO.ByteBuffer.fromBase64(cipherText64).toArrayBuffer()
|
|
);
|
|
|
|
const serverPubKey = new Uint8Array(
|
|
dcodeIO.ByteBuffer.fromBase64(serverPubKey64).toArrayBuffer()
|
|
);
|
|
const keyPair = await textsecure.storage.protocol.getIdentityKeyPair();
|
|
if (!keyPair) {
|
|
throw new Error('Failed to get keypair for token decryption');
|
|
}
|
|
const { privKey } = keyPair;
|
|
const symmetricKey = libsignal.Curve.calculateAgreement(
|
|
serverPubKey,
|
|
privKey
|
|
);
|
|
|
|
const token = await DHDecrypt(symmetricKey, ivAndCiphertext);
|
|
|
|
const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8');
|
|
return tokenString;
|
|
}
|
|
|
|
const sha512 = data => crypto.subtle.digest('SHA-512', data);
|
|
|
|
const PairingType = Object.freeze({
|
|
REQUEST: 1,
|
|
GRANT: 2,
|
|
});
|
|
|
|
/**
|
|
* A wrapper around Signal's SessionCipher.
|
|
* This handles specific session reset logic that we need.
|
|
*/
|
|
class LokiSessionCipher {
|
|
constructor(storage, protocolAddress) {
|
|
this.storage = storage;
|
|
this.protocolAddress = protocolAddress;
|
|
this.sessionCipher = new libsignal.SessionCipher(
|
|
storage,
|
|
protocolAddress
|
|
);
|
|
this.TYPE = Object.freeze({
|
|
MESSAGE: 1,
|
|
PREKEY: 2,
|
|
});
|
|
}
|
|
|
|
decryptWhisperMessage(buffer, encoding) {
|
|
return this._decryptMessage(this.TYPE.MESSAGE, buffer, encoding);
|
|
}
|
|
|
|
decryptPreKeyWhisperMessage(buffer, encoding) {
|
|
return this._decryptMessage(this.TYPE.PREKEY, buffer, encoding);
|
|
}
|
|
|
|
async _decryptMessage(type, buffer, encoding) {
|
|
// Capture active session
|
|
const activeSessionBaseKey = await this._getCurrentSessionBaseKey();
|
|
|
|
if (type === this.TYPE.PREKEY && !activeSessionBaseKey) {
|
|
const wrapped = dcodeIO.ByteBuffer.wrap(buffer);
|
|
await window.libloki.storage.verifyFriendRequestAcceptPreKey(
|
|
this.protocolAddress.getName(),
|
|
wrapped
|
|
);
|
|
}
|
|
|
|
const decryptFunction =
|
|
type === this.TYPE.PREKEY
|
|
? this.sessionCipher.decryptPreKeyWhisperMessage
|
|
: this.sessionCipher.decryptWhisperMessage;
|
|
const result = await decryptFunction(buffer, encoding);
|
|
|
|
// Handle session reset
|
|
// This needs to be done synchronously so that the next time we decrypt a message,
|
|
// we have the correct session
|
|
try {
|
|
await this._handleSessionResetIfNeeded(activeSessionBaseKey);
|
|
} catch (e) {
|
|
window.log.info('Failed to handle session reset: ', e);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async _handleSessionResetIfNeeded(previousSessionBaseKey) {
|
|
if (!previousSessionBaseKey) {
|
|
return;
|
|
}
|
|
|
|
let conversation;
|
|
try {
|
|
conversation = await window.ConversationController.getOrCreateAndWait(
|
|
this.protocolAddress.getName(),
|
|
'private'
|
|
);
|
|
} catch (e) {
|
|
window.log.info(
|
|
'Error getting conversation: ',
|
|
this.protocolAddress.getName()
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (conversation.isSessionResetOngoing()) {
|
|
const currentSessionBaseKey = await this._getCurrentSessionBaseKey();
|
|
if (currentSessionBaseKey !== previousSessionBaseKey) {
|
|
if (conversation.isSessionResetReceived()) {
|
|
// The other user used an old session to contact us; wait for them to switch to a new one.
|
|
await this._restoreSession(previousSessionBaseKey);
|
|
} else {
|
|
// Our session reset was successful; we initiated one and got a new session back from the other user.
|
|
await this._deleteAllSessionExcept(currentSessionBaseKey);
|
|
await conversation.onNewSessionAdopted();
|
|
}
|
|
} else if (conversation.isSessionResetReceived()) {
|
|
// Our session reset was successful; we received a message with the same session from the other user.
|
|
await this._deleteAllSessionExcept(previousSessionBaseKey);
|
|
await conversation.onNewSessionAdopted();
|
|
}
|
|
}
|
|
}
|
|
|
|
async _getCurrentSessionBaseKey() {
|
|
const record = await this.sessionCipher.getRecord(
|
|
this.protocolAddress.toString()
|
|
);
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
const openSession = record.getOpenSession();
|
|
if (!openSession) {
|
|
return null;
|
|
}
|
|
const { baseKey } = openSession.indexInfo;
|
|
return baseKey;
|
|
}
|
|
|
|
async _restoreSession(sessionBaseKey) {
|
|
const record = await this.sessionCipher.getRecord(
|
|
this.protocolAddress.toString()
|
|
);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
record.archiveCurrentState();
|
|
|
|
const sessionToRestore = record.sessions[sessionBaseKey];
|
|
if (!sessionToRestore) {
|
|
throw new Error(`Cannot find session with base key ${sessionBaseKey}`);
|
|
}
|
|
|
|
record.promoteState(sessionToRestore);
|
|
record.updateSessionState(sessionToRestore);
|
|
await this.storage.storeSession(
|
|
this.protocolAddress.toString(),
|
|
record.serialize()
|
|
);
|
|
}
|
|
|
|
async _deleteAllSessionExcept(sessionBaseKey) {
|
|
const record = await this.sessionCipher.getRecord(
|
|
this.protocolAddress.toString()
|
|
);
|
|
if (!record) {
|
|
return;
|
|
}
|
|
const sessionToKeep = record.sessions[sessionBaseKey];
|
|
record.sessions = {};
|
|
record.updateSessionState(sessionToKeep);
|
|
await this.storage.storeSession(
|
|
this.protocolAddress.toString(),
|
|
record.serialize()
|
|
);
|
|
}
|
|
}
|
|
|
|
window.libloki.crypto = {
|
|
DHEncrypt,
|
|
EncryptGCM, // AES-GCM
|
|
DHDecrypt,
|
|
DecryptGCM, // AES-GCM
|
|
FallBackSessionCipher,
|
|
FallBackDecryptionError,
|
|
decryptToken,
|
|
generateSignatureForPairing,
|
|
verifyPairingSignature,
|
|
verifyAuthorisation,
|
|
validateAuthorisation,
|
|
PairingType,
|
|
LokiSessionCipher,
|
|
generateEphemeralKeyPair,
|
|
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,
|
|
sha512,
|
|
};
|
|
})();
|