Merge pull request #78 from vector-im/bwindels/olm-decrypt

Olm decryption
This commit is contained in:
Bruno Windels 2020-09-01 16:05:49 +00:00 committed by GitHub
commit 3698dd9b92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 290 additions and 6 deletions

View file

@ -61,6 +61,16 @@
JSON.parse(bob.identity_keys()).curve25519,
bobOneTimeKey
);
log("alice outbound session created");
var aliceSessionPickled = aliceSession.pickle("secret");
log("aliceSession pickled", aliceSessionPickled);
try {
var tmp = new Olm.Session();
tmp.unpickle("secret", aliceSessionPickled);
log("aliceSession unpickled");
} finally {
tmp.free();
}
var message = aliceSession.encrypt("hello secret world");
log("message", message);
// decrypt

View file

@ -35,10 +35,11 @@ export class Session {
this._user = new User(sessionInfo.userId);
this._olm = olm;
this._e2eeAccount = null;
const olmUtil = olm ? new olm.Utility() : null;
this._deviceTracker = olm ? new DeviceTracker({
storage,
getSyncToken: () => this.syncToken,
olm,
olmUtil,
}) : null;
}

View file

@ -31,7 +31,7 @@ export class Account {
account.unpickle(pickleKey, pickledAccount);
const serverOTKCount = await txn.session.get(SERVER_OTK_COUNT_SESSION_KEY);
return new Account({pickleKey, hsApi, account, userId,
deviceId, areDeviceKeysUploaded, serverOTKCount});
deviceId, areDeviceKeysUploaded, serverOTKCount, olm});
}
}
@ -47,10 +47,11 @@ export class Account {
await txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded);
await txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, 0);
return new Account({pickleKey, hsApi, account, userId,
deviceId, areDeviceKeysUploaded, serverOTKCount: 0});
deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm});
}
constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount}) {
constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount, olm}) {
this._olm = olm;
this._pickleKey = pickleKey;
this._hsApi = hsApi;
this._account = account;
@ -58,6 +59,11 @@ export class Account {
this._deviceId = deviceId;
this._areDeviceKeysUploaded = areDeviceKeysUploaded;
this._serverOTKCount = serverOTKCount;
this._identityKeys = JSON.parse(this._account.identity_keys());
}
get identityKeys() {
return this._identityKeys;
}
async uploadKeys(storage) {
@ -118,6 +124,21 @@ export class Account {
return false;
}
createInboundOlmSession(senderKey, body) {
const newSession = new this._olm.Session();
newSession.create_inbound_from(this._account, senderKey, body);
return newSession;
}
writeRemoveOneTimeKey(session, txn) {
// this is side-effecty and will have applied the change if the txn fails,
// but don't want to clone the account for now
// and it is not the worst thing to think we have used a OTK when
// decrypting the message that actually used it threw for some reason.
this._account.remove_one_time_keys(session);
txn.session.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
}
writeSync(deviceOneTimeKeysCount, txn) {
// we only upload signed_curve25519 otks
const otkCount = deviceOneTimeKeysCount.signed_curve25519;

View file

@ -36,11 +36,11 @@ function deviceKeysAsDeviceIdentity(deviceSection) {
}
export class DeviceTracker {
constructor({storage, getSyncToken, olm}) {
constructor({storage, getSyncToken, olmUtil}) {
this._storage = storage;
this._getSyncToken = getSyncToken;
this._identityChangedForRoom = null;
this._olmUtil = new olm.Utility();
this._olmUtil = olmUtil;
}
async writeDeviceChanges(deviceLists, txn) {

View file

@ -18,3 +18,11 @@ limitations under the License.
export const SESSION_KEY_PREFIX = "e2ee:";
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
export class DecryptionError extends Error {
constructor(code, detailsObj = null) {
super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`);
this.code = code;
this.details = detailsObj;
}
}

View file

@ -0,0 +1,187 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {DecryptionError} from "../common.js";
const SESSION_LIMIT_PER_SENDER_KEY = 4;
function isPreKeyMessage(message) {
return message.type === 0;
}
export class Decryption {
constructor({account, pickleKey, now, ownUserId, storage, olm}) {
this._account = account;
this._pickleKey = pickleKey;
this._now = now;
this._ownUserId = ownUserId;
this._storage = storage;
this._olm = olm;
this._createOutboundSessionPromise = null;
}
// we can't run this in the sync txn because decryption will be async ...
// should we store the encrypted events in the sync loop and then pop them from there?
// it would be good in any case to run the (next) sync request in parallel with decryption
async decrypt(event) {
const senderKey = event.content?.["sender_key"];
const ciphertext = event.content?.ciphertext;
if (!ciphertext) {
throw new DecryptionError("OLM_MISSING_CIPHERTEXT");
}
const message = ciphertext?.[this._account.identityKeys.curve25519];
if (!message) {
// TODO: use same error messages as element-web
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS");
}
const sortedSessionIds = await this._getSortedSessionIds(senderKey);
let plaintext;
for (const sessionId of sortedSessionIds) {
try {
plaintext = await this._attemptDecryption(senderKey, sessionId, message);
} catch (err) {
throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", {senderKey, error: err.message});
}
if (typeof plaintext === "string") {
break;
}
}
if (typeof plaintext !== "string" && isPreKeyMessage(message)) {
plaintext = await this._createOutboundSessionAndDecrypt(senderKey, message, sortedSessionIds);
}
if (typeof plaintext === "string") {
return this._parseAndValidatePayload(plaintext, event);
}
}
async _getSortedSessionIds(senderKey) {
const readTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
const sortedSessions = await readTxn.olmSessions.getAll(senderKey);
// sort most recent used sessions first
sortedSessions.sort((a, b) => {
return b.lastUsed - a.lastUsed;
});
return sortedSessions.map(s => s.sessionId);
}
async _createOutboundSessionAndDecrypt(senderKey, message, sortedSessionIds) {
// serialize calls so the account isn't written from multiple
// sessions at once
while (this._createOutboundSessionPromise) {
await this._createOutboundSessionPromise;
}
this._createOutboundSessionPromise = (async () => {
try {
return await this._createOutboundSessionAndDecryptImpl(senderKey, message, sortedSessionIds);
} finally {
this._createOutboundSessionPromise = null;
}
})();
return await this._createOutboundSessionPromise;
}
// this could internally dispatch to a web-worker
async _createOutboundSessionAndDecryptImpl(senderKey, message, sortedSessionIds) {
let plaintext;
const session = this._account.createInboundOlmSession(senderKey, message.body);
try {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.session,
this._storage.storeNames.olmSessions,
]);
try {
// do this before removing the OTK removal, so we know decryption succeeded beforehand,
// as we don't have a way of undoing the OTK removal atm.
plaintext = session.decrypt(message.type, message.body);
this._account.writeRemoveOneTimeKey(session, txn);
// remove oldest session if we reach the limit including the new session
if (sortedSessionIds.length >= SESSION_LIMIT_PER_SENDER_KEY) {
// given they are sorted, the oldest one is the last one
const oldestSessionId = sortedSessionIds[sortedSessionIds.length - 1];
txn.olmSessions.remove(senderKey, oldestSessionId);
}
txn.olmSessions.set({
session: session.pickle(this._pickleKey),
sessionId: session.session_id(),
senderKey,
lastUsed: this._now(),
});
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
} finally {
session.free();
}
return plaintext;
}
// this could internally dispatch to a web-worker
async _attemptDecryption(senderKey, sessionId, message) {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]);
const session = new this._olm.Session();
let plaintext;
try {
const sessionEntry = await txn.olmSessions.get(senderKey, sessionId);
session.unpickle(this._pickleKey, sessionEntry.session);
if (isPreKeyMessage(message) && !session.matches_inbound(message.body)) {
return;
}
try {
plaintext = session.decrypt(message.type, message.body);
} catch (err) {
if (isPreKeyMessage(message)) {
throw new Error(`Error decrypting prekey message with existing session id ${sessionId}: ${err.message}`);
}
// decryption failed, bail out
return;
}
sessionEntry.session = session.pickle(this._pickleKey);
sessionEntry.lastUsed = this._now();
txn.olmSessions.set(sessionEntry);
} catch(err) {
txn.abort();
throw err;
} finally {
session.free();
}
await txn.complete();
return plaintext;
}
_parseAndValidatePayload(plaintext, event) {
const payload = JSON.parse(plaintext);
if (payload.sender !== event.sender) {
throw new DecryptionError("OLM_FORWARDED_MESSAGE", {sentBy: event.sender, encryptedBy: payload.sender});
}
if (payload.recipient !== this._ownUserId) {
throw new DecryptionError("OLM_BAD_RECIPIENT", {recipient: payload.recipient});
}
if (payload.recipient_keys?.ed25519 !== this._account.identityKeys.ed25519) {
throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", {key: payload.recipient_keys?.ed25519});
}
// TODO: check room_id
if (!payload.type) {
throw new Error("missing type on payload");
}
if (!payload.content) {
throw new Error("missing content on payload");
}
return payload;
}
}

View file

@ -24,6 +24,7 @@ export const STORE_NAMES = Object.freeze([
"pendingEvents",
"userIdentities",
"deviceIdentities",
"olmSessions",
]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {

View file

@ -26,6 +26,7 @@ import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
import {PendingEventStore} from "./stores/PendingEventStore.js";
import {UserIdentityStore} from "./stores/UserIdentityStore.js";
import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js";
import {OlmSessionStore} from "./stores/OlmSessionStore.js";
export class Transaction {
constructor(txn, allowedStoreNames) {
@ -91,6 +92,10 @@ export class Transaction {
return this._store("deviceIdentities", idbStore => new DeviceIdentityStore(idbStore));
}
get olmSessions() {
return this._store("olmSessions", idbStore => new OlmSessionStore(idbStore));
}
complete() {
return txnAsPromise(this._txn);
}

View file

@ -10,6 +10,7 @@ export const schema = [
createMemberStore,
migrateSession,
createIdentityStores,
createOlmSessionStore,
];
// TODO: how to deal with git merge conflicts of this array?
@ -70,3 +71,8 @@ function createIdentityStores(db) {
db.createObjectStore("userIdentities", {keyPath: "userId"});
db.createObjectStore("deviceIdentities", {keyPath: "key"});
}
//v5
function createOlmSessionStore(db) {
db.createObjectStore("olmSessions", {keyPath: "key"});
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function encodeKey(senderKey, sessionId) {
return `${senderKey}|${sessionId}`;
}
export class OlmSessionStore {
constructor(store) {
this._store = store;
}
getAll(senderKey) {
const range = IDBKeyRange.lowerBound(encodeKey(senderKey, ""));
return this._store.selectWhile(range, session => {
return session.senderKey === senderKey;
});
}
get(senderKey, sessionId) {
return this._store.get(encodeKey(senderKey, sessionId));
}
set(session) {
session.key = encodeKey(session.senderKey, session.sessionId);
return this._store.put(session);
}
remove(senderKey, sessionId) {
return this._store.delete(encodeKey(senderKey, sessionId));
}
}