libsignal
Version:
Open Whisper Systems' libsignal for Node.js
335 lines (311 loc) • 14.2 kB
JavaScript
// vim: ts=4:sw=4:expandtab
const ChainType = require('./chain_type');
const ProtocolAddress = require('./protocol_address');
const SessionBuilder = require('./session_builder');
const SessionRecord = require('./session_record');
const crypto = require('./crypto');
const curve = require('./curve');
const errors = require('./errors');
const protobufs = require('./protobufs');
const queueJob = require('./queue_job');
const VERSION = 3;
function assertBuffer(value) {
if (!(value instanceof Buffer)) {
throw TypeError(`Expected Buffer instead of: ${value.constructor.name}`);
}
return value;
}
class SessionCipher {
constructor(storage, protocolAddress) {
if (!(protocolAddress instanceof ProtocolAddress)) {
throw new TypeError("protocolAddress must be a ProtocolAddress");
}
this.addr = protocolAddress;
this.storage = storage;
}
_encodeTupleByte(number1, number2) {
if (number1 > 15 || number2 > 15) {
throw TypeError("Numbers must be 4 bits or less");
}
return (number1 << 4) | number2;
}
_decodeTupleByte(byte) {
return [byte >> 4, byte & 0xf];
}
toString() {
return `<SessionCipher(${this.addr.toString()})>`;
}
async getRecord() {
const record = await this.storage.loadSession(this.addr.toString());
if (record && !(record instanceof SessionRecord)) {
throw new TypeError('SessionRecord type expected from loadSession');
}
return record;
}
async storeRecord(record) {
record.removeOldSessions();
await this.storage.storeSession(this.addr.toString(), record);
}
async queueJob(awaitable) {
return await queueJob(this.addr.toString(), awaitable);
}
async encrypt(data) {
assertBuffer(data);
const ourIdentityKey = await this.storage.getOurIdentity();
return await this.queueJob(async () => {
const record = await this.getRecord();
if (!record) {
throw new errors.SessionError("No sessions");
}
const session = record.getOpenSession();
if (!session) {
throw new errors.SessionError("No open session");
}
const remoteIdentityKey = session.indexInfo.remoteIdentityKey;
if (!await this.storage.isTrustedIdentity(this.addr.id, remoteIdentityKey)) {
throw new errors.UntrustedIdentityKeyError(this.addr.id, remoteIdentityKey);
}
const chain = session.getChain(session.currentRatchet.ephemeralKeyPair.pubKey);
if (chain.chainType === ChainType.RECEIVING) {
throw new Error("Tried to encrypt on a receiving chain");
}
this.fillMessageKeys(chain, chain.chainKey.counter + 1);
const keys = crypto.deriveSecrets(chain.messageKeys[chain.chainKey.counter],
Buffer.alloc(32), Buffer.from("WhisperMessageKeys"));
delete chain.messageKeys[chain.chainKey.counter];
const msg = protobufs.WhisperMessage.create();
msg.ephemeralKey = session.currentRatchet.ephemeralKeyPair.pubKey;
msg.counter = chain.chainKey.counter;
msg.previousCounter = session.currentRatchet.previousCounter;
msg.ciphertext = crypto.encrypt(keys[0], data, keys[2].slice(0, 16));
const msgBuf = protobufs.WhisperMessage.encode(msg).finish();
const macInput = new Buffer(msgBuf.byteLength + (33 * 2) + 1);
macInput.set(ourIdentityKey.pubKey);
macInput.set(session.indexInfo.remoteIdentityKey, 33);
macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION);
macInput.set(msgBuf, (33 * 2) + 1);
const mac = crypto.calculateMAC(keys[1], macInput);
const result = new Buffer(msgBuf.byteLength + 9);
result[0] = this._encodeTupleByte(VERSION, VERSION);
result.set(msgBuf, 1);
result.set(mac.slice(0, 8), msgBuf.byteLength + 1);
await this.storeRecord(record);
let type, body;
if (session.pendingPreKey) {
type = 3; // prekey bundle
const preKeyMsg = protobufs.PreKeyWhisperMessage.create({
identityKey: ourIdentityKey.pubKey,
registrationId: await this.storage.getOurRegistrationId(),
baseKey: session.pendingPreKey.baseKey,
signedPreKeyId: session.pendingPreKey.signedKeyId,
message: result
});
if (session.pendingPreKey.preKeyId) {
preKeyMsg.preKeyId = session.pendingPreKey.preKeyId;
}
body = Buffer.concat([
Buffer.from([this._encodeTupleByte(VERSION, VERSION)]),
protobufs.PreKeyWhisperMessage.encode(preKeyMsg).finish()
]);
} else {
type = 1; // normal
body = result;
}
return {
type,
body,
registrationId: session.registrationId
};
});
}
async decryptWithSessions(data, sessions) {
// Iterate through the sessions, attempting to decrypt using each one.
// Stop and return the result if we get a valid result.
if (!sessions.length) {
throw new errors.SessionError("No sessions available");
}
const errs = [];
for (const session of sessions) {
let plaintext;
try {
plaintext = await this.doDecryptWhisperMessage(data, session);
session.indexInfo.used = Date.now();
return {
session,
plaintext
};
} catch(e) {
errs.push(e);
}
}
console.error("Failed to decrypt message with any known session...");
for (const e of errs) {
console.error("Session error:" + e, e.stack);
}
throw new errors.SessionError("No matching sessions found for message");
}
async decryptWhisperMessage(data) {
assertBuffer(data);
return await this.queueJob(async () => {
const record = await this.getRecord();
if (!record) {
throw new errors.SessionError("No session record");
}
const result = await this.decryptWithSessions(data, record.getSessions());
const remoteIdentityKey = result.session.indexInfo.remoteIdentityKey;
if (!await this.storage.isTrustedIdentity(this.addr.id, remoteIdentityKey)) {
throw new errors.UntrustedIdentityKeyError(this.addr.id, remoteIdentityKey);
}
if (record.isClosed(result.session)) {
// It's possible for this to happen when processing a backlog of messages.
// The message was, hopefully, just sent back in a time when this session
// was the most current. Simply make a note of it and continue. If our
// actual open session is for reason invalid, that must be handled via
// a full SessionError response.
console.warn("Decrypted message with closed session.");
}
await this.storeRecord(record);
return result.plaintext;
});
}
async decryptPreKeyWhisperMessage(data) {
assertBuffer(data);
const versions = this._decodeTupleByte(data[0]);
if (versions[1] > 3 || versions[0] < 3) { // min version > 3 or max version < 3
throw new Error("Incompatible version number on PreKeyWhisperMessage");
}
return await this.queueJob(async () => {
let record = await this.getRecord();
const preKeyProto = protobufs.PreKeyWhisperMessage.decode(data.slice(1));
if (!record) {
if (preKeyProto.registrationId == null) {
throw new Error("No registrationId");
}
record = new SessionRecord();
}
const builder = new SessionBuilder(this.storage, this.addr);
const preKeyId = await builder.initIncoming(record, preKeyProto);
const session = record.getSession(preKeyProto.baseKey);
const plaintext = await this.doDecryptWhisperMessage(preKeyProto.message, session);
await this.storeRecord(record);
if (preKeyId) {
await this.storage.removePreKey(preKeyId);
}
return plaintext;
});
}
async doDecryptWhisperMessage(messageBuffer, session) {
assertBuffer(messageBuffer);
if (!session) {
throw new TypeError("session required");
}
const versions = this._decodeTupleByte(messageBuffer[0]);
if (versions[1] > 3 || versions[0] < 3) { // min version > 3 or max version < 3
throw new Error("Incompatible version number on WhisperMessage");
}
const messageProto = messageBuffer.slice(1, -8);
const message = protobufs.WhisperMessage.decode(messageProto);
this.maybeStepRatchet(session, message.ephemeralKey, message.previousCounter);
const chain = session.getChain(message.ephemeralKey);
if (chain.chainType === ChainType.SENDING) {
throw new Error("Tried to decrypt on a sending chain");
}
this.fillMessageKeys(chain, message.counter);
if (!chain.messageKeys.hasOwnProperty(message.counter)) {
// Most likely the message was already decrypted and we are trying to process
// twice. This can happen if the user restarts before the server gets an ACK.
throw new errors.MessageCounterError('Key used already or never filled');
}
const messageKey = chain.messageKeys[message.counter];
delete chain.messageKeys[message.counter];
const keys = crypto.deriveSecrets(messageKey, Buffer.alloc(32),
Buffer.from("WhisperMessageKeys"));
const ourIdentityKey = await this.storage.getOurIdentity();
const macInput = new Buffer(messageProto.byteLength + (33 * 2) + 1);
macInput.set(session.indexInfo.remoteIdentityKey);
macInput.set(ourIdentityKey.pubKey, 33);
macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION);
macInput.set(messageProto, (33 * 2) + 1);
// This is where we most likely fail if the session is not a match.
// Don't misinterpret this as corruption.
crypto.verifyMAC(macInput, keys[1], messageBuffer.slice(-8), 8);
const plaintext = crypto.decrypt(keys[0], message.ciphertext, keys[2].slice(0, 16));
delete session.pendingPreKey;
return plaintext;
}
fillMessageKeys(chain, counter) {
if (chain.chainKey.counter >= counter) {
return;
}
if (counter - chain.chainKey.counter > 2000) {
throw new errors.SessionError('Over 2000 messages into the future!');
}
if (chain.chainKey.key === undefined) {
throw new errors.SessionError('Chain closed');
}
const key = chain.chainKey.key;
chain.messageKeys[chain.chainKey.counter + 1] = crypto.calculateMAC(key, Buffer.from([1]));
chain.chainKey.key = crypto.calculateMAC(key, Buffer.from([2]));
chain.chainKey.counter += 1;
return this.fillMessageKeys(chain, counter);
}
maybeStepRatchet(session, remoteKey, previousCounter) {
if (session.getChain(remoteKey)) {
return;
}
const ratchet = session.currentRatchet;
let previousRatchet = session.getChain(ratchet.lastRemoteEphemeralKey);
if (previousRatchet) {
this.fillMessageKeys(previousRatchet, previousCounter);
delete previousRatchet.chainKey.key; // Close
}
this.calculateRatchet(session, remoteKey, false);
// Now swap the ephemeral key and calculate the new sending chain
const prevCounter = session.getChain(ratchet.ephemeralKeyPair.pubKey);
if (prevCounter) {
ratchet.previousCounter = prevCounter.chainKey.counter;
session.deleteChain(ratchet.ephemeralKeyPair.pubKey);
}
ratchet.ephemeralKeyPair = curve.generateKeyPair();
this.calculateRatchet(session, remoteKey, true);
ratchet.lastRemoteEphemeralKey = remoteKey;
}
calculateRatchet(session, remoteKey, sending) {
let ratchet = session.currentRatchet;
const sharedSecret = curve.calculateAgreement(remoteKey, ratchet.ephemeralKeyPair.privKey);
const masterKey = crypto.deriveSecrets(sharedSecret, ratchet.rootKey,
Buffer.from("WhisperRatchet"), /*chunks*/ 2);
const chainKey = sending ? ratchet.ephemeralKeyPair.pubKey : remoteKey;
session.addChain(chainKey, {
messageKeys: {},
chainKey: {
counter: -1,
key: masterKey[1]
},
chainType: sending ? ChainType.SENDING : ChainType.RECEIVING
});
ratchet.rootKey = masterKey[0];
}
async hasOpenSession() {
return await this.queueJob(async () => {
const record = await this.getRecord();
if (!record) {
return false;
}
return record.haveOpenSession();
});
}
async closeOpenSession() {
return await this.queueJob(async () => {
const record = await this.getRecord();
if (record) {
const openSession = record.getOpenSession();
if (openSession) {
record.closeSession(openSession);
await this.storeRecord(record);
}
}
});
}
}
module.exports = SessionCipher;