Skip to content

Commit

Permalink
Map decryption errors correctly from rust (#3710)
Browse files Browse the repository at this point in the history
* Refactor key backup recovery to prepare for rust

* rust backup restore support

* map decryption errors correctly from rust

* Move export out of old crypto to api with re-export

* extract base64 utility

* add tests for base64 util

* more efficient regex

* fix typo

* use different vector for bob

* missing import

* Group tests for decryption errors

* Do not map unneeded rust error for now
  • Loading branch information
BillCarsonFr authored Sep 13, 2023
1 parent 1503acb commit 6d11800
Show file tree
Hide file tree
Showing 4 changed files with 342 additions and 44 deletions.
103 changes: 103 additions & 0 deletions spec/integ/crypto/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
RoomStateEvent,
} from "../../../src/matrix";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import * as testData from "../../test-utils/test-data";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
import { escapeRegExp } from "../../../src/utils";
Expand All @@ -70,6 +71,7 @@ import {
import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { CrossSigningKey, CryptoCallbacks, KeyBackupInfo } from "../../../src/crypto-api";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { DecryptionError } from "../../../src/crypto/algorithms";

afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
Expand Down Expand Up @@ -629,6 +631,107 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(decryptedEvent.getContent().body).toEqual("42");
});

describe("Unable to decrypt error codes", function () {
it("Encryption fails with expected UISI error", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();

const awaitUISI = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
if (error.code == "MEGOLM_UNKNOWN_INBOUND_SESSION_ID") {
resolve();
}
});
});

// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
},
},
};

syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);

await awaitUISI;
});

it("Encryption fails with expected Unknown Index error", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();

const awaitUnknownIndex = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
if (error.code == "OLM_UNKNOWN_MESSAGE_INDEX") {
resolve();
}
});
});

await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]);

// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
},
},
};

syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);

await awaitUnknownIndex;
});

it("Encryption fails with Unable to decrypt for other errors", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();

await aliceClient.getCrypto()!.importRoomKeys([testData.MEGOLM_SESSION_DATA]);

const awaitDecryptionError = new Promise<void>((resolve) => {
aliceClient.on(MatrixEventEvent.Decrypted, (ev, err) => {
const error = err as DecryptionError;
// rust and libolm can't have an exact 1:1 mapping for all errors,
// but some errors are part of API and should match
if (
error.code != "MEGOLM_UNKNOWN_INBOUND_SESSION_ID" &&
error.code != "OLM_UNKNOWN_MESSAGE_INDEX"
) {
resolve();
}
});
});

const malformedEvent: Partial<IEvent> = JSON.parse(JSON.stringify(testData.ENCRYPTED_EVENT));
malformedEvent.content!.ciphertext = "AwgAEnAkBmciEAyhh1j6DCk29UXJ7kv/kvayUNfuNT0iAioLxcXjFX";

// Alice gets both the events in a single sync
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [malformedEvent] } },
},
},
};

syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);

await awaitDecryptionError;
});
});

it("Alice receives a megolm message before the session keys", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
Expand Down
87 changes: 80 additions & 7 deletions spec/test-utils/test-data/generate-test-data.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"TEST_ROOM_ID": "!room:id",
# any 32-byte string can be an ed25519 private key.
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"deadbeefdeadbeefdeadbeefdeadbeef",
# any 32-byte string can be an curve25519 private key.
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"deadmuledeadmuledeadmuledeadmule",

"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"doyouspeakwhaaaaaaaaaaaaaaaaaale",
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"useruseruseruseruseruseruseruser",
Expand All @@ -60,6 +62,8 @@
"TEST_ROOM_ID": "!room:id",
# any 32-byte string can be an ed25519 private key.
"TEST_DEVICE_PRIVATE_KEY_BYTES": b"Deadbeefdeadbeefdeadbeefdeadbeef",
# any 32-byte string can be an curve25519 private key.
"TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES": b"Deadmuledeadmuledeadmuledeadmule",

"MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Doyouspeakwhaaaaaaaaaaaaaaaaaale",
"USER_CROSS_SIGNING_PRIVATE_KEY_BYTES": b"Useruseruseruseruseruseruseruser",
Expand All @@ -80,7 +84,7 @@ def main() -> None:
*/
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult }} from "../../../src";
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
Expand All @@ -102,6 +106,11 @@ def build_test_data(user_data, prefix = "") -> str:
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
user_data["TEST_DEVICE_PRIVATE_KEY_BYTES"]
)

device_curve_key = x25519.X25519PrivateKey.from_private_bytes(
user_data["TEST_DEVICE_CURVE_PRIVATE_KEY_BYTES"]
)

b64_public_key = encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
Expand Down Expand Up @@ -169,9 +178,10 @@ def build_test_data(user_data, prefix = "") -> str:
user_data["TEST_USER_ID"]: {f"ed25519:{user_data['TEST_DEVICE_ID']}": sig}
}

set_of_exported_room_keys = [build_exported_megolm_key(), build_exported_megolm_key()]
set_of_exported_room_keys = [build_exported_megolm_key(device_curve_key)[0], build_exported_megolm_key(device_curve_key)[0]]

additional_exported_room_key = build_exported_megolm_key()
additional_exported_room_key, additional_exported_ed_key = build_exported_megolm_key(device_curve_key)
ratcheted_exported_room_key = symetric_ratchet_step_of_megolm_key(additional_exported_room_key, additional_exported_ed_key)

otk_to_sign = {
"key": user_data['OTK']
Expand All @@ -194,6 +204,8 @@ def build_test_data(user_data, prefix = "") -> str:

backed_up_room_key = encrypt_megolm_key_for_backup(additional_exported_room_key, backup_decryption_key.public_key())

clear_event, encrypted_event = generate_encrypted_event_content(additional_exported_room_key, additional_exported_ed_key, device_curve_key)

backup_recovery_key = export_recovery_key(user_data["B64_BACKUP_DECRYPTION_KEY"])

return f"""\
Expand Down Expand Up @@ -252,8 +264,19 @@ def build_test_data(user_data, prefix = "") -> str:
json.dumps(additional_exported_room_key, indent=4)
};
/** A ratcheted version of {prefix}MEGOLM_SESSION_DATA */
export const {prefix}RATCHTED_MEGOLM_SESSION_DATA: IMegolmSessionData = {
json.dumps(ratcheted_exported_room_key, indent=4)
};
/** The key from {prefix}MEGOLM_SESSION_DATA, encrypted for backup using `m.megolm_backup.v1.curve25519-aes-sha2` algorithm*/
export const {prefix}CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {json.dumps(backed_up_room_key, indent=4)};
/** A test clear event */
export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, indent=4)};
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
"""


Expand Down Expand Up @@ -350,10 +373,11 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:

return signature_base64

def build_exported_megolm_key() -> dict:
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
"""
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
that can be imported via importRoomKeys API.
Returns the exported key, the matching privat edKey (needed to encrypt)
"""
index = 0
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32))
Expand All @@ -369,8 +393,10 @@ def build_exported_megolm_key() -> dict:

megolm_export = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!roomA:example.org",
"sender_key": "/Bu9e34hUClhddpf4E5gu5qEAdMY31+1A9HbiAeeQgo",
"room_id": "!room:id",
"sender_key": encode_base64(
device_curve_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
"session_id": encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
Expand All @@ -381,6 +407,53 @@ def build_exported_megolm_key() -> dict:
"forwarding_curve25519_key_chain": [],
}

return megolm_export, private_key

def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed25519.Ed25519PrivateKey) -> dict:

"""
Very simple ratchet step from 0 to 1
Used to generate a ratcheted key to test unknown message index.
"""
session_key: str = previous["session_key"]

# Get the megolm R0 from the export format
decoded = base64.b64decode(session_key.encode("ascii"))
ri = decoded[5:133]

ri0 = ri[0:32]
ri1 = ri[32:64]
ri2 = ri[64:96]
ri3 = ri[96:128]

h = hmac.HMAC(ri3, hashes.SHA256())
h.update(b'x\03')
ri1_3 = h.finalize()

index = 1
private_key = megolm_private_key

# exported key, start with version byte
exported_key = bytearray(b'\x01')
exported_key += index.to_bytes(4, 'big')
exported_key += ri0
exported_key += ri1
exported_key += ri2
exported_key += ri1_3
# KPub
exported_key += private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)


megolm_export = {
"algorithm": "m.megolm.v1.aes-sha2",
"room_id": "!room:id",
"sender_key": previous["sender_key"],
"session_id": previous["session_id"],
"session_key": encode_base64(exported_key),
"sender_claimed_keys": previous["sender_claimed_keys"],
"forwarding_curve25519_key_chain": [],
}

return megolm_export


Expand Down Expand Up @@ -499,7 +572,7 @@ def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519

ct = encryptor.update(padded_data) + encryptor.finalize()

# The ratchet index i, and the cipher-text, are then packed
# The ratchet index i, and the cipher-text, are then packed
# into a message as described in Message format. Then the entire message
# (including the version bytes and all payload bytes) are passed through
# HMAC-SHA-256. The first 8 bytes of the MAC are appended to the message.
Expand Down
Loading

0 comments on commit 6d11800

Please sign in to comment.