You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-30 04:23:07 +03:00
Clean up code for handling decryption failures (#4126)
Various improvements, including: * Defining an enum for decryption failure reasons * Exposing the reason code as a property on Event
This commit is contained in:
committed by
GitHub
parent
a573727662
commit
d1259b241c
@ -80,12 +80,12 @@ import { SecretStorageKeyDescription } from "../../../src/secret-storage";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
CryptoCallbacks,
|
||||
DecryptionFailureCode,
|
||||
EventShieldColour,
|
||||
EventShieldReason,
|
||||
KeyBackupInfo,
|
||||
} from "../../../src/crypto-api";
|
||||
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
|
||||
import { DecryptionError } from "../../../src/crypto/algorithms";
|
||||
import { IKeyBackup } from "../../../src/crypto/backup";
|
||||
import {
|
||||
createOlmAccount,
|
||||
@ -470,9 +470,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
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") {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev) => {
|
||||
if (ev.decryptionFailureReason === DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@ -499,9 +498,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
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") {
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev) => {
|
||||
if (ev.decryptionFailureReason === DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@ -532,13 +530,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
|
||||
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;
|
||||
aliceClient.on(MatrixEventEvent.Decrypted, (ev) => {
|
||||
// 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"
|
||||
ev.decryptionFailureReason !== DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID &&
|
||||
ev.decryptionFailureReason !== DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
|
@ -27,6 +27,8 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
TweakName,
|
||||
} from "../../../src";
|
||||
import { DecryptionFailureCode } from "../../../src/crypto-api";
|
||||
import { DecryptionError } from "../../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("MatrixEvent", () => {
|
||||
it("should create copies of itself", () => {
|
||||
@ -360,20 +362,50 @@ describe("MatrixEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should report decryption errors", async () => {
|
||||
it("should report unknown decryption errors", async () => {
|
||||
const decryptionListener = jest.fn();
|
||||
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
|
||||
|
||||
const testError = new Error("test error");
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockRejectedValue(new Error("test error")),
|
||||
decryptEvent: jest.fn().mockRejectedValue(testError),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: Error: test error **",
|
||||
});
|
||||
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
|
||||
});
|
||||
|
||||
it("should report known decryption errors", async () => {
|
||||
const decryptionListener = jest.fn();
|
||||
encryptedEvent.addListener(MatrixEventEvent.Decrypted, decryptionListener);
|
||||
|
||||
const testError = new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, "uisi");
|
||||
const crypto = {
|
||||
decryptEvent: jest.fn().mockRejectedValue(testError),
|
||||
} as unknown as Crypto;
|
||||
|
||||
await encryptedEvent.attemptDecryption(crypto);
|
||||
expect(encryptedEvent.isEncrypted()).toBeTruthy();
|
||||
expect(encryptedEvent.isBeingDecrypted()).toBeFalsy();
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBeTruthy();
|
||||
expect(encryptedEvent.decryptionFailureReason).toEqual(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
);
|
||||
expect(encryptedEvent.isEncryptedDisabledForUnverifiedDevices).toBeFalsy();
|
||||
expect(encryptedEvent.getContent()).toEqual({
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: DecryptionError: uisi **",
|
||||
});
|
||||
expect(decryptionListener).toHaveBeenCalledWith(encryptedEvent, testError);
|
||||
});
|
||||
|
||||
it(`should report "DecryptionError: The sender has disabled encrypting to unverified devices."`, async () => {
|
||||
@ -423,6 +455,8 @@ describe("MatrixEvent", () => {
|
||||
expect(eventAttemptDecryptionSpy).toHaveBeenCalledTimes(2);
|
||||
expect(crypto.decryptEvent).toHaveBeenCalledTimes(2);
|
||||
expect(encryptedEvent.getType()).toEqual("m.room.message");
|
||||
expect(encryptedEvent.isDecryptionFailure()).toBe(false);
|
||||
expect(encryptedEvent.decryptionFailureReason).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -25,10 +25,11 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { M_BEACON } from "../../src/@types/beacon";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { DecryptionError } from "../../src/crypto/algorithms";
|
||||
import { defer } from "../../src/utils";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { KnownMembership } from "../../src/@types/membership";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
import { DecryptionError } from "../../src/common-crypto/CryptoBackend";
|
||||
|
||||
describe("RoomState", function () {
|
||||
const roomId = "!foo:bar";
|
||||
@ -1040,7 +1041,9 @@ describe("RoomState", function () {
|
||||
content: beacon1RelationContent,
|
||||
});
|
||||
jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true);
|
||||
mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg"));
|
||||
mockClient.decryptEventIfNeeded.mockRejectedValue(
|
||||
new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "msg"),
|
||||
);
|
||||
// spy on event.once
|
||||
const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once");
|
||||
|
||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import { mkDecryptionFailureMatrixEvent, mkEncryptedMatrixEvent, mkMatrixEvent } from "../../src/testing";
|
||||
import { EventType } from "../../src";
|
||||
import { DecryptionFailureCode } from "../../src/crypto-api";
|
||||
|
||||
describe("testing", () => {
|
||||
describe("mkMatrixEvent", () => {
|
||||
@ -60,6 +61,7 @@ describe("testing", () => {
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(false);
|
||||
expect(event.decryptionFailureReason).toBe(null);
|
||||
expect(event.getContent()).toEqual({ body: "blah" });
|
||||
expect(event.getType()).toEqual("m.room.message");
|
||||
});
|
||||
@ -70,13 +72,14 @@ describe("testing", () => {
|
||||
const event = await mkDecryptionFailureMatrixEvent({
|
||||
sender: "@alice:test",
|
||||
roomId: "!test:room",
|
||||
code: "UNKNOWN",
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
msg: "blah",
|
||||
});
|
||||
|
||||
expect(event.sender?.userId).toEqual("@alice:test");
|
||||
expect(event.isEncrypted()).toBe(true);
|
||||
expect(event.isDecryptionFailure()).toBe(true);
|
||||
expect(event.decryptionFailureReason).toEqual(DecryptionFailureCode.UNKNOWN_ERROR);
|
||||
expect(event.getContent()).toEqual({
|
||||
body: "** Unable to decrypt: DecryptionError: blah **",
|
||||
msgtype: "m.bad.encrypted",
|
||||
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { IClearEvent, MatrixEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { CryptoApi, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { CryptoApi, DecryptionFailureCode, ImportRoomKeysOpts } from "../crypto-api";
|
||||
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
|
||||
import { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
|
||||
@ -226,10 +226,6 @@ export interface EventDecryptionResult {
|
||||
* restored from backup)
|
||||
*/
|
||||
untrusted?: boolean;
|
||||
/**
|
||||
* The sender doesn't authorize the unverified devices to decrypt his messages
|
||||
*/
|
||||
encryptedDisabledForUnverifiedDevices?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -263,3 +259,43 @@ export interface BackupDecryptor {
|
||||
*/
|
||||
free(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when decryption fails
|
||||
*
|
||||
* @param code - Reason code for the failure.
|
||||
*
|
||||
* @param msg - user-visible message describing the problem
|
||||
*
|
||||
* @param details - key/value pairs reported in the logs but not shown
|
||||
* to the user.
|
||||
*/
|
||||
export class DecryptionError extends Error {
|
||||
public readonly detailedString: string;
|
||||
|
||||
public constructor(
|
||||
public readonly code: DecryptionFailureCode,
|
||||
msg: string,
|
||||
details?: Record<string, string | Error>,
|
||||
) {
|
||||
super(msg);
|
||||
this.name = "DecryptionError";
|
||||
this.detailedString = detailedStringForDecryptionError(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
|
||||
let result = err.name + "[msg: " + err.message;
|
||||
|
||||
if (details) {
|
||||
result +=
|
||||
", " +
|
||||
Object.keys(details)
|
||||
.map((k) => k + ": " + details[k])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
result += "]";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -498,6 +498,57 @@ export interface CryptoApi {
|
||||
deleteKeyBackupVersion(version: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A reason code for a failure to decrypt an event. */
|
||||
export enum DecryptionFailureCode {
|
||||
/** Message was encrypted with a Megolm session whose keys have not been shared with us. */
|
||||
MEGOLM_UNKNOWN_INBOUND_SESSION_ID = "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
|
||||
/** Message was encrypted with a Megolm session which has been shared with us, but in a later ratchet state. */
|
||||
OLM_UNKNOWN_MESSAGE_INDEX = "OLM_UNKNOWN_MESSAGE_INDEX",
|
||||
|
||||
/** Unknown or unclassified error. */
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_BAD_ROOM = "MEGOLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
MEGOLM_MISSING_FIELDS = "MEGOLM_MISSING_FIELDS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_DECRYPT_GROUP_MESSAGE_ERROR = "OLM_DECRYPT_GROUP_MESSAGE_ERROR",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ENCRYPTED_MESSAGE = "OLM_BAD_ENCRYPTED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT = "OLM_BAD_RECIPIENT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_RECIPIENT_KEY = "OLM_BAD_RECIPIENT_KEY",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_ROOM = "OLM_BAD_ROOM",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER_CHECK_FAILED = "OLM_BAD_SENDER_CHECK_FAILED",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_BAD_SENDER = "OLM_BAD_SENDER",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_FORWARDED_MESSAGE = "OLM_FORWARDED_MESSAGE",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_MISSING_CIPHERTEXT = "OLM_MISSING_CIPHERTEXT",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
OLM_NOT_INCLUDED_IN_RECIPIENTS = "OLM_NOT_INCLUDED_IN_RECIPIENTS",
|
||||
|
||||
/** @deprecated only used in legacy crypto */
|
||||
UNKNOWN_ENCRYPTION_ALGORITHM = "UNKNOWN_ENCRYPTION_ALGORITHM",
|
||||
}
|
||||
|
||||
/**
|
||||
* Options object for `CryptoApi.bootstrapCrossSigning`.
|
||||
*/
|
||||
|
@ -18,11 +18,12 @@ import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility }
|
||||
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
|
||||
import * as algorithms from "./algorithms";
|
||||
import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
|
||||
import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
|
||||
import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { DecryptionFailureCode } from "../crypto-api";
|
||||
import { DecryptionError } from "../common-crypto/CryptoBackend";
|
||||
|
||||
// The maximum size of an event is 65K, and we base64 the content, so this is a
|
||||
// reasonable approximation to the biggest plaintext we can encrypt.
|
||||
@ -1220,8 +1221,8 @@ export class OlmDevice {
|
||||
this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => {
|
||||
if (session === null || sessionData === null) {
|
||||
if (withheld) {
|
||||
error = new algorithms.DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
error = new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
calculateWithheldMessage(withheld),
|
||||
{
|
||||
session: senderKey + "|" + sessionId,
|
||||
@ -1236,8 +1237,8 @@ export class OlmDevice {
|
||||
res = session.decrypt(body);
|
||||
} catch (e) {
|
||||
if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) {
|
||||
error = new algorithms.DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
error = new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
calculateWithheldMessage(withheld),
|
||||
{
|
||||
session: senderKey + "|" + sessionId,
|
||||
|
@ -199,45 +199,6 @@ export abstract class DecryptionAlgorithm {
|
||||
public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when decryption fails
|
||||
*
|
||||
* @param msg - user-visible message describing the problem
|
||||
*
|
||||
* @param details - key/value pairs reported in the logs but not shown
|
||||
* to the user.
|
||||
*/
|
||||
export class DecryptionError extends Error {
|
||||
public readonly detailedString: string;
|
||||
|
||||
public constructor(
|
||||
public readonly code: string,
|
||||
msg: string,
|
||||
details?: Record<string, string | Error>,
|
||||
) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
this.name = "DecryptionError";
|
||||
this.detailedString = detailedStringForDecryptionError(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
|
||||
let result = err.name + "[msg: " + err.message;
|
||||
|
||||
if (details) {
|
||||
result +=
|
||||
", " +
|
||||
Object.keys(details)
|
||||
.map((k) => k + ": " + details[k])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
result += "]";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class UnknownDeviceError extends Error {
|
||||
/**
|
||||
* Exception thrown specifically when we want to warn the user to consider
|
||||
@ -274,3 +235,6 @@ export function registerAlgorithm<P extends IParams = IParams>(
|
||||
ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm);
|
||||
DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm);
|
||||
}
|
||||
|
||||
/* Re-export for backwards compatibility. Deprecated: this is an internal class. */
|
||||
export { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
@ -26,7 +26,6 @@ import * as olmlib from "../olmlib";
|
||||
import {
|
||||
DecryptionAlgorithm,
|
||||
DecryptionClassParams,
|
||||
DecryptionError,
|
||||
EncryptionAlgorithm,
|
||||
IParams,
|
||||
registerAlgorithm,
|
||||
@ -45,6 +44,8 @@ import { OlmGroupSessionExtraData } from "../../@types/crypto";
|
||||
import { MatrixError } from "../../http-api";
|
||||
import { immediate, MapWithDefault } from "../../utils";
|
||||
import { KnownMembership } from "../../@types/membership";
|
||||
import { DecryptionFailureCode } from "../../crypto-api";
|
||||
import { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
||||
// determine whether the key can be shared with invitees
|
||||
export function isRoomSharedHistory(room: Room): boolean {
|
||||
@ -1313,7 +1314,7 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
const content = event.getWireContent();
|
||||
|
||||
if (!content.sender_key || !content.session_id || !content.ciphertext) {
|
||||
throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input");
|
||||
throw new DecryptionError(DecryptionFailureCode.MEGOLM_MISSING_FIELDS, "Missing fields in input");
|
||||
}
|
||||
|
||||
// we add the event to the pending list *before* we start decryption.
|
||||
@ -1339,12 +1340,12 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
throw e;
|
||||
}
|
||||
|
||||
let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
|
||||
let errorCode = DecryptionFailureCode.OLM_DECRYPT_GROUP_MESSAGE_ERROR;
|
||||
|
||||
if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") {
|
||||
this.requestKeysForEvent(event);
|
||||
|
||||
errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
|
||||
errorCode = DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX;
|
||||
}
|
||||
|
||||
throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
|
||||
@ -1377,13 +1378,13 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
if (problem.fixed) {
|
||||
problemDescription += " Trying to create a new secure channel and re-requesting the keys.";
|
||||
}
|
||||
throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, {
|
||||
throw new DecryptionError(DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID, problemDescription, {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
throw new DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
@ -1405,7 +1406,10 @@ export class MegolmDecryption extends DecryptionAlgorithm {
|
||||
// (this is somewhat redundant, since the megolm session is scoped to the
|
||||
// room, so neither the sender nor a MITM can lie about the room_id).
|
||||
if (payload.room_id !== event.getRoomId()) {
|
||||
throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id);
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.MEGOLM_BAD_ROOM,
|
||||
"Message intended for room " + payload.room_id,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -22,11 +22,13 @@ import type { IEventDecryptionResult } from "../../@types/crypto";
|
||||
import { logger } from "../../logger";
|
||||
import * as olmlib from "../olmlib";
|
||||
import { DeviceInfo } from "../deviceinfo";
|
||||
import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base";
|
||||
import { DecryptionAlgorithm, EncryptionAlgorithm, registerAlgorithm } from "./base";
|
||||
import { Room } from "../../models/room";
|
||||
import { IContent, MatrixEvent } from "../../models/event";
|
||||
import { IEncryptedContent, IOlmEncryptedContent } from "../index";
|
||||
import { IInboundSession } from "../OlmDevice";
|
||||
import { DecryptionFailureCode } from "../../crypto-api";
|
||||
import { DecryptionError } from "../../common-crypto/CryptoBackend";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@ -159,11 +161,14 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
const ciphertext = content.ciphertext;
|
||||
|
||||
if (!ciphertext) {
|
||||
throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
|
||||
throw new DecryptionError(DecryptionFailureCode.OLM_MISSING_CIPHERTEXT, "Missing ciphertext");
|
||||
}
|
||||
|
||||
if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) {
|
||||
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_NOT_INCLUDED_IN_RECIPIENTS,
|
||||
"Not included in recipients",
|
||||
);
|
||||
}
|
||||
const message = ciphertext[this.olmDevice.deviceCurve25519Key!];
|
||||
let payloadString: string;
|
||||
@ -171,7 +176,7 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
try {
|
||||
payloadString = await this.decryptMessage(deviceKey, message);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
|
||||
throw new DecryptionError(DecryptionFailureCode.OLM_BAD_ENCRYPTED_MESSAGE, "Bad Encrypted Message", {
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
@ -182,14 +187,21 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// check that we were the intended recipient, to avoid unknown-key attack
|
||||
// https://github.com/vector-im/vector-web/issues/2483
|
||||
if (payload.recipient != this.userId) {
|
||||
throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_RECIPIENT,
|
||||
"Message was intended for " + payload.recipient,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
|
||||
throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_RECIPIENT_KEY,
|
||||
"Message not intended for this device",
|
||||
{
|
||||
intended: payload.recipient_keys.ed25519,
|
||||
our_key: this.olmDevice.deviceEd25519Key!,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// check that the device that encrypted the event belongs to the user that the event claims it's from.
|
||||
@ -216,18 +228,26 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
try {
|
||||
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
|
||||
} catch (e) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER_CHECK_FAILED", "Could not verify sender identity", {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_SENDER_CHECK_FAILED,
|
||||
"Could not verify sender identity",
|
||||
{
|
||||
sender: deviceKey,
|
||||
err: e as Error,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
|
||||
}
|
||||
if (senderKeyUser !== event.getSender() && senderKeyUser !== undefined && senderKeyUser !== null) {
|
||||
throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_SENDER,
|
||||
"Message claimed to be from " + event.getSender(),
|
||||
{
|
||||
real_sender: senderKeyUser,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// check that the original sender matches what the homeserver told us, to
|
||||
@ -235,16 +255,24 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
// (this check is also provided via the sender's embedded ed25519 key,
|
||||
// which is checked elsewhere).
|
||||
if (payload.sender != event.getSender()) {
|
||||
throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_FORWARDED_MESSAGE,
|
||||
"Message forwarded from " + payload.sender,
|
||||
{
|
||||
reported_sender: event.getSender()!,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Olm events intended for a room have a room_id.
|
||||
if (payload.room_id !== event.getRoomId()) {
|
||||
throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, {
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.OLM_BAD_ROOM,
|
||||
"Message intended for room " + payload.room_id,
|
||||
{
|
||||
reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const claimedKeys = payload.keys || {};
|
||||
|
@ -73,7 +73,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { IMessage } from "./algorithms/olm";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { MapWithDefault, recursiveMapToObject } from "../utils";
|
||||
import {
|
||||
@ -90,6 +90,7 @@ import {
|
||||
BackupTrustInfo,
|
||||
BootstrapCrossSigningOpts,
|
||||
CrossSigningStatus,
|
||||
DecryptionFailureCode,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
@ -97,8 +98,8 @@ import {
|
||||
ImportRoomKeysOpts,
|
||||
KeyBackupCheck,
|
||||
KeyBackupInfo,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
OwnDeviceKeys,
|
||||
VerificationRequest as CryptoApiVerificationRequest,
|
||||
} from "../crypto-api";
|
||||
import { Device, DeviceMap } from "../models/device";
|
||||
import { deviceInfoToDevice } from "./device-converter";
|
||||
@ -4209,8 +4210,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
|
||||
if (!AlgClass) {
|
||||
throw new algorithms.DecryptionError(
|
||||
"UNKNOWN_ENCRYPTION_ALGORITHM",
|
||||
throw new DecryptionError(
|
||||
DecryptionFailureCode.UNKNOWN_ENCRYPTION_ALGORITHM,
|
||||
'Unknown encryption algorithm "' + algorithm + '".',
|
||||
);
|
||||
}
|
||||
|
@ -41,13 +41,13 @@ import { TypedReEmitter } from "../ReEmitter";
|
||||
import { MatrixError } from "../http-api";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { EventStatus } from "./event-status";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
import { CryptoBackend } from "../common-crypto/CryptoBackend";
|
||||
import { CryptoBackend, DecryptionError } from "../common-crypto/CryptoBackend";
|
||||
import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
|
||||
import { IAnnotatedPushRule } from "../@types/PushRules";
|
||||
import { Room } from "./room";
|
||||
import { EventTimeline } from "./event-timeline";
|
||||
import { Membership } from "../@types/membership";
|
||||
import { DecryptionFailureCode } from "../crypto-api";
|
||||
|
||||
export { EventStatus } from "./event-status";
|
||||
|
||||
@ -227,7 +227,18 @@ export interface IMessageVisibilityHidden {
|
||||
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
|
||||
|
||||
export enum MatrixEventEvent {
|
||||
/**
|
||||
* An event has been decrypted, or we have failed to decrypt it.
|
||||
*
|
||||
* The payload consists of:
|
||||
*
|
||||
* * `event` - The {@link MatrixEvent} which we attempted to decrypt.
|
||||
*
|
||||
* * `err` - The error that occurred during decryption, or `undefined` if no error occurred.
|
||||
* Avoid use of this: {@link MatrixEvent.decryptionFailureReason} is more useful.
|
||||
*/
|
||||
Decrypted = "Event.decrypted",
|
||||
|
||||
BeforeRedaction = "Event.beforeRedaction",
|
||||
VisibilityChange = "Event.visibilityChange",
|
||||
LocalEventIdReplaced = "Event.localEventIdReplaced",
|
||||
@ -239,12 +250,6 @@ export enum MatrixEventEvent {
|
||||
export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update;
|
||||
|
||||
export type MatrixEventHandlerMap = {
|
||||
/**
|
||||
* Fires when an event is decrypted
|
||||
*
|
||||
* @param event - The matrix event which has been decrypted
|
||||
* @param err - The error that occurred during decryption, or `undefined` if no error occurred.
|
||||
*/
|
||||
[MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void;
|
||||
[MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void;
|
||||
[MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void;
|
||||
@ -274,6 +279,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
private _hasCachedExtEv = false;
|
||||
private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
|
||||
|
||||
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
|
||||
private _decryptionFailureReason: DecryptionFailureCode | null = null;
|
||||
|
||||
/* curve25519 key which we believe belongs to the sender of the event. See
|
||||
* getSenderKey()
|
||||
*/
|
||||
@ -771,7 +779,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* couldn't decrypt.
|
||||
*/
|
||||
public isDecryptionFailure(): boolean {
|
||||
return this.clearEvent?.content?.msgtype === "m.bad.encrypted";
|
||||
return this._decryptionFailureReason !== null;
|
||||
}
|
||||
|
||||
/** If we failed to decrypt this event, the reason for the failure. Otherwise, `null`. */
|
||||
public get decryptionFailureReason(): DecryptionFailureCode | null {
|
||||
return this._decryptionFailureReason;
|
||||
}
|
||||
|
||||
/*
|
||||
@ -884,17 +897,14 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
while (true) {
|
||||
this.retryDecryption = false;
|
||||
|
||||
let res: IEventDecryptionResult;
|
||||
let err: Error | undefined = undefined;
|
||||
try {
|
||||
if (!crypto) {
|
||||
res = this.badEncryptedMessage("Encryption not enabled");
|
||||
} else {
|
||||
res = await crypto.decryptEvent(this);
|
||||
const res = await crypto.decryptEvent(this);
|
||||
if (options.isRetry === true) {
|
||||
logger.info(`Decrypted event on retry (${this.getDetails()})`);
|
||||
}
|
||||
}
|
||||
this.setClearData(res);
|
||||
this._decryptionFailureReason = null;
|
||||
} catch (e) {
|
||||
const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
|
||||
|
||||
@ -927,14 +937,12 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// so we don't bother to log `e` separately.
|
||||
logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
|
||||
|
||||
res = this.badEncryptedMessage(String(e));
|
||||
this.setClearDataForDecryptionFailure(String(e));
|
||||
this._decryptionFailureReason =
|
||||
e instanceof DecryptionError ? (<DecryptionError>e).code : DecryptionFailureCode.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
// at this point, we've either successfully decrypted the event, or have given up
|
||||
// (and set res to a 'badEncryptedMessage'). Either way, we can now set the
|
||||
// cleartext of the event and raise Event.decrypted.
|
||||
//
|
||||
// make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
|
||||
// Make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
|
||||
// otherwise the app will be confused to see `isBeingDecrypted` still set when
|
||||
// there isn't an `Event.decrypted` on the way.
|
||||
//
|
||||
@ -942,7 +950,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
//
|
||||
this.decryptionPromise = null;
|
||||
this.retryDecryption = false;
|
||||
this.setClearData(res);
|
||||
|
||||
// Before we emit the event, clear the push actions so that they can be recalculated
|
||||
// by relevant code. We do this because the clear event has now changed, making it
|
||||
@ -960,19 +967,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
}
|
||||
}
|
||||
|
||||
private badEncryptedMessage(reason: string): IEventDecryptionResult {
|
||||
return {
|
||||
clearEvent: {
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: "** Unable to decrypt: " + reason + " **",
|
||||
},
|
||||
},
|
||||
encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleartext data on this event.
|
||||
*
|
||||
@ -981,9 +975,6 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @internal
|
||||
*
|
||||
* @param decryptionResult - the decryption result, including the plaintext and some key info
|
||||
*
|
||||
* @remarks
|
||||
* Fires {@link MatrixEventEvent.Decrypted}
|
||||
*/
|
||||
private setClearData(decryptionResult: IEventDecryptionResult): void {
|
||||
this.clearEvent = decryptionResult.clearEvent;
|
||||
@ -991,7 +982,28 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
|
||||
this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
|
||||
this.untrusted = decryptionResult.untrusted || false;
|
||||
this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false;
|
||||
this.encryptedDisabledForUnverifiedDevices = false;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cleartext data on this event after a decryption failure.
|
||||
*
|
||||
* @param reason - the textual reason for the failure
|
||||
*/
|
||||
private setClearDataForDecryptionFailure(reason: string): void {
|
||||
this.clearEvent = {
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: "m.bad.encrypted",
|
||||
body: `** Unable to decrypt: ${reason} **`,
|
||||
},
|
||||
};
|
||||
this.senderCurve25519Key = null;
|
||||
this.claimedEd25519Key = null;
|
||||
this.forwardingCurve25519KeyChain = [];
|
||||
this.untrusted = false;
|
||||
this.encryptedDisabledForUnverifiedDevices = reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`;
|
||||
this.invalidateExtensibleEvent();
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ import type { IEncryptedEventInfo } from "../crypto/api";
|
||||
import { IContent, MatrixEvent, MatrixEventEvent } from "../models/event";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { BackupDecryptor, CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { BackupDecryptor, CryptoBackend, DecryptionError, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
|
||||
import { logger, Logger } from "../logger";
|
||||
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
|
||||
import { RoomEncryptor } from "./RoomEncryptor";
|
||||
@ -39,6 +39,7 @@ import {
|
||||
CrossSigningStatus,
|
||||
CryptoCallbacks,
|
||||
Curve25519AuthData,
|
||||
DecryptionFailureCode,
|
||||
DeviceVerificationStatus,
|
||||
EventEncryptionInfo,
|
||||
EventShieldColour,
|
||||
@ -70,7 +71,6 @@ import { randomString } from "../randomstring";
|
||||
import { ClientStoppedError } from "../errors";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { encodeBase64 } from "../base64";
|
||||
import { DecryptionError } from "../crypto/algorithms";
|
||||
import { OutgoingRequestsManager } from "./OutgoingRequestsManager";
|
||||
import { PerSessionKeyBackupDownloader } from "./PerSessionKeyBackupDownloader";
|
||||
|
||||
@ -1685,7 +1685,7 @@ class EventDecryptor {
|
||||
switch (err.code) {
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.MissingRoomKey: {
|
||||
jsError = new DecryptionError(
|
||||
"MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
"The sender's device has not sent us the keys for this message.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
@ -1699,7 +1699,7 @@ class EventDecryptor {
|
||||
}
|
||||
case RustSdkCryptoJs.DecryptionErrorCode.UnknownMessageIndex: {
|
||||
jsError = new DecryptionError(
|
||||
"OLM_UNKNOWN_MESSAGE_INDEX",
|
||||
DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
"The sender's device has not sent us the keys for this message at this index.",
|
||||
{
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
@ -1712,9 +1712,9 @@ class EventDecryptor {
|
||||
break;
|
||||
}
|
||||
// We don't map MismatchedIdentityKeys for now, as there is no equivalent in legacy.
|
||||
// Just put it on the `UNABLE_TO_DECRYPT` bucket.
|
||||
// Just put it on the `UNKNOWN_ERROR` bucket.
|
||||
default: {
|
||||
jsError = new DecryptionError("UNABLE_TO_DECRYPT", err.description, {
|
||||
jsError = new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, err.description, {
|
||||
session: content.sender_key + "|" + content.session_id,
|
||||
});
|
||||
break;
|
||||
@ -1722,7 +1722,7 @@ class EventDecryptor {
|
||||
}
|
||||
throw jsError;
|
||||
}
|
||||
throw new DecryptionError("UNABLE_TO_DECRYPT", "Unknown error");
|
||||
throw new DecryptionError(DecryptionFailureCode.UNKNOWN_ERROR, "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ import { RoomMember } from "./models/room-member";
|
||||
import { EventType } from "./@types/event";
|
||||
import { IEventDecryptionResult } from "./@types/crypto";
|
||||
import { DecryptionError } from "./crypto/algorithms";
|
||||
import { DecryptionFailureCode } from "./crypto-api";
|
||||
|
||||
/**
|
||||
* Create a {@link MatrixEvent}.
|
||||
@ -143,7 +144,7 @@ export async function mkDecryptionFailureMatrixEvent(opts: {
|
||||
sender: string;
|
||||
|
||||
/** The reason code for the failure */
|
||||
code: string;
|
||||
code: DecryptionFailureCode;
|
||||
|
||||
/** A textual reason for the failure */
|
||||
msg: string;
|
||||
|
Reference in New Issue
Block a user