converse.js
Version:
Browser based XMPP chat client
310 lines (269 loc) • 11.4 kB
JavaScript
/**
* @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
*/
import { Model } from "@converse/skeletor";
import { generateDeviceID } from "./utils.js";
import { _converse, api, converse, log } from "@converse/headless";
const { Strophe, stx, u } = converse.env;
class OMEMOStore extends Model {
get Direction() {
return {
SENDING: 1,
RECEIVING: 2,
};
}
getIdentityKeyPair() {
const keypair = this.get("identity_keypair");
return Promise.resolve({
"privKey": u.base64ToArrayBuffer(keypair.privKey),
"pubKey": u.base64ToArrayBuffer(keypair.pubKey),
});
}
getLocalRegistrationId() {
return Promise.resolve(parseInt(this.get("device_id"), 10));
}
isTrustedIdentity(identifier, identity_key, _direction) {
if (identifier === null || identifier === undefined) {
throw new Error("Can't check identity key for invalid key");
}
if (!(identity_key instanceof ArrayBuffer)) {
throw new Error("Expected identity_key to be an ArrayBuffer");
}
const trusted = this.get("identity_key" + identifier);
if (trusted === undefined) {
return Promise.resolve(true);
}
return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
}
loadIdentityKey(identifier) {
if (identifier === null || identifier === undefined) {
throw new Error("Can't load identity_key for invalid identifier");
}
return Promise.resolve(u.base64ToArrayBuffer(this.get("identity_key" + identifier)));
}
saveIdentity(identifier, identity_key) {
if (identifier === null || identifier === undefined) {
throw new Error("Can't save identity_key for invalid identifier");
}
const { libsignal } = /** @type WindowWithLibsignal */ (window);
const address = new libsignal.SignalProtocolAddress.fromString(identifier);
const existing = this.get("identity_key" + address.getName());
const b64_idkey = u.arrayBufferToBase64(identity_key);
this.save("identity_key" + address.getName(), b64_idkey);
if (existing && b64_idkey !== existing) {
return Promise.resolve(true);
} else {
return Promise.resolve(false);
}
}
getPreKeys() {
return this.get("prekeys") || {};
}
loadPreKey(key_id) {
const res = this.getPreKeys()[key_id];
if (res) {
return Promise.resolve({
"privKey": u.base64ToArrayBuffer(res.privKey),
"pubKey": u.base64ToArrayBuffer(res.pubKey),
});
}
return Promise.resolve();
}
storePreKey(key_id, key_pair) {
const prekey = {};
prekey[key_id] = {
"pubKey": u.arrayBufferToBase64(key_pair.pubKey),
"privKey": u.arrayBufferToBase64(key_pair.privKey),
};
this.save("prekeys", Object.assign(this.getPreKeys(), prekey));
return Promise.resolve();
}
removePreKey(key_id) {
const prekeys = { ...this.getPreKeys() };
delete prekeys[key_id];
this.save("prekeys", prekeys);
return Promise.resolve();
}
loadSignedPreKey(_keyId) {
const res = this.get("signed_prekey");
if (res) {
return Promise.resolve({
"privKey": u.base64ToArrayBuffer(res.privKey),
"pubKey": u.base64ToArrayBuffer(res.pubKey),
});
}
return Promise.resolve();
}
storeSignedPreKey(spk) {
if (typeof spk !== "object") {
// XXX: We've changed the signature of this method from the
// example given in InMemorySignalProtocolStore.
// Should be fine because the libsignal code doesn't
// actually call this method.
throw new Error("storeSignedPreKey: expected an object");
}
this.save("signed_prekey", {
"id": spk.keyId,
"privKey": u.arrayBufferToBase64(spk.keyPair.privKey),
"pubKey": u.arrayBufferToBase64(spk.keyPair.pubKey),
// XXX: The InMemorySignalProtocolStore does not pass
// in or store the signature, but we need it when we
// publish our bundle and this method isn't called from
// within libsignal code, so we modify it to also store
// the signature.
"signature": u.arrayBufferToBase64(spk.signature),
});
return Promise.resolve();
}
removeSignedPreKey(key_id) {
if (this.get("signed_prekey")["id"] === key_id) {
this.unset("signed_prekey");
this.save();
}
return Promise.resolve();
}
loadSession(identifier) {
return Promise.resolve(this.get("session" + identifier));
}
storeSession(identifier, record) {
return Promise.resolve(this.save("session" + identifier, record));
}
removeSession(identifier) {
return Promise.resolve(this.unset("session" + identifier));
}
removeAllSessions(identifier) {
const keys = Object.keys(this.attributes).filter((key) =>
key.startsWith("session" + identifier) ? key : false
);
const attrs = {};
keys.forEach((key) => {
attrs[key] = undefined;
});
this.save(attrs);
return Promise.resolve();
}
publishBundle() {
const signed_prekey = this.get("signed_prekey");
const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get("device_id")}`;
const item = stx`
<item>
<bundle xmlns="${Strophe.NS.OMEMO}">
<signedPreKeyPublic signedPreKeyId="${signed_prekey.id}">${signed_prekey.pubKey}</signedPreKeyPublic>
<signedPreKeySignature>${signed_prekey.signature}</signedPreKeySignature>
<identityKey>${this.get("identity_keypair").pubKey}</identityKey>
<prekeys>${Object.values(this.get("prekeys")).map(
(prekey, id) => stx`<preKeyPublic preKeyId="${id}">${prekey.pubKey}</preKeyPublic>`
)}
</prekeys>
</bundle>
</item>`;
const options = { access_model: "open" };
return api.pubsub.publish(null, node, item, options, false);
}
async generateMissingPreKeys() {
const { libsignal } = /** @type WindowWithLibsignal */ (window);
const { KeyHelper } = libsignal;
const prekeyIds = Object.keys(this.getPreKeys());
const missing_keys = Array.from({ length: _converse.NUM_PREKEYS }, (_, id) => id.toString()).filter(
(id) => !prekeyIds.includes(id)
);
if (missing_keys.length < 1) {
log.debug("No missing prekeys to generate for our own device");
return Promise.resolve();
}
const keys = await Promise.all(missing_keys.map((id) => KeyHelper.generatePreKey(parseInt(id, 10))));
keys.forEach((k) => this.storePreKey(k.keyId, k.keyPair));
const prekeys = this.getPreKeys();
const marshalled_keys = Object.keys(prekeys).map((id) => ({
id,
key: prekeys[id].pubKey,
}));
const bare_jid = _converse.session.get("bare_jid");
const devicelist = await api.omemo.devicelists.get(bare_jid);
const device = devicelist.devices.get(this.get("device_id"));
const bundle = await device.getBundle();
device.save("bundle", Object.assign(bundle, { "prekeys": marshalled_keys }));
}
/**
* Generates, stores and then returns pre-keys.
*
* Pre-keys are one half of a X3DH key exchange and are published as part
* of the device bundle.
*
* For a new contact or device to establish an encrypted session, it needs
* to use a pre-key, which it chooses randomly from the list of available
* ones.
*/
async generatePreKeys() {
const amount = _converse.NUM_PREKEYS;
const { libsignal } = /** @type WindowWithLibsignal */ (window);
const { KeyHelper } = libsignal;
const keys = await Promise.all([...Array(amount).keys()].map((id) => KeyHelper.generatePreKey(id)));
keys.forEach((k) => this.storePreKey(k.keyId, k.keyPair));
return keys.map((k) => ({
id: k.keyId,
key: u.arrayBufferToBase64(k.keyPair.pubKey),
}));
}
/**
* Generate the cryptographic data used by the X3DH key agreement protocol
* in order to build a session with other devices.
*
* By generating a bundle, and publishing it via PubSub, we allow other
* clients to download it and start asynchronous encrypted sessions with us,
* even if we're offline at that time.
*/
async generateBundle() {
const { libsignal } = /** @type WindowWithLibsignal */ (window);
// The first thing that needs to happen if a client wants to
// start using OMEMO is they need to generate an IdentityKey
// and a Device ID.
// The IdentityKey is a Curve25519 public/private Key pair.
const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
// The Device ID is a randomly generated integer between 1 and 2^31 - 1.
const device_id = await generateDeviceID();
this.save({
device_id,
identity_key,
identity_keypair: {
privKey: u.arrayBufferToBase64(identity_keypair.privKey),
pubKey: identity_key,
},
});
const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
this.storeSignedPreKey(signed_prekey);
const prekeys = await this.generatePreKeys();
const bundle = { identity_key, device_id, prekeys };
bundle["signed_prekey"] = {
id: signed_prekey.keyId,
public_key: u.arrayBufferToBase64(signed_prekey.keyPair.pubKey),
signature: u.arrayBufferToBase64(signed_prekey.signature),
};
const bare_jid = _converse.session.get("bare_jid");
const devicelist = await api.omemo.devicelists.get(bare_jid);
const device = await devicelist.devices.create({ id: bundle.device_id, "jid": bare_jid }, { promise: true });
device.save("bundle", bundle);
}
fetchSession() {
if (this._setup_promise === undefined) {
this._setup_promise = new Promise((resolve, reject) => {
this.fetch({
success: () => {
if (!this.get("device_id")) {
this.generateBundle().then(resolve).catch(reject);
} else {
resolve();
}
},
error: (_model, resp) => {
log.warn(`Could restore OMEMO session, we'll generate a new one: ${resp}`);
this.generateBundle().then(resolve).catch(reject);
},
});
});
}
return this._setup_promise;
}
}
export default OMEMOStore;