Skip to content

Commit

Permalink
Merge branch 'develop' into florianduros/rip-out-legacy-crypto/add-cr…
Browse files Browse the repository at this point in the history
…ypto-api-getbackupinfo
  • Loading branch information
florianduros authored Nov 14, 2024
2 parents ccc07bb + 325dace commit ff9dc7b
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 8 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,37 @@ jobs:

- name: Run linter
run: "yarn run lint:knip"

element-web:
name: Downstream tsc element-web
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
repository: element-hq/element-web

- uses: actions/setup-node@v4
with:
cache: "yarn"
node-version: "lts/*"

- name: Install Dependencies
run: "./scripts/layered.sh"
env:
# tell layered.sh to check out the right sha of the JS-SDK
JS_SDK_GITHUB_BASE_REF: ${{ github.sha }}

- name: Typecheck
run: "yarn run lint:types"

# Hook for branch protection to skip downstream typechecking outside of merge queues
downstream:
name: Downstream Typescript Syntax Check
runs-on: ubuntu-24.04
if: always()
needs:
- element-web
steps:
- if: needs.element-web.result != 'skipped' && needs.element-web.result != 'success'
run: exit 1
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ client.publicRooms(function (err, data) {
});
```

See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node/README.md) for a more complex example.
See [below](#end-to-end-encryption-support) for how to enable end-to-end-encryption, or check
[the Node.js terminal app](https://github.com/matrix-org/matrix-js-sdk/tree/develop/examples/node) for a more complex example.

To start the client:

Expand Down
147 changes: 146 additions & 1 deletion spec/unit/embedded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ITurnServer,
IRoomEvent,
IOpenIDCredentials,
ISendEventFromWidgetResponseData,
WidgetApiResponseError,
} from "matrix-widget-api";

Expand All @@ -40,6 +41,7 @@ import { ICapabilities, RoomWidgetClient } from "../../src/embedded";
import { MatrixEvent } from "../../src/models/event";
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { sleep } from "../../src/utils";

const testOIDCToken = {
access_token: "12345678",
Expand Down Expand Up @@ -127,9 +129,16 @@ describe("RoomWidgetClient", () => {
const makeClient = async (
capabilities: ICapabilities,
sendContentLoaded: boolean | undefined = undefined,
userId?: string,
): Promise<void> => {
const baseUrl = "https://example.org";
client = createRoomWidgetClient(widgetApi, capabilities, "!1:example.org", { baseUrl }, sendContentLoaded);
client = createRoomWidgetClient(
widgetApi,
capabilities,
"!1:example.org",
{ baseUrl, userId },
sendContentLoaded,
);
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
widgetApi.emit("ready");
await client.startClient();
Expand Down Expand Up @@ -192,6 +201,142 @@ describe("RoomWidgetClient", () => {
.map((e) => e.getEffectiveEvent()),
).toEqual([event]);
});
describe("local echos", () => {
const setupRemoteEcho = () => {
makeClient(
{
receiveEvent: ["org.matrix.rageshake_request"],
sendEvent: ["org.matrix.rageshake_request"],
},
undefined,
"@me:example.org",
);
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
const injectSpy = jest.spyOn((client as any).syncApi, "injectRoomEvents");
const widgetSendEmitter = new EventEmitter();
const widgetSendPromise = new Promise<void>((resolve) =>
widgetSendEmitter.once("send", () => resolve()),
);
const resolveWidgetSend = () => widgetSendEmitter.emit("send");
widgetApi.sendRoomEvent.mockImplementation(
async (eType, content, roomId): Promise<ISendEventFromWidgetResponseData> => {
await widgetSendPromise;
return { room_id: "!1:example.org", event_id: "event_id" };
},
);
return { injectSpy, resolveWidgetSend };
};
const remoteEchoEvent = new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
detail: {
data: {
type: "org.matrix.rageshake_request",

room_id: "!1:example.org",
event_id: "event_id",
sender: "@me:example.org",
state_key: "bar",
content: { hello: "world" },
unsigned: { transaction_id: "1234" },
},
},
cancelable: true,
});
it("get response then local echo", async () => {
await sleep(600);
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();

// Begin by sending an event:
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
// we do not expect it to be send -- until we call `resolveWidgetSend`
expect(injectSpy).not.toHaveBeenCalled();

// We first get the response from the widget
resolveWidgetSend();
// We then get the remote echo from the widget
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);

// gets emitted after the event got injected
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
expect(injectSpy).toHaveBeenCalled();

const call = injectSpy.mock.calls[0] as any;
const injectedEv = call[2][0];
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");
});

it("get local echo then response", async () => {
await sleep(600);
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();

// Begin by sending an event:
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
// we do not expect it to be send -- until we call `resolveWidgetSend`
expect(injectSpy).not.toHaveBeenCalled();

// We first get the remote echo from the widget
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);
expect(injectSpy).not.toHaveBeenCalled();

// We then get the response from the widget
resolveWidgetSend();

// Gets emitted after the event got injected
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
expect(injectSpy).toHaveBeenCalled();

const call = injectSpy.mock.calls[0] as any;
const injectedEv = call[2][0];
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");
});
it("__ local echo then response", async () => {
await sleep(600);
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();

// Begin by sending an event:
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
// we do not expect it to be send -- until we call `resolveWidgetSend`
expect(injectSpy).not.toHaveBeenCalled();

// We first get the remote echo from the widget
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);
const otherRemoteEcho = new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
detail: { data: { ...remoteEchoEvent.detail.data } },
});
otherRemoteEcho.detail.data.unsigned.transaction_id = "4567";
otherRemoteEcho.detail.data.event_id = "other_id";
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, otherRemoteEcho);

// Simulate the wait time while the widget is waiting for a response
// after we already received the remote echo
await sleep(20);
// even after the wait we do not want any event to be injected.
// we do not know their event_id and cannot know if they are the remote echo
// where we need to update the txId because they are send by this client
expect(injectSpy).not.toHaveBeenCalled();
// We then get the response from the widget
resolveWidgetSend();

// Gets emitted after the event got injected
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
// Now we want both events to be injected since we know the txId - event_id match
expect(injectSpy).toHaveBeenCalled();

// it has been called with the event sent by ourselves
const call = injectSpy.mock.calls[0] as any;
const injectedEv = call[2][0];
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");

// It has been called by the event we blocked because of our send right afterwards
const call2 = injectSpy.mock.calls[1] as any;
const injectedEv2 = call2[2][0];
expect(injectedEv2.getType()).toBe("org.matrix.rageshake_request");
expect(injectedEv2.getUnsigned().transaction_id).toBe("4567");
});
});

it("handles widget errors with generic error data", async () => {
const error = new Error("failed to send");
Expand Down
8 changes: 5 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ import {
CryptoEventHandlerMap as LegacyCryptoEventHandlerMap,
fixBackupKey,
ICheckOwnCrossSigningTrustOpts,
ICryptoCallbacks,
IRoomKeyRequestBody,
isCryptoAvailable,
} from "./crypto/index.ts";
Expand Down Expand Up @@ -229,6 +228,7 @@ import {
ImportRoomKeysOpts,
CryptoEvent,
CryptoEventHandlerMap,
CryptoCallbacks,
} from "./crypto-api/index.ts";
import { DeviceInfoMap } from "./crypto/DeviceList.ts";
import {
Expand Down Expand Up @@ -437,7 +437,7 @@ export interface ICreateClientOpts {
/**
* Crypto callbacks provided by the application
*/
cryptoCallbacks?: ICryptoCallbacks;
cryptoCallbacks?: CryptoCallbacks;

/**
* Method to generate room names for empty rooms and rooms names based on membership.
Expand Down Expand Up @@ -1253,7 +1253,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend

private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
public groupCallEventHandler?: GroupCallEventHandler;
public supportsCallTransfer = false; // XXX: Intended private, used in code.
Expand Down Expand Up @@ -3157,6 +3157,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns true if the sender of this event has been verified using
* {@link MatrixClient#setDeviceVerified}.
*
* @deprecated Not supported for Rust Cryptography.
*/
public async isEventSenderVerified(event: MatrixEvent): Promise<boolean> {
const device = await this.getEventSenderDeviceInfo(event);
Expand Down
67 changes: 66 additions & 1 deletion src/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { DeviceInfo } from "./crypto/deviceinfo.ts";
import { IOlmDevice } from "./crypto/algorithms/megolm.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { TypedEventEmitter } from "./matrix.ts";

interface IStateEventRequest {
eventType: string;
Expand Down Expand Up @@ -123,6 +124,10 @@ export interface ICapabilities {
updateDelayedEvents?: boolean;
}

export enum RoomWidgetClientEvent {
PendingEventsChanged = "PendingEvent.pendingEventsChanged",
}
export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: () => void };
/**
* A MatrixClient that routes its requests through the widget API instead of the
* real CS API.
Expand All @@ -134,6 +139,9 @@ export class RoomWidgetClient extends MatrixClient {
private lifecycle?: AbortController;
private syncState: SyncState | null = null;

private pendingSendingEventsTxId: { type: string; id: string | undefined; txId: string }[] = [];
private eventEmitter = new TypedEventEmitter<keyof EventHandlerMap, EventHandlerMap>();

/**
*
* @param widgetApi - The widget api to use for communication.
Expand Down Expand Up @@ -330,6 +338,8 @@ export class RoomWidgetClient extends MatrixClient {
const content = event.event.redacts
? { ...event.getContent(), redacts: event.event.redacts }
: event.getContent();

// Delayed event special case.
if (delayOpts) {
// TODO: updatePendingEvent for delayed events?
const response = await this.widgetApi.sendRoomEvent(
Expand All @@ -342,16 +352,26 @@ export class RoomWidgetClient extends MatrixClient {
return this.validateSendDelayedEventResponse(response);
}

const txId = event.getTxnId();
// Add the txnId to the pending list (still with unknown evID)
if (txId) this.pendingSendingEventsTxId.push({ type: event.getType(), id: undefined, txId });

let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi.sendRoomEvent(event.getType(), content, room.roomId);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
throw e;
}

// This also checks for an event id on the response
room.updatePendingEvent(event, EventStatus.SENT, response.event_id);

// Update the pending events list with the eventId
this.pendingSendingEventsTxId.forEach((p) => {
if (p.txId === txId) p.id = response.event_id;
});
this.eventEmitter.emit(RoomWidgetClientEvent.PendingEventsChanged);

return { event_id: response.event_id! };
}

Expand Down Expand Up @@ -495,13 +515,58 @@ export class RoomWidgetClient extends MatrixClient {
await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
}

private updateTxId = async (event: MatrixEvent): Promise<void> => {
// We update the txId for remote echos that originate from this client.
// This happens with the help of `pendingSendingEventsTxId` where we store all events that are currently sending
// with their widget txId and once ready the final evId.
if (
// This could theoretically be an event send by this device
// In that case we need to update the txId of the event because the embedded client/widget
// knows this event with a different transaction Id than what was used by the host client.
event.getSender() === this.getUserId() &&
// We optimize by not blocking events from types that we have not send
// with this client.
this.pendingSendingEventsTxId.some((p) => event.getType() === p.type)
) {
// Compare by event Id if we have a matching pending event where we know the txId.
let matchingTxId = this.pendingSendingEventsTxId.find((p) => p.id === event.getId())?.txId;
// Block any further processing of this event until we have received the sending response.
// -> until we know the event id.
// -> until we have not pending events anymore.
while (!matchingTxId && this.pendingSendingEventsTxId.length > 0) {
// Recheck whenever the PendingEventsChanged
await new Promise<void>((resolve) =>
this.eventEmitter.once(RoomWidgetClientEvent.PendingEventsChanged, () => resolve()),
);
matchingTxId = this.pendingSendingEventsTxId.find((p) => p.id === event.getId())?.txId;
}

// We found the correct txId: we update the event and delete the entry of the pending events.
if (matchingTxId) {
event.setTxnId(matchingTxId);
event.setUnsigned({ ...event.getUnsigned(), transaction_id: matchingTxId });
}
this.pendingSendingEventsTxId = this.pendingSendingEventsTxId.filter((p) => p.id !== event.getId());

// Emit once there are no pending events anymore to release all other events that got
// awaited in the `while (!matchingTxId && this.pendingSendingEventsTxId.length > 0)` loop
// but are not send by this client.
if (this.pendingSendingEventsTxId.length === 0) {
this.eventEmitter.emit(RoomWidgetClientEvent.PendingEventsChanged);
}
}
};

private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>): Promise<void> => {
ev.preventDefault();

// Verify the room ID matches, since it's possible for the client to
// send us events from other rooms if this widget is always on screen
if (ev.detail.data.room_id === this.roomId) {
const event = new MatrixEvent(ev.detail.data as Partial<IEvent>);

// Only inject once we have update the txId
await this.updateTxId(event);
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
this.emit(ClientEvent.Event, event);
this.setSyncState(SyncState.Syncing);
Expand Down
Loading

0 comments on commit ff9dc7b

Please sign in to comment.