diff --git a/src/session_builder.js b/src/session_builder.js index 9bd333f1..1d651747 100644 --- a/src/session_builder.js +++ b/src/session_builder.js @@ -8,6 +8,7 @@ const crypto = require('./crypto'); const curve = require('./curve'); const errors = require('./errors'); const queueJob = require('./queue_job'); +const Util = require('./util'); class SessionBuilder { @@ -24,12 +25,12 @@ class SessionBuilder { throw new errors.UntrustedIdentityKeyError(this.addr.id, device.identityKey); } curve.verifySignature(device.identityKey, device.signedPreKey.publicKey, - device.signedPreKey.signature); + device.signedPreKey.signature); const baseKey = curve.generateKeyPair(); const devicePreKey = device.preKey && device.preKey.publicKey; const session = await this.initSession(true, baseKey, undefined, device.identityKey, - devicePreKey, device.signedPreKey.publicKey, - device.registrationId); + devicePreKey, device.signedPreKey.publicKey, + device.registrationId); session.pendingPreKey = { signedKeyId: device.signedPreKey.keyId, baseKey: baseKey.pubKey @@ -40,14 +41,14 @@ class SessionBuilder { let record = await this.storage.loadSession(fqAddr); if (!record) { record = new SessionRecord(); - } else { - const openSession = record.getOpenSession(); - if (openSession) { - console.warn("Closing stale open session for new outgoing prekey bundle"); - record.closeSession(openSession); - } } - record.setSession(session); + const openSession = record.getOpenSession(); + record.archiveCurrentState(); + if (openSession && session && !Util.isEqual(openSession.indexInfo.remoteIdentityKey, session.indexInfo.remoteIdentityKey)) { + console.warn("Deleting all sessions because identity has changed"); + record.deleteAllSessions(); + } + record.updateSessionState(session); await this.storage.storeSession(fqAddr, record); }); } @@ -61,27 +62,35 @@ class SessionBuilder { // This just means we haven't replied. return; } - const preKeyPair = await this.storage.loadPreKey(message.preKeyId); - if (message.preKeyId && !preKeyPair) { - throw new errors.PreKeyError('Invalid PreKey ID'); - } - const signedPreKeyPair = await this.storage.loadSignedPreKey(message.signedPreKeyId); - if (!signedPreKeyPair) { - throw new errors.PreKeyError("Missing SignedPreKey"); - } + const [preKeyPair, signedPreKeyPair] = await Promise.all([ + this.storage.loadPreKey(message.preKeyId), + this.storage.loadSignedPreKey(message.signedPreKeyId) + ]); const existingOpenSession = record.getOpenSession(); + if (!signedPreKeyPair) { + if (existingOpenSession && existingOpenSession.currentRatchet) return; + throw new errors.PreKeyError("Missing Signed PreKey for PreKeyWhisperMessage"); + } if (existingOpenSession) { - console.warn("Closing open session in favor of incoming prekey bundle"); - record.closeSession(existingOpenSession); + record.archiveCurrentState(); + } + if (message.preKeyId && !preKeyPair) { + throw new errors.PreKeyError("Invalid PreKey ID"); + } + const session = await this.initSession(false, preKeyPair, signedPreKeyPair, + message.identityKey, message.baseKey, + undefined, message.registrationId); + if (existingOpenSession && session && !Util.isEqual(existingOpenSession.indexInfo.remoteIdentityKey, session.indexInfo.remoteIdentityKey)) { + console.warn("Deleting all sessions because identity has changed"); + record.deleteAllSessions(); } - record.setSession(await this.initSession(false, preKeyPair, signedPreKeyPair, - message.identityKey, message.baseKey, - undefined, message.registrationId)); + record.updateSessionState(session); + // this.storage.saveIdentity return message.preKeyId; } async initSession(isInitiator, ourEphemeralKey, ourSignedKey, theirIdentityPubKey, - theirEphemeralPubKey, theirSignedPubKey, registrationId) { + theirEphemeralPubKey, theirSignedPubKey, registrationId) { if (isInitiator) { if (ourSignedKey) { throw new Error("Invalid call to initSession"); @@ -119,7 +128,7 @@ class SessionBuilder { sharedSecret.set(new Uint8Array(a4), 32 * 4); } const masterKey = crypto.deriveSecrets(Buffer.from(sharedSecret), Buffer.alloc(32), - Buffer.from("WhisperText")); + Buffer.from("WhisperText")); const session = SessionRecord.createEntry(); session.registrationId = registrationId; session.currentRatchet = { diff --git a/src/session_cipher.js b/src/session_cipher.js index 0e6df11e..cb7cadc8 100644 --- a/src/session_cipher.js +++ b/src/session_cipher.js @@ -4,6 +4,7 @@ const ChainType = require('./chain_type'); const ProtocolAddress = require('./protocol_address'); const SessionBuilder = require('./session_builder'); const SessionRecord = require('./session_record'); +const Util = require('./util'); const crypto = require('./crypto'); const curve = require('./curve'); const errors = require('./errors'); @@ -45,6 +46,7 @@ class SessionCipher { return ``; } + /** @returns {Promise} */ async getRecord() { const record = await this.storage.loadSession(this.addr.toString()); if (record && !(record instanceof SessionRecord)) { @@ -64,9 +66,12 @@ class SessionCipher { async encrypt(data) { assertBuffer(data); - const ourIdentityKey = await this.storage.getOurIdentity(); return await this.queueJob(async () => { - const record = await this.getRecord(); + const [ourIdentityKey, ourRegistrationId, record] = await Promise.all([ + this.storage.getOurIdentity(), + this.storage.getOurRegistrationId(), + this.getRecord() + ]); if (!record) { throw new errors.SessionError("No sessions"); } @@ -74,41 +79,47 @@ class SessionCipher { 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 = Buffer.alloc(msgBuf.byteLength + (33 * 2) + 1); - macInput.set(ourIdentityKey.pubKey); - macInput.set(session.indexInfo.remoteIdentityKey, 33); - macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION); + 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 = Buffer.alloc(msgBuf.byteLength + (33 * 2) + 1); + macInput.set(ourIdentityKey.pubKey); + macInput.set(session.indexInfo.remoteIdentityKey, 33); + macInput[33 * 2] = this._encodeTupleByte(VERSION, VERSION); // 51 macInput.set(msgBuf, (33 * 2) + 1); const mac = crypto.calculateMAC(keys[1], macInput); const result = Buffer.alloc(msgBuf.byteLength + 9); result[0] = this._encodeTupleByte(VERSION, VERSION); result.set(msgBuf, 1); result.set(mac.slice(0, 8), msgBuf.byteLength + 1); + + const remoteIdentityKey = session.indexInfo.remoteIdentityKey; + if (!await this.storage.isTrustedIdentity(this.addr.id, remoteIdentityKey)) { + throw new errors.UntrustedIdentityKeyError(this.addr.id, remoteIdentityKey); + } + + // this.storage.saveIdentity(session.indexInfo.remoteIdentityKey) + + record.updateSessionState(session); 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(), + registrationId: ourRegistrationId, baseKey: session.pendingPreKey.baseKey, signedPreKeyId: session.pendingPreKey.signedKeyId, message: result @@ -134,31 +145,26 @@ class SessionCipher { }); } - async decryptWithSessions(data, sessions) { + async decryptWithSessions(data, sessions, errors = []) { // 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"); + throw new errors.SessionError(errors[0] || "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); + const session = sessions.pop(); + try { + const plaintext = await this.doDecryptWhisperMessage(data, session); + session.indexInfo.used = Date.now(); + return { + session, + plaintext + }; + } catch (e) { + if (e.name === "MessageCounterError") + throw e; + errors.push(e); + return await this.decryptWithSessions(data, sessions, errors); } - throw new errors.SessionError("No matching sessions found for message"); } async decryptWhisperMessage(data) { @@ -169,6 +175,11 @@ class SessionCipher { throw new errors.SessionError("No session record"); } const result = await this.decryptWithSessions(data, record.getSessions()); + const session = (await this.getRecord()).getOpenSession(); + if (result.session.indexInfo.baseKey != session.indexInfo.baseKey) { + record.archiveCurrentState(); + record.openSession(result.session); + } const remoteIdentityKey = result.session.indexInfo.remoteIdentityKey; if (!await this.storage.isTrustedIdentity(this.addr.id, remoteIdentityKey)) { throw new errors.UntrustedIdentityKeyError(this.addr.id, remoteIdentityKey); @@ -181,6 +192,8 @@ class SessionCipher { // a full SessionError response. console.warn("Decrypted message with closed session."); } + // this.storage.saveIdentity + record.updateSessionState(result.session); await this.storeRecord(record); return result.plaintext; }); @@ -205,6 +218,16 @@ class SessionCipher { const preKeyId = await builder.initIncoming(record, preKeyProto); const session = record.getSession(preKeyProto.baseKey); const plaintext = await this.doDecryptWhisperMessage(preKeyProto.message, session); + record.updateSessionState(session); + + const openSession = record.getOpenSession(); + if (session && openSession && !Util.isEqual(session.indexInfo.remoteIdentityKey, openSession.indexInfo.remoteIdentityKey)) { + console.warn("Promote the old session and update identity"); + record.archiveCurrentState(); + record.openSession(session); + // this.storage.saveIdentity + } + await this.storeRecord(record); if (preKeyId) { await this.storage.removePreKey(preKeyId); @@ -216,7 +239,7 @@ class SessionCipher { async doDecryptWhisperMessage(messageBuffer, session) { assertBuffer(messageBuffer); if (!session) { - throw new TypeError("session required"); + throw new Error("No session found to decrypt message from " + this.addr.toString()) } const versions = this._decodeTupleByte(messageBuffer[0]); if (versions[1] > 3 || versions[0] < 3) { // min version > 3 or max version < 3 @@ -224,7 +247,7 @@ class SessionCipher { } const messageProto = messageBuffer.slice(1, -8); const message = protobufs.WhisperMessage.decode(messageProto); - this.maybeStepRatchet(session, message.ephemeralKey, message.previousCounter); + await 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"); @@ -233,7 +256,7 @@ class SessionCipher { 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'); + throw new errors.MessageCounterError("Message key not found. The counter was repeated or the key was not filled."); } const messageKey = chain.messageKeys[message.counter]; delete chain.messageKeys[message.counter]; @@ -258,10 +281,10 @@ class SessionCipher { return; } if (counter - chain.chainKey.counter > 2000) { - throw new errors.SessionError('Over 2000 messages into the future!'); + throw new errors.SessionError("Over 2000 messages into the future!"); } if (chain.chainKey.key === undefined) { - throw new errors.SessionError('Chain closed'); + throw new errors.SessionError("Got invalid request to extend chain after it was already closed"); } const key = chain.chainKey.key; chain.messageKeys[chain.chainKey.counter + 1] = crypto.calculateMAC(key, Buffer.from([1])); @@ -270,7 +293,7 @@ class SessionCipher { return this.fillMessageKeys(chain, counter); } - maybeStepRatchet(session, remoteKey, previousCounter) { + async maybeStepRatchet(session, remoteKey, previousCounter) { if (session.getChain(remoteKey)) { return; } @@ -325,7 +348,7 @@ class SessionCipher { if (record) { const openSession = record.getOpenSession(); if (openSession) { - record.closeSession(openSession); + record.archiveCurrentState(); await this.storeRecord(record); } } diff --git a/src/session_record.js b/src/session_record.js index 7626a392..a8f80fdd 100644 --- a/src/session_record.js +++ b/src/session_record.js @@ -237,6 +237,7 @@ class SessionRecord { getSession(key) { assertBuffer(key); + this.detectDuplicateOpenSessions(); const session = this.sessions[key.toString('base64')]; if (session && session.indexInfo.baseKeyType === BaseKeyType.OURS) { throw new Error("Tried to lookup a session using our basekey"); @@ -245,6 +246,7 @@ class SessionRecord { } getOpenSession() { + this.detectDuplicateOpenSessions(); for (const session of Object.values(this.sessions)) { if (!this.isClosed(session)) { return session; @@ -270,7 +272,7 @@ class SessionRecord { console.warn("Session already closed", session); return; } - console.info("Closing session:", session); + // console.info("Closing session:", session); session.indexInfo.closed = Date.now(); } @@ -278,7 +280,7 @@ class SessionRecord { if (!this.isClosed(session)) { console.warn("Session already open"); } - console.info("Opening session:", session); + // console.info("Opening session:", session); session.indexInfo.closed = -1; } @@ -286,6 +288,21 @@ class SessionRecord { return session.indexInfo.closed !== -1; } + updateSessionState(session) { + // this.removeOldChains(session); + this.setSession(session); + this.removeOldSessions(); + + } + + archiveCurrentState() { + let open_session = this.getOpenSession(); + if (open_session !== undefined) { + this.closeSession(open_session); + this.updateSessionState(open_session); + } + } + removeOldSessions() { while (Object.keys(this.sessions).length > CLOSED_SESSIONS_MAX) { let oldestKey; @@ -298,7 +315,7 @@ class SessionRecord { } } if (oldestKey) { - console.info("Removing old closed session:", oldestSession); + // console.info("Removing old closed session:", oldestSession); delete this.sessions[oldestKey]; } else { throw new Error('Corrupt sessions object'); @@ -307,8 +324,19 @@ class SessionRecord { } deleteAllSessions() { - for (const key of Object.keys(this.sessions)) { - delete this.sessions[key]; + this.sessions = {}; + } + + detectDuplicateOpenSessions() { + let openSession; + let sessions = this.sessions; + for (const key in sessions) { + if (!this.isClosed(sessions[key])) { + if (openSession !== undefined) { + throw new Error("Datastore inconsistensy: multiple open sessions"); + } + openSession = sessions[key]; + } } } } diff --git a/src/util.js b/src/util.js new file mode 100644 index 00000000..aa13430b --- /dev/null +++ b/src/util.js @@ -0,0 +1,16 @@ +class Util { + static toString(data) { + if (typeof data === "string") return data; + return data.toString('base64'); + } + static isEqual(a, b) { + if (a == null || b == null) return false; + a = Util.toString(a); + b = Util.toString(b); + const maxLength = Math.max(a.length, b.length); + if (maxLength < 5) throw new Error("a/b compare too short"); + return a.substring(0, Math.min(maxLength, a.length)) == b.substring(0, Math.min(maxLength, b.length)); + } +} + +module.exports = Util; \ No newline at end of file