Files
2026-10-24 17:46:20 +07:00

573 lines
20 KiB
JavaScript

var KeyHelper = libsignal.KeyHelper;
var ChatOmemo = {
refreshed: false,
initiateBundle: async function (devicesIds) {
var store = new ChatOmemoStorage();
store.removeAllSessions();
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
const bundle = {};
const localDeviceId = await store.getLocalRegistrationId();
const deviceId = localDeviceId ?? KeyHelper.generateRegistrationId();
bundle['identityKey'] = MovimUtils.arrayBufferToBase64(identityKeyPair.pubKey);
bundle['deviceId'] = deviceId;
store.setLocalRegistrationId(deviceId);
store.setIdentityKeyPair(identityKeyPair);
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SIGNED_PREKEY_ID);
store.storeSignedPreKey(signedPreKey.keyId, signedPreKey);
bundle['signedPreKey'] = {
'id': signedPreKey.keyId,
'publicKey': signedPreKey.keyPair.pubKey,
'signature': signedPreKey.signature
}
const keys = await Promise.all(MovimUtils.range(0, NUM_PREKEYS).map(id => KeyHelper.generatePreKey(id)));
keys.forEach(k => store.storePreKey(k.keyId, k.keyPair));
const preKeys = keys.map(k => ({ 'id': k.keyId, 'key': k.keyPair.pubKey }));
bundle['preKeys'] = preKeys;
ChatOmemo_ajaxNotifyGeneratedBundle();
ChatOmemo_ajaxAnnounceBundle(bundle, devicesIds);
ChatOmemo_ajaxGetDevicesList(USER_JID);
},
refreshBundle: async function (devicesIds, publishOnly) {
var store = new ChatOmemoStorage();
const bundle = {};
const localDeviceId = await store.getLocalRegistrationId();
if (!localDeviceId) ChatOmemo.initiateBundle(devicesIds);
// We get the base of the bundle from the store
let keyPair = await store.getIdentityKeyPair();
const deviceId = localDeviceId;
bundle['identityKey'] = MovimUtils.arrayBufferToBase64(keyPair.pubKey);
bundle['deviceId'] = deviceId;
let signedPreKey = store.loadCompleteSignedPreKey(SIGNED_PREKEY_ID);
bundle['signedPreKey'] = {
'id': signedPreKey.keyId,
'publicKey': MovimUtils.arrayBufferToBase64(signedPreKey.keyPair.pubKey),
'signature': MovimUtils.arrayBufferToBase64(signedPreKey.signature)
}
// We refresh or load all the preKeys
let keys = [];
let preKeys = [];
if (publishOnly) {
keys = await Promise.all(MovimUtils.range(0, NUM_PREKEYS).map(id => store.loadPreKey(id)));
let counter = -1;
preKeys = keys.map(k => {
counter++;
return { 'id': counter, 'key': MovimUtils.arrayBufferToBase64(k.pubKey) };
});
} else {
keys = await Promise.all(MovimUtils.range(0, NUM_PREKEYS).map(id => KeyHelper.generatePreKey(id)));
keys.forEach(k => store.storePreKey(k.keyId, k.keyPair));
preKeys = keys.map(k => ({ 'id': k.keyId, 'key': k.keyPair.pubKey }));
}
bundle['preKeys'] = preKeys;
ChatOmemo_ajaxAnnounceBundle(bundle, devicesIds);
},
ownDevicesReceived: async function (from, devicesIds) {
var store = new ChatOmemoStorage();
const localDeviceId = await store.getLocalRegistrationId();
devicesIds = Object.values(devicesIds);
// A key backup was restored (Account.omemoRestore): publish it with new
// prekeys and drop the device it replaced from our list
let restored = localStorage.getObject(OMEMO_RESTORED_KEY);
if (restored && localDeviceId != undefined) {
localStorage.removeItem(OMEMO_RESTORED_KEY);
devicesIds = devicesIds.filter(id => id != restored.replaced);
ChatOmemo.refreshBundle(devicesIds, false);
ChatOmemo.refreshed = true;
return;
}
if (localDeviceId == undefined) {
ChatOmemo.initiateBundle(devicesIds);
return;
}
if (!devicesIds.includes(localDeviceId.toString()) && ChatOmemo.refreshed == false) {
ChatOmemo.refreshBundle(devicesIds, true);
ChatOmemo.refreshed = true;
}
},
/**
* A contact send us a new device list, we check if we can already build a new session
*/
devicesReceived: async function (from, devicesIds) {
var store = new ChatOmemoStorage();
let localIds = store.getSessionsIds(from);
if (await store.getContactState(from)) {
for (deviceId of devicesIds.filter(key => !localIds.includes(key))) {
ChatOmemo_ajaxGetBundle(from, deviceId);
}
/**
* If we have open sessions from devices that doesn't exists anymore
* we close them
*/
for (deviceId of localIds.filter(key => !devicesIds.includes(key))) {
var address = new libsignal.SignalProtocolAddress(from, deviceId);
store.removeSession(address);
}
}
},
bundlesRefreshError(jid) {
var store = new ChatOmemoStorage();
store.setContactTombstone(jid);
ChatOmemo.disableContactState(jid);
},
bundlesRefreshed(jid) {
var store = new ChatOmemoStorage();
if (typeof Chat == 'undefined') return;
let textarea = Chat && Chat.getTextarea();
if (textarea) {
// First time we handle a session, we enforce the OMEMO state to true by default
if (!store.hasContactState(jid)) {
store.setContactState(jid, true);
}
if (textarea.dataset.jid == jid) {
Chat.setOmemoState('yes');
Chat.disableSending();
Chat_ajaxGet(jid);
if (textarea.value.length > 0) {
Chat.sendMessage();
}
}
}
},
handlePreKey: async function (jid, deviceId, preKey) {
var store = new ChatOmemoStorage();
const localDeviceId = await store.getLocalRegistrationId();
if (localDeviceId == undefined) return;
var address = new libsignal.SignalProtocolAddress(jid, deviceId);
const session = await store.loadSession(address.toString());
// If we already have a session we don't have to build it
if (session) {
var promise = Promise.resolve();
} else {
var sessionBuilder = new libsignal.SessionBuilder(store, address);
var promise = sessionBuilder.processPreKey({
registrationId: parseInt(deviceId, 10),
identityKey: MovimUtils.base64ToArrayBuffer(preKey.identitykey),
signedPreKey: {
keyId: parseInt(preKey.signedprekeyid, 10),
publicKey: MovimUtils.base64ToArrayBuffer(preKey.signedprekeypublic),
signature: MovimUtils.base64ToArrayBuffer(preKey.signedprekeysignature)
},
preKey: {
keyId: preKey.prekey.id,
publicKey: MovimUtils.base64ToArrayBuffer(preKey.prekey.value)
}
});
}
promise.then(function onsuccess() {
console.log('success ' + jid + ':' + deviceId);
if (typeof Chat == 'undefined') return;
let textarea = Chat.getTextarea();
if (textarea && textarea.dataset.jid == jid) {
Chat.setOmemoState('yes');
}
});
promise.catch(function onerror(error) {
console.log(error);
});
return promise;
},
encrypt: async function (to, plaintext, muc) {
var store = new ChatOmemoStorage();
// https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-messagesend
let iv = crypto.getRandomValues(new Uint8Array(12));
let key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
let algo = {
'name': 'AES-GCM',
'iv': iv,
'tagLength': 128
};
let encrypted = await crypto.subtle.encrypt(algo, key, MovimUtils.stringToArrayBuffer(plaintext));
let length = encrypted.byteLength - ((128 + 7) >> 3);
let ciphertext = encrypted.slice(0, length);
let tag = encrypted.slice(length);
let exportedKey = await crypto.subtle.exportKey('raw', key);
// obj
let keyAndTag = MovimUtils.appendArrayBuffer(exportedKey, tag);
let biv = MovimUtils.arrayBufferToBase64(iv);
let payload = MovimUtils.arrayBufferToBase64(ciphertext);
let deviceId = await store.getLocalRegistrationId();
let ownKeys = await this.encryptJid(keyAndTag, store.jid);
let remoteKeys = [];
if (muc) {
for (member of Chat.groupChatMembers) {
remoteKeys = remoteKeys.concat(await this.encryptJid(keyAndTag, member));
}
} else {
remoteKeys = await this.encryptJid(keyAndTag, to);
}
ownKeys = ownKeys.concat(remoteKeys);
let messageKeys = {};
ownKeys.map(result => {
messageKeys[result.device] = {
payload: btoa(result.payload.body),
prekey: 3 == parseInt(result.payload.type, 10)
};
});
return {
'sid': deviceId,
'keys': messageKeys,
'iv': biv,
'payload': payload
};
},
decrypt: async function (message) {
if (message.omemoheader == undefined) return;
var store = new ChatOmemoStorage();
let resolvedId = message.mine
? message.messageid
: message.id;
let maybeDecrypted = await ChatOmemoDB.getMessage(resolvedId);
if (maybeDecrypted !== undefined) {
return maybeDecrypted;
}
// Resolved jid from a muc message
var jid = message.mucjid ?? message.jidfrom;
// If there was an encryption tombstone on this contact we can lift it
store.removeContactTombstone(jid);
// No keys
if (message.omemoheader.keys == undefined) return;
let deviceId = await store.getLocalRegistrationId();
if (!deviceId) return;
/**
* Check if we need to build more sessions
*/
if (Chat) {
textarea = Chat.getTextarea();
if (!Boolean(textarea.dataset.muc)) {
for (bundleId of Object.keys(message.omemoheader.keys).filter(
key => !store.getSessionsIds(jid)
.concat(store.getOwnSessionsIds(), [deviceId.toString()]).includes(key)
)
) {
ChatOmemo_ajaxGetBundle(jid, bundleId);
}
}
}
if (message.omemoheader.keys[deviceId] == undefined) {
console.log('Message not encrypted for this device');
ChatOmemoDB.putMessage(resolvedId, false);
return;
}
let key = message.omemoheader.keys[deviceId];
let plainKey;
try {
plainKey = await this.decryptDevice(MovimUtils.base64ToArrayBuffer(key.payload), key.prekey, jid, message.omemoheader.sid);
} catch (err) {
ChatOmemoDB.putMessage(resolvedId, false);
console.log('Error during decryption: ' + err);
// Let the session heal, a new prekey will be send next time
//var address = new libsignal.SignalProtocolAddress(jid, message.omemoheader.sid);
//store.removeSession(address);
return;
}
let exportedAESKey = plainKey.slice(0, 16);
let authenticationTag = plainKey.slice(16);
if (authenticationTag.byteLength < 16) {
if (authenticationTag.byteLength > 0) {
throw new Error('Authentication tag too short');
}
console.log(`Authentication tag is only ${authenticationTag.byteLength} byte long`);
}
if (!message.omemoheader.payload) {
console.log('No payload to decrypt');
}
// One of our key was used, lets refresh the bundle
if (key.prekey) {
ChatOmemo.refreshBundle(store.getOwnSessionsIds());
}
let iv = MovimUtils.base64ToArrayBuffer(message.omemoheader.iv);
let ciphertextAndAuthenticationTag = MovimUtils.appendArrayBuffer(
MovimUtils.base64ToArrayBuffer(message.omemoheader.payload),
authenticationTag
);
let importedKey = await crypto.subtle.importKey('raw', exportedAESKey, 'AES-GCM', false, ['decrypt']);
let decryptedBuffer = await crypto.subtle.decrypt({
name: 'AES-GCM',
iv,
tagLength: 128
}, importedKey, ciphertextAndAuthenticationTag);
let plaintext = MovimUtils.arrayBufferToString(decryptedBuffer);
ChatOmemoDB.putMessage(resolvedId, plaintext);
return plaintext;
},
enableContactState: function (jid, muc) {
var store = new ChatOmemoStorage();
store.setContactState(jid, true);
if (muc) {
Chat_ajaxGetRoom(jid);
ChatOmemo_ajaxEnableRoomState();
Chat.groupChatMembers.forEach(member => {
if (!store.isJidResolved(member)) {
ChatOmemo_ajaxGetDevicesList(member);
}
});
} else {
Chat_ajaxGet(jid);
ChatOmemo_ajaxEnableContactState();
ChatOmemo_ajaxGetDevicesList(jid);
}
},
disableContactState: function (jid, muc) {
var store = new ChatOmemoStorage();
store.setContactState(jid, false);
Chat.setOmemoState("disabled");
if (muc) {
ChatOmemo_ajaxDisableRoomState();
} else {
ChatOmemo_ajaxDisableContactState();
}
},
getContactState: async function (jid) {
var store = new ChatOmemoStorage();
return store.getContactState(jid);
},
encryptJid: function (plaintext, jid) {
var store = new ChatOmemoStorage();
let promises = store.filter('.session' + jid)
.map(key => key.split(/[\s.]+/).pop())
.map(deviceId => store.getSessionState(jid + '.' + deviceId).then(state => {
if (state) {
return this.encryptDevice(plaintext, jid, deviceId);
}
return Promise.resolve(false);
}));
return Promise.all(promises).then(result => {
return result.filter(encrypted => encrypted !== false);
});
},
encryptDevice: function (plaintext, jid, deviceId) {
var address = new libsignal.SignalProtocolAddress(jid, parseInt(deviceId, 10));
var store = new ChatOmemoStorage();
var sessionCipher = new libsignal.SessionCipher(store, address);
return sessionCipher.encrypt(plaintext)
.then(payload => ({ 'payload': payload, 'device': deviceId }));
},
decryptDevice: async function (ciphertext, preKey, jid, deviceId) {
var address = new libsignal.SignalProtocolAddress(jid, parseInt(deviceId, 10));
var store = new ChatOmemoStorage();
var sessionCipher = new libsignal.SessionCipher(store, address);
return (preKey)
? await sessionCipher.decryptPreKeyWhisperMessage(ciphertext, 'binary')
: await sessionCipher.decryptWhisperMessage(ciphertext, 'binary');
},
// Decrypted files of this page: aesgcm URL → blob URL
decryptedFiles: new Map(),
/**
* Render an aesgcm:// message (XEP-0454) in p: pictures and audio inline,
* other files as a download link. Returns false if it is not one.
* Built with the DOM: the URL comes from the contact.
*/
renderEncryptedFile: function (p, plaintext) {
let encryptedUrl = plaintext.trim();
let matches = encryptedUrl.match(AESGCM_REGEX);
if (!matches) return false;
let [, , filename, extension] = matches;
let type = AESGCM_TYPES[extension.toLowerCase()] ?? '';
p.textContent = '';
if (type.startsWith('image/') || type.startsWith('audio/')) {
let media = document.createElement(type.startsWith('image/') ? 'img' : 'audio');
media.className = 'encrypted_media';
media.title = filename;
if (media.tagName == 'AUDIO') media.controls = true;
p.appendChild(media);
ChatOmemo.decryptFile(encryptedUrl).then(url => {
media.src = url;
if (media.tagName == 'IMG') media.onclick = () => window.open(url, '_blank');
}).catch(e => {
console.log('Cannot decrypt ' + filename, e);
p.textContent = '';
p.appendChild(ChatOmemo.encryptedFileLink(encryptedUrl, filename));
});
} else {
p.appendChild(ChatOmemo.encryptedFileLink(encryptedUrl, filename));
}
return true;
},
encryptedFileLink: function (encryptedUrl, filename) {
let fragment = document.createDocumentFragment();
let icon = document.createElement('i');
icon.className = 'material-symbols';
icon.textContent = 'file_download';
let link = document.createElement('a');
link.href = '#';
link.className = 'encrypted_file';
link.textContent = filename;
link.onclick = e => {
e.preventDefault();
ChatOmemo.getEncryptedFile(encryptedUrl);
};
fragment.append(icon, ' ', link);
return fragment;
},
// Fetch and decrypt an aesgcm:// file, returns a blob URL (cached)
decryptFile: async function (encryptedUrl) {
if (ChatOmemo.decryptedFiles.has(encryptedUrl)) {
return ChatOmemo.decryptedFiles.get(encryptedUrl);
}
let [, url, , extension, hash] = encryptedUrl.match(AESGCM_REGEX);
let response = await fetch('https://' + url);
if (!response.ok) throw new Error('HTTP ' + response.status);
let cipher = await response.arrayBuffer();
const iv = hash.slice(0, hash.length - 64);
const key = hash.slice(hash.length - 64);
const keyObj = await crypto.subtle.importKey('raw', MovimUtils.hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
const algo = {
'name': 'AES-GCM',
'iv': MovimUtils.hexToArrayBuffer(iv),
};
let plainFile = await crypto.subtle.decrypt(algo, keyObj, cipher);
let blobUrl = URL.createObjectURL(new Blob([plainFile], { type: AESGCM_TYPES[extension.toLowerCase()] ?? 'application/octet-stream' }));
ChatOmemo.decryptedFiles.set(encryptedUrl, blobUrl);
return blobUrl;
},
getEncryptedFile: async function (encryptedUrl) {
Chat.enableSending();
let [, , filename] = encryptedUrl.match(AESGCM_REGEX);
try {
const link = document.createElement('a');
link.href = await ChatOmemo.decryptFile(encryptedUrl);
link.download = filename;
link.click();
} catch (e) {
console.log('Cannot get the following file: ' + filename, e);
}
Chat.disableSending();
},
resolveContactFingerprints: function (jid) {
let omemoStorage = new ChatOmemoStorage;
let sessionLoaded = [];
omemoStorage.getSessionsIds(jid).forEach(deviceId => {
var address = new libsignal.SignalProtocolAddress(jid, deviceId);
sessionLoaded.push(omemoStorage.loadSession(address.toString()));
});
const promise = new Promise((resolve) => {
Promise.all(sessionLoaded).then(sessions => {
let remoteKeys = [];
sessions.forEach(session => {
let parsed = Object.values(JSON.parse(session).sessions)[0];
remoteKeys.push({
jid: jid,
self: false,
bundleid: parsed.registrationId.toString(),
fingerprint: btoa(parsed.indexInfo.remoteIdentityKey)
});
});
resolve(remoteKeys);
});
});
return promise;
}
}
MovimWebsocket.attach(async () => {
var store = new ChatOmemoStorage();
const localDeviceId = await store.getLocalRegistrationId();
if (!localDeviceId || localStorage.getItem(OMEMO_RESTORED_KEY)) {
ChatOmemo_ajaxGetDevicesList(USER_JID);
}
});