From 826ea5bc5838633177b9c7152e2b447f9c785100 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 13 Jun 2023 12:55:37 +0100 Subject: [PATCH 01/40] Pull out a new `VerificationRequest` interface (#3449) * add a test for incoming verification requests * Move `VerificationRequestEvent` to crypto-api * Move `VerificationPhase` to `crypto-api` * Define `VerificationRequest` interface * Implement `canAcceptVerificationRequest` --- spec/integ/crypto/verification.spec.ts | 47 ++++- src/crypto-api/verification.ts | 189 ++++++++++++++++++ .../request/VerificationRequest.ts | 42 ++-- 3 files changed, 251 insertions(+), 27 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index bd8d15cec0b..2f366558723 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -17,10 +17,10 @@ limitations under the License. import fetchMock from "fetch-mock-jest"; import { MockResponse } from "fetch-mock"; -import { createClient, MatrixClient } from "../../../src"; +import { createClient, CryptoEvent, MatrixClient } from "../../../src"; import { ShowQrCodeCallbacks, ShowSasCallbacks, Verifier, VerifierEvent } from "../../../src/crypto-api/verification"; import { escapeRegExp } from "../../../src/utils"; -import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils"; +import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { MASTER_CROSS_SIGNING_PUBLIC_KEY_BASE64, @@ -350,6 +350,49 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }, ); + oldBackendOnly("Incoming verification: can accept", async () => { + // expect requests to download our own keys + fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { + device_keys: { + [TEST_USER_ID]: { + [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + }, + }, + }); + + const TRANSACTION_ID = "abcd"; + + // Initiate the request by sending a to-device message + returnToDeviceMessageFromSync({ + type: "m.key.verification.request", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: TRANSACTION_ID, + timestamp: Date.now() - 1000, + }, + }); + const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); + expect(request.transactionId).toEqual(TRANSACTION_ID); + expect(request.phase).toEqual(Phase.Requested); + expect(request.roomId).toBeUndefined(); + expect(request.canAccept).toBe(true); + + // Alice accepts, by sending a to-device message + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); + const acceptPromise = request.accept(); + expect(request.canAccept).toBe(false); + expect(request.phase).toEqual(Phase.Requested); + await acceptPromise; + const requestBody = await sendToDevicePromise; + expect(request.phase).toEqual(Phase.Ready); + + const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.methods).toContain("m.sas.v1"); + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID); + }); + function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); diff --git a/src/crypto-api/verification.ts b/src/crypto-api/verification.ts index 8a078df1cca..daaf405b1d1 100644 --- a/src/crypto-api/verification.ts +++ b/src/crypto-api/verification.ts @@ -17,6 +17,187 @@ limitations under the License. import { MatrixEvent } from "../models/event"; import { TypedEventEmitter } from "../models/typed-event-emitter"; +/** + * An incoming, or outgoing, request to verify a user or a device via cross-signing. + */ +export interface VerificationRequest + extends TypedEventEmitter { + /** + * Unique ID for this verification request. + * + * An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases. + */ + get transactionId(): string | undefined; + + /** + * For an in-room verification, the ID of the room. + * + * For to-device verifictions, `undefined`. + */ + get roomId(): string | undefined; + + /** + * True if this request was initiated by the local client. + * + * For in-room verifications, the initiator is who sent the `m.key.verification.request` event. + * For to-device verifications, the initiator is who sent the `m.key.verification.start` event. + */ + get initiatedByMe(): boolean; + + /** The user id of the other party in this request */ + get otherUserId(): string; + + /** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */ + get otherDeviceId(): string | undefined; + + /** True if the other party in this request is one of this user's own devices. */ + get isSelfVerification(): boolean; + + /** current phase of the request. */ + get phase(): VerificationPhase; + + /** True if the request has sent its initial event and needs more events to complete + * (ie it is in phase `Requested`, `Ready` or `Started`). + */ + get pending(): boolean; + + /** + * True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Ready}. + */ + get accepting(): boolean; + + /** + * True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Cancelled}). + */ + get declining(): boolean; + + /** + * The remaining number of ms before the request will be automatically cancelled. + * + * `null` indicates that there is no timeout + */ + get timeout(): number | null; + + /** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */ + get methods(): string[]; + + /** the method picked in the .start event */ + get chosenMethod(): string | null; + + /** + * Checks whether the other party supports a given verification method. + * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: + * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. + * For methods that need to be supported by both ends, use the `methods` property. + * + * @param method - the method to check + * @returns true if the other party said they supported the method + */ + otherPartySupportsMethod(method: string): boolean; + + /** + * Accepts the request, sending a .ready event to the other party + * + * @returns Promise which resolves when the event has been sent. + */ + accept(): Promise; + + /** + * Cancels the request, sending a cancellation to the other party + * + * @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code` + * (defaults to `m.user`). + * + * @returns Promise which resolves when the event has been sent. + */ + cancel(params?: { reason?: string; code?: string }): Promise; + + /** + * Create a {@link Verifier} to do this verification via a particular method. + * + * If a verifier has already been created for this request, returns that verifier. + * + * This does *not* send the `m.key.verification.start` event - to do so, call {@link Crypto.Verifier#verify} on the + * returned verifier. + * + * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. + * + * @param method - the name of the verification method to use. + * @param targetDevice - details of where to send the request to. + * + * @returns The verifier which will do the actual verification. + */ + beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier; + + /** + * The verifier which is doing the actual verification, once the method has been established. + * Only defined when the `phase` is Started. + */ + get verifier(): Verifier | undefined; + + /** + * Get the data for a QR code allowing the other device to verify this one, if it supports it. + * + * Only set after a .ready if the other party can scan a QR code, otherwise undefined. + */ + getQRCodeBytes(): Buffer | undefined; + + /** + * If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling + * this verification. + */ + get cancellationCode(): string | null; + + /** + * The id of the user that cancelled the request. + * + * Only defined when phase is Cancelled + */ + get cancellingUserId(): string | undefined; +} + +/** Events emitted by {@link VerificationRequest}. */ +export enum VerificationRequestEvent { + /** + * Fires whenever the state of the request object has changed. + * + * There is no payload to the event. + */ + Change = "change", +} + +/** + * Listener type map for {@link VerificationRequestEvent}s. + * + * @internal + */ +export type VerificationRequestEventHandlerMap = { + [VerificationRequestEvent.Change]: () => void; +}; + +/** The current phase of a verification request. */ +export enum VerificationPhase { + /** Initial state: no event yet exchanged */ + Unsent = 1, + + /** An `m.key.verification.request` event has been sent or received */ + Requested, + + /** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */ + Ready, + + /** An `m.key.verification.start` event has been sent or received, choosing a verification method */ + Started, + + /** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */ + Cancelled, + + /** An `m.key.verification.done` event has been **sent**, completing the verification request. */ + Done, +} + /** * A `Verifier` is responsible for performing the verification using a particular method, such as via QR code or SAS * (emojis). @@ -169,3 +350,11 @@ export interface GeneratedSas { * English name. */ export type EmojiMapping = [emoji: string, name: string]; + +/** + * True if the request is in a state where it can be accepted (ie, that we're in phases {@link VerificationPhase.Unsent} + * or {@link VerificationPhase.Requested}, and that we're not in the process of sending a `ready` or `cancel`). + */ +export function canAcceptVerificationRequest(req: VerificationRequest): boolean { + return req.phase < VerificationPhase.Ready && !req.accepting && !req.declining; +} diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts index 5fc69adf70a..c5ffba21419 100644 --- a/src/crypto/verification/request/VerificationRequest.ts +++ b/src/crypto/verification/request/VerificationRequest.ts @@ -24,6 +24,16 @@ import { EventType } from "../../../@types/event"; import { VerificationBase } from "../Base"; import { VerificationMethod } from "../../index"; import { TypedEventEmitter } from "../../../models/typed-event-emitter"; +import { + canAcceptVerificationRequest, + VerificationPhase as Phase, + VerificationRequest as IVerificationRequest, + VerificationRequestEvent, + VerificationRequestEventHandlerMap, +} from "../../../crypto-api/verification"; + +// backwards-compatibility exports +export { VerificationPhase as Phase, VerificationRequestEvent } from "../../../crypto-api/verification"; // How long after the event's timestamp that the request times out const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes @@ -44,15 +54,6 @@ export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; export const DONE_TYPE = EVENT_PREFIX + "done"; export const READY_TYPE = EVENT_PREFIX + "ready"; -export enum Phase { - Unsent = 1, - Requested, - Ready, - Started, - Cancelled, - Done, -} - // Legacy export fields export const PHASE_UNSENT = Phase.Unsent; export const PHASE_REQUESTED = Phase.Requested; @@ -71,26 +72,17 @@ interface ITransition { event?: MatrixEvent; } -export enum VerificationRequestEvent { - Change = "change", -} - -type EventHandlerMap = { - /** - * Fires whenever the state of the request object has changed. - */ - [VerificationRequestEvent.Change]: () => void; -}; - /** * State machine for verification requests. * Things that differ based on what channel is used to * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. + * + * @deprecated Avoid direct references: instead prefer {@link Crypto.VerificationRequest}. */ -export class VerificationRequest extends TypedEventEmitter< - VerificationRequestEvent, - EventHandlerMap -> { +export class VerificationRequest + extends TypedEventEmitter + implements IVerificationRequest +{ private eventsByUs = new Map(); private eventsByThem = new Map(); private _observeOnly = false; @@ -257,7 +249,7 @@ export class VerificationRequest Date: Wed, 14 Jun 2023 08:52:40 +1200 Subject: [PATCH 02/40] Update typescript-eslint monorepo to v5.59.9 (#3466) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 116 +++++++++++++++++++++++++++--------------------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/yarn.lock b/yarn.lock index c0c49780b47..dd15a07b0f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1852,14 +1852,14 @@ "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.45.0": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.8.tgz#1e7a3e5318ece22251dfbc5c9c6feeb4793cc509" - integrity sha512-JDMOmhXteJ4WVKOiHXGCoB96ADWg9q7efPWHRViT/f09bA8XOMLAVHHju3l0MkZnG1izaWXYmgvQcUjTRcpShQ== + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.11.tgz#8d466aa21abea4c3f37129997b198d141f09e76f" + integrity sha512-XxuOfTkCUiOSyBWIvHlUraLw/JT/6Io1365RO6ZuI88STKMavJZPNMU0lFcUTeQXEhHiv64CbxYxBNoDVSmghg== dependencies: "@eslint-community/regexpp" "^4.4.0" - "@typescript-eslint/scope-manager" "5.59.8" - "@typescript-eslint/type-utils" "5.59.8" - "@typescript-eslint/utils" "5.59.8" + "@typescript-eslint/scope-manager" "5.59.11" + "@typescript-eslint/type-utils" "5.59.11" + "@typescript-eslint/utils" "5.59.11" debug "^4.3.4" grapheme-splitter "^1.0.4" ignore "^5.2.0" @@ -1868,15 +1868,23 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.45.0": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.8.tgz#60cbb00671d86cf746044ab797900b1448188567" - integrity sha512-AnR19RjJcpjoeGojmwZtCwBX/RidqDZtzcbG3xHrmz0aHHoOcbWnpDllenRDmDvsV0RQ6+tbb09/kyc+UT9Orw== + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.11.tgz#af7d4b7110e3068ce0b97550736de455e4250103" + integrity sha512-s9ZF3M+Nym6CAZEkJJeO2TFHHDsKAM3ecNkLuH4i4s8/RCPnF5JRip2GyviYkeEAcwGMJxkqG9h2dAsnA1nZpA== dependencies: - "@typescript-eslint/scope-manager" "5.59.8" - "@typescript-eslint/types" "5.59.8" - "@typescript-eslint/typescript-estree" "5.59.8" + "@typescript-eslint/scope-manager" "5.59.11" + "@typescript-eslint/types" "5.59.11" + "@typescript-eslint/typescript-estree" "5.59.11" debug "^4.3.4" +"@typescript-eslint/scope-manager@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.11.tgz#5d131a67a19189c42598af9fb2ea1165252001ce" + integrity sha512-dHFOsxoLFtrIcSj5h0QoBT/89hxQONwmn3FOQ0GOQcLOOXm+MIrS8zEAhs4tWl5MraxCY3ZJpaXQQdFMc2Tu+Q== + dependencies: + "@typescript-eslint/types" "5.59.11" + "@typescript-eslint/visitor-keys" "5.59.11" + "@typescript-eslint/scope-manager@5.59.6": version "5.59.6" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz#d43a3687aa4433868527cfe797eb267c6be35f19" @@ -1885,71 +1893,63 @@ "@typescript-eslint/types" "5.59.6" "@typescript-eslint/visitor-keys" "5.59.6" -"@typescript-eslint/scope-manager@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.8.tgz#ff4ad4fec6433647b817c4a7d4b4165d18ea2fa8" - integrity sha512-/w08ndCYI8gxGf+9zKf1vtx/16y8MHrZs5/tnjHhMLNSixuNcJavSX4wAiPf4aS5x41Es9YPCn44MIe4cxIlig== - dependencies: - "@typescript-eslint/types" "5.59.8" - "@typescript-eslint/visitor-keys" "5.59.8" - -"@typescript-eslint/type-utils@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.8.tgz#aa6c029a9d7706d26bbd25eb4666398781df6ea2" - integrity sha512-+5M518uEIHFBy3FnyqZUF3BMP+AXnYn4oyH8RF012+e7/msMY98FhGL5SrN29NQ9xDgvqCgYnsOiKp1VjZ/fpA== +"@typescript-eslint/type-utils@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.11.tgz#5eb67121808a84cb57d65a15f48f5bdda25f2346" + integrity sha512-LZqVY8hMiVRF2a7/swmkStMYSoXMFlzL6sXV6U/2gL5cwnLWQgLEG8tjWPpaE4rMIdZ6VKWwcffPlo1jPfk43g== dependencies: - "@typescript-eslint/typescript-estree" "5.59.8" - "@typescript-eslint/utils" "5.59.8" + "@typescript-eslint/typescript-estree" "5.59.11" + "@typescript-eslint/utils" "5.59.11" debug "^4.3.4" tsutils "^3.21.0" +"@typescript-eslint/types@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.11.tgz#1a9018fe3c565ba6969561f2a49f330cf1fe8db1" + integrity sha512-epoN6R6tkvBYSc+cllrz+c2sOFWkbisJZWkOE+y3xHtvYaOE6Wk6B8e114McRJwFRjGvYdJwLXQH5c9osME/AA== + "@typescript-eslint/types@5.59.6": version "5.59.6" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.6.tgz#5a6557a772af044afe890d77c6a07e8c23c2460b" integrity sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA== -"@typescript-eslint/types@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.8.tgz#212e54414733618f5d0fd50b2da2717f630aebf8" - integrity sha512-+uWuOhBTj/L6awoWIg0BlWy0u9TyFpCHrAuQ5bNfxDaZ1Ppb3mx6tUigc74LHcbHpOHuOTOJrBoAnhdHdaea1w== - -"@typescript-eslint/typescript-estree@5.59.6": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz#2fb80522687bd3825504925ea7e1b8de7bb6251b" - integrity sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA== +"@typescript-eslint/typescript-estree@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.11.tgz#b2caaa31725e17c33970c1197bcd54e3c5f42b9f" + integrity sha512-YupOpot5hJO0maupJXixi6l5ETdrITxeo5eBOeuV7RSKgYdU3G5cxO49/9WRnJq9EMrB7AuTSLH/bqOsXi7wPA== dependencies: - "@typescript-eslint/types" "5.59.6" - "@typescript-eslint/visitor-keys" "5.59.6" + "@typescript-eslint/types" "5.59.11" + "@typescript-eslint/visitor-keys" "5.59.11" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.8.tgz#801a7b1766481629481b3b0878148bd7a1f345d7" - integrity sha512-Jy/lPSDJGNow14vYu6IrW790p7HIf/SOV1Bb6lZ7NUkLc2iB2Z9elESmsaUtLw8kVqogSbtLH9tut5GCX1RLDg== +"@typescript-eslint/typescript-estree@5.59.6": + version "5.59.6" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz#2fb80522687bd3825504925ea7e1b8de7bb6251b" + integrity sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA== dependencies: - "@typescript-eslint/types" "5.59.8" - "@typescript-eslint/visitor-keys" "5.59.8" + "@typescript-eslint/types" "5.59.6" + "@typescript-eslint/visitor-keys" "5.59.6" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.8.tgz#34d129f35a2134c67fdaf024941e8f96050dca2b" - integrity sha512-Tr65630KysnNn9f9G7ROF3w1b5/7f6QVCJ+WK9nhIocWmx9F+TmCAcglF26Vm7z8KCTwoKcNEBZrhlklla3CKg== +"@typescript-eslint/utils@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.11.tgz#9dbff49dc80bfdd9289f9f33548f2e8db3c59ba1" + integrity sha512-didu2rHSOMUdJThLk4aZ1Or8IcO3HzCw/ZvEjTTIfjIrcdd5cvSIwwDy2AOlE7htSNp7QIZ10fLMyRCveesMLg== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.59.8" - "@typescript-eslint/types" "5.59.8" - "@typescript-eslint/typescript-estree" "5.59.8" + "@typescript-eslint/scope-manager" "5.59.11" + "@typescript-eslint/types" "5.59.11" + "@typescript-eslint/typescript-estree" "5.59.11" eslint-scope "^5.1.1" semver "^7.3.7" @@ -1967,6 +1967,14 @@ eslint-scope "^5.1.1" semver "^7.3.7" +"@typescript-eslint/visitor-keys@5.59.11": + version "5.59.11" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.11.tgz#dca561ddad169dc27d62396d64f45b2d2c3ecc56" + integrity sha512-KGYniTGG3AMTuKF9QBD7EIrvufkB6O6uX3knP73xbKLMpH+QRPcgnCxjWXSHjMRuOxFLovljqQgQpR0c7GvjoA== + dependencies: + "@typescript-eslint/types" "5.59.11" + eslint-visitor-keys "^3.3.0" + "@typescript-eslint/visitor-keys@5.59.6": version "5.59.6" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz#673fccabf28943847d0c8e9e8d008e3ada7be6bb" @@ -1975,14 +1983,6 @@ "@typescript-eslint/types" "5.59.6" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.59.8": - version "5.59.8" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.8.tgz#aa6a7ef862add919401470c09e1609392ef3cc40" - integrity sha512-pJhi2ms0x0xgloT7xYabil3SGGlojNNKjK/q6dB3Ey0uJLMjK2UDGJvHieiyJVW/7C3KI+Z4Q3pEHkm4ejA+xQ== - dependencies: - "@typescript-eslint/types" "5.59.8" - eslint-visitor-keys "^3.3.0" - JSONStream@^1.0.3: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" From 9f30defcd14e7e076b70701b648cd4450578d21d Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Wed, 14 Jun 2023 04:02:48 -0400 Subject: [PATCH 03/40] Upgrade JS-DevTools/npm-publish to v2 (#3456) - Upgrade JS-DevTools/npm-publish to v2.2.0 - Remove workaround for bug JS-DevTools/npm-publish#15 - Remove usage of `jq` in favor of npm-publish output Signed-off-by: Michael Cousins --- .github/workflows/release-npm.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index daa9cb589bc..e4837153589 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -24,18 +24,16 @@ jobs: - name: 🚀 Publish to npm id: npm-publish - uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1 + uses: JS-DevTools/npm-publish@a25b4180b728b0279fca97d4e5bccf391685aead # v2.2.0 with: token: ${{ secrets.NPM_TOKEN }} access: public tag: next + ignore-scripts: false - name: 🎖️ Add `latest` dist-tag to final releases - if: github.event.release.prerelease == false - run: | - package=$(cat package.json | jq -er .name) - npm dist-tag add "$package@$release" latest + if: github.event.release.prerelease == false && steps.npm-publish.outputs.id + run: npm dist-tag add "$release" latest env: - # JS-DevTools/npm-publish overrides `NODE_AUTH_TOKEN` with `INPUT_TOKEN` in .npmrc - INPUT_TOKEN: ${{ secrets.NPM_TOKEN }} - release: ${{ steps.npm-publish.outputs.version }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + release: ${{ steps.npm-publish.outputs.id }} From d14fc426e651faacb8c5219a2be2296b5b3af131 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 Jun 2023 11:49:28 +0100 Subject: [PATCH 04/40] Update dependency eslint-plugin-jsdoc to v46 (#3468) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8ffe173ea67..556c4c7148f 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "eslint-import-resolver-typescript": "^3.5.1", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jest": "^27.1.6", - "eslint-plugin-jsdoc": "^45.0.0", + "eslint-plugin-jsdoc": "^46.0.0", "eslint-plugin-matrix-org": "^1.0.0", "eslint-plugin-tsdoc": "^0.2.17", "eslint-plugin-unicorn": "^47.0.0", diff --git a/yarn.lock b/yarn.lock index dd15a07b0f0..79359fa20d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3623,10 +3623,10 @@ eslint-plugin-jest@^27.1.6: dependencies: "@typescript-eslint/utils" "^5.10.0" -eslint-plugin-jsdoc@^45.0.0: - version "45.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-45.0.0.tgz#6be84e4842a7138cc571a907ea9c31c42eaac5c0" - integrity sha512-l2+Jcs/Ps7oFA+SWY+0sweU/e5LgricnEl6EsDlyRTF5y0+NWL1y9Qwz9PHwHAxtdJq6lxPjEQWmYLMkvhzD4g== +eslint-plugin-jsdoc@^46.0.0: + version "46.2.6" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.2.6.tgz#f25590d371859f20691d65b5dcd4cbe370d65564" + integrity sha512-zIaK3zbSrKuH12bP+SPybPgcHSM6MFzh3HFeaODzmsF1N8C1l8dzJ22cW1aq4g0+nayU1VMjmNf7hg0dpShLrA== dependencies: "@es-joy/jsdoccomment" "~0.39.4" are-docs-informative "^0.0.2" @@ -3634,6 +3634,7 @@ eslint-plugin-jsdoc@^45.0.0: debug "^4.3.4" escape-string-regexp "^4.0.0" esquery "^1.5.0" + is-builtin-module "^3.2.1" semver "^7.5.1" spdx-expression-parse "^3.0.1" From 0545f6df096a1322bb2761aec9fb1a0b922df450 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 14 Jun 2023 16:38:43 +0200 Subject: [PATCH 05/40] ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation (#3472) * Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation * Use `crypto` * Rename `IRecoveryKey` into `GeneratedSecretStorageKey` for rust crypto * Improve comments * Improve `createRecoveryKeyFromPassphrase` --- spec/unit/rust-crypto/rust-crypto.spec.ts | 33 ++++++++++++++++++++ src/crypto-api.ts | 26 ++++++++++++++++ src/crypto/api.ts | 12 ++----- src/rust-crypto/rust-crypto.ts | 38 +++++++++++++++++++++-- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 8f44d041424..1e617c22d11 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -356,6 +356,39 @@ describe("RustCrypto", () => { expect(res).toBe(null); }); }); + + describe("createRecoveryKeyFromPassphrase", () => { + let rustCrypto: RustCrypto; + + beforeEach(async () => { + rustCrypto = await makeTestRustCrypto(); + }); + + it("should create a recovery key without password", async () => { + const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase(); + + // Expected the encoded private key to have 59 chars + expect(recoveryKey.encodedPrivateKey?.length).toBe(59); + // Expect the private key to be an Uint8Array with a length of 32 + expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array); + expect(recoveryKey.privateKey.length).toBe(32); + // Expect keyInfo to be empty + expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0); + }); + + it("should create a recovery key with password", async () => { + const recoveryKey = await rustCrypto.createRecoveryKeyFromPassphrase("my password"); + + // Expected the encoded private key to have 59 chars + expect(recoveryKey.encodedPrivateKey?.length).toBe(59); + // Expect the private key to be an Uint8Array with a length of 32 + expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array); + expect(recoveryKey.privateKey.length).toBe(32); + // Expect keyInfo.passphrase to be filled + expect(recoveryKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2"); + expect(recoveryKey.keyInfo?.passphrase?.iterations).toBe(500000); + }); + }); }); /** build a basic RustCrypto instance for testing diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 8c89c408615..7f438307b1e 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -18,6 +18,7 @@ import type { IMegolmSessionData } from "./@types/crypto"; import { Room } from "./models/room"; import { DeviceMap } from "./models/device"; import { UIAuthCallback } from "./interactive-auth"; +import { AddSecretStorageKeyOpts } from "./secret-storage"; /** Types of cross-signing key */ export enum CrossSigningKey { @@ -26,6 +27,17 @@ export enum CrossSigningKey { UserSigning = "user_signing", } +/** + * Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} + */ +export interface GeneratedSecretStorageKey { + keyInfo?: AddSecretStorageKeyOpts; + /** The raw generated private key. */ + privateKey: Uint8Array; + /** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */ + encodedPrivateKey?: string; +} + /** * Public interface to the cryptography parts of the js-sdk * @@ -201,6 +213,20 @@ export interface CryptoApi { * @returns The current status of cross-signing keys: whether we have public and private keys cached locally, and whether the private keys are in secret storage. */ getCrossSigningStatus(): Promise; + + /** + * Create a recovery key (ie, a key suitable for use with server-side secret storage). + * + * The key can either be based on a user-supplied passphrase, or just created randomly. + * + * @param password - Optional passphrase string to use to derive the key, + * which can later be entered by the user as an alternative to entering the + * recovery key itself. If omitted, a key is generated randomly. + * + * @returns Object including recovery key and server upload parameters. + * The private key should be disposed of after displaying to the use. + */ + createRecoveryKeyFromPassphrase(password?: string): Promise; } /** diff --git a/src/crypto/api.ts b/src/crypto/api.ts index db9503300ff..c676389099d 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -16,10 +16,10 @@ limitations under the License. import { DeviceInfo } from "./deviceinfo"; import { IKeyBackupInfo } from "./keybackup"; -import type { AddSecretStorageKeyOpts } from "../secret-storage"; +import { GeneratedSecretStorageKey } from "../crypto-api"; /* re-exports for backwards compatibility. */ -export { CrossSigningKey } from "../crypto-api"; +export { CrossSigningKey, GeneratedSecretStorageKey as IRecoveryKey } from "../crypto-api"; export type { ImportRoomKeyProgressData as IImportOpts, @@ -66,12 +66,6 @@ export interface IEncryptedEventInfo { mismatchedSender: boolean; } -export interface IRecoveryKey { - keyInfo?: AddSecretStorageKeyOpts; - privateKey: Uint8Array; - encodedPrivateKey?: string; -} - export interface ICreateSecretStorageOpts { /** * Function called to await a secret storage key creation flow. @@ -79,7 +73,7 @@ export interface ICreateSecretStorageOpts { * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ - createSecretStorageKey?: () => Promise; + createSecretStorageKey?: () => Promise; /** * The current key backup object. If passed, diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index b23c7fb4662..598c4b00073 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -34,16 +34,20 @@ import { BootstrapCrossSigningOpts, CrossSigningStatus, DeviceVerificationStatus, + GeneratedSecretStorageKey, ImportRoomKeyProgressData, ImportRoomKeysOpts, + CrossSigningKey, } from "../crypto-api"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; import { Device, DeviceMap } from "../models/device"; -import { ServerSideSecretStorage } from "../secret-storage"; -import { CrossSigningKey } from "../crypto/api"; +import { AddSecretStorageKeyOpts, ServerSideSecretStorage } from "../secret-storage"; import { CrossSigningIdentity } from "./CrossSigningIdentity"; import { secretStorageContainsCrossSigningKeys } from "./secret-storage"; +import { keyFromPassphrase } from "../crypto/key_passphrase"; +import { encodeRecoveryKey } from "../crypto/recoverykey"; +import { crypto } from "../crypto/crypto"; /** * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. @@ -405,6 +409,36 @@ export class RustCrypto implements CryptoBackend { }; } + /** + * Implementation of {@link CryptoApi#createRecoveryKeyFromPassphrase} + */ + public async createRecoveryKeyFromPassphrase(password?: string): Promise { + let key: Uint8Array; + + const keyInfo: AddSecretStorageKeyOpts = {}; + if (password) { + // Generate the key from the passphrase + const derivation = await keyFromPassphrase(password); + keyInfo.passphrase = { + algorithm: "m.pbkdf2", + iterations: derivation.iterations, + salt: derivation.salt, + }; + key = derivation.key; + } else { + // Using the navigator crypto API to generate the private key + key = new Uint8Array(32); + crypto.getRandomValues(key); + } + + const encodedPrivateKey = encodeRecoveryKey(key); + return { + keyInfo, + encodedPrivateKey, + privateKey: key, + }; + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation From c425945353b1d955e11fde2702589f8796f0f861 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 15 Jun 2023 12:24:01 +0100 Subject: [PATCH 06/40] Avoid deprecated classes in verification integ test (#3473) https://github.com/matrix-org/matrix-js-sdk/pull/3449 deprecated a bunch of exports from `src/crypto/verification/request/VerificationRequest`. Let's stop using them in the integration test. --- spec/integ/crypto/verification.spec.ts | 40 ++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 2f366558723..0ceac47246d 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -18,7 +18,16 @@ import fetchMock from "fetch-mock-jest"; import { MockResponse } from "fetch-mock"; import { createClient, CryptoEvent, MatrixClient } from "../../../src"; -import { ShowQrCodeCallbacks, ShowSasCallbacks, Verifier, VerifierEvent } from "../../../src/crypto-api/verification"; +import { + ShowQrCodeCallbacks, + ShowSasCallbacks, + Verifier, + VerifierEvent, + VerificationPhase, + VerificationRequest, + VerificationRequestEvent, + canAcceptVerificationRequest, +} from "../../../src/crypto-api/verification"; import { escapeRegExp } from "../../../src/utils"; import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils"; import { SyncResponder } from "../../test-utils/SyncResponder"; @@ -31,11 +40,6 @@ import { TEST_USER_ID, } from "../../test-utils/test-data"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; -import { - Phase, - VerificationRequest, - VerificationRequestEvent, -} from "../../../src/crypto/verification/request/VerificationRequest"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations // to ensure that we don't end up with dangling timeouts. @@ -130,7 +134,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st ]); const transactionId = request.transactionId; expect(transactionId).toBeDefined(); - expect(request.phase).toEqual(Phase.Requested); + expect(request.phase).toEqual(VerificationPhase.Requested); expect(request.roomId).toBeUndefined(); let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; @@ -148,7 +152,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }, }); await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(Phase.Ready); + expect(request.phase).toEqual(VerificationPhase.Ready); expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); // ... and picks a method with m.key.verification.start @@ -165,7 +169,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }, }); await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(Phase.Started); + expect(request.phase).toEqual(VerificationPhase.Started); expect(request.chosenMethod).toEqual("m.sas.v1"); // there should now be a verifier @@ -238,7 +242,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // ... and the whole thing should be done! await verificationPromise; - expect(request.phase).toEqual(Phase.Done); + expect(request.phase).toEqual(VerificationPhase.Done); // we're done with the temporary keypair olmSAS.free(); @@ -290,7 +294,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }, }); await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(Phase.Ready); + expect(request.phase).toEqual(VerificationPhase.Ready); // we should now have QR data we can display const qrCodeBuffer = request.getQRCodeBytes()!; @@ -320,7 +324,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }, }); await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(Phase.Started); + expect(request.phase).toEqual(VerificationPhase.Started); expect(request.chosenMethod).toEqual("m.reciprocate.v1"); // there should now be a verifier @@ -346,7 +350,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // ... and the whole thing should be done! await verificationPromise; - expect(request.phase).toEqual(Phase.Done); + expect(request.phase).toEqual(VerificationPhase.Done); }, ); @@ -374,18 +378,18 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st }); const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); expect(request.transactionId).toEqual(TRANSACTION_ID); - expect(request.phase).toEqual(Phase.Requested); + expect(request.phase).toEqual(VerificationPhase.Requested); expect(request.roomId).toBeUndefined(); - expect(request.canAccept).toBe(true); + expect(canAcceptVerificationRequest(request)).toBe(true); // Alice accepts, by sending a to-device message const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); const acceptPromise = request.accept(); - expect(request.canAccept).toBe(false); - expect(request.phase).toEqual(Phase.Requested); + expect(canAcceptVerificationRequest(request)).toBe(false); + expect(request.phase).toEqual(VerificationPhase.Requested); await acceptPromise; const requestBody = await sendToDevicePromise; - expect(request.phase).toEqual(Phase.Ready); + expect(request.phase).toEqual(VerificationPhase.Ready); const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.methods).toContain("m.sas.v1"); From 9b5b533c6f84455ba1a4dc9d8040747b19153036 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 15 Jun 2023 13:25:29 +0100 Subject: [PATCH 07/40] Close IDB database before deleting it to prevent spurious unexpected close errors (#3478) --- src/store/indexeddb-local-backend.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index 3bc5914066a..96cd1f87d04 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -338,12 +338,16 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { /** * Clear the entire database. This should be used when logging out of a client - * to prevent mixing data between accounts. + * to prevent mixing data between accounts. Closes the database. * @returns Resolved when the database is cleared. */ public clearDatabase(): Promise { return new Promise((resolve) => { logger.log(`Removing indexeddb instance: ${this.dbName}`); + + // Close the database first to avoid firing unexpected close events + this.db?.close(); + const req = this.indexedDB.deleteDatabase(this.dbName); req.onblocked = (): void => { From 1bae10c4b214da1eb1be3ac3516dad1664de8569 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 15 Jun 2023 14:46:42 +0200 Subject: [PATCH 08/40] Fix export type `GeneratedSecretStorageKey` (#3479) --- src/crypto/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/crypto/api.ts b/src/crypto/api.ts index c676389099d..cb0f71631ad 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -19,7 +19,9 @@ import { IKeyBackupInfo } from "./keybackup"; import { GeneratedSecretStorageKey } from "../crypto-api"; /* re-exports for backwards compatibility. */ -export { CrossSigningKey, GeneratedSecretStorageKey as IRecoveryKey } from "../crypto-api"; +// CrossSigningKey is used as a value in `client.ts`, we can't export it as a type +export { CrossSigningKey } from "../crypto-api"; +export type { GeneratedSecretStorageKey as IRecoveryKey } from "../crypto-api"; export type { ImportRoomKeyProgressData as IImportOpts, From 22f0b781ea5507966adf39a677ac71b510e367f0 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 15 Jun 2023 14:56:50 +0100 Subject: [PATCH 09/40] Add new methods for verification to `CryptoApi` (#3474) * Add accessors for verification requests to CryptoApi Part of https://github.com/vector-im/crypto-internal/issues/97 * Add new methods for verification to `CryptoApi` and deprecate old method https://github.com/vector-im/crypto-internal/issues/98 --- spec/integ/crypto/verification.spec.ts | 10 ++-- src/client.ts | 10 +++- src/common-crypto/CryptoBackend.ts | 10 ---- src/crypto-api.ts | 46 ++++++++++++++++ src/crypto/index.ts | 9 ++++ src/rust-crypto/rust-crypto.ts | 73 +++++++++++++++++++++----- 6 files changed, 129 insertions(+), 29 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 0ceac47246d..b5f500f5887 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -19,14 +19,14 @@ import { MockResponse } from "fetch-mock"; import { createClient, CryptoEvent, MatrixClient } from "../../../src"; import { + canAcceptVerificationRequest, ShowQrCodeCallbacks, ShowSasCallbacks, - Verifier, - VerifierEvent, VerificationPhase, VerificationRequest, VerificationRequestEvent, - canAcceptVerificationRequest, + Verifier, + VerifierEvent, } from "../../../src/crypto-api/verification"; import { escapeRegExp } from "../../../src/utils"; import { CRYPTO_BACKENDS, emitPromise, InitCrypto } from "../../test-utils/test-utils"; @@ -130,7 +130,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // have alice initiate a verification. She should send a m.key.verification.request let [requestBody, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), - aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), ]); const transactionId = request.transactionId; expect(transactionId).toBeDefined(); @@ -273,7 +273,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // have alice initiate a verification. She should send a m.key.verification.request const [requestBody, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), - aliceClient.requestVerification(TEST_USER_ID, [TEST_DEVICE_ID]), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), ]); const transactionId = request.transactionId; diff --git a/src/client.ts b/src/client.ts index bf7b5adfcc5..d0d71266206 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2431,12 +2431,17 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts index 6049cb76585..fef3950775b 100644 --- a/src/common-crypto/CryptoBackend.ts +++ b/src/common-crypto/CryptoBackend.ts @@ -21,7 +21,6 @@ import { CryptoApi } from "../crypto-api"; import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning"; import { IEncryptedEventInfo } from "../crypto/api"; import { IEventDecryptionResult } from "../@types/crypto"; -import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; /** * Common interface for the crypto implementations @@ -79,15 +78,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { */ getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo; - /** - * Finds a DM verification request that is already in progress for the given room id - * - * @param roomId - the room to use for verification - * - * @returns the VerificationRequest that is in progress, if any - */ - findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined; - /** * Get the cross signing information for a given user. * diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 7f438307b1e..b91dde6bf1d 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -19,6 +19,7 @@ import { Room } from "./models/room"; import { DeviceMap } from "./models/device"; import { UIAuthCallback } from "./interactive-auth"; import { AddSecretStorageKeyOpts } from "./secret-storage"; +import { VerificationRequest } from "./crypto-api/verification"; /** Types of cross-signing key */ export enum CrossSigningKey { @@ -227,6 +228,51 @@ export interface CryptoApi { * The private key should be disposed of after displaying to the use. */ createRecoveryKeyFromPassphrase(password?: string): Promise; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // Device/User verification + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Returns to-device verification requests that are already in progress for the given user id. + * + * @param userId - the ID of the user to query + * + * @returns the VerificationRequests that are in progress + */ + getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[]; + + /** + * Finds a DM verification request that is already in progress for the given room id + * + * @param roomId - the room to use for verification + * + * @returns the VerificationRequest that is in progress, if any + */ + findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined; + + /** + * Send a verification request to our other devices. + * + * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * + * @returns a VerificationRequest when the request has been sent to the other party. + */ + requestOwnUserVerification(): Promise; + + /** + * Request an interactive verification with the given device. + * + * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * + * @param userId - ID of the owner of the device to verify + * @param deviceId - ID of the device to verify + * + * @returns a VerificationRequest when the request has been sent to the other party. + */ + requestDeviceVerification(userId: string, deviceId: string): Promise; } /** diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 16462d74445..d9ebd74a9ae 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -2356,6 +2356,7 @@ export class Crypto extends TypedEventEmitter { if (!devices) { devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); @@ -2368,6 +2369,14 @@ export class Crypto extends TypedEventEmitter { + return this.requestVerification(this.userId); + } + + public requestDeviceVerification(userId: string, deviceId: string): Promise { + return this.requestVerification(userId, [deviceId]); + } + private async requestVerificationWithChannel( userId: string, channel: IVerificationChannel, diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 598c4b00073..84a21c548d3 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -32,12 +32,13 @@ import { KeyClaimManager } from "./KeyClaimManager"; import { MapWithDefault } from "../utils"; import { BootstrapCrossSigningOpts, + CrossSigningKey, CrossSigningStatus, DeviceVerificationStatus, GeneratedSecretStorageKey, ImportRoomKeyProgressData, ImportRoomKeysOpts, - CrossSigningKey, + VerificationRequest, } from "../crypto-api"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; @@ -165,18 +166,6 @@ export class RustCrypto implements CryptoBackend { return new UserTrustLevel(false, false, false); } - /** - * Finds a DM verification request that is already in progress for the given room id - * - * @param roomId - the room to use for verification - * - * @returns the VerificationRequest that is in progress, if any - */ - public findVerificationRequestDMInProgress(roomId: string): undefined { - // TODO - return; - } - /** * Get the cross signing information for a given user. * @@ -439,6 +428,64 @@ export class RustCrypto implements CryptoBackend { }; } + /** + * Returns to-device verification requests that are already in progress for the given user id. + * + * Implementation of {@link CryptoApi#getVerificationRequestsToDeviceInProgress} + * + * @param userId - the ID of the user to query + * + * @returns the VerificationRequests that are in progress + */ + public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { + // TODO + return []; + } + + /** + * Finds a DM verification request that is already in progress for the given room id + * + * Implementation of {@link CryptoApi#findVerificationRequestDMInProgress} + * + * @param roomId - the room to use for verification + * + * @returns the VerificationRequest that is in progress, if any + * + */ + public findVerificationRequestDMInProgress(roomId: string): undefined { + // TODO + return; + } + + /** + * Send a verification request to our other devices. + * + * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * + * Implementation of {@link CryptoApi#requestOwnUserVerification}. + * + * @returns a VerificationRequest when the request has been sent to the other party. + */ + public requestOwnUserVerification(): Promise { + throw new Error("not implemented"); + } + + /** + * Request an interactive verification with the given device. + * + * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * + * Implementation of {@link CryptoApi#requestDeviceVerification }. + * + * @param userId - ID of the owner of the device to verify + * @param deviceId - ID of the device to verify + * + * @returns a VerificationRequest when the request has been sent to the other party. + */ + public requestDeviceVerification(userId: string, deviceId: string): Promise { + throw new Error("not implemented"); + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation From f938d10f7b2dfb9bf18f32d9364374ca80adeab2 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 16 Jun 2023 10:12:03 +1200 Subject: [PATCH 10/40] remove polls from room state on redaction (#3475) --- spec/unit/room.spec.ts | 18 ++++++++++++++++++ src/models/room.ts | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 2ed44dffd7b..150f2c30cb7 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -55,6 +55,7 @@ import * as threadUtils from "../test-utils/thread"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client"; import { logger } from "../../src/logger"; import { IMessageOpts } from "../test-utils/test-utils"; +import { flushPromises } from "../test-utils/flushPromises"; describe("Room", function () { const roomId = "!foo:bar"; @@ -3388,6 +3389,23 @@ describe("Room", function () { const poll = room.polls.get(pollStartEventId); expect(poll?.pollId).toBe(pollStartEventId); }); + + it("removes poll from state when redacted", async () => { + const pollStartEvent = makePollStart("1"); + const events = [pollStartEvent]; + + await room.processPollEvents(events); + + expect(room.polls.get(pollStartEvent.getId()!)).toBeTruthy(); + + const redactedEvent = new MatrixEvent({ type: "m.room.redaction" }); + pollStartEvent.makeRedacted(redactedEvent); + + await flushPromises(); + + // removed from poll state + expect(room.polls.get(pollStartEvent.getId()!)).toBeFalsy(); + }); }); describe("findPredecessorRoomId", () => { diff --git a/src/models/room.ts b/src/models/room.ts index 48c8b00ff1b..a73c750bad3 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2008,6 +2008,11 @@ export class Room extends ReadReceipt { const poll = new Poll(event, this.client, this); this.polls.set(event.getId()!, poll); this.emit(PollEvent.New, poll); + + // remove the poll when redacted + event.once(MatrixEventEvent.BeforeRedaction, (redactedEvent: MatrixEvent) => { + this.polls.delete(redactedEvent.getId()!); + }); } catch {} // poll creation can fail for malformed poll start events return; From afc70528ccd1db534e6e6aa5090ea1ec8d06f36c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Jun 2023 14:35:02 +1200 Subject: [PATCH 11/40] Update definitelyTyped (#3465) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index 79359fa20d4..fb37fbf7171 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1790,9 +1790,9 @@ integrity sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ== "@types/node@18": - version "18.16.16" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.16.tgz#3b64862856c7874ccf7439e6bab872d245c86d8e" - integrity sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g== + version "18.16.18" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.18.tgz#85da09bafb66d4bc14f7c899185336d0c1736390" + integrity sha512-/aNaQZD0+iSBAGnvvN2Cx92HqE5sZCPZtx2TsK+4nvV23fFe09jVDvpArXr2j9DnYlzuU9WuoykDDc6wqvpNcw== "@types/normalize-package-data@^2.4.0": version "2.4.1" @@ -1830,9 +1830,9 @@ integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== "@types/uuid@9": - version "9.0.1" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6" - integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA== + version "9.0.2" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.2.tgz#ede1d1b1e451548d44919dc226253e32a6952c4b" + integrity sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ== "@types/webidl-conversions@*": version "7.0.0" From 9c62d15447912d62055491a345e6b31dedb151ef Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 19 Jun 2023 08:38:24 +0100 Subject: [PATCH 12/40] Specify git tags for cypress workflow so updating tests is gated by renovate PRs (#3480) --- .github/workflows/cypress.yml | 2 +- .github/workflows/downstream-artifacts.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index c6c715ceaf7..cfeb33c6010 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -15,7 +15,7 @@ concurrency: jobs: cypress: name: Cypress - uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@develop + uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.73.1 permissions: actions: read issues: read diff --git a/.github/workflows/downstream-artifacts.yml b/.github/workflows/downstream-artifacts.yml index b16528cb66c..5615ac6d754 100644 --- a/.github/workflows/downstream-artifacts.yml +++ b/.github/workflows/downstream-artifacts.yml @@ -19,7 +19,7 @@ concurrency: jobs: build-element-web: name: Build element-web - uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@develop + uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.73.1 with: matrix-js-sdk-sha: ${{ github.sha }} react-sdk-repository: matrix-org/matrix-react-sdk From 80cdbe1058c15c2dff34f10e4657fbeb06e76dde Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 19 Jun 2023 22:11:04 +0100 Subject: [PATCH 13/40] Element-R: implement `userHasCrossSigningKeys` (#3488) --- spec/unit/rust-crypto/rust-crypto.spec.ts | 32 ++++++++++++++++++++++- src/rust-crypto/rust-crypto.ts | 7 +++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 1e617c22d11..85c18af1a40 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -26,9 +26,10 @@ import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../.. import { mkEvent } from "../../test-utils/test-utils"; import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult } from "../../../src/@types/crypto"; -import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; +import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; import { ServerSideSecretStorage } from "../../../src/secret-storage"; import { ImportRoomKeysOpts } from "../../../src/crypto-api"; +import * as testData from "../../test-utils/test-data"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -357,6 +358,35 @@ describe("RustCrypto", () => { }); }); + describe("userHasCrossSigningKeys", () => { + let rustCrypto: RustCrypto; + + beforeEach(async () => { + rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID); + }); + + it("returns false if there is no cross-signing identity", async () => { + await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(false); + }); + + it("returns true if OlmMachine has a cross-signing identity", async () => { + // @ts-ignore private field + const olmMachine = rustCrypto.olmMachine; + + const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests(); + // pick out the KeysQueryRequest, and respond to it with the cross-signing keys + const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!; + await olmMachine.markRequestAsSent( + req.id!, + req.type, + JSON.stringify(testData.SIGNED_CROSS_SIGNING_KEYS_DATA), + ); + + // ... and we should now have cross-signing keys. + await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(true); + }); + }); + describe("createRecoveryKeyFromPassphrase", () => { let rustCrypto: RustCrypto; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 84a21c548d3..0405ed42954 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -188,9 +188,12 @@ export class RustCrypto implements CryptoBackend { public globalBlacklistUnverifiedDevices = false; + /** + * Implementation of {@link CryptoApi.userHasCrossSigningKeys}. + */ public async userHasCrossSigningKeys(): Promise { - // TODO - return false; + const userIdentity = await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId)); + return userIdentity !== undefined; } public prepareToEncrypt(room: Room): void { From 8df4be09395b53ff00d051187ce7e9fc47113c4e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 20 Jun 2023 09:12:33 +0100 Subject: [PATCH 14/40] Fix order of things in `crypto-api.ts` (#3491) * `CryptoApi` should be first * `export *` should be last * everything else in the middle --- src/crypto-api.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/crypto-api.ts b/src/crypto-api.ts index b91dde6bf1d..068b6b518bf 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -21,24 +21,6 @@ import { UIAuthCallback } from "./interactive-auth"; import { AddSecretStorageKeyOpts } from "./secret-storage"; import { VerificationRequest } from "./crypto-api/verification"; -/** Types of cross-signing key */ -export enum CrossSigningKey { - Master = "master", - SelfSigning = "self_signing", - UserSigning = "user_signing", -} - -/** - * Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} - */ -export interface GeneratedSecretStorageKey { - keyInfo?: AddSecretStorageKeyOpts; - /** The raw generated private key. */ - privateKey: Uint8Array; - /** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */ - encodedPrivateKey?: string; -} - /** * Public interface to the cryptography parts of the js-sdk * @@ -373,8 +355,6 @@ export interface ImportRoomKeysOpts { source?: String; // TODO: Enum (backup, file, ??) } -export * from "./crypto-api/verification"; - /** * The result of a call to {@link CryptoApi.getCrossSigningStatus}. */ @@ -396,3 +376,23 @@ export interface CrossSigningStatus { userSigningKey: boolean; }; } + +/** Types of cross-signing key */ +export enum CrossSigningKey { + Master = "master", + SelfSigning = "self_signing", + UserSigning = "user_signing", +} + +/** + * Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} + */ +export interface GeneratedSecretStorageKey { + keyInfo?: AddSecretStorageKeyOpts; + /** The raw generated private key. */ + privateKey: Uint8Array; + /** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */ + encodedPrivateKey?: string; +} + +export * from "./crypto-api/verification"; From 49f11578f76d0283639021ba0a842250d4d62d96 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 20 Jun 2023 10:40:11 +0200 Subject: [PATCH 15/40] ElementR: Add `CryptoApi#bootstrapSecretStorage` (#3483) * Add WIP bootstrapSecretStorage * Add new test if `createSecretStorageKey` is not set * Remove old comments * Add docs for `crypto-api.bootstrapSecretStorage` * Remove default parameter for `createSecretStorageKey` * Move `bootstrapSecretStorage` next to `isSecretStorageReady` * Deprecate `bootstrapSecretStorage` in `MatrixClient` * Update documentations * Raise error if missing `keyInfo` * Update behavior around `setupNewSecretStorage` * Move `ICreateSecretStorageOpts` to `rust-crypto` * Move `ICryptoCallbacks` to `rust-crypto` * Update `bootstrapSecretStorage` documentation * Add partial `CryptoCallbacks` documentation * Fix typo * Review changes * Review changes --- .eslintrc.js | 2 +- spec/integ/crypto/crypto.spec.ts | 161 ++++++++++++++++++++++ spec/unit/rust-crypto/rust-crypto.spec.ts | 7 +- src/client.ts | 9 +- src/crypto-api.ts | 82 ++++++++++- src/crypto-api/keybackup.ts | 42 ++++++ src/crypto/api.ts | 42 +----- src/crypto/index.ts | 25 +--- src/crypto/keybackup.ts | 27 +--- src/rust-crypto/index.ts | 5 +- src/rust-crypto/rust-crypto.ts | 50 ++++++- 11 files changed, 365 insertions(+), 87 deletions(-) create mode 100644 src/crypto-api/keybackup.ts diff --git a/.eslintrc.js b/.eslintrc.js index 1a2b5f822f2..7db8e71402f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -74,7 +74,7 @@ module.exports = { "jest/no-standalone-expect": [ "error", { - additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly"], + additionalTestBlockFunctions: ["beforeAll", "beforeEach", "oldBackendOnly", "newBackendOnly"], }, ], }, diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index f4d7fc49f44..1618ae304e7 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -51,6 +51,7 @@ import { escapeRegExp } from "../../../src/utils"; import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter"; import { flushPromises } from "../../test-utils/flushPromises"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; +import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; const ROOM_ID = "!room:id"; @@ -402,6 +403,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the // Rust backend. Once we have full support in the rust sdk, it will go away. const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; + const newBackendOnly = backend !== "rust-sdk" ? test.skip : test; const Olm = global.Olm; @@ -2169,4 +2171,163 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, ); }); }); + + describe("bootstrapSecretStorage", () => { + /** + * Create a fake secret storage key + * Async because `bootstrapSecretStorage` expect an async method + */ + const createSecretStorageKey = jest.fn().mockResolvedValue({ + keyInfo: {}, // Returning undefined here used to cause a crash + privateKey: Uint8Array.of(32, 33), + }); + + /** + * Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type` + * Resolved when a key is uploaded (ie in `body.content.key`) + * https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype + */ + function awaitKeyStoredInAccountData(): Promise { + return new Promise((resolve) => { + // This url is called multiple times during the secret storage bootstrap process + // When we received the newly generated key, we return it + fetchMock.put( + "express:/_matrix/client/r0/user/:userId/account_data/:type", + (url: string, options: RequestInit) => { + const content = JSON.parse(options.body as string); + + if (content.key) { + resolve(content.key); + } + + return {}; + }, + { overwriteRoutes: true }, + ); + }); + } + + /** + * Send in the sync response the provided `secretStorageKey` into the account_data field + * The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events + * https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3sync + * @param secretStorageKey + */ + function sendSyncResponse(secretStorageKey: string) { + syncResponder.sendOrQueueSyncResponse({ + next_batch: 1, + account_data: { + events: [ + { + type: "m.secret_storage.default_key", + content: { + key: secretStorageKey, + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, + }, + }, + // Needed for secretStorage.getKey or secretStorage.hasKey + { + type: `m.secret_storage.key.${secretStorageKey}`, + content: { + key: secretStorageKey, + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, + }, + }, + ], + }, + }); + } + + beforeEach(async () => { + createSecretStorageKey.mockClear(); + + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + }); + + newBackendOnly("should do no nothing if createSecretStorageKey is not set", async () => { + await aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }); + + // No key was created + expect(createSecretStorageKey).toHaveBeenCalledTimes(0); + }); + + newBackendOnly("should create a new key", async () => { + const bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + const secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Finally, wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + const defaultKeyId = await aliceClient.secretStorage.getDefaultKeyId(); + // Check that the uploaded key in stored in the secret storage + expect(await aliceClient.secretStorage.hasKey(secretStorageKey)).toBeTruthy(); + // Check that the uploaded key is the default key + expect(defaultKeyId).toBe(secretStorageKey); + }); + + newBackendOnly( + "should do nothing if an AES key is already in the secret storage and setupNewSecretStorage is not set", + async () => { + const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + const secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // Call again bootstrapSecretStorage + await aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); + + // createSecretStorageKey should be called only on the first run of bootstrapSecretStorage + expect(createSecretStorageKey).toHaveBeenCalledTimes(1); + }, + ); + + newBackendOnly( + "should create a new key if setupNewSecretStorage is at true even if an AES key is already in the secret storage", + async () => { + let bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + let secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // Call again bootstrapSecretStorage + bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + secretStorageKey = await awaitKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // createSecretStorageKey should have been called twice, one time every bootstrapSecretStorage call + expect(createSecretStorageKey).toHaveBeenCalledTimes(2); + }, + ); + }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 85c18af1a40..bfa16c62b73 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -28,7 +28,7 @@ import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult } from "../../../src/@types/crypto"; import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor"; import { ServerSideSecretStorage } from "../../../src/secret-storage"; -import { ImportRoomKeysOpts } from "../../../src/crypto-api"; +import { CryptoCallbacks, ImportRoomKeysOpts } from "../../../src/crypto-api"; import * as testData from "../../test-utils/test-data"; afterEach(() => { @@ -212,6 +212,7 @@ describe("RustCrypto", () => { TEST_USER, TEST_DEVICE_ID, {} as ServerSideSecretStorage, + {} as CryptoCallbacks, ); rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor; }); @@ -334,6 +335,7 @@ describe("RustCrypto", () => { TEST_USER, TEST_DEVICE_ID, {} as ServerSideSecretStorage, + {} as CryptoCallbacks, ); }); @@ -430,6 +432,7 @@ async function makeTestRustCrypto( userId: string = TEST_USER, deviceId: string = TEST_DEVICE_ID, secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage, + cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks, ): Promise { - return await initRustCrypto(http, userId, deviceId, secretStorage); + return await initRustCrypto(http, userId, deviceId, secretStorage, cryptoCallbacks); } diff --git a/src/client.ts b/src/client.ts index d0d71266206..d3e6c4524db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2223,7 +2223,13 @@ export class MatrixClient extends TypedEventEmitter { if (!this.crypto) { diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 068b6b518bf..1a78f11b434 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -18,8 +18,9 @@ import type { IMegolmSessionData } from "./@types/crypto"; import { Room } from "./models/room"; import { DeviceMap } from "./models/device"; import { UIAuthCallback } from "./interactive-auth"; -import { AddSecretStorageKeyOpts } from "./secret-storage"; +import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage"; import { VerificationRequest } from "./crypto-api/verification"; +import { KeyBackupInfo } from "./crypto-api/keybackup"; /** * Public interface to the cryptography parts of the js-sdk @@ -190,6 +191,18 @@ export interface CryptoApi { */ isSecretStorageReady(): Promise; + /** + * Bootstrap the secret storage by creating a new secret storage key and store it in the secret storage. + * + * - Do nothing if an AES key is already stored in the secret storage and `setupNewKeyBackup` is not set; + * - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`. + * - Store this key in the secret storage and set it as the default key. + * - Call `cryptoCallbacks.cacheSecretStorageKey` if provided. + * + * @param opts - Options object. + */ + bootstrapSecretStorage(opts: CreateSecretStorageOpts): Promise; + /** * Get the status of our cross-signing keys. * @@ -377,6 +390,72 @@ export interface CrossSigningStatus { }; } +/** + * Crypto callbacks provided by the application + */ +export interface CryptoCallbacks extends SecretStorageCallbacks { + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; + saveCrossSigningKeys?: (keys: Record) => void; + shouldUpgradeDeviceVerifications?: (users: Record) => Promise; + /** + * Called by {@link CryptoApi#bootstrapSecretStorage} + * @param keyId - secret storage key id + * @param keyInfo - secret storage key info + * @param key - private key to store + */ + cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; + onSecretRequested?: ( + userId: string, + deviceId: string, + requestId: string, + secretName: string, + deviceTrust: DeviceVerificationStatus, + ) => Promise; + getDehydrationKey?: ( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise; + getBackupKey?: () => Promise; +} + +/** + * Parameter of {@link CryptoApi#bootstrapSecretStorage} + */ +export interface CreateSecretStorageOpts { + /** + * Function called to await a secret storage key creation flow. + * @returns Promise resolving to an object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + createSecretStorageKey?: () => Promise; + + /** + * The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + */ + keyBackupInfo?: KeyBackupInfo; + + /** + * If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + */ + setupNewKeyBackup?: boolean; + + /** + * Reset even if keys already exist. + */ + setupNewSecretStorage?: boolean; + + /** + * Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Uint8Array + * containing the key, or rejects if the key cannot be obtained. + */ + getKeyBackupPassphrase?: () => Promise; +} + /** Types of cross-signing key */ export enum CrossSigningKey { Master = "master", @@ -396,3 +475,4 @@ export interface GeneratedSecretStorageKey { } export * from "./crypto-api/verification"; +export * from "./crypto-api/keybackup"; diff --git a/src/crypto-api/keybackup.ts b/src/crypto-api/keybackup.ts new file mode 100644 index 00000000000..629d27aed4f --- /dev/null +++ b/src/crypto-api/keybackup.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 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 { ISigned } from "../@types/signed"; + +export interface Curve25519AuthData { + public_key: string; + private_key_salt?: string; + private_key_iterations?: number; + private_key_bits?: number; +} + +export interface Aes256AuthData { + iv: string; + mac: string; + private_key_salt?: string; + private_key_iterations?: number; +} + +/** + * Extra info of a recovery key + */ +export interface KeyBackupInfo { + algorithm: string; + auth_data: ISigned & (Curve25519AuthData | Aes256AuthData); + count?: number; + etag?: string; + version?: string; // number contained within +} diff --git a/src/crypto/api.ts b/src/crypto/api.ts index cb0f71631ad..a0e11a415be 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -15,13 +15,14 @@ limitations under the License. */ import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupInfo } from "./keybackup"; -import { GeneratedSecretStorageKey } from "../crypto-api"; /* re-exports for backwards compatibility. */ // CrossSigningKey is used as a value in `client.ts`, we can't export it as a type export { CrossSigningKey } from "../crypto-api"; -export type { GeneratedSecretStorageKey as IRecoveryKey } from "../crypto-api"; +export type { + GeneratedSecretStorageKey as IRecoveryKey, + CreateSecretStorageOpts as ICreateSecretStorageOpts, +} from "../crypto-api"; export type { ImportRoomKeyProgressData as IImportOpts, @@ -67,38 +68,3 @@ export interface IEncryptedEventInfo { */ mismatchedSender: boolean; } - -export interface ICreateSecretStorageOpts { - /** - * Function called to await a secret storage key creation flow. - * @returns Promise resolving to an object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - */ - createSecretStorageKey?: () => Promise; - - /** - * The current key backup object. If passed, - * the passphrase and recovery key from this backup will be used. - */ - keyBackupInfo?: IKeyBackupInfo; - - /** - * If true, a new key backup version will be - * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo - * is supplied. - */ - setupNewKeyBackup?: boolean; - - /** - * Reset even if keys already exist. - */ - setupNewSecretStorage?: boolean; - - /** - * Function called to get the user's - * current key backup passphrase. Should return a promise that resolves with a Uint8Array - * containing the key, or rejects if the key cannot be obtained. - */ - getKeyBackupPassphrase?: () => Promise; -} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index d9ebd74a9ae..699561f2734 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -80,7 +80,6 @@ import { AccountDataClient, AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, - SecretStorageCallbacks, SecretStorageKeyDescription, SecretStorageKeyObject, SecretStorageKeyTuple, @@ -97,7 +96,10 @@ import { Device, DeviceMap } from "../models/device"; import { deviceInfoToDevice } from "./device-converter"; /* re-exports for backwards compatibility */ -export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api"; +export type { + BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts, + CryptoCallbacks as ICryptoCallbacks, +} from "../crypto-api"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -134,25 +136,6 @@ interface IInitOpts { pickleKey?: string; } -export interface ICryptoCallbacks extends SecretStorageCallbacks { - getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; - saveCrossSigningKeys?: (keys: Record) => void; - shouldUpgradeDeviceVerifications?: (users: Record) => Promise; - cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; - onSecretRequested?: ( - userId: string, - deviceId: string, - requestId: string, - secretName: string, - deviceTrust: DeviceTrustLevel, - ) => Promise; - getDehydrationKey?: ( - keyInfo: SecretStorageKeyDescription, - checkFunc: (key: Uint8Array) => void, - ) => Promise; - getBackupKey?: () => Promise; -} - /* eslint-disable camelcase */ interface IRoomKey { room_id: string; diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 67e213c4a92..f15ab73438f 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ISigned } from "../@types/signed"; import { IEncryptedPayload } from "./aes"; export interface Curve25519SessionData { @@ -35,27 +34,13 @@ export interface IKeyBackupRoomSessions { [sessionId: string]: IKeyBackupSession; } -export interface ICurve25519AuthData { - public_key: string; - private_key_salt?: string; - private_key_iterations?: number; - private_key_bits?: number; -} +// Export for backward compatibility +export type { + Curve25519AuthData as ICurve25519AuthData, + Aes256AuthData as IAes256AuthData, + KeyBackupInfo as IKeyBackupInfo, +} from "../crypto-api/keybackup"; -export interface IAes256AuthData { - iv: string; - mac: string; - private_key_salt?: string; - private_key_iterations?: number; -} - -export interface IKeyBackupInfo { - algorithm: string; - auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData); - count?: number; - etag?: string; - version?: string; // number contained within -} /* eslint-enable camelcase */ export interface IKeyBackupPrepareOpts { diff --git a/src/rust-crypto/index.ts b/src/rust-crypto/index.ts index 80370b3fbe9..8f4aefdaaba 100644 --- a/src/rust-crypto/index.ts +++ b/src/rust-crypto/index.ts @@ -21,6 +21,7 @@ import { logger } from "../logger"; import { RUST_SDK_STORE_PREFIX } from "./constants"; import { IHttpOpts, MatrixHttpApi } from "../http-api"; import { ServerSideSecretStorage } from "../secret-storage"; +import { ICryptoCallbacks } from "../crypto"; /** * Create a new `RustCrypto` implementation @@ -30,12 +31,14 @@ import { ServerSideSecretStorage } from "../secret-storage"; * @param userId - The local user's User ID. * @param deviceId - The local user's Device ID. * @param secretStorage - Interface to server-side secret storage. + * @param cryptoCallbacks - Crypto callbacks provided by the application */ export async function initRustCrypto( http: MatrixHttpApi, userId: string, deviceId: string, secretStorage: ServerSideSecretStorage, + cryptoCallbacks: ICryptoCallbacks, ): Promise { // initialise the rust matrix-sdk-crypto-js, if it hasn't already been done await RustSdkCryptoJs.initAsync(); @@ -49,7 +52,7 @@ export async function initRustCrypto( // TODO: use the pickle key for the passphrase const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass"); - const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage); + const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks); await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) => rustCrypto.onRoomKeysUpdated(sessions), ); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 0405ed42954..033063bc437 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -39,11 +39,13 @@ import { ImportRoomKeyProgressData, ImportRoomKeysOpts, VerificationRequest, + CreateSecretStorageOpts, + CryptoCallbacks, } from "../crypto-api"; import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter"; import { IDownloadKeyResult, IQueryKeysRequest } from "../client"; import { Device, DeviceMap } from "../models/device"; -import { AddSecretStorageKeyOpts, ServerSideSecretStorage } from "../secret-storage"; +import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES, ServerSideSecretStorage } from "../secret-storage"; import { CrossSigningIdentity } from "./CrossSigningIdentity"; import { secretStorageContainsCrossSigningKeys } from "./secret-storage"; import { keyFromPassphrase } from "../crypto/key_passphrase"; @@ -90,6 +92,9 @@ export class RustCrypto implements CryptoBackend { /** Interface to server-side secret storage */ private readonly secretStorage: ServerSideSecretStorage, + + /** Crypto callbacks provided by the application */ + private readonly cryptoCallbacks: CryptoCallbacks, ) { this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http); this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor); @@ -375,6 +380,49 @@ export class RustCrypto implements CryptoBackend { return false; } + /** + * Implementation of {@link CryptoApi#bootstrapSecretStorage} + */ + public async bootstrapSecretStorage({ + createSecretStorageKey, + setupNewSecretStorage, + }: CreateSecretStorageOpts = {}): Promise { + // If createSecretStorageKey is not set, we stop + if (!createSecretStorageKey) return; + + // See if we already have an AES secret-storage key. + const secretStorageKeyTuple = await this.secretStorage.getKey(); + + if (secretStorageKeyTuple) { + const [, keyInfo] = secretStorageKeyTuple; + + // If an AES Key is already stored in the secret storage and setupNewSecretStorage is not set + // we don't want to create a new key + if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES && !setupNewSecretStorage) { + return; + } + } + + const recoveryKey = await createSecretStorageKey(); + + // keyInfo is required to continue + if (!recoveryKey.keyInfo) { + throw new Error("missing keyInfo field in the secret storage key created by createSecretStorageKey"); + } + + const secretStorageKeyObject = await this.secretStorage.addKey( + SECRET_STORAGE_ALGORITHM_V1_AES, + recoveryKey.keyInfo, + ); + await this.secretStorage.setDefaultKeyId(secretStorageKeyObject.keyId); + + this.cryptoCallbacks.cacheSecretStorageKey?.( + secretStorageKeyObject.keyId, + secretStorageKeyObject.keyInfo, + recoveryKey.privateKey, + ); + } + /** * Implementation of {@link CryptoApi#getCrossSigningStatus} */ From b77fe465f75fc0c273e22d6ff8bde658f35a68b6 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 20 Jun 2023 10:14:37 +0100 Subject: [PATCH 16/40] Resetting package fields for development --- package.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e06496436dd..f9b6d50bd79 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", - "browser": "./lib/browser-index.js", + "main": "./src/index.ts", + "browser": "./src/browser-index.ts", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.ts", "matrix_lib_main": "./lib/index.js", @@ -156,6 +156,5 @@ "no-rust-crypto": { "src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts" } - }, - "typings": "./lib/index.d.ts" + } } From 9c6d5a6c550efce66988fbc0d8c8cd7b3ba3879d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 20 Jun 2023 10:29:41 +0100 Subject: [PATCH 17/40] Element-R: wait for OlmMachine on startup (#3487) * Element-R: wait for OlmMachine on startup Previously, if you called `CryptoApi.getUserDeviceInfo()` before the first `/sync` request happened, it would return an empty list, which made a bunch of the tests racy. Add a hack to get the OlmMachine to think about its device lists during startup. * add a test --- spec/unit/rust-crypto/rust-crypto.spec.ts | 42 ++++++++++++++++++++++- src/rust-crypto/index.ts | 11 ++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index bfa16c62b73..c2826130ba9 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -19,10 +19,19 @@ import { IDBFactory } from "fake-indexeddb"; import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-js"; import { Mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; import { RustCrypto } from "../../../src/rust-crypto/rust-crypto"; import { initRustCrypto } from "../../../src/rust-crypto"; -import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src"; +import { + HttpApiEvent, + HttpApiEventHandlerMap, + IHttpOpts, + IToDeviceEvent, + MatrixClient, + MatrixHttpApi, + TypedEventEmitter, +} from "../../../src"; import { mkEvent } from "../../test-utils/test-utils"; import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend"; import { IEventDecryptionResult } from "../../../src/@types/crypto"; @@ -421,6 +430,37 @@ describe("RustCrypto", () => { expect(recoveryKey.keyInfo?.passphrase?.iterations).toBe(500000); }); }); + + it("should wait for a keys/query before returning devices", async () => { + jest.useFakeTimers(); + + const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter(), { + baseUrl: "http://server/", + prefix: "", + onlyData: true, + }); + fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} }); + fetchMock.post("path:/_matrix/client/v3/keys/query", { + device_keys: { + [testData.TEST_USER_ID]: { + [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA, + }, + }, + }); + + const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID); + + // an attempt to fetch the device list should block + const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]); + + // ... until a /sync completes, and we trigger the outgoingRequests. + rustCrypto.onSyncCompleted({}); + + const deviceMap = (await devicesPromise).get(testData.TEST_USER_ID)!; + expect(deviceMap.has(TEST_DEVICE_ID)).toBe(true); + expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true); + rustCrypto.stop(); + }); }); /** build a basic RustCrypto instance for testing diff --git a/src/rust-crypto/index.ts b/src/rust-crypto/index.ts index 8f4aefdaaba..1fa8a2e93d3 100644 --- a/src/rust-crypto/index.ts +++ b/src/rust-crypto/index.ts @@ -57,6 +57,17 @@ export async function initRustCrypto( rustCrypto.onRoomKeysUpdated(sessions), ); + // Tell the OlmMachine to think about its outgoing requests before we hand control back to the application. + // + // This is primarily a fudge to get it to correctly populate the `users_for_key_query` list, so that future + // calls to getIdentity (etc) block until the key queries are performed. + // + // Note that we don't actually need to *make* any requests here; it is sufficient to tell the Rust side to think + // about them. + // + // XXX: find a less hacky way to do this. + await olmMachine.outgoingRequests(); + logger.info("Completed rust crypto-sdk setup"); return rustCrypto; } From ca00094e674a3b1cf7aba23d976b883aad7e2cd1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 20 Jun 2023 16:16:43 +0100 Subject: [PATCH 18/40] Fix bug where switching media caused media in subsequent calls to fail (#3489) --- src/webrtc/mediaHandler.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/webrtc/mediaHandler.ts b/src/webrtc/mediaHandler.ts index 7f65835d18f..0c4181ecef4 100644 --- a/src/webrtc/mediaHandler.ts +++ b/src/webrtc/mediaHandler.ts @@ -334,7 +334,19 @@ export class MediaHandler extends TypedEventEmitter< this.emit(MediaHandlerEvent.LocalStreamsChanged); if (this.localUserMediaStream === mediaStream) { + // if we have this stream cahced, remove it, because we've stopped it this.localUserMediaStream = undefined; + } else { + // If it's not the same stream. remove any tracks from the cached stream that + // we have just stopped, and if we do stop any, call the same method on the + // cached stream too in order to stop all its tracks (in case they are different) + // and un-cache it. + for (const track of mediaStream.getTracks()) { + if (this.localUserMediaStream?.getTrackById(track.id)) { + this.stopUserMediaStream(this.localUserMediaStream); + break; + } + } } } From 8b9672ba43a24d34f24758c4c63f3437111dfcf8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 20 Jun 2023 16:28:02 +0100 Subject: [PATCH 19/40] Add debug logging to figure out missing reactions in main timeline (#3494) * Fix debug logging not working * Add debug logging to figure out missing reactions in main timeline --- src/logger.ts | 6 +++++- src/models/room.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/logger.ts b/src/logger.ts index ba7f7421039..af0cba21ee8 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -38,7 +38,11 @@ log.methodFactory = function (methodName, logLevel, loggerName) { } /* eslint-enable @typescript-eslint/no-invalid-this */ const supportedByConsole = - methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info"; + methodName === "error" || + methodName === "warn" || + methodName === "trace" || + methodName === "info" || + methodName === "debug"; /* eslint-disable no-console */ if (supportedByConsole) { return console[methodName](...args); diff --git a/src/models/room.ts b/src/models/room.ts index a73c750bad3..713e5470e31 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2103,6 +2103,7 @@ export class Room extends ReadReceipt { threadId?: string; } { if (!this.client?.supportsThreads()) { + logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} client does not support threads`); return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2111,6 +2112,11 @@ export class Room extends ReadReceipt { // A thread root is always shown in both timelines if (event.isThreadRoot || roots?.has(event.getId()!)) { + if (event.isThreadRoot) { + logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} isThreadRoot is true`); + } else { + logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} is a known thread root`); + } return { shouldLiveInRoom: true, shouldLiveInThread: true, @@ -2121,6 +2127,9 @@ export class Room extends ReadReceipt { // A thread relation (1st and 2nd order) is always only shown in a thread const threadRootId = event.threadRootId; if (threadRootId != undefined) { + logger.debug( + `Room::eventShouldLiveIn: eventId=${event.getId()} threadRootId=${threadRootId} is part of a thread`, + ); return { shouldLiveInRoom: false, shouldLiveInThread: true, @@ -2132,6 +2141,9 @@ export class Room extends ReadReceipt { let parentEvent: MatrixEvent | undefined; if (parentEventId) { parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); + logger.debug( + `Room::eventShouldLiveIn: eventId=${event.getId()} parentEventId=${parentEventId} found=${!!parentEvent}`, + ); } // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead @@ -2140,6 +2152,7 @@ export class Room extends ReadReceipt { } if (!event.isRelation()) { + logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} not a relation`); return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2148,6 +2161,11 @@ export class Room extends ReadReceipt { // Edge case where we know the event is a relation but don't have the parentEvent if (roots?.has(event.relationEventId!)) { + logger.debug( + `Room::eventShouldLiveIn: eventId=${event.getId()} relationEventId=${ + event.relationEventId + } is a known root`, + ); return { shouldLiveInRoom: true, shouldLiveInThread: true, @@ -2158,6 +2176,7 @@ export class Room extends ReadReceipt { // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts + logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} belongs to an unknown timeline`); return { shouldLiveInRoom: false, shouldLiveInThread: false, From 80fec814a2db4b21270ced621edcb82353260f2d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 21 Jun 2023 12:54:30 +0100 Subject: [PATCH 20/40] Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room (#3493) --- src/models/event-timeline-set.ts | 4 ++-- src/models/read-receipt.ts | 9 +++++++++ src/models/thread.ts | 13 ++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index dfe9694c2ed..a003f136239 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -778,7 +778,7 @@ export class EventTimelineSet extends TypedEventEmitter Date: Thu, 22 Jun 2023 09:55:25 +1200 Subject: [PATCH 21/40] OIDC: add dynamic client registration util function (#3481) * rename OidcDiscoveryError to OidcError * oidc client registration functions * test registerOidcClient * tidy test file * reexport OidcDiscoveryError for backwards compatibility --- spec/unit/autodiscovery.spec.ts | 12 ++-- spec/unit/oidc/register.spec.ts | 84 ++++++++++++++++++++++++ spec/unit/oidc/validate.spec.ts | 21 +++--- src/autodiscovery.ts | 20 ++---- src/oidc/error.ts | 25 +++++++ src/oidc/register.ts | 111 ++++++++++++++++++++++++++++++++ src/oidc/validate.ts | 20 +++--- 7 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 spec/unit/oidc/register.spec.ts create mode 100644 src/oidc/error.ts create mode 100644 src/oidc/register.ts diff --git a/spec/unit/autodiscovery.spec.ts b/spec/unit/autodiscovery.spec.ts index 2a8f080ed20..e2e2c30824f 100644 --- a/spec/unit/autodiscovery.spec.ts +++ b/spec/unit/autodiscovery.spec.ts @@ -18,7 +18,7 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; import { AutoDiscovery } from "../../src/autodiscovery"; -import { OidcDiscoveryError } from "../../src/oidc/validate"; +import { OidcError } from "../../src/oidc/error"; describe("AutoDiscovery", function () { const getHttpBackend = (): MockHttpBackend => { @@ -400,7 +400,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -441,7 +441,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -485,7 +485,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "FAIL_ERROR", - error: OidcDiscoveryError.Misconfigured, + error: OidcError.Misconfigured, }, }; @@ -719,7 +719,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; @@ -775,7 +775,7 @@ describe("AutoDiscovery", function () { }, "m.authentication": { state: "IGNORE", - error: OidcDiscoveryError.NotSupported, + error: OidcError.NotSupported, }, }; diff --git a/spec/unit/oidc/register.spec.ts b/spec/unit/oidc/register.spec.ts new file mode 100644 index 00000000000..e9ad60463a7 --- /dev/null +++ b/spec/unit/oidc/register.spec.ts @@ -0,0 +1,84 @@ +/* +Copyright 2023 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 fetchMockJest from "fetch-mock-jest"; + +import { OidcError } from "../../../src/oidc/error"; +import { registerOidcClient } from "../../../src/oidc/register"; + +describe("registerOidcClient()", () => { + const issuer = "https://auth.com/"; + const registrationEndpoint = "https://auth.com/register"; + const clientName = "Element"; + const baseUrl = "https://just.testing"; + const dynamicClientId = "xyz789"; + + const delegatedAuthConfig = { + issuer, + registrationEndpoint, + authorizationEndpoint: issuer + "auth", + tokenEndpoint: issuer + "token", + }; + beforeEach(() => { + fetchMockJest.mockClear(); + fetchMockJest.resetBehavior(); + }); + + it("should make correct request to register client", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 200, + body: JSON.stringify({ client_id: dynamicClientId }), + }); + expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId); + expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, { + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + client_name: clientName, + client_uri: baseUrl, + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: [baseUrl], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + application_type: "web", + }), + }); + }); + + it("should throw when registration request fails", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 500, + }); + expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( + OidcError.DynamicRegistrationFailed, + ); + }); + + it("should throw when registration response is invalid", async () => { + fetchMockJest.post(registrationEndpoint, { + status: 200, + // no clientId in response + body: "{}", + }); + expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow( + OidcError.DynamicRegistrationInvalid, + ); + }); +}); diff --git a/spec/unit/oidc/validate.spec.ts b/spec/unit/oidc/validate.spec.ts index 2ad62afc327..d5091ed89f9 100644 --- a/spec/unit/oidc/validate.spec.ts +++ b/spec/unit/oidc/validate.spec.ts @@ -16,11 +16,8 @@ limitations under the License. import { M_AUTHENTICATION } from "../../../src"; import { logger } from "../../../src/logger"; -import { - OidcDiscoveryError, - validateOIDCIssuerWellKnown, - validateWellKnownAuthentication, -} from "../../../src/oidc/validate"; +import { validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "../../../src/oidc/validate"; +import { OidcError } from "../../../src/oidc/error"; describe("validateWellKnownAuthentication()", () => { const baseWk = { @@ -29,7 +26,7 @@ describe("validateWellKnownAuthentication()", () => { }, }; it("should throw not supported error when wellKnown has no m.authentication section", () => { - expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcDiscoveryError.NotSupported); + expect(() => validateWellKnownAuthentication(baseWk)).toThrow(OidcError.NotSupported); }); it("should throw misconfigured error when authentication issuer is not a string", () => { @@ -39,7 +36,7 @@ describe("validateWellKnownAuthentication()", () => { issuer: { url: "test.com" }, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should throw misconfigured error when authentication account is not a string", () => { @@ -50,7 +47,7 @@ describe("validateWellKnownAuthentication()", () => { account: { url: "test" }, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should throw misconfigured error when authentication account is false", () => { @@ -61,7 +58,7 @@ describe("validateWellKnownAuthentication()", () => { account: false, }, }; - expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcDiscoveryError.Misconfigured); + expect(() => validateWellKnownAuthentication(wk)).toThrow(OidcError.Misconfigured); }); it("should return valid config when wk uses stable m.authentication", () => { @@ -137,7 +134,7 @@ describe("validateOIDCIssuerWellKnown", () => { it("should throw OP support error when wellKnown is not an object", () => { expect(() => { validateOIDCIssuerWellKnown([]); - }).toThrow(OidcDiscoveryError.OpSupport); + }).toThrow(OidcError.OpSupport); expect(logger.error).toHaveBeenCalledWith("Issuer configuration not found or malformed"); }); @@ -148,7 +145,7 @@ describe("validateOIDCIssuerWellKnown", () => { authorization_endpoint: undefined, response_types_supported: [], }); - }).toThrow(OidcDiscoveryError.OpSupport); + }).toThrow(OidcError.OpSupport); expect(logger.error).toHaveBeenCalledWith("OIDC issuer configuration: authorization_endpoint is invalid"); expect(logger.error).toHaveBeenCalledWith( "OIDC issuer configuration: response_types_supported is invalid. code is required.", @@ -194,6 +191,6 @@ describe("validateOIDCIssuerWellKnown", () => { ...validWk, [key]: value, }; - expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcDiscoveryError.OpSupport); + expect(() => validateOIDCIssuerWellKnown(wk)).toThrow(OidcError.OpSupport); }); }); diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index a43410bd630..f9cf0398c2b 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -18,12 +18,8 @@ limitations under the License. import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client"; import { logger } from "./logger"; import { MatrixError, Method, timeoutSignal } from "./http-api"; -import { - OidcDiscoveryError, - ValidatedIssuerConfig, - validateOIDCIssuerWellKnown, - validateWellKnownAuthentication, -} from "./oidc/validate"; +import { ValidatedIssuerConfig, validateOIDCIssuerWellKnown, validateWellKnownAuthentication } from "./oidc/validate"; +import { OidcError } from "./oidc/error"; // Dev note: Auto discovery is part of the spec. // See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery @@ -297,7 +293,7 @@ export class AutoDiscovery { if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) { logger.error("Failed to fetch issuer openid configuration"); - throw new Error(OidcDiscoveryError.General); + throw new Error(OidcError.General); } const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw); @@ -310,15 +306,11 @@ export class AutoDiscovery { }; return delegatedAuthConfig; } catch (error) { - const errorMessage = (error as Error).message as unknown as OidcDiscoveryError; - const errorType = Object.values(OidcDiscoveryError).includes(errorMessage) - ? errorMessage - : OidcDiscoveryError.General; + const errorMessage = (error as Error).message as unknown as OidcError; + const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General; const state = - errorType === OidcDiscoveryError.NotSupported - ? AutoDiscoveryAction.IGNORE - : AutoDiscoveryAction.FAIL_ERROR; + errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR; return { state, diff --git a/src/oidc/error.ts b/src/oidc/error.ts new file mode 100644 index 00000000000..b77fbbf75f5 --- /dev/null +++ b/src/oidc/error.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 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. +*/ + +export enum OidcError { + NotSupported = "OIDC authentication not supported", + Misconfigured = "OIDC is misconfigured", + General = "Something went wrong with OIDC discovery", + OpSupport = "Configured OIDC OP does not support required functions", + DynamicRegistrationNotSupported = "Dynamic registration not supported", + DynamicRegistrationFailed = "Dynamic registration failed", + DynamicRegistrationInvalid = "Dynamic registration invalid response", +} diff --git a/src/oidc/register.ts b/src/oidc/register.ts new file mode 100644 index 00000000000..c09517ba09d --- /dev/null +++ b/src/oidc/register.ts @@ -0,0 +1,111 @@ +/* +Copyright 2023 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 { IDelegatedAuthConfig } from "../client"; +import { OidcError } from "./error"; +import { Method } from "../http-api"; +import { logger } from "../logger"; +import { ValidatedIssuerConfig } from "./validate"; + +/** + * Client metadata passed to registration endpoint + */ +export type OidcRegistrationClientMetadata = { + clientName: string; + clientUri: string; + redirectUris: string[]; +}; + +/** + * Make the client registration request + * @param registrationEndpoint - URL as returned from issuer ./well-known/openid-configuration + * @param clientMetadata - registration metadata + * @returns resolves to the registered client id when registration is successful + * @throws when registration request fails, or response is invalid + */ +const doRegistration = async ( + registrationEndpoint: string, + clientMetadata: OidcRegistrationClientMetadata, +): Promise => { + // https://openid.net/specs/openid-connect-registration-1_0.html + const metadata = { + client_name: clientMetadata.clientName, + client_uri: clientMetadata.clientUri, + response_types: ["code"], + grant_types: ["authorization_code", "refresh_token"], + redirect_uris: clientMetadata.redirectUris, + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + application_type: "web", + }; + const headers = { + "Accept": "application/json", + "Content-Type": "application/json", + }; + + try { + const response = await fetch(registrationEndpoint, { + method: Method.Post, + headers, + body: JSON.stringify(metadata), + }); + + if (response.status >= 400) { + throw new Error(OidcError.DynamicRegistrationFailed); + } + + const body = await response.json(); + const clientId = body["client_id"]; + if (!clientId || typeof clientId !== "string") { + throw new Error(OidcError.DynamicRegistrationInvalid); + } + + return clientId; + } catch (error) { + if (Object.values(OidcError).includes((error as Error).message as OidcError)) { + throw error; + } else { + logger.error("Dynamic registration request failed", error); + throw new Error(OidcError.DynamicRegistrationFailed); + } + } +}; + +/** + * Attempts dynamic registration against the configured registration endpoint + * @param delegatedAuthConfig - Auth config from ValidatedServerConfig + * @param clientName - Client name to register with the OP, eg 'Element' + * @param baseUrl - URL of the home page of the Client, eg 'https://app.element.io/' + * @returns Promise resolved with registered clientId + * @throws when registration is not supported, on failed request or invalid response + */ +export const registerOidcClient = async ( + delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig, + clientName: string, + baseUrl: string, +): Promise => { + const clientMetadata = { + clientName, + clientUri: baseUrl, + redirectUris: [baseUrl], + }; + if (!delegatedAuthConfig.registrationEndpoint) { + throw new Error(OidcError.DynamicRegistrationNotSupported); + } + const clientId = await doRegistration(delegatedAuthConfig.registrationEndpoint, clientMetadata); + + return clientId; +}; diff --git a/src/oidc/validate.ts b/src/oidc/validate.ts index 1a5f672b4a7..09ecf5e609d 100644 --- a/src/oidc/validate.ts +++ b/src/oidc/validate.ts @@ -16,13 +16,13 @@ limitations under the License. import { IClientWellKnown, IDelegatedAuthConfig, M_AUTHENTICATION } from "../client"; import { logger } from "../logger"; +import { OidcError } from "./error"; -export enum OidcDiscoveryError { - NotSupported = "OIDC authentication not supported", - Misconfigured = "OIDC is misconfigured", - General = "Something went wrong with OIDC discovery", - OpSupport = "Configured OIDC OP does not support required functions", -} +/** + * re-export for backwards compatibility + * @deprecated use OidcError + */ +export { OidcError as OidcDiscoveryError }; export type ValidatedIssuerConfig = { authorizationEndpoint: string; @@ -41,7 +41,7 @@ export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): ID const authentication = M_AUTHENTICATION.findIn(wellKnown); if (!authentication) { - throw new Error(OidcDiscoveryError.NotSupported); + throw new Error(OidcError.NotSupported); } if ( @@ -54,7 +54,7 @@ export const validateWellKnownAuthentication = (wellKnown: IClientWellKnown): ID }; } - throw new Error(OidcDiscoveryError.Misconfigured); + throw new Error(OidcError.Misconfigured); }; const isRecord = (value: unknown): value is Record => @@ -93,7 +93,7 @@ const requiredArrayValue = (wellKnown: Record, key: string, val export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuerConfig => { if (!isRecord(wellKnown)) { logger.error("Issuer configuration not found or malformed"); - throw new Error(OidcDiscoveryError.OpSupport); + throw new Error(OidcError.OpSupport); } const isInvalid = [ @@ -114,5 +114,5 @@ export const validateOIDCIssuerWellKnown = (wellKnown: unknown): ValidatedIssuer } logger.error("Issuer configuration not valid"); - throw new Error(OidcDiscoveryError.OpSupport); + throw new Error(OidcError.OpSupport); }; From e8c89e997733e44d9cbe3f6269ed4cbb859d12ac Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 22 Jun 2023 10:43:39 +0100 Subject: [PATCH 22/40] Element-R: speed up slow unit test (#3492) A couple of tests were waiting for a request that wasn't happening, so timing out after 1.5 seconds. Let's avoid the extra slowth. (This was introduced by changes in https://github.com/matrix-org/matrix-js-sdk/pull/3487, but the changes in this PR do no harm anyway) --- spec/unit/rust-crypto/rust-crypto.spec.ts | 35 ++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index c2826130ba9..bc3007d689f 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -376,7 +376,35 @@ describe("RustCrypto", () => { rustCrypto = await makeTestRustCrypto(undefined, testData.TEST_USER_ID); }); + afterEach(() => { + jest.useRealTimers(); + }); + + it("returns false initially", async () => { + jest.useFakeTimers(); + const prom = rustCrypto.userHasCrossSigningKeys(); + // the getIdentity() request should wait for a /keys/query request to complete, but times out after 1500ms + await jest.advanceTimersByTimeAsync(2000); + await expect(prom).resolves.toBe(false); + }); + it("returns false if there is no cross-signing identity", async () => { + // @ts-ignore private field + const olmMachine = rustCrypto.olmMachine; + + const outgoingRequests: OutgoingRequest[] = await olmMachine.outgoingRequests(); + // pick out the KeysQueryRequest, and respond to it with the device keys but *no* cross-signing keys. + const req = outgoingRequests.find((r) => r instanceof KeysQueryRequest)!; + await olmMachine.markRequestAsSent( + req.id!, + req.type, + JSON.stringify({ + device_keys: { + [testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA }, + }, + }), + ); + await expect(rustCrypto.userHasCrossSigningKeys()).resolves.toBe(false); }); @@ -390,7 +418,12 @@ describe("RustCrypto", () => { await olmMachine.markRequestAsSent( req.id!, req.type, - JSON.stringify(testData.SIGNED_CROSS_SIGNING_KEYS_DATA), + JSON.stringify({ + device_keys: { + [testData.TEST_USER_ID]: { [testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA }, + }, + ...testData.SIGNED_CROSS_SIGNING_KEYS_DATA, + }), ); // ... and we should now have cross-signing keys. From c8f6c4dd0d053d3ff4f0d6f5c07f3f7e262da53d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 23 Jun 2023 13:32:56 +0100 Subject: [PATCH 23/40] Increase crypto test timeout (#3500) For some reason, some tests seem to be timing out in GHA. Let's try bumping up the timeout. --- spec/integ/crypto/crypto.spec.ts | 50 +++++++++++++++++--------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 1618ae304e7..89ff7da9d8b 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -530,32 +530,36 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }; } - beforeEach(async () => { - // anything that we don't have a specific matcher for silently returns a 404 - fetchMock.catch(404); - fetchMock.config.warnOnFallback = false; - - const homeserverUrl = "https://alice-server.com"; - aliceClient = createClient({ - baseUrl: homeserverUrl, - userId: "@alice:localhost", - accessToken: "akjgkrgjs", - deviceId: "xzcvb", - }); + beforeEach( + async () => { + // anything that we don't have a specific matcher for silently returns a 404 + fetchMock.catch(404); + fetchMock.config.warnOnFallback = false; + + const homeserverUrl = "https://alice-server.com"; + aliceClient = createClient({ + baseUrl: homeserverUrl, + userId: "@alice:localhost", + accessToken: "akjgkrgjs", + deviceId: "xzcvb", + }); - /* set up listeners for /keys/upload and /sync */ - keyReceiver = new E2EKeyReceiver(homeserverUrl); - syncResponder = new SyncResponder(homeserverUrl); + /* set up listeners for /keys/upload and /sync */ + keyReceiver = new E2EKeyReceiver(homeserverUrl); + syncResponder = new SyncResponder(homeserverUrl); - await initCrypto(aliceClient); + await initCrypto(aliceClient); - // create a test olm device which we will use to communicate with alice. We use libolm to implement this. - await Olm.init(); - testOlmAccount = new Olm.Account(); - testOlmAccount.create(); - const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); - testSenderKey = testE2eKeys.curve25519; - }); + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + await Olm.init(); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); + testSenderKey = testE2eKeys.curve25519; + }, + /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */ + 10000, + ); afterEach(async () => { await aliceClient.stopClient(); From 3c59476cf70cbbee857590ea0a90ffbb8e5d089d Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 23 Jun 2023 15:10:54 +0200 Subject: [PATCH 24/40] Element-R: Store cross signing keys in secret storage (#3498) * Store cross signing keys in secret storage * Update `bootstrapSecretStorage` doc * Throw error when `createSecretStorageKey` is not set * Move mocking functions * Store cross signing keys and user signing keys * Fix `awaitCrossSigningKeyUpload` documentation * Remove useless comment * Fix formatting after merge conflict --- spec/integ/crypto/cross-signing.spec.ts | 38 +-------- spec/integ/crypto/crypto.spec.ts | 107 ++++++++++++++++++++---- spec/test-utils/mockEndpoints.ts | 25 ++++++ src/client.ts | 3 + src/crypto-api.ts | 9 +- src/rust-crypto/rust-crypto.ts | 86 +++++++++++++++---- 6 files changed, 201 insertions(+), 67 deletions(-) diff --git a/spec/integ/crypto/cross-signing.spec.ts b/spec/integ/crypto/cross-signing.spec.ts index 16ba6df1d48..4043379e1e7 100644 --- a/spec/integ/crypto/cross-signing.spec.ts +++ b/spec/integ/crypto/cross-signing.spec.ts @@ -19,7 +19,8 @@ import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; import { CRYPTO_BACKENDS, InitCrypto } from "../../test-utils/test-utils"; -import { createClient, MatrixClient, IAuthDict, UIAuthCallback } from "../../../src"; +import { createClient, IAuthDict, MatrixClient } from "../../../src"; +import { mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -62,45 +63,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s }); /** - * Mock the requests needed to set up cross signing - * - * Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request - * Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check) - * Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check) - */ - function mockSetupCrossSigningRequests(): void { - // have account_data requests return an empty object - fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {}); - - // we expect a request to upload signatures for our device ... - fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); - - // ... and one to upload the cross-signing keys (with UIA) - fetchMock.post( - // legacy crypto uses /unstable/; /v3/ is correct - { - url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"), - name: "upload-keys", - }, - {}, - ); - } - - /** - * Create cross-signing keys, publish the keys - * Mock and bootstrap all the required steps + * Create cross-signing keys and publish the keys * * @param authDict - The parameters to as the `auth` dict in the key upload request. * @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types */ async function bootstrapCrossSigning(authDict: IAuthDict): Promise { - const uiaCallback: UIAuthCallback = async (makeRequest) => { - await makeRequest(authDict); - }; - - // now bootstrap cross signing, and check it resolves successfully await aliceClient.getCrypto()?.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: uiaCallback, + authUploadDeviceSigningKeys: (makeRequest) => makeRequest(authDict).then(() => undefined), }); } diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index 89ff7da9d8b..fd751e57f7a 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -50,8 +50,9 @@ import { ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder"; import { escapeRegExp } from "../../../src/utils"; import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter"; import { flushPromises } from "../../test-utils/flushPromises"; -import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; -import { SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; +import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints"; +import { AddSecretStorageKeyOpts, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage"; +import { CryptoCallbacks } from "../../../src/crypto-api"; const ROOM_ID = "!room:id"; @@ -530,6 +531,27 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }; } + /** + * Create the {@link CryptoCallbacks} + */ + function createCryptoCallbacks(): CryptoCallbacks { + // Store the cached secret storage key and return it when `getSecretStorageKey` is called + let cachedKey: { keyId: string; key: Uint8Array }; + const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => { + cachedKey = { + keyId, + key, + }; + }; + + const getSecretStorageKey = () => Promise.resolve<[string, Uint8Array]>([cachedKey.keyId, cachedKey.key]); + + return { + cacheSecretStorageKey, + getSecretStorageKey, + }; + } + beforeEach( async () => { // anything that we don't have a specific matcher for silently returns a 404 @@ -542,6 +564,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, userId: "@alice:localhost", accessToken: "akjgkrgjs", deviceId: "xzcvb", + cryptoCallbacks: createCryptoCallbacks(), }); /* set up listeners for /keys/upload and /sync */ @@ -2187,16 +2210,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); /** - * Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type` + * Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)` * Resolved when a key is uploaded (ie in `body.content.key`) * https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype */ - function awaitKeyStoredInAccountData(): Promise { + function awaitSecretStorageKeyStoredInAccountData(): Promise { return new Promise((resolve) => { // This url is called multiple times during the secret storage bootstrap process // When we received the newly generated key, we return it fetchMock.put( - "express:/_matrix/client/r0/user/:userId/account_data/:type", + "express:/_matrix/client/r0/user/:userId/account_data/:type(m.secret_storage.*)", (url: string, options: RequestInit) => { const content = JSON.parse(options.body as string); @@ -2211,6 +2234,25 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); } + /** + * Create a mock to respond to the PUT request `/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}` + * Resolved when the cross signing key is uploaded + * https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype + */ + function awaitCrossSigningKeyUpload(key: string): Promise> { + return new Promise((resolve) => { + // Called when the cross signing key is uploaded + fetchMock.put( + `express:/_matrix/client/r0/user/:userId/account_data/m.cross_signing.${key}`, + (url: string, options: RequestInit) => { + const content = JSON.parse(options.body as string); + resolve(content.encrypted); + return {}; + }, + ); + }); + } + /** * Send in the sync response the provided `secretStorageKey` into the account_data field * The key is set for the `m.secret_storage.default_key` and `m.secret_storage.key.${secretStorageKey}` events @@ -2249,12 +2291,14 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await startClientAndAwaitFirstSync(); }); - newBackendOnly("should do no nothing if createSecretStorageKey is not set", async () => { - await aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }); - - // No key was created - expect(createSecretStorageKey).toHaveBeenCalledTimes(0); - }); + newBackendOnly( + "should throw an error if we are unable to create a key because createSecretStorageKey is not set", + async () => { + await expect( + aliceClient.getCrypto()!.bootstrapSecretStorage({ setupNewSecretStorage: true }), + ).rejects.toThrow("unable to create a new secret storage key, createSecretStorageKey is not set"); + }, + ); newBackendOnly("should create a new key", async () => { const bootstrapPromise = aliceClient @@ -2262,7 +2306,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); // Wait for the key to be uploaded in the account data - const secretStorageKey = await awaitKeyStoredInAccountData(); + const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData(); // Return the newly created key in the sync response sendSyncResponse(secretStorageKey); @@ -2283,7 +2327,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, const bootstrapPromise = aliceClient.getCrypto()!.bootstrapSecretStorage({ createSecretStorageKey }); // Wait for the key to be uploaded in the account data - const secretStorageKey = await awaitKeyStoredInAccountData(); + const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData(); // Return the newly created key in the sync response sendSyncResponse(secretStorageKey); @@ -2307,7 +2351,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); // Wait for the key to be uploaded in the account data - let secretStorageKey = await awaitKeyStoredInAccountData(); + let secretStorageKey = await awaitSecretStorageKeyStoredInAccountData(); // Return the newly created key in the sync response sendSyncResponse(secretStorageKey); @@ -2321,7 +2365,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); // Wait for the key to be uploaded in the account data - secretStorageKey = await awaitKeyStoredInAccountData(); + secretStorageKey = await awaitSecretStorageKeyStoredInAccountData(); // Return the newly created key in the sync response sendSyncResponse(secretStorageKey); @@ -2333,5 +2377,38 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(createSecretStorageKey).toHaveBeenCalledTimes(2); }, ); + + newBackendOnly("should upload cross signing keys", async () => { + mockSetupCrossSigningRequests(); + + // Before setting up secret-storage, bootstrap cross-signing, so that the client has cross-signing keys. + await aliceClient.getCrypto()?.bootstrapCrossSigning({}); + + // Now, when we bootstrap secret-storage, the cross-signing keys should be uploaded. + const bootstrapPromise = aliceClient + .getCrypto()! + .bootstrapSecretStorage({ setupNewSecretStorage: true, createSecretStorageKey }); + + // Wait for the key to be uploaded in the account data + const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData(); + + // Return the newly created key in the sync response + sendSyncResponse(secretStorageKey); + + // Wait for the cross signing keys to be uploaded + const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([ + awaitCrossSigningKeyUpload("master"), + awaitCrossSigningKeyUpload("user_signing"), + awaitCrossSigningKeyUpload("self_signing"), + ]); + + // Finally, wait for bootstrapSecretStorage to finished + await bootstrapPromise; + + // Expect the cross signing master key to be uploaded and to be encrypted with `secretStorageKey` + expect(masterKey[secretStorageKey]).toBeDefined(); + expect(userSigningKey[secretStorageKey]).toBeDefined(); + expect(selfSigningKey[secretStorageKey]).toBeDefined(); + }); }); }); diff --git a/spec/test-utils/mockEndpoints.ts b/spec/test-utils/mockEndpoints.ts index a4c162867fe..bd5bb819a42 100644 --- a/spec/test-utils/mockEndpoints.ts +++ b/spec/test-utils/mockEndpoints.ts @@ -28,3 +28,28 @@ export function mockInitialApiRequests(homeserverUrl: string) { filter_id: "fid", }); } + +/** + * Mock the requests needed to set up cross signing + * + * Return `{}` for `GET _matrix/client/r0/user/:userId/account_data/:type` request + * Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check) + * Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check) + */ +export function mockSetupCrossSigningRequests(): void { + // have account_data requests return an empty object + fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {}); + + // we expect a request to upload signatures for our device ... + fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); + + // ... and one to upload the cross-signing keys (with UIA) + fetchMock.post( + // legacy crypto uses /unstable/; /v3/ is correct + { + url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"), + name: "upload-keys", + }, + {}, + ); +} diff --git a/src/client.ts b/src/client.ts index d3e6c4524db..5297eee9d88 100644 --- a/src/client.ts +++ b/src/client.ts @@ -367,6 +367,9 @@ export interface ICreateClientOpts { */ useE2eForGroupCall?: boolean; + /** + * Crypto callbacks provided by the application + */ cryptoCallbacks?: ICryptoCallbacks; /** diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 1a78f11b434..967fc8a61a6 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -192,12 +192,17 @@ export interface CryptoApi { isSecretStorageReady(): Promise; /** - * Bootstrap the secret storage by creating a new secret storage key and store it in the secret storage. + * Bootstrap the secret storage by creating a new secret storage key, add it in the secret storage and + * store the cross signing keys in the secret storage. * - * - Do nothing if an AES key is already stored in the secret storage and `setupNewKeyBackup` is not set; * - Generate a new key {@link GeneratedSecretStorageKey} with `createSecretStorageKey`. + * Only if `setupNewSecretStorage` is set or if there is no AES key in the secret storage * - Store this key in the secret storage and set it as the default key. * - Call `cryptoCallbacks.cacheSecretStorageKey` if provided. + * - Store the cross signing keys in the secret storage if + * - the cross signing is ready + * - a new key was created during the previous step + * - or the secret storage already contains the cross signing keys * * @param opts - Options object. */ diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 033063bc437..1b791f3cafd 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -387,42 +387,96 @@ export class RustCrypto implements CryptoBackend { createSecretStorageKey, setupNewSecretStorage, }: CreateSecretStorageOpts = {}): Promise { - // If createSecretStorageKey is not set, we stop - if (!createSecretStorageKey) return; + // If an AES Key is already stored in the secret storage and setupNewSecretStorage is not set + // we don't want to create a new key + const isNewSecretStorageKeyNeeded = setupNewSecretStorage || !(await this.secretStorageHasAESKey()); - // See if we already have an AES secret-storage key. - const secretStorageKeyTuple = await this.secretStorage.getKey(); + if (isNewSecretStorageKeyNeeded) { + if (!createSecretStorageKey) { + throw new Error("unable to create a new secret storage key, createSecretStorageKey is not set"); + } - if (secretStorageKeyTuple) { - const [, keyInfo] = secretStorageKeyTuple; + // Create a new storage key and add it to secret storage + const recoveryKey = await createSecretStorageKey(); + await this.addSecretStorageKeyToSecretStorage(recoveryKey); + } - // If an AES Key is already stored in the secret storage and setupNewSecretStorage is not set - // we don't want to create a new key - if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES && !setupNewSecretStorage) { - return; + const crossSigningStatus: RustSdkCryptoJs.CrossSigningStatus = await this.olmMachine.crossSigningStatus(); + const hasPrivateKeys = + crossSigningStatus.hasMaster && crossSigningStatus.hasSelfSigning && crossSigningStatus.hasUserSigning; + + // If we have cross-signing private keys cached, store them in secret + // storage if they are not there already. + if ( + hasPrivateKeys && + (isNewSecretStorageKeyNeeded || !(await secretStorageContainsCrossSigningKeys(this.secretStorage))) + ) { + const crossSigningPrivateKeys: RustSdkCryptoJs.CrossSigningKeyExport = + await this.olmMachine.exportCrossSigningKeys(); + + if (!crossSigningPrivateKeys.masterKey) { + throw new Error("missing master key in cross signing private keys"); } - } - const recoveryKey = await createSecretStorageKey(); + if (!crossSigningPrivateKeys.userSigningKey) { + throw new Error("missing user signing key in cross signing private keys"); + } + if (!crossSigningPrivateKeys.self_signing_key) { + throw new Error("missing self signing key in cross signing private keys"); + } + + await this.secretStorage.store("m.cross_signing.master", crossSigningPrivateKeys.masterKey); + await this.secretStorage.store("m.cross_signing.user_signing", crossSigningPrivateKeys.userSigningKey); + await this.secretStorage.store("m.cross_signing.self_signing", crossSigningPrivateKeys.self_signing_key); + } + } + + /** + * Add the secretStorage key to the secret storage + * - The secret storage key must have the `keyInfo` field filled + * - The secret storage key is set as the default key of the secret storage + * - Call `cryptoCallbacks.cacheSecretStorageKey` when done + * + * @param secretStorageKey - The secret storage key to add in the secret storage. + */ + private async addSecretStorageKeyToSecretStorage(secretStorageKey: GeneratedSecretStorageKey): Promise { // keyInfo is required to continue - if (!recoveryKey.keyInfo) { - throw new Error("missing keyInfo field in the secret storage key created by createSecretStorageKey"); + if (!secretStorageKey.keyInfo) { + throw new Error("missing keyInfo field in the secret storage key"); } const secretStorageKeyObject = await this.secretStorage.addKey( SECRET_STORAGE_ALGORITHM_V1_AES, - recoveryKey.keyInfo, + secretStorageKey.keyInfo, ); + await this.secretStorage.setDefaultKeyId(secretStorageKeyObject.keyId); this.cryptoCallbacks.cacheSecretStorageKey?.( secretStorageKeyObject.keyId, secretStorageKeyObject.keyInfo, - recoveryKey.privateKey, + secretStorageKey.privateKey, ); } + /** + * Check if a secret storage AES Key is already added in secret storage + * + * @returns True if an AES key is in the secret storage + */ + private async secretStorageHasAESKey(): Promise { + // See if we already have an AES secret-storage key. + const secretStorageKeyTuple = await this.secretStorage.getKey(); + + if (!secretStorageKeyTuple) return false; + + const [, keyInfo] = secretStorageKeyTuple; + + // Check if the key is an AES key + return keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES; + } + /** * Implementation of {@link CryptoApi#getCrossSigningStatus} */ From f884c78579c336a03bc20ff8f4e92c46582822b6 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 23 Jun 2023 15:38:38 +0100 Subject: [PATCH 25/40] Improve integration test for interactive verification (#3495) * Tweaks to the integ test to conform to the spec Rust is a bit more insistent than legacy crypto... * Improve documentation on request*Verification * Check more things in the integration test * Create an E2EKeyResponder * Test verification with custom method list * Add a test for SAS cancellation * Update spec/integ/crypto/verification.spec.ts --- spec/integ/crypto/verification.spec.ts | 432 +++++++++++++++---------- spec/test-utils/E2EKeyResponder.ts | 99 ++++++ src/client.ts | 5 + src/crypto-api.ts | 15 +- 4 files changed, 374 insertions(+), 177 deletions(-) create mode 100644 spec/test-utils/E2EKeyResponder.ts diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index b5f500f5887..960dbb375d0 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -40,6 +40,7 @@ import { TEST_USER_ID, } from "../../test-utils/test-data"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations // to ensure that we don't end up with dangling timeouts. @@ -79,7 +80,18 @@ afterAll(() => { * These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as * to provide the most effective integration tests possible. */ +// we test with both crypto stacks... describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => { + // and with (1) the default verification method list, (2) a custom verification method list. + describe.each([undefined, ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]])( + "supported methods=%s", + (methods) => { + runTests(backend, initCrypto, methods); + }, + ); +}); + +function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | undefined) { // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the // Rust backend. Once we have full support in the rust sdk, it will go away. const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; @@ -90,6 +102,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st /** an object which intercepts `/sync` requests from {@link #aliceClient} */ let syncResponder: SyncResponder; + /** an object which intercepts `/keys/query` requests from {@link #aliceClient} */ + let e2eKeyResponder: E2EKeyResponder; + beforeEach(async () => { // anything that we don't have a specific matcher for silently returns a 404 fetchMock.catch(404); @@ -101,9 +116,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st userId: TEST_USER_ID, accessToken: "akjgkrgjs", deviceId: "device_under_test", + verificationMethods: methods, }); await initCrypto(aliceClient); + + e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); + mockInitialApiRequests(aliceClient.getHomeserverUrl()); + await aliceClient.startClient(); }); afterEach(async () => { @@ -111,156 +132,157 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st fetchMock.mockReset(); }); - beforeEach(() => { - syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); - mockInitialApiRequests(aliceClient.getHomeserverUrl()); - aliceClient.startClient(); - }); + describe("Outgoing verification requests for another device", () => { + beforeEach(async () => { + // pretend that we have another device, which we will verify + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + }); - oldBackendOnly("Outgoing verification: can verify another device via SAS", async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + oldBackendOnly("can verify via SAS", async () => { + // have alice initiate a verification. She should send a m.key.verification.request + let [requestBody, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), + ]); + const transactionId = request.transactionId; + expect(transactionId).toBeDefined(); + expect(request.phase).toEqual(VerificationPhase.Requested); + expect(request.roomId).toBeUndefined(); + expect(request.isSelfVerification).toBe(true); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(false); // no reply yet + expect(request.chosenMethod).toBe(null); // nothing chosen yet + expect(request.initiatedByMe).toBe(true); + expect(request.otherUserId).toEqual(TEST_USER_ID); + + let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + if (methods !== undefined) { + // eslint-disable-next-line jest/no-conditional-expect + expect(new Set(toDeviceMessage.methods)).toEqual(new Set(methods)); + } + + // The dummy device replies with an m.key.verification.ready... + returnToDeviceMessageFromSync({ + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: transactionId, }, - }, - }); + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Ready); + expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); - // have alice initiate a verification. She should send a m.key.verification.request - let [requestBody, request] = await Promise.all([ - expectSendToDeviceMessage("m.key.verification.request"), - aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), - ]); - const transactionId = request.transactionId; - expect(transactionId).toBeDefined(); - expect(request.phase).toEqual(VerificationPhase.Requested); - expect(request.roomId).toBeUndefined(); - - let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.methods).toContain("m.sas.v1"); - expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // The dummy device replies with an m.key.verification.ready... - returnToDeviceMessageFromSync({ - type: "m.key.verification.ready", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: transactionId, - }, - }); - await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(VerificationPhase.Ready); - expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); - - // ... and picks a method with m.key.verification.start - returnToDeviceMessageFromSync({ - type: "m.key.verification.start", - content: { - from_device: TEST_DEVICE_ID, - method: "m.sas.v1", - transaction_id: transactionId, - hashes: ["sha256"], - key_agreement_protocols: ["curve25519"], - message_authentication_codes: ["hkdf-hmac-sha256.v2"], - short_authentication_string: ["emoji"], - }, - }); - await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(VerificationPhase.Started); - expect(request.chosenMethod).toEqual("m.sas.v1"); - - // there should now be a verifier - const verifier: Verifier = request.verifier!; - expect(verifier).toBeDefined(); - expect(verifier.getShowSasCallbacks()).toBeNull(); - - // start off the verification process: alice will send an `accept` - const verificationPromise = verifier.verify(); - // advance the clock, because the devicelist likes to sleep for 5ms during key downloads - jest.advanceTimersByTime(10); - - requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519"); - expect(toDeviceMessage.short_authentication_string).toEqual(["emoji"]); - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key' - // We use the Curve25519, HMAC and HKDF implementations in libolm, for now - const olmSAS = new global.Olm.SAS(); - returnToDeviceMessageFromSync({ - type: "m.key.verification.key", - content: { - transaction_id: transactionId, - key: olmSAS.get_pubkey(), - }, - }); + // ... and picks a method with m.key.verification.start + returnToDeviceMessageFromSync({ + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.sas.v1", + transaction_id: transactionId, + hashes: ["sha256"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], + message_authentication_codes: ["hkdf-hmac-sha256.v2"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Started); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true); + expect(request.chosenMethod).toEqual("m.sas.v1"); - // alice responds with a 'key' ... - requestBody = await expectSendToDeviceMessage("m.key.verification.key"); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - const aliceDevicePubKeyBase64 = toDeviceMessage.key; - olmSAS.set_their_key(aliceDevicePubKeyBase64); + // there should now be a verifier + const verifier: Verifier = request.verifier!; + expect(verifier).toBeDefined(); + expect(verifier.getShowSasCallbacks()).toBeNull(); - // ... and the client is notified to show the emoji - const showSas = await new Promise((resolve) => { - verifier.once(VerifierEvent.ShowSas, resolve); - }); + // start off the verification process: alice will send an `accept` + const verificationPromise = verifier.verify(); + // advance the clock, because the devicelist likes to sleep for 5ms during key downloads + jest.advanceTimersByTime(10); + + requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256"); + expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]); + const macMethod = toDeviceMessage.message_authentication_code; + expect(macMethod).toEqual("hkdf-hmac-sha256.v2"); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); - // `getShowSasCallbacks` is an alternative way to get the callbacks - expect(verifier.getShowSasCallbacks()).toBe(showSas); - expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); - - // user confirms that the emoji match, and alice sends a 'mac' - [requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]); - toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.transaction_id).toEqual(transactionId); - - // the dummy device also confirms that the emoji match, and sends a mac - const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`; - returnToDeviceMessageFromSync({ - type: "m.key.verification.mac", - content: { - keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`), - transaction_id: transactionId, - mac: { - [`ed25519:${TEST_DEVICE_ID}`]: calculateMAC( - olmSAS, - TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, - `${macInfoBase}ed25519:${TEST_DEVICE_ID}`, - ), + // The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key' + // We use the Curve25519, HMAC and HKDF implementations in libolm, for now + const olmSAS = new global.Olm.SAS(); + returnToDeviceMessageFromSync({ + type: "m.key.verification.key", + content: { + transaction_id: transactionId, + key: olmSAS.get_pubkey(), }, - }, - }); + }); - // that should satisfy Alice, who should reply with a 'done' - await expectSendToDeviceMessage("m.key.verification.done"); + // alice responds with a 'key' ... + requestBody = await expectSendToDeviceMessage("m.key.verification.key"); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + const aliceDevicePubKeyBase64 = toDeviceMessage.key; + olmSAS.set_their_key(aliceDevicePubKeyBase64); - // ... and the whole thing should be done! - await verificationPromise; - expect(request.phase).toEqual(VerificationPhase.Done); + // ... and the client is notified to show the emoji + const showSas = await new Promise((resolve) => { + verifier.once(VerifierEvent.ShowSas, resolve); + }); - // we're done with the temporary keypair - olmSAS.free(); - }); + // `getShowSasCallbacks` is an alternative way to get the callbacks + expect(verifier.getShowSasCallbacks()).toBe(showSas); + expect(verifier.getReciprocateQrCodeCallbacks()).toBeNull(); + + // user confirms that the emoji match, and alice sends a 'mac' + [requestBody] = await Promise.all([expectSendToDeviceMessage("m.key.verification.mac"), showSas.confirm()]); + toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.transaction_id).toEqual(transactionId); - oldBackendOnly( - "Outgoing verification: can verify another device via QR code with an untrusted cross-signing key", - async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + // the dummy device also confirms that the emoji match, and sends a mac + const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${TEST_USER_ID}${aliceClient.deviceId}${transactionId}`; + returnToDeviceMessageFromSync({ + type: "m.key.verification.mac", + content: { + keys: calculateMAC(olmSAS, `ed25519:${TEST_DEVICE_ID}`, `${macInfoBase}KEY_IDS`), + transaction_id: transactionId, + mac: { + [`ed25519:${TEST_DEVICE_ID}`]: calculateMAC( + olmSAS, + TEST_DEVICE_PUBLIC_ED25519_KEY_BASE64, + `${macInfoBase}ed25519:${TEST_DEVICE_ID}`, + ), }, }, - ...SIGNED_CROSS_SIGNING_KEYS_DATA, }); + // that should satisfy Alice, who should reply with a 'done' + await expectSendToDeviceMessage("m.key.verification.done"); + + // the dummy device also confirms done-ness + returnToDeviceMessageFromSync({ + type: "m.key.verification.done", + content: { + transaction_id: transactionId, + }, + }); + + // ... and the whole thing should be done! + await verificationPromise; + expect(request.phase).toEqual(VerificationPhase.Done); + + // we're done with the temporary keypair + olmSAS.free(); + }); + + oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { + e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); + // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. // // Completing the initial sync will make the device list download outdated device lists (of which our own @@ -279,8 +301,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1"); - expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); expect(toDeviceMessage.methods).toContain("m.reciprocate.v1"); + if (methods === undefined) { + expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); + } expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); expect(toDeviceMessage.transaction_id).toEqual(transactionId); @@ -351,57 +375,115 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st // ... and the whole thing should be done! await verificationPromise; expect(request.phase).toEqual(VerificationPhase.Done); - }, - ); + }); + + oldBackendOnly("can cancel during the SAS phase", async () => { + // have alice initiate a verification. She should send a m.key.verification.request + const [, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), + ]); + const transactionId = request.transactionId; - oldBackendOnly("Incoming verification: can accept", async () => { - // expect requests to download our own keys - fetchMock.post(new RegExp("/_matrix/client/(r0|v3)/keys/query"), { - device_keys: { - [TEST_USER_ID]: { - [TEST_DEVICE_ID]: SIGNED_TEST_DEVICE_DATA, + // The dummy device replies with an m.key.verification.ready... + returnToDeviceMessageFromSync({ + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: transactionId, }, - }, + }); + await waitForVerificationRequestChanged(request); + + // ... and picks a method with m.key.verification.start + returnToDeviceMessageFromSync({ + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.sas.v1", + transaction_id: transactionId, + hashes: ["sha256"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], + message_authentication_codes: ["hkdf-hmac-sha256.v2"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], + }, + }); + await waitForVerificationRequestChanged(request); + expect(request.phase).toEqual(VerificationPhase.Started); + + // there should now be a verifier... + const verifier: Verifier = request.verifier!; + expect(verifier).toBeDefined(); + expect(verifier.hasBeenCancelled).toBe(false); + + // start off the verification process: alice will send an `accept` + const verificationPromise = verifier.verify(); + // advance the clock, because the devicelist likes to sleep for 5ms during key downloads + jest.advanceTimersByTime(10); + await expectSendToDeviceMessage("m.key.verification.accept"); + + // now we unceremoniously cancel + const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel"); + verifier.cancel(new Error("blah")); + await requestPromise; + + // ... which should cancel the verifier + await expect(verificationPromise).rejects.toThrow(); + expect(request.phase).toEqual(VerificationPhase.Cancelled); + expect(verifier.hasBeenCancelled).toBe(true); }); + }); - const TRANSACTION_ID = "abcd"; + describe("Incoming verification from another device", () => { + beforeEach(() => { + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); + }); - // Initiate the request by sending a to-device message - returnToDeviceMessageFromSync({ - type: "m.key.verification.request", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: TRANSACTION_ID, - timestamp: Date.now() - 1000, - }, + oldBackendOnly("Incoming verification: can accept", async () => { + const TRANSACTION_ID = "abcd"; + + // Initiate the request by sending a to-device message + returnToDeviceMessageFromSync({ + type: "m.key.verification.request", + content: { + from_device: TEST_DEVICE_ID, + methods: ["m.sas.v1"], + transaction_id: TRANSACTION_ID, + timestamp: Date.now() - 1000, + }, + }); + const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); + expect(request.transactionId).toEqual(TRANSACTION_ID); + expect(request.phase).toEqual(VerificationPhase.Requested); + expect(request.roomId).toBeUndefined(); + expect(request.initiatedByMe).toBe(false); + expect(request.otherUserId).toEqual(TEST_USER_ID); + expect(request.chosenMethod).toBe(null); // nothing chosen yet + expect(canAcceptVerificationRequest(request)).toBe(true); + + // Alice accepts, by sending a to-device message + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); + const acceptPromise = request.accept(); + expect(canAcceptVerificationRequest(request)).toBe(false); + expect(request.phase).toEqual(VerificationPhase.Requested); + await acceptPromise; + const requestBody = await sendToDevicePromise; + expect(request.phase).toEqual(VerificationPhase.Ready); + + const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.methods).toContain("m.sas.v1"); + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID); }); - const request: VerificationRequest = await emitPromise(aliceClient, CryptoEvent.VerificationRequest); - expect(request.transactionId).toEqual(TRANSACTION_ID); - expect(request.phase).toEqual(VerificationPhase.Requested); - expect(request.roomId).toBeUndefined(); - expect(canAcceptVerificationRequest(request)).toBe(true); - - // Alice accepts, by sending a to-device message - const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.ready"); - const acceptPromise = request.accept(); - expect(canAcceptVerificationRequest(request)).toBe(false); - expect(request.phase).toEqual(VerificationPhase.Requested); - await acceptPromise; - const requestBody = await sendToDevicePromise; - expect(request.phase).toEqual(VerificationPhase.Ready); - - const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; - expect(toDeviceMessage.methods).toContain("m.sas.v1"); - expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); - expect(toDeviceMessage.transaction_id).toEqual(TRANSACTION_ID); }); function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); } -}); +} /** * Wait for the client under test to send a to-device message of the given type. diff --git a/spec/test-utils/E2EKeyResponder.ts b/spec/test-utils/E2EKeyResponder.ts new file mode 100644 index 00000000000..a704d756d45 --- /dev/null +++ b/spec/test-utils/E2EKeyResponder.ts @@ -0,0 +1,99 @@ +/* +Copyright 2023 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 fetchMock from "fetch-mock-jest"; + +import { MapWithDefault } from "../../src/utils"; +import { IDownloadKeyResult } from "../../src"; +import { IDeviceKeys } from "../../src/@types/crypto"; + +/** + * An object which intercepts `/keys/query` fetches via fetch-mock. + */ +export class E2EKeyResponder { + private deviceKeysByUserByDevice = new MapWithDefault>(() => new Map()); + private masterKeysByUser: Record = {}; + private selfSigningKeysByUser: Record = {}; + private userSigningKeysByUser: Record = {}; + + /** + * Construct a new E2EKeyResponder. + * + * It will immediately register an intercept of `/keys/query` requests for the given homeserverUrl. + * Only /query requests made to this server will be intercepted: this allows a single test to use more than one + * client and have the keys collected separately. + * + * @param homeserverUrl - the Homeserver Url of the client under test. + */ + public constructor(homeserverUrl: string) { + // set up a listener for /keys/query. + const listener = (url: string, options: RequestInit) => this.onKeyQueryRequest(options); + // catch both r0 and v3 variants + fetchMock.post(new URL("/_matrix/client/r0/keys/query", homeserverUrl).toString(), listener); + fetchMock.post(new URL("/_matrix/client/v3/keys/query", homeserverUrl).toString(), listener); + } + + private onKeyQueryRequest(options: RequestInit) { + const content = JSON.parse(options.body as string); + const usersToReturn = Object.keys(content["device_keys"]); + const response = { + device_keys: {} as { [userId: string]: any }, + master_keys: {} as { [userId: string]: any }, + self_signing_keys: {} as { [userId: string]: any }, + user_signing_keys: {} as { [userId: string]: any }, + failures: {} as { [serverName: string]: any }, + }; + for (const user of usersToReturn) { + const userKeys = this.deviceKeysByUserByDevice.get(user); + if (userKeys !== undefined) { + response.device_keys[user] = Object.fromEntries(userKeys.entries()); + } + if (this.masterKeysByUser.hasOwnProperty(user)) { + response.master_keys[user] = this.masterKeysByUser[user]; + } + if (this.selfSigningKeysByUser.hasOwnProperty(user)) { + response.self_signing_keys[user] = this.selfSigningKeysByUser[user]; + } + if (this.userSigningKeysByUser.hasOwnProperty(user)) { + response.user_signing_keys[user] = this.userSigningKeysByUser[user]; + } + } + return response; + } + + /** + * Add a set of device keys for return by a future `/keys/query`, as if they had been `/upload`ed + * + * @param userId - user the keys belong to + * @param deviceId - device the keys belong to + * @param keys - device keys for this device. + */ + public addDeviceKeys(userId: string, deviceId: string, keys: IDeviceKeys) { + this.deviceKeysByUserByDevice.getOrCreate(userId).set(deviceId, keys); + } + + /** Add a set of cross-signing keys for return by a future `/keys/query`, as if they had been `/keys/device_signing/upload`ed + * + * @param data cross-signing data + */ + public addCrossSigningData( + data: Pick, + ) { + Object.assign(this.masterKeysByUser, data.master_keys); + Object.assign(this.selfSigningKeysByUser, data.self_signing_keys); + Object.assign(this.userSigningKeysByUser, data.user_signing_keys); + } +} diff --git a/src/client.ts b/src/client.ts index 5297eee9d88..18eec33671f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -336,6 +336,11 @@ export interface ICreateClientOpts { */ pickleKey?: string; + /** + * Verification methods we should offer to the other side when performing an interactive verification. + * If unset, we will offer all known methods. Currently these are: showing a QR code, scanning a QR code, and SAS + * (aka "emojis"). + */ verificationMethods?: Array; /** diff --git a/src/crypto-api.ts b/src/crypto-api.ts index 967fc8a61a6..7428a3181c3 100644 --- a/src/crypto-api.ts +++ b/src/crypto-api.ts @@ -256,7 +256,12 @@ export interface CryptoApi { /** * Send a verification request to our other devices. * - * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * This is normally used when the current device is new, and we want to ask another of our devices to cross-sign. + * + * If an all-devices verification is already in flight, returns it. Otherwise, initiates a new one. + * + * To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the + * MatrixClient. * * @returns a VerificationRequest when the request has been sent to the other party. */ @@ -265,7 +270,13 @@ export interface CryptoApi { /** * Request an interactive verification with the given device. * - * If a verification is already in flight, returns it. Otherwise, initiates a new one. + * This is normally used on one of our own devices, when the current device is already cross-signed, and we want to + * validate another device. + * + * If a verification for this user/device is already in flight, returns it. Otherwise, initiates a new one. + * + * To control the methods offered, set {@link ICreateClientOpts.verificationMethods} when creating the + * MatrixClient. * * @param userId - ID of the owner of the device to verify * @param deviceId - ID of the device to verify From f16a6bc6540acf50a905a527695b3d930491a63f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 26 Jun 2023 09:39:25 +0100 Subject: [PATCH 26/40] Aggregate relations regardless of whether event fits into the timeline (#3496) --- spec/unit/event-timeline-set.spec.ts | 29 ++++++++++++++++++++++++++++ src/models/event-timeline-set.ts | 20 +++++++++---------- src/models/room.ts | 19 ------------------ 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index b5445a03397..a817127569c 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked } from "jest-mock"; + import * as utils from "../test-utils/test-utils"; import { DuplicateStrategy, @@ -160,6 +162,33 @@ describe("EventTimelineSet", () => { eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false); }).not.toThrow(); }); + + it("should aggregate relations which belong to unknown timeline without adding them to any timeline", () => { + // If threads are disabled all events go into the main timeline + mocked(client.supportsThreads).mockReturnValue(true); + const reactionEvent = utils.mkReaction(messageEvent, client, client.getSafeUserId(), roomId); + + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents()).toHaveLength(1); + const [event] = liveTimeline.getEvents(); + const reactions = eventTimelineSet.relations!.getChildEventsForEvent( + event.getId()!, + "m.annotation", + "m.reaction", + )!; + const relations = reactions.getRelations(); + expect(relations).toHaveLength(1); + expect(relations[0].getId()).toBe(reactionEvent.getId()); + }); }); describe("addEventToTimeline (thread timeline)", () => { diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index a003f136239..cc41e543c44 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -721,13 +721,17 @@ export class EventTimelineSet extends TypedEventEmitter { threadId?: string; } { if (!this.client?.supportsThreads()) { - logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} client does not support threads`); return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2112,11 +2111,6 @@ export class Room extends ReadReceipt { // A thread root is always shown in both timelines if (event.isThreadRoot || roots?.has(event.getId()!)) { - if (event.isThreadRoot) { - logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} isThreadRoot is true`); - } else { - logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} is a known thread root`); - } return { shouldLiveInRoom: true, shouldLiveInThread: true, @@ -2127,9 +2121,6 @@ export class Room extends ReadReceipt { // A thread relation (1st and 2nd order) is always only shown in a thread const threadRootId = event.threadRootId; if (threadRootId != undefined) { - logger.debug( - `Room::eventShouldLiveIn: eventId=${event.getId()} threadRootId=${threadRootId} is part of a thread`, - ); return { shouldLiveInRoom: false, shouldLiveInThread: true, @@ -2141,9 +2132,6 @@ export class Room extends ReadReceipt { let parentEvent: MatrixEvent | undefined; if (parentEventId) { parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); - logger.debug( - `Room::eventShouldLiveIn: eventId=${event.getId()} parentEventId=${parentEventId} found=${!!parentEvent}`, - ); } // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead @@ -2152,7 +2140,6 @@ export class Room extends ReadReceipt { } if (!event.isRelation()) { - logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} not a relation`); return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2161,11 +2148,6 @@ export class Room extends ReadReceipt { // Edge case where we know the event is a relation but don't have the parentEvent if (roots?.has(event.relationEventId!)) { - logger.debug( - `Room::eventShouldLiveIn: eventId=${event.getId()} relationEventId=${ - event.relationEventId - } is a known root`, - ); return { shouldLiveInRoom: true, shouldLiveInThread: true, @@ -2176,7 +2158,6 @@ export class Room extends ReadReceipt { // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts - logger.debug(`Room::eventShouldLiveIn: eventId=${event.getId()} belongs to an unknown timeline`); return { shouldLiveInRoom: false, shouldLiveInThread: false, From 48c4127035e058abde5e7bc7eda6208c891cb602 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 09:48:44 +0100 Subject: [PATCH 27/40] Element-R: Basic implementation of SAS verification (#3490) * Return uploaded keys from `/keys/query` * Basic implementation of SAS verification in Rust * Update the `verifier` *before* emitting `erificationRequestEvent.Change` * remove dead code --- package.json | 2 +- spec/integ/crypto/verification.spec.ts | 80 ++-- spec/test-utils/E2EKeyReceiver.ts | 7 + spec/test-utils/E2EKeyResponder.ts | 24 ++ spec/unit/rust-crypto/rust-crypto.spec.ts | 9 + src/client.ts | 2 + src/rust-crypto/rust-crypto.ts | 30 +- src/rust-crypto/verification.ts | 429 ++++++++++++++++++++++ yarn.lock | 8 +- 9 files changed, 559 insertions(+), 32 deletions(-) create mode 100644 src/rust-crypto/verification.ts diff --git a/package.json b/package.json index f9b6d50bd79..03bfe44accd 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.10", + "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.11", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 960dbb375d0..1e71fdcd17f 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -16,6 +16,7 @@ limitations under the License. import fetchMock from "fetch-mock-jest"; import { MockResponse } from "fetch-mock"; +import "fake-indexeddb/auto"; import { createClient, CryptoEvent, MatrixClient } from "../../../src"; import { @@ -41,6 +42,7 @@ import { } from "../../test-utils/test-data"; import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; +import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; // The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations // to ensure that we don't end up with dangling timeouts. @@ -48,7 +50,7 @@ jest.useFakeTimers(); let previousCrypto: Crypto | undefined; -beforeAll(() => { +beforeAll(async () => { // Stub out global.crypto previousCrypto = global["crypto"]; @@ -60,6 +62,9 @@ beforeAll(() => { }, }, }); + + // we use the libolm primitives in the test, so init the Olm library + await global.Olm.init(); }); // restore the original global.crypto @@ -105,6 +110,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u /** an object which intercepts `/keys/query` requests from {@link #aliceClient} */ let e2eKeyResponder: E2EKeyResponder; + /** an object which intercepts `/keys/upload` requests from {@link #aliceClient} */ + let e2eKeyReceiver: E2EKeyReceiver; + beforeEach(async () => { // anything that we don't have a specific matcher for silently returns a 404 fetchMock.catch(404); @@ -121,7 +129,10 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u await initCrypto(aliceClient); + e2eKeyReceiver = new E2EKeyReceiver(aliceClient.getHomeserverUrl()); e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver); + syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); mockInitialApiRequests(aliceClient.getHomeserverUrl()); await aliceClient.startClient(); @@ -129,6 +140,10 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u afterEach(async () => { await aliceClient.stopClient(); + + // Allow in-flight things to complete before we tear down the test + await jest.runAllTimersAsync(); + fetchMock.mockReset(); }); @@ -138,7 +153,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); }); - oldBackendOnly("can verify via SAS", async () => { + it("can verify another device via SAS", async () => { + await waitForDeviceList(); + // have alice initiate a verification. She should send a m.key.verification.request let [requestBody, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), @@ -189,22 +206,29 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u short_authentication_string: ["decimal", "emoji"], }, }); - await waitForVerificationRequestChanged(request); - expect(request.phase).toEqual(VerificationPhase.Started); - expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true); - expect(request.chosenMethod).toEqual("m.sas.v1"); - - // there should now be a verifier - const verifier: Verifier = request.verifier!; - expect(verifier).toBeDefined(); - expect(verifier.getShowSasCallbacks()).toBeNull(); + // as soon as the Changed event arrives, `verifier` should be defined + const verifier = await new Promise((resolve) => { + function onChange() { + expect(request.phase).toEqual(VerificationPhase.Started); + expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true); + expect(request.chosenMethod).toEqual("m.sas.v1"); + + const verifier: Verifier = request.verifier!; + expect(verifier).toBeDefined(); + expect(verifier.getShowSasCallbacks()).toBeNull(); + + resolve(verifier); + } + request.once(VerificationRequestEvent.Change, onChange); + }); // start off the verification process: alice will send an `accept` + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept"); const verificationPromise = verifier.verify(); // advance the clock, because the devicelist likes to sleep for 5ms during key downloads jest.advanceTimersByTime(10); - requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); + requestBody = await sendToDevicePromise; toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256"); expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]); @@ -281,15 +305,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { - e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); - // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. - // - // Completing the initial sync will make the device list download outdated device lists (of which our own - // user will be one). - syncResponder.sendOrQueueSyncResponse({}); - // DeviceList has a sleep(5) which we need to make happen - await jest.advanceTimersByTimeAsync(10); + e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); + await waitForDeviceList(); expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy(); // have alice initiate a verification. She should send a m.key.verification.request @@ -377,7 +395,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(request.phase).toEqual(VerificationPhase.Done); }); - oldBackendOnly("can cancel during the SAS phase", async () => { + it("can cancel during the SAS phase", async () => { + await waitForDeviceList(); + // have alice initiate a verification. She should send a m.key.verification.request const [, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), @@ -419,12 +439,13 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(verifier.hasBeenCancelled).toBe(false); // start off the verification process: alice will send an `accept` + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept"); const verificationPromise = verifier.verify(); // advance the clock, because the devicelist likes to sleep for 5ms during key downloads jest.advanceTimersByTime(10); - await expectSendToDeviceMessage("m.key.verification.accept"); + await sendToDevicePromise; - // now we unceremoniously cancel + // now we unceremoniously cancel. We expect the verificatationPromise to reject. const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel"); verifier.cancel(new Error("blah")); await requestPromise; @@ -479,6 +500,19 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); }); + /** make sure that the client knows about the dummy device */ + async function waitForDeviceList(): Promise { + // Completing the initial sync will make the device list download outdated device lists (of which our own + // user will be one). + syncResponder.sendOrQueueSyncResponse({}); + // DeviceList has a sleep(5) which we need to make happen + await jest.advanceTimersByTimeAsync(10); + + // The client should now know about the dummy device + const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]); + expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID); + } + function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index 51ba77160bc..44a863e8b07 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -145,6 +145,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { return this.deviceKeys.keys[keyIds[0]]; } + /** + * If the device keys have already been uploaded, return them. Else return null. + */ + public getUploadedDeviceKeys(): IDeviceKeys | null { + return this.deviceKeys; + } + /** * If one-time keys have already been uploaded, return them. Otherwise, * set up an expectation that the keys will be uploaded, and wait for diff --git a/spec/test-utils/E2EKeyResponder.ts b/spec/test-utils/E2EKeyResponder.ts index a704d756d45..c232fd819b7 100644 --- a/spec/test-utils/E2EKeyResponder.ts +++ b/spec/test-utils/E2EKeyResponder.ts @@ -19,12 +19,14 @@ import fetchMock from "fetch-mock-jest"; import { MapWithDefault } from "../../src/utils"; import { IDownloadKeyResult } from "../../src"; import { IDeviceKeys } from "../../src/@types/crypto"; +import { E2EKeyReceiver } from "./E2EKeyReceiver"; /** * An object which intercepts `/keys/query` fetches via fetch-mock. */ export class E2EKeyResponder { private deviceKeysByUserByDevice = new MapWithDefault>(() => new Map()); + private e2eKeyReceiversByUser = new Map(); private masterKeysByUser: Record = {}; private selfSigningKeysByUser: Record = {}; private userSigningKeysByUser: Record = {}; @@ -61,6 +63,16 @@ export class E2EKeyResponder { if (userKeys !== undefined) { response.device_keys[user] = Object.fromEntries(userKeys.entries()); } + + const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user); + if (e2eKeyReceiver !== undefined) { + const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys(); + if (deviceKeys !== null) { + response.device_keys[user] ??= {}; + response.device_keys[user][deviceKeys.device_id] = deviceKeys; + } + } + if (this.masterKeysByUser.hasOwnProperty(user)) { response.master_keys[user] = this.masterKeysByUser[user]; } @@ -96,4 +108,16 @@ export class E2EKeyResponder { Object.assign(this.selfSigningKeysByUser, data.self_signing_keys); Object.assign(this.userSigningKeysByUser, data.user_signing_keys); } + + /** + * Add an E2EKeyReceiver to poll for uploaded keys + * + * Any keys which have been uploaded to the given `E2EKeyReceiver` at the time of the `/keys/query` request will + * be added to the response. + * + * @param e2eKeyReceiver + */ + public addKeyReceiver(userId: string, e2eKeyReceiver: E2EKeyReceiver) { + this.e2eKeyReceiversByUser.set(userId, e2eKeyReceiver); + } } diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index bc3007d689f..08bc5e397f6 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -494,6 +494,15 @@ describe("RustCrypto", () => { expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true); rustCrypto.stop(); }); + + describe("requestDeviceVerification", () => { + it("throws an error if the device is unknown", async () => { + const rustCrypto = await makeTestRustCrypto(); + await expect(() => rustCrypto.requestDeviceVerification(TEST_USER, "unknown")).rejects.toThrow( + "Not a known device", + ); + }); + }); }); /** build a basic RustCrypto instance for testing diff --git a/src/client.ts b/src/client.ts index 18eec33671f..c56ea22a17f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2238,6 +2238,8 @@ export class MatrixClient extends TypedEventEmitter { + public async requestOwnUserVerification(): Promise { throw new Error("not implemented"); } @@ -580,15 +588,29 @@ export class RustCrypto implements CryptoBackend { * * If a verification is already in flight, returns it. Otherwise, initiates a new one. * - * Implementation of {@link CryptoApi#requestDeviceVerification }. + * Implementation of {@link CryptoApi#requestDeviceVerification}. * * @param userId - ID of the owner of the device to verify * @param deviceId - ID of the device to verify * * @returns a VerificationRequest when the request has been sent to the other party. */ - public requestDeviceVerification(userId: string, deviceId: string): Promise { - throw new Error("not implemented"); + public async requestDeviceVerification(userId: string, deviceId: string): Promise { + const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice( + new RustSdkCryptoJs.UserId(userId), + new RustSdkCryptoJs.DeviceId(deviceId), + ); + + if (!device) { + throw new Error("Not a known device"); + } + + const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] = + await device.requestVerification( + this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod), + ); + await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest); + return new RustVerificationRequest(request, this.outgoingRequestProcessor); } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/rust-crypto/verification.ts b/src/rust-crypto/verification.ts new file mode 100644 index 00000000000..f4c8f3697fd --- /dev/null +++ b/src/rust-crypto/verification.ts @@ -0,0 +1,429 @@ +/* +Copyright 2023 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 * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; +import { Emoji } from "@matrix-org/matrix-sdk-crypto-js"; + +import { + ShowQrCodeCallbacks, + ShowSasCallbacks, + VerificationPhase, + VerificationRequest, + VerificationRequestEvent, + VerificationRequestEventHandlerMap, + Verifier, + VerifierEvent, + VerifierEventHandlerMap, +} from "../crypto-api/verification"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; + +/** + * An incoming, or outgoing, request to verify a user or a device via cross-signing. + */ +export class RustVerificationRequest + extends TypedEventEmitter + implements VerificationRequest +{ + private _verifier: Verifier | undefined; + + public constructor( + private readonly inner: RustSdkCryptoJs.VerificationRequest, + outgoingRequestProcessor: OutgoingRequestProcessor, + ) { + super(); + + const onChange = async (): Promise => { + // if we now have a `Verification` where we lacked one before, wrap it. + // TODO: QR support + if (this._verifier === undefined) { + const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification(); + if (verification instanceof RustSdkCryptoJs.Sas) { + this._verifier = new RustSASVerifier(verification, this, outgoingRequestProcessor); + } + } + + this.emit(VerificationRequestEvent.Change); + }; + inner.registerChangesCallback(onChange); + } + + /** + * Unique ID for this verification request. + * + * An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases. + */ + public get transactionId(): string | undefined { + return this.inner.flowId; + } + + /** + * For an in-room verification, the ID of the room. + * + * For to-device verifications, `undefined`. + */ + public get roomId(): string | undefined { + return this.inner.roomId?.toString(); + } + + /** + * True if this request was initiated by the local client. + * + * For in-room verifications, the initiator is who sent the `m.key.verification.request` event. + * For to-device verifications, the initiator is who sent the `m.key.verification.start` event. + */ + public get initiatedByMe(): boolean { + return this.inner.weStarted(); + } + + /** The user id of the other party in this request */ + public get otherUserId(): string { + return this.inner.otherUserId.toString(); + } + + /** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */ + public get otherDeviceId(): string | undefined { + return this.inner.otherDeviceId?.toString(); + } + + /** True if the other party in this request is one of this user's own devices. */ + public get isSelfVerification(): boolean { + return this.inner.isSelfVerification(); + } + + /** current phase of the request. */ + public get phase(): VerificationPhase { + const phase = this.inner.phase(); + + switch (phase) { + case RustSdkCryptoJs.VerificationRequestPhase.Created: + case RustSdkCryptoJs.VerificationRequestPhase.Requested: + return VerificationPhase.Requested; + case RustSdkCryptoJs.VerificationRequestPhase.Ready: + return VerificationPhase.Ready; + case RustSdkCryptoJs.VerificationRequestPhase.Transitioned: + return VerificationPhase.Started; + case RustSdkCryptoJs.VerificationRequestPhase.Done: + return VerificationPhase.Done; + case RustSdkCryptoJs.VerificationRequestPhase.Cancelled: + return VerificationPhase.Cancelled; + } + + throw new Error(`Unknown verification phase ${phase}`); + } + + /** True if the request has sent its initial event and needs more events to complete + * (ie it is in phase `Requested`, `Ready` or `Started`). + */ + public get pending(): boolean { + throw new Error("not implemented"); + } + + /** + * True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Ready}. + */ + public get accepting(): boolean { + throw new Error("not implemented"); + } + + /** + * True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Cancelled}). + */ + public get declining(): boolean { + throw new Error("not implemented"); + } + + /** + * The remaining number of ms before the request will be automatically cancelled. + * + * `null` indicates that there is no timeout + */ + public get timeout(): number | null { + throw new Error("not implemented"); + } + + /** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */ + public get methods(): string[] { + throw new Error("not implemented"); + } + + /** the method picked in the .start event */ + public get chosenMethod(): string | null { + const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification(); + // TODO: this isn't quite right. The existence of a Verification doesn't prove that we have .started. + if (verification instanceof RustSdkCryptoJs.Sas) { + return "m.sas.v1"; + } else { + return null; + } + } + + /** + * Checks whether the other party supports a given verification method. + * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: + * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. + * For methods that need to be supported by both ends, use the `methods` property. + * + * @param method - the method to check + * @returns true if the other party said they supported the method + */ + public otherPartySupportsMethod(method: string): boolean { + const theirMethods: RustSdkCryptoJs.VerificationMethod[] | undefined = this.inner.theirSupportedMethods; + if (theirMethods === undefined) { + // no message from the other side yet + return false; + } + + const requiredMethod = verificationMethodsByIdentifier[method]; + return theirMethods.some((m) => m === requiredMethod); + } + + /** + * Accepts the request, sending a .ready event to the other party + * + * @returns Promise which resolves when the event has been sent. + */ + public accept(): Promise { + throw new Error("not implemented"); + } + + /** + * Cancels the request, sending a cancellation to the other party + * + * @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code` + * (defaults to `m.user`). + * + * @returns Promise which resolves when the event has been sent. + */ + public cancel(params?: { reason?: string; code?: string }): Promise { + throw new Error("not implemented"); + } + + /** + * Create a {@link Verifier} to do this verification via a particular method. + * + * If a verifier has already been created for this request, returns that verifier. + * + * This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier#verifier} on the + * returned verifier. + * + * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. + * + * @param method - the name of the verification method to use. + * @param targetDevice - details of where to send the request to. + * + * @returns The verifier which will do the actual verification. + */ + public beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier { + throw new Error("not implemented"); + } + + /** + * The verifier which is doing the actual verification, once the method has been established. + * Only defined when the `phase` is Started. + */ + public get verifier(): Verifier | undefined { + return this._verifier; + } + + /** + * Get the data for a QR code allowing the other device to verify this one, if it supports it. + * + * Only set after a .ready if the other party can scan a QR code, otherwise undefined. + */ + public getQRCodeBytes(): Buffer | undefined { + // TODO + return undefined; + } + + /** + * If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling + * this verification. + */ + public get cancellationCode(): string | null { + throw new Error("not implemented"); + } + + /** + * The id of the user that cancelled the request. + * + * Only defined when phase is Cancelled + */ + public get cancellingUserId(): string | undefined { + throw new Error("not implemented"); + } +} + +export class RustSASVerifier extends TypedEventEmitter implements Verifier { + /** A promise which completes when the verification completes (or rejects when it is cancelled/fails) */ + private readonly completionPromise: Promise; + + private callbacks: ShowSasCallbacks | null = null; + + public constructor( + private readonly inner: RustSdkCryptoJs.Sas, + _verificationRequest: RustVerificationRequest, + private readonly outgoingRequestProcessor: OutgoingRequestProcessor, + ) { + super(); + + this.completionPromise = new Promise((resolve, reject) => { + const onChange = async (): Promise => { + this.updateCallbacks(); + + if (this.inner.isDone()) { + resolve(undefined); + } else if (this.inner.isCancelled()) { + const cancelInfo = this.inner.cancelInfo()!; + reject( + new Error( + `Verification cancelled by ${ + cancelInfo.cancelledbyUs() ? "us" : "them" + } with code ${cancelInfo.cancelCode()}: ${cancelInfo.reason()}`, + ), + ); + } + }; + inner.registerChangesCallback(onChange); + }); + // stop the runtime complaining if nobody catches a failure + this.completionPromise.catch(() => null); + } + + /** if we can now show the callbacks, do so */ + private updateCallbacks(): void { + if (this.callbacks === null) { + const emoji: Array | undefined = this.inner.emoji(); + const decimal = this.inner.decimals() as [number, number, number] | undefined; + + if (emoji === undefined && decimal === undefined) { + return; + } + + this.callbacks = { + sas: { + decimal: decimal, + emoji: emoji?.map((e) => [e.symbol, e.description]), + }, + confirm: async (): Promise => { + const requests: Array = await this.inner.confirm(); + for (const m of requests) { + await this.outgoingRequestProcessor.makeOutgoingRequest(m); + } + }, + mismatch: (): void => { + throw new Error("impl"); + }, + cancel: (): void => { + throw new Error("impl"); + }, + }; + this.emit(VerifierEvent.ShowSas, this.callbacks); + } + } + + /** + * Returns true if the verification has been cancelled, either by us or the other side. + */ + public get hasBeenCancelled(): boolean { + return this.inner.isCancelled(); + } + + /** + * The ID of the other user in the verification process. + */ + public get userId(): string { + return this.inner.otherUserId.toString(); + } + + /** + * Start the key verification, if it has not already been started. + * + * This means sending a `m.key.verification.start` if we are the first responder, or a `m.key.verification.accept` + * if the other side has already sent a start event. + * + * @returns Promise which resolves when the verification has completed, or rejects if the verification is cancelled + * or times out. + */ + public async verify(): Promise { + const req: undefined | OutgoingRequest = this.inner.accept(); + if (req) { + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + await this.completionPromise; + } + + /** + * Cancel a verification. + * + * We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise + * will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted. + * + * @param e - the reason for the cancellation. + */ + public cancel(e: Error): void { + // TODO: something with `e` + const req: undefined | OutgoingRequest = this.inner.cancel(); + if (req) { + this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + } + + /** + * Get the details for an SAS verification, if one is in progress + * + * Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm + * the SAS matches. + */ + public getShowSasCallbacks(): ShowSasCallbacks | null { + return this.callbacks; + } + + /** + * Get the details for reciprocating QR code verification, if one is in progress + * + * Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has + * already scanned our QR code), and we are waiting for the user to confirm. + */ + public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { + return null; + } +} + +/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */ +const verificationMethodsByIdentifier: Record = { + "m.sas.v1": RustSdkCryptoJs.VerificationMethod.SasV1, + "m.qr_code.scan.v1": RustSdkCryptoJs.VerificationMethod.QrCodeScanV1, + "m.qr_code.show.v1": RustSdkCryptoJs.VerificationMethod.QrCodeShowV1, + "m.reciprocate.v1": RustSdkCryptoJs.VerificationMethod.ReciprocateV1, +}; + +/** + * Convert a specced verification method identifier into a rust-side `VerificationMethod`. + * + * @param method - specced method identifier, for example `m.sas.v1`. + * @returns Rust-side `VerificationMethod` corresponding to `method`. + * @throws An error if the method is unknown. + */ +export function verificationMethodIdentifierToMethod(method: string): RustSdkCryptoJs.VerificationMethod { + const meth = verificationMethodsByIdentifier[method]; + if (meth === undefined) { + throw new Error(`Unknown verification method ${method}`); + } + return meth; +} diff --git a/yarn.lock b/yarn.lock index fb37fbf7171..bd25074a6e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1426,10 +1426,10 @@ dependencies: lodash "^4.17.21" -"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.10": - version "0.1.0-alpha.10" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.10.tgz#b6a6395cffd3197ae2e0a88f4eeae8b315571fd2" - integrity sha512-8V2NKuzGOFzEZeZVgF2is7gmuopdRbMZ064tzPDE0vN34iX6s3O8A4oxIT7SA3qtymwm3t1yEvTnT+0gfbmh4g== +"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.11": + version "0.1.0-alpha.11" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.11.tgz#24d705318c3159ef7dbe43bca464ac2bdd11e45d" + integrity sha512-HD3rskPkqrUUSaKzGLg97k/bN+OZrkcX7ODB/pNBs/jqq+/A0wDKqsszJotzFwsQcDPpWn78BmMyvBo4tLxKjw== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14" From 3e646bdfa0d49eaf4cb61e825e9480320287edee Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 10:09:20 +0100 Subject: [PATCH 28/40] Bump version of the react-sdk cypress workflow file (#3501) `cypress.yaml` is currently pinned to an old version of the react-sdk, meaning that each attempt to run it is currently failing with an error. (Introduced by https://github.com/matrix-org/matrix-js-sdk/pull/3480) --- .github/workflows/cypress.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index cfeb33c6010..2724ff37d0c 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -15,7 +15,7 @@ concurrency: jobs: cypress: name: Cypress - uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.73.1 + uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@v3.74.0 permissions: actions: read issues: read From 96e484a3feba476d6d248630dbf7e748f5d0aeb0 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:31:35 +0100 Subject: [PATCH 29/40] Element-R: implement `CryptoApi.getVerificationRequestsToDeviceInProgress` (#3497) * Element-R: Implement `CryptoApi.getVerificationRequestsToDeviceInProgress` * Element-R: Implement `requestOwnUserVerification` (#3503) * Revert "Element-R: Implement `requestOwnUserVerification` (#3503)" This reverts commit 8da756503c3d72b8ecbf50b4c2cf807ac36229aa. oops, merged too soon --- spec/integ/crypto/verification.spec.ts | 13 +++++++++++++ src/rust-crypto/rust-crypto.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 1e71fdcd17f..0750a66af76 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -156,6 +156,12 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u it("can verify another device via SAS", async () => { await waitForDeviceList(); + // initially there should be no verifications in progress + { + const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID); + expect(requests.length).toEqual(0); + } + // have alice initiate a verification. She should send a m.key.verification.request let [requestBody, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), @@ -171,6 +177,13 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(request.initiatedByMe).toBe(true); expect(request.otherUserId).toEqual(TEST_USER_ID); + // and now the request should be visible via `getVerificationRequestsToDeviceInProgress` + { + const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID); + expect(requests.length).toEqual(1); + expect(requests[0].transactionId).toEqual(transactionId); + } + let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); expect(toDeviceMessage.transaction_id).toEqual(transactionId); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 4057c75dbbb..2807b782c55 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -544,8 +544,12 @@ export class RustCrypto implements CryptoBackend { * @returns the VerificationRequests that are in progress */ public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] { - // TODO - return []; + const requests: RustSdkCryptoJs.VerificationRequest[] = this.olmMachine.getVerificationRequests( + new RustSdkCryptoJs.UserId(this.userId), + ); + return requests + .filter((request) => request.roomId === undefined) + .map((request) => new RustVerificationRequest(request, this.outgoingRequestProcessor)); } /** From bd66e3859d41fb2cdd73c99fbaf1feb2dc337c5e Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:17:35 +0100 Subject: [PATCH 30/40] Element R: Implement `requestOwnUserVerification` (#3508) Part of https://github.com/vector-im/element-web/issues/25319. --- spec/integ/crypto/verification.spec.ts | 42 ++++++++++++++++++++++++-- src/rust-crypto/rust-crypto.ts | 14 ++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 0750a66af76..af553790418 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -14,10 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import fetchMock from "fetch-mock-jest"; -import { MockResponse } from "fetch-mock"; import "fake-indexeddb/auto"; +import { MockResponse } from "fetch-mock"; +import fetchMock from "fetch-mock-jest"; +import { IDBFactory } from "fake-indexeddb"; + import { createClient, CryptoEvent, MatrixClient } from "../../../src"; import { canAcceptVerificationRequest, @@ -67,6 +69,13 @@ beforeAll(async () => { await global.Olm.init(); }); +afterEach(() => { + // reset fake-indexeddb after each test, to make sure we don't leak connections + // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state + // eslint-disable-next-line no-global-assign + indexedDB = new IDBFactory(); +}); + // restore the original global.crypto afterAll(() => { if (previousCrypto === undefined) { @@ -317,6 +326,35 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u olmSAS.free(); }); + it("Can make a verification request to *all* devices", async () => { + // we need an existing cross-signing key for this + e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); + await waitForDeviceList(); + + // have alice initiate a verification. She should send a m.key.verification.request + const [requestBody, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestOwnUserVerification(), + ]); + + const transactionId = request.transactionId; + expect(transactionId).toBeDefined(); + expect(request.phase).toEqual(VerificationPhase.Requested); + + // and now the request should be visible via `getVerificationRequestsToDeviceInProgress` + { + const requests = aliceClient.getCrypto()!.getVerificationRequestsToDeviceInProgress(TEST_USER_ID); + expect(requests.length).toEqual(1); + expect(requests[0].transactionId).toEqual(transactionId); + } + + // legacy crypto picks devices individually; rust crypto uses a broadcast message + const toDeviceMessage = + requestBody.messages[TEST_USER_ID]["*"] ?? requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + }); + oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 2807b782c55..aa73a4c4b55 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -584,7 +584,19 @@ export class RustCrypto implements CryptoBackend { * @returns a VerificationRequest when the request has been sent to the other party. */ public async requestOwnUserVerification(): Promise { - throw new Error("not implemented"); + const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity( + new RustSdkCryptoJs.UserId(this.userId), + ); + if (userIdentity === undefined) { + throw new Error("cannot request verification for this device when there is no existing cross-signing key"); + } + + const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] = + await userIdentity.requestVerification( + this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod), + ); + await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest); + return new RustVerificationRequest(request, this.outgoingRequestProcessor); } /** From e8fb47fdca2d0bf3b428e24abd58ba2f49260f5f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:30:06 +0100 Subject: [PATCH 31/40] Update all non-major dependencies (#3467) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 112 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 50 deletions(-) diff --git a/yarn.lock b/yarn.lock index bd25074a6e8..8977caf773a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1885,13 +1885,13 @@ "@typescript-eslint/types" "5.59.11" "@typescript-eslint/visitor-keys" "5.59.11" -"@typescript-eslint/scope-manager@5.59.6": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz#d43a3687aa4433868527cfe797eb267c6be35f19" - integrity sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ== +"@typescript-eslint/scope-manager@5.60.0": + version "5.60.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.60.0.tgz#ae511967b4bd84f1d5e179bb2c82857334941c1c" + integrity sha512-hakuzcxPwXi2ihf9WQu1BbRj1e/Pd8ZZwVTG9kfbxAMZstKz8/9OoexIwnmLzShtsdap5U/CoQGRCWlSuPbYxQ== dependencies: - "@typescript-eslint/types" "5.59.6" - "@typescript-eslint/visitor-keys" "5.59.6" + "@typescript-eslint/types" "5.60.0" + "@typescript-eslint/visitor-keys" "5.60.0" "@typescript-eslint/type-utils@5.59.11": version "5.59.11" @@ -1908,10 +1908,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.11.tgz#1a9018fe3c565ba6969561f2a49f330cf1fe8db1" integrity sha512-epoN6R6tkvBYSc+cllrz+c2sOFWkbisJZWkOE+y3xHtvYaOE6Wk6B8e114McRJwFRjGvYdJwLXQH5c9osME/AA== -"@typescript-eslint/types@5.59.6": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.6.tgz#5a6557a772af044afe890d77c6a07e8c23c2460b" - integrity sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA== +"@typescript-eslint/types@5.60.0": + version "5.60.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.60.0.tgz#3179962b28b4790de70e2344465ec97582ce2558" + integrity sha512-ascOuoCpNZBccFVNJRSC6rPq4EmJ2NkuoKnd6LDNyAQmdDnziAtxbCGWCbefG1CNzmDvd05zO36AmB7H8RzKPA== "@typescript-eslint/typescript-estree@5.59.11": version "5.59.11" @@ -1926,13 +1926,13 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.59.6": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz#2fb80522687bd3825504925ea7e1b8de7bb6251b" - integrity sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA== +"@typescript-eslint/typescript-estree@5.60.0": + version "5.60.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.0.tgz#4ddf1a81d32a850de66642d9b3ad1e3254fb1600" + integrity sha512-R43thAuwarC99SnvrBmh26tc7F6sPa2B3evkXp/8q954kYL6Ro56AwASYWtEEi+4j09GbiNAHqYwNNZuNlARGQ== dependencies: - "@typescript-eslint/types" "5.59.6" - "@typescript-eslint/visitor-keys" "5.59.6" + "@typescript-eslint/types" "5.60.0" + "@typescript-eslint/visitor-keys" "5.60.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -1954,16 +1954,16 @@ semver "^7.3.7" "@typescript-eslint/utils@^5.10.0": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.6.tgz#82960fe23788113fc3b1f9d4663d6773b7907839" - integrity sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg== + version "5.60.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.60.0.tgz#4667c5aece82f9d4f24a667602f0f300864b554c" + integrity sha512-ba51uMqDtfLQ5+xHtwlO84vkdjrqNzOnqrnwbMHMRY8Tqeme8C2Q8Fc7LajfGR+e3/4LoYiWXUM6BpIIbHJ4hQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.59.6" - "@typescript-eslint/types" "5.59.6" - "@typescript-eslint/typescript-estree" "5.59.6" + "@typescript-eslint/scope-manager" "5.60.0" + "@typescript-eslint/types" "5.60.0" + "@typescript-eslint/typescript-estree" "5.60.0" eslint-scope "^5.1.1" semver "^7.3.7" @@ -1975,12 +1975,12 @@ "@typescript-eslint/types" "5.59.11" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.59.6": - version "5.59.6" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz#673fccabf28943847d0c8e9e8d008e3ada7be6bb" - integrity sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q== +"@typescript-eslint/visitor-keys@5.60.0": + version "5.60.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.0.tgz#b48b29da3f5f31dd1656281727004589d2722a66" + integrity sha512-wm9Uz71SbCyhUKgcaPRauBdTegUyY/ZWl8gLwD/i/ybJqscrrdVSFImpvUz16BLPChIeKBK5Fa9s6KDQjsjyWw== dependencies: - "@typescript-eslint/types" "5.59.6" + "@typescript-eslint/types" "5.60.0" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -2055,11 +2055,16 @@ acorn@^7.0.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.1.0, acorn@^8.4.1, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2: +acorn@^8.1.0, acorn@^8.4.1, acorn@^8.8.0, acorn@^8.8.1: version "8.8.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +acorn@^8.8.2: + version "8.9.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.9.0.tgz#78a16e3b2bcc198c10822786fa6679e245db5b59" + integrity sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -3617,9 +3622,9 @@ eslint-plugin-import@^2.26.0: tsconfig-paths "^3.14.1" eslint-plugin-jest@^27.1.6: - version "27.2.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-27.2.1.tgz#b85b4adf41c682ea29f1f01c8b11ccc39b5c672c" - integrity sha512-l067Uxx7ZT8cO9NJuf+eJHvt6bqJyz2Z29wykyEdz/OtmcELQl2MQGQLX8J94O1cSJWAwUSEvCjwjA7KEK3Hmg== + version "27.2.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-27.2.2.tgz#be4ded5f91905d9ec89aa8968d39c71f3b072c0c" + integrity sha512-euzbp06F934Z7UDl5ZUaRPLAc9MKjh0rMPERrHT7UhlCEwgb25kBj37TvMgWeHZVkR5I9CayswrpoaqZU1RImw== dependencies: "@typescript-eslint/utils" "^5.10.0" @@ -3639,9 +3644,9 @@ eslint-plugin-jsdoc@^46.0.0: spdx-expression-parse "^3.0.1" eslint-plugin-matrix-org@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-1.1.0.tgz#cb3c313b58aa84ee0dd52c57f4a614a1795e8744" - integrity sha512-UArLqthBuaCljVajS2TtlPQLXNMZZAPKRt+gA8D0ayzcAj+Ghl50amwGtvLHMzISGv3sqNDBFBMD9cElntE1zA== + version "1.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-1.2.0.tgz#84b78969c93e6d3d593fe8bf25ee67ec4dcd2883" + integrity sha512-Wp5CeLnyEwGBn8ZfVbSuO2y0Fs51IWonPJ1QRQTntaRxOkEQnnky3gOPwpfGJ8JB0CxYr1zXfeHh8LcYHW4wcg== eslint-plugin-tsdoc@^0.2.17: version "0.2.17" @@ -5716,9 +5721,9 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc brace-expansion "^1.1.7" minimatch@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56" - integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w== + version "9.0.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.2.tgz#397e387fff22f6795844d00badc903a3d5de7057" + integrity sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg== dependencies: brace-expansion "^2.0.1" @@ -6805,13 +6810,20 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.1: +semver@^7.3.5, semver@^7.3.8, semver@^7.5.1: version "7.5.1" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== dependencies: lru-cache "^6.0.0" +semver@^7.3.7: + version "7.5.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e" + integrity sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ== + dependencies: + lru-cache "^6.0.0" + sha.js@^2.4.0, sha.js@^2.4.8: version "2.4.11" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" @@ -6852,9 +6864,9 @@ shell-quote@^1.6.1: integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== shiki@^0.14.1: - version "0.14.2" - resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.14.2.tgz#d51440800b701392b31ce2336036058e338247a1" - integrity sha512-ltSZlSLOuSY0M0Y75KA+ieRaZ0Trf5Wl3gutE7jzLuIcWxLp5i/uEnLoQWNvgKXQ5OMpGkJnVMRLAuzjc0LJ2A== + version "0.14.3" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.14.3.tgz#d1a93c463942bdafb9866d74d619a4347d0bbf64" + integrity sha512-U3S/a+b0KS+UkTyMjoNojvTgrBHjgp7L6ovhFVZsXmBGnVdQ4K4U9oK0z63w538S91ATngv1vXigHCSWOwnr+g== dependencies: ansi-sequence-parser "^1.1.0" jsonc-parser "^3.2.0" @@ -7197,9 +7209,9 @@ tapable@^2.2.0: integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== terser@^5.5.1: - version "5.17.7" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.7.tgz#2a8b134826fe179b711969fd9d9a0c2479b2a8c3" - integrity sha512-/bi0Zm2C6VAexlGgLlVxA0P2lru/sdLyfCVaRMfKVo9nWxbmz7f/sD8VPybPeSUJaJcwmCJis9pBIhcVcG1QcQ== + version "5.18.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.18.1.tgz#6d8642508ae9fb7b48768e48f16d675c89a78460" + integrity sha512-j1n0Ao919h/Ai5r43VAnfV/7azUYW43GPxK7qSATzrsERfW7+y2QW9Cp9ufnRF5CQUWbnLSo7UJokSWCqg4tsQ== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -7510,9 +7522,9 @@ typedoc-plugin-versions@^0.2.3: semver "^7.3.7" typedoc@^0.24.0: - version "0.24.7" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.24.7.tgz#7eeb272a1894b3789acc1a94b3f2ae8e7330ee39" - integrity sha512-zzfKDFIZADA+XRIp2rMzLe9xZ6pt12yQOhCr7cD7/PBTjhPmMyMvGrkZ2lPNJitg3Hj1SeiYFNzCsSDrlpxpKw== + version "0.24.8" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.24.8.tgz#cce9f47ba6a8d52389f5e583716a2b3b4335b63e" + integrity sha512-ahJ6Cpcvxwaxfu4KtjA8qZNqS43wYt6JL27wYiIgl1vd38WW/KWX11YuAeZhuz9v+ttrutSsgK+XO1CjL1kA3w== dependencies: lunr "^2.3.9" marked "^4.3.0" @@ -7530,9 +7542,9 @@ typescript@^4.5.4: integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== typescript@^5.0.0: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== + version "5.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826" + integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" From 326a13bcfe96e18ce6dad29275c8216d7d4c69dc Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 15:44:42 +0100 Subject: [PATCH 32/40] Rearrange the verification integration tests, again (#3504) * Element-R: Implement `CryptoApi.getVerificationRequestsToDeviceInProgress` * Element-R: Implement `requestOwnUserVerification` * init aliceClient *after* the fetch interceptors * Initialise the test client separately for each test * Avoid running all the tests twice Currently all of these tests are running twice, with different client configurations. That's not really adding much value; we just need to run specific tests that way. * Factor out functions for building responses --- spec/integ/crypto/verification.spec.ts | 164 +++++++++++-------------- 1 file changed, 75 insertions(+), 89 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index af553790418..11be866b85b 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -20,7 +20,7 @@ import { MockResponse } from "fetch-mock"; import fetchMock from "fetch-mock-jest"; import { IDBFactory } from "fake-indexeddb"; -import { createClient, CryptoEvent, MatrixClient } from "../../../src"; +import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src"; import { canAcceptVerificationRequest, ShowQrCodeCallbacks, @@ -88,6 +88,9 @@ afterAll(() => { } }); +/** The homeserver url that we give to the test client, and where we intercept /sync, /keys, etc requests. */ +const TEST_HOMESERVER_URL = "https://alice-server.com"; + /** * Integration tests for verification functionality. * @@ -96,16 +99,6 @@ afterAll(() => { */ // we test with both crypto stacks... describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: string, initCrypto: InitCrypto) => { - // and with (1) the default verification method list, (2) a custom verification method list. - describe.each([undefined, ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]])( - "supported methods=%s", - (methods) => { - runTests(backend, initCrypto, methods); - }, - ); -}); - -function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | undefined) { // oldBackendOnly is an alternative to `it` or `test` which will skip the test if we are running against the // Rust backend. Once we have full support in the rust sdk, it will go away. const oldBackendOnly = backend === "rust-sdk" ? test.skip : test; @@ -113,13 +106,13 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u /** the client under test */ let aliceClient: MatrixClient; - /** an object which intercepts `/sync` requests from {@link #aliceClient} */ + /** an object which intercepts `/sync` requests on the test homeserver */ let syncResponder: SyncResponder; - /** an object which intercepts `/keys/query` requests from {@link #aliceClient} */ + /** an object which intercepts `/keys/query` requests on the test homeserver */ let e2eKeyResponder: E2EKeyResponder; - /** an object which intercepts `/keys/upload` requests from {@link #aliceClient} */ + /** an object which intercepts `/keys/upload` requests on the test homeserver */ let e2eKeyReceiver: E2EKeyReceiver; beforeEach(async () => { @@ -127,28 +120,18 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u fetchMock.catch(404); fetchMock.config.warnOnFallback = false; - const homeserverUrl = "https://alice-server.com"; - aliceClient = createClient({ - baseUrl: homeserverUrl, - userId: TEST_USER_ID, - accessToken: "akjgkrgjs", - deviceId: "device_under_test", - verificationMethods: methods, - }); - - await initCrypto(aliceClient); - - e2eKeyReceiver = new E2EKeyReceiver(aliceClient.getHomeserverUrl()); - e2eKeyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl()); + e2eKeyReceiver = new E2EKeyReceiver(TEST_HOMESERVER_URL); + e2eKeyResponder = new E2EKeyResponder(TEST_HOMESERVER_URL); e2eKeyResponder.addKeyReceiver(TEST_USER_ID, e2eKeyReceiver); + syncResponder = new SyncResponder(TEST_HOMESERVER_URL); - syncResponder = new SyncResponder(aliceClient.getHomeserverUrl()); - mockInitialApiRequests(aliceClient.getHomeserverUrl()); - await aliceClient.startClient(); + mockInitialApiRequests(TEST_HOMESERVER_URL); }); afterEach(async () => { - await aliceClient.stopClient(); + if (aliceClient !== undefined) { + await aliceClient.stopClient(); + } // Allow in-flight things to complete before we tear down the test await jest.runAllTimersAsync(); @@ -162,7 +145,10 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); }); - it("can verify another device via SAS", async () => { + // test with (1) the default verification method list, (2) a custom verification method list. + const TEST_METHODS = ["m.sas.v1", "m.qr_code.show.v1", "m.reciprocate.v1"]; + it.each([undefined, TEST_METHODS])("can verify via SAS (supported methods=%s)", async (methods) => { + aliceClient = await startTestClient({ verificationMethods: methods }); await waitForDeviceList(); // initially there should be no verifications in progress @@ -176,7 +162,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expectSendToDeviceMessage("m.key.verification.request"), aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), ]); - const transactionId = request.transactionId; + const transactionId = request.transactionId!; expect(transactionId).toBeDefined(); expect(request.phase).toEqual(VerificationPhase.Requested); expect(request.roomId).toBeUndefined(); @@ -202,32 +188,14 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u } // The dummy device replies with an m.key.verification.ready... - returnToDeviceMessageFromSync({ - type: "m.key.verification.ready", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: transactionId, - }, - }); + returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"])); await waitForVerificationRequestChanged(request); expect(request.phase).toEqual(VerificationPhase.Ready); expect(request.otherDeviceId).toEqual(TEST_DEVICE_ID); // ... and picks a method with m.key.verification.start - returnToDeviceMessageFromSync({ - type: "m.key.verification.start", - content: { - from_device: TEST_DEVICE_ID, - method: "m.sas.v1", - transaction_id: transactionId, - hashes: ["sha256"], - key_agreement_protocols: ["curve25519-hkdf-sha256"], - message_authentication_codes: ["hkdf-hmac-sha256.v2"], - // we have to include "decimal" per the spec. - short_authentication_string: ["decimal", "emoji"], - }, - }); + returnToDeviceMessageFromSync(buildSasStartMessage(transactionId)); + // as soon as the Changed event arrives, `verifier` should be defined const verifier = await new Promise((resolve) => { function onChange() { @@ -327,6 +295,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); it("Can make a verification request to *all* devices", async () => { + aliceClient = await startTestClient(); // we need an existing cross-signing key for this e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); await waitForDeviceList(); @@ -356,6 +325,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { + aliceClient = await startTestClient(); // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); await waitForDeviceList(); @@ -366,26 +336,17 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expectSendToDeviceMessage("m.key.verification.request"), aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), ]); - const transactionId = request.transactionId; + const transactionId = request.transactionId!; const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.methods).toContain("m.qr_code.show.v1"); expect(toDeviceMessage.methods).toContain("m.reciprocate.v1"); - if (methods === undefined) { - expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); - } + expect(toDeviceMessage.methods).toContain("m.qr_code.scan.v1"); expect(toDeviceMessage.from_device).toEqual(aliceClient.deviceId); expect(toDeviceMessage.transaction_id).toEqual(transactionId); // The dummy device replies with an m.key.verification.ready, with an indication we can scan the QR code - returnToDeviceMessageFromSync({ - type: "m.key.verification.ready", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.qr_code.scan.v1"], - transaction_id: transactionId, - }, - }); + returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.qr_code.scan.v1"])); await waitForVerificationRequestChanged(request); expect(request.phase).toEqual(VerificationPhase.Ready); @@ -447,6 +408,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); it("can cancel during the SAS phase", async () => { + aliceClient = await startTestClient(); await waitForDeviceList(); // have alice initiate a verification. She should send a m.key.verification.request @@ -454,33 +416,14 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expectSendToDeviceMessage("m.key.verification.request"), aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), ]); - const transactionId = request.transactionId; + const transactionId = request.transactionId!; // The dummy device replies with an m.key.verification.ready... - returnToDeviceMessageFromSync({ - type: "m.key.verification.ready", - content: { - from_device: TEST_DEVICE_ID, - methods: ["m.sas.v1"], - transaction_id: transactionId, - }, - }); + returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"])); await waitForVerificationRequestChanged(request); // ... and picks a method with m.key.verification.start - returnToDeviceMessageFromSync({ - type: "m.key.verification.start", - content: { - from_device: TEST_DEVICE_ID, - method: "m.sas.v1", - transaction_id: transactionId, - hashes: ["sha256"], - key_agreement_protocols: ["curve25519-hkdf-sha256"], - message_authentication_codes: ["hkdf-hmac-sha256.v2"], - // we have to include "decimal" per the spec. - short_authentication_string: ["decimal", "emoji"], - }, - }); + returnToDeviceMessageFromSync(buildSasStartMessage(transactionId)); await waitForVerificationRequestChanged(request); expect(request.phase).toEqual(VerificationPhase.Started); @@ -514,6 +457,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); oldBackendOnly("Incoming verification: can accept", async () => { + aliceClient = await startTestClient(); const TRANSACTION_ID = "abcd"; // Initiate the request by sending a to-device message @@ -551,6 +495,19 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); }); + async function startTestClient(opts: Partial = {}): Promise { + const client = createClient({ + baseUrl: TEST_HOMESERVER_URL, + userId: TEST_USER_ID, + accessToken: "akjgkrgjs", + deviceId: "device_under_test", + ...opts, + }); + await initCrypto(client); + await client.startClient(); + return client; + } + /** make sure that the client knows about the dummy device */ async function waitForDeviceList(): Promise { // Completing the initial sync will make the device list download outdated device lists (of which our own @@ -568,7 +525,7 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); } -} +}); /** * Wait for the client under test to send a to-device message of the given type. @@ -613,3 +570,32 @@ function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string { function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, ""); } + +/** build an m.key.verification.ready to-device message originating from the dummy device */ +function buildReadyMessage(transactionId: string, methods: string[]): { type: string; content: object } { + return { + type: "m.key.verification.ready", + content: { + from_device: TEST_DEVICE_ID, + methods: methods, + transaction_id: transactionId, + }, + }; +} + +/** build an m.key.verification.start to-device message suitable for the SAS flow, originating from the dummy device */ +function buildSasStartMessage(transactionId: string): { type: string; content: object } { + return { + type: "m.key.verification.start", + content: { + from_device: TEST_DEVICE_ID, + method: "m.sas.v1", + transaction_id: transactionId, + hashes: ["sha256"], + key_agreement_protocols: ["curve25519-hkdf-sha256"], + message_authentication_codes: ["hkdf-hmac-sha256.v2"], + // we have to include "decimal" per the spec. + short_authentication_string: ["decimal", "emoji"], + }, + }; +} From d1dec4cd0858f51271c8d051619c665371904722 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 17:56:57 +0100 Subject: [PATCH 33/40] Implement `VerificationRequest.cancel` (#3505) --- spec/integ/crypto/verification.spec.ts | 36 +++++++++++++++++++++++++- src/crypto-api/verification.ts | 2 +- src/rust-crypto/verification.ts | 9 ++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 11be866b85b..e95a82fccbe 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -290,6 +290,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st await verificationPromise; expect(request.phase).toEqual(VerificationPhase.Done); + // at this point, cancelling should do nothing. + await request.cancel(); + expect(request.phase).toEqual(VerificationPhase.Done); + // we're done with the temporary keypair olmSAS.free(); }); @@ -406,11 +410,41 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st await verificationPromise; expect(request.phase).toEqual(VerificationPhase.Done); }); + }); + + describe("cancellation", () => { + beforeEach(async () => { + // pretend that we have another device, which we will start verifying + e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); - it("can cancel during the SAS phase", async () => { aliceClient = await startTestClient(); await waitForDeviceList(); + }); + + it("can cancel during the Ready phase", async () => { + // have alice initiate a verification. She should send a m.key.verification.request + const [, request] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.request"), + aliceClient.getCrypto()!.requestDeviceVerification(TEST_USER_ID, TEST_DEVICE_ID), + ]); + const transactionId = request.transactionId!; + // The dummy device replies with an m.key.verification.ready... + returnToDeviceMessageFromSync(buildReadyMessage(transactionId, ["m.sas.v1"])); + await waitForVerificationRequestChanged(request); + + // now alice changes her mind + const [requestBody] = await Promise.all([ + expectSendToDeviceMessage("m.key.verification.cancel"), + request.cancel(), + ]); + const toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; + expect(toDeviceMessage.transaction_id).toEqual(transactionId); + expect(toDeviceMessage.code).toEqual("m.user"); + expect(request.phase).toEqual(VerificationPhase.Cancelled); + }); + + it("can cancel during the SAS phase", async () => { // have alice initiate a verification. She should send a m.key.verification.request const [, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), diff --git a/src/crypto-api/verification.ts b/src/crypto-api/verification.ts index daaf405b1d1..fc5c75bc7a0 100644 --- a/src/crypto-api/verification.ts +++ b/src/crypto-api/verification.ts @@ -108,7 +108,7 @@ export interface VerificationRequest * Cancels the request, sending a cancellation to the other party * * @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code` - * (defaults to `m.user`). + * (defaults to `m.user`). **Deprecated**: this parameter is ignored by the Rust cryptography implementation. * * @returns Promise which resolves when the event has been sent. */ diff --git a/src/rust-crypto/verification.ts b/src/rust-crypto/verification.ts index f4c8f3697fd..7fb314c78d9 100644 --- a/src/rust-crypto/verification.ts +++ b/src/rust-crypto/verification.ts @@ -42,7 +42,7 @@ export class RustVerificationRequest public constructor( private readonly inner: RustSdkCryptoJs.VerificationRequest, - outgoingRequestProcessor: OutgoingRequestProcessor, + private readonly outgoingRequestProcessor: OutgoingRequestProcessor, ) { super(); @@ -210,8 +210,11 @@ export class RustVerificationRequest * * @returns Promise which resolves when the event has been sent. */ - public cancel(params?: { reason?: string; code?: string }): Promise { - throw new Error("not implemented"); + public async cancel(params?: { reason?: string; code?: string }): Promise { + const req: undefined | OutgoingRequest = this.inner.cancel(); + if (req) { + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + } } /** From b703d4a2cc16b5b8bc084c2f56923640d77eb6cb Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 26 Jun 2023 22:15:56 +0100 Subject: [PATCH 34/40] More slow test fixes (#3515) We still seem to be suffering test timeouts. Hopefully this will fix the integ tests, where #3509 etc fix the unit tests. --- spec/integ/crypto/verification.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index e95a82fccbe..041881df0a6 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -69,6 +69,12 @@ beforeAll(async () => { await global.Olm.init(); }); +// load the rust library. This can take a few seconds on a slow GH worker. +beforeAll(async () => { + const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-js"); + await RustSdkCryptoJs.initAsync(); +}, 10000); + afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state From 9de4a057df6e546716cd9ab29be336976c8daa2b Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 27 Jun 2023 11:46:53 +1200 Subject: [PATCH 35/40] OIDC: navigate to authorization endpoint (#3499) * utils for authorization step in OIDC code grant * tidy * completeAuthorizationCodeGrant util functions * response_mode=query * add scope to bearertoken type * add is_guest to whoami response type * doc comments Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * use shimmed TextEncoder * fetchMockJest -> fetchMock * comment * bearertokenresponse * test for lowercase bearer * handle lowercase token_type --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- spec/unit/oidc/authorize.spec.ts | 209 ++++++++++++++++++++++++++++++ src/client.ts | 1 + src/oidc/authorize.ts | 210 +++++++++++++++++++++++++++++++ src/oidc/error.ts | 2 + 4 files changed, 422 insertions(+) create mode 100644 spec/unit/oidc/authorize.spec.ts create mode 100644 src/oidc/authorize.ts diff --git a/spec/unit/oidc/authorize.spec.ts b/spec/unit/oidc/authorize.spec.ts new file mode 100644 index 00000000000..51f46aadf8d --- /dev/null +++ b/spec/unit/oidc/authorize.spec.ts @@ -0,0 +1,209 @@ +/* +Copyright 2023 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 fetchMock from "fetch-mock-jest"; + +import { Method } from "../../../src"; +import * as crypto from "../../../src/crypto/crypto"; +import { logger } from "../../../src/logger"; +import { + completeAuthorizationCodeGrant, + generateAuthorizationParams, + generateAuthorizationUrl, +} from "../../../src/oidc/authorize"; +import { OidcError } from "../../../src/oidc/error"; + +// save for resetting mocks +const realSubtleCrypto = crypto.subtleCrypto; + +describe("oidc authorization", () => { + const issuer = "https://auth.com/"; + const authorizationEndpoint = "https://auth.com/authorization"; + const tokenEndpoint = "https://auth.com/token"; + const delegatedAuthConfig = { + issuer, + registrationEndpoint: issuer + "registration", + authorizationEndpoint: issuer + "auth", + tokenEndpoint, + }; + const clientId = "xyz789"; + const baseUrl = "https://test.com"; + + beforeAll(() => { + jest.spyOn(logger, "warn"); + }); + + afterEach(() => { + // @ts-ignore reset any ugly mocking we did + crypto.subtleCrypto = realSubtleCrypto; + }); + + it("should generate authorization params", () => { + const result = generateAuthorizationParams({ redirectUri: baseUrl }); + + expect(result.redirectUri).toEqual(baseUrl); + + // random strings + expect(result.state.length).toEqual(8); + expect(result.nonce.length).toEqual(8); + expect(result.codeVerifier.length).toEqual(64); + + const expectedScope = + "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:"; + expect(result.scope.startsWith(expectedScope)).toBeTruthy(); + // deviceId of 10 characters is appended to the device scope + expect(result.scope.length).toEqual(expectedScope.length + 10); + }); + + describe("generateAuthorizationUrl()", () => { + it("should generate url with correct parameters", async () => { + // test the no crypto case here + // @ts-ignore mocking + crypto.subtleCrypto = undefined; + + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const authUrl = new URL( + await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams), + ); + + expect(authUrl.searchParams.get("response_mode")).toEqual("query"); + expect(authUrl.searchParams.get("response_type")).toEqual("code"); + expect(authUrl.searchParams.get("client_id")).toEqual(clientId); + expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256"); + expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope); + expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state); + expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce); + + // crypto not available, plain text code_challenge is used + expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier); + expect(logger.warn).toHaveBeenCalledWith( + "A secure context is required to generate code challenge. Using plain text code challenge", + ); + }); + + it("uses a s256 code challenge when crypto is available", async () => { + jest.spyOn(crypto.subtleCrypto, "digest"); + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const authUrl = new URL( + await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams), + ); + + const codeChallenge = authUrl.searchParams.get("code_challenge"); + expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object)); + + // didn't use plain text code challenge + expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge); + expect(codeChallenge).toBeTruthy(); + }); + }); + + describe("completeAuthorizationCodeGrant", () => { + const codeVerifier = "abc123"; + const redirectUri = baseUrl; + const code = "auth_code_xyz"; + const validBearerTokenResponse = { + token_type: "Bearer", + access_token: "test_access_token", + refresh_token: "test_refresh_token", + expires_in: 12345, + }; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.resetBehavior(); + + fetchMock.post(tokenEndpoint, { + status: 200, + body: JSON.stringify(validBearerTokenResponse), + }); + }); + + it("should make correct request to the token endpoint", async () => { + await completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }); + + expect(fetchMock).toHaveBeenCalledWith(tokenEndpoint, { + method: Method.Post, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`, + }); + }); + + it("should return with valid bearer token", async () => { + const result = await completeAuthorizationCodeGrant(code, { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }); + + expect(result).toEqual(validBearerTokenResponse); + }); + + it("should return with valid bearer token where token_type is lowercase", async () => { + const tokenResponse = { + ...validBearerTokenResponse, + token_type: "bearer", + }; + fetchMock.post( + tokenEndpoint, + { + status: 200, + body: JSON.stringify(tokenResponse), + }, + { overwriteRoutes: true }, + ); + + const result = await completeAuthorizationCodeGrant(code, { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }); + + // results in token that uses 'Bearer' token type + expect(result).toEqual(validBearerTokenResponse); + expect(result.token_type).toEqual("Bearer"); + }); + + it("should throw with code exchange failed error when request fails", async () => { + fetchMock.post( + tokenEndpoint, + { + status: 500, + }, + { overwriteRoutes: true }, + ); + await expect(() => + completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }), + ).rejects.toThrow(new Error(OidcError.CodeExchangeFailed)); + }); + + it("should throw invalid token error when token is invalid", async () => { + const invalidBearerTokenResponse = { + ...validBearerTokenResponse, + access_token: null, + }; + fetchMock.post( + tokenEndpoint, + { status: 200, body: JSON.stringify(invalidBearerTokenResponse) }, + { overwriteRoutes: true }, + ); + await expect(() => + completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }), + ).rejects.toThrow(new Error(OidcError.InvalidBearerTokenResponse)); + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index c56ea22a17f..a848abba183 100644 --- a/src/client.ts +++ b/src/client.ts @@ -871,6 +871,7 @@ export interface TimestampToEventResponse { interface IWhoamiResponse { user_id: string; device_id?: string; + is_guest?: boolean; } /* eslint-enable camelcase */ diff --git a/src/oidc/authorize.ts b/src/oidc/authorize.ts new file mode 100644 index 00000000000..26211a3d486 --- /dev/null +++ b/src/oidc/authorize.ts @@ -0,0 +1,210 @@ +/* +Copyright 2023 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 { IDelegatedAuthConfig } from "../client"; +import { Method } from "../http-api"; +import { subtleCrypto, TextEncoder } from "../crypto/crypto"; +import { logger } from "../logger"; +import { randomString } from "../randomstring"; +import { OidcError } from "./error"; +import { ValidatedIssuerConfig } from "./validate"; + +/** + * Authorization parameters which are used in the authentication request of an OIDC auth code flow. + * + * See https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters. + */ +export type AuthorizationParams = { + state: string; + scope: string; + redirectUri: string; + codeVerifier: string; + nonce: string; +}; + +const generateScope = (): string => { + const deviceId = randomString(10); + return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`; +}; + +// https://www.rfc-editor.org/rfc/rfc7636 +const generateCodeChallenge = async (codeVerifier: string): Promise => { + if (!subtleCrypto) { + // @TODO(kerrya) should this be allowed? configurable? + logger.warn("A secure context is required to generate code challenge. Using plain text code challenge"); + return codeVerifier; + } + const utf8 = new TextEncoder().encode(codeVerifier); + + const digest = await subtleCrypto.digest("SHA-256", utf8); + + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +}; + +/** + * Generate authorization params to pass to {@link generateAuthorizationUrl}. + * + * Used as part of an authorization code OIDC flow: see https://openid.net/specs/openid-connect-basic-1_0.html#CodeFlow. + * + * @param redirectUri - absolute url for OP to redirect to after authorization + * @returns AuthorizationParams + */ +export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: string }): AuthorizationParams => ({ + scope: generateScope(), + redirectUri, + state: randomString(8), + nonce: randomString(8), + codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters +}); + +/** + * Generate a URL to attempt authorization with the OP + * See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest + * @param authorizationUrl - endpoint to attempt authorization with the OP + * @param clientId - id of this client as registered with the OP + * @param authorizationParams - params to be used in the url + * @returns a Promise with the url as a string + */ +export const generateAuthorizationUrl = async ( + authorizationUrl: string, + clientId: string, + { scope, redirectUri, state, nonce, codeVerifier }: AuthorizationParams, +): Promise => { + const url = new URL(authorizationUrl); + url.searchParams.append("response_mode", "query"); + url.searchParams.append("response_type", "code"); + url.searchParams.append("redirect_uri", redirectUri); + url.searchParams.append("client_id", clientId); + url.searchParams.append("state", state); + url.searchParams.append("scope", scope); + url.searchParams.append("nonce", nonce); + + url.searchParams.append("code_challenge_method", "S256"); + url.searchParams.append("code_challenge", await generateCodeChallenge(codeVerifier)); + + return url.toString(); +}; + +/** + * The expected response type from the token endpoint during authorization code flow + * Normalized to always use capitalized 'Bearer' for token_type + * + * See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4, + * https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ +export type BearerTokenResponse = { + token_type: "Bearer"; + access_token: string; + scope: string; + refresh_token?: string; + expires_in?: number; + id_token?: string; +}; + +/** + * Expected response type from the token endpoint during authorization code flow + * as it comes over the wire. + * Should be normalized to use capital case 'Bearer' for token_type property + * + * See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4, + * https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK. + */ +type WireBearerTokenResponse = BearerTokenResponse & { + token_type: "Bearer" | "bearer"; +}; + +const isResponseObject = (response: unknown): response is Record => + !!response && typeof response === "object"; + +/** + * Normalize token_type to use capital case to make consuming the token response easier + * token_type is case insensitive, and it is spec-compliant for OPs to return token_type: "bearer" + * Later, when used in auth headers it is case sensitive and must be Bearer + * See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 + * + * @param response - validated token response + * @returns response with token_type set to 'Bearer' + */ +const normalizeBearerTokenResponseTokenType = (response: WireBearerTokenResponse): BearerTokenResponse => ({ + ...response, + token_type: "Bearer", +}); + +const isValidBearerTokenResponse = (response: unknown): response is WireBearerTokenResponse => + isResponseObject(response) && + typeof response["token_type"] === "string" && + // token_type is case insensitive, some OPs return `token_type: "bearer"` + response["token_type"].toLowerCase() === "bearer" && + typeof response["access_token"] === "string" && + (!("refresh_token" in response) || typeof response["refresh_token"] === "string") && + (!("expires_in" in response) || typeof response["expires_in"] === "number"); + +/** + * Attempt to exchange authorization code for bearer token. + * + * Takes the authorization code returned by the OpenID Provider via the authorization URL, and makes a + * request to the Token Endpoint, to obtain the access token, refresh token, etc. + * + * @param code - authorization code as returned by OP during authorization + * @param storedAuthorizationParams - stored params from start of oidc login flow + * @returns valid bearer token response + * @throws when request fails, or returned token response is invalid + */ +export const completeAuthorizationCodeGrant = async ( + code: string, + { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }: { + clientId: string; + codeVerifier: string; + redirectUri: string; + delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig; + }, +): Promise => { + const params = new URLSearchParams(); + params.append("grant_type", "authorization_code"); + params.append("client_id", clientId); + params.append("code_verifier", codeVerifier); + params.append("redirect_uri", redirectUri); + params.append("code", code); + const metadata = params.toString(); + + const headers = { "Content-Type": "application/x-www-form-urlencoded" }; + + const response = await fetch(delegatedAuthConfig.tokenEndpoint, { + method: Method.Post, + headers, + body: metadata, + }); + + if (response.status >= 400) { + throw new Error(OidcError.CodeExchangeFailed); + } + + const token = await response.json(); + + if (isValidBearerTokenResponse(token)) { + return normalizeBearerTokenResponseTokenType(token); + } + + throw new Error(OidcError.InvalidBearerTokenResponse); +}; diff --git a/src/oidc/error.ts b/src/oidc/error.ts index b77fbbf75f5..6e70283a6ca 100644 --- a/src/oidc/error.ts +++ b/src/oidc/error.ts @@ -22,4 +22,6 @@ export enum OidcError { DynamicRegistrationNotSupported = "Dynamic registration not supported", DynamicRegistrationFailed = "Dynamic registration failed", DynamicRegistrationInvalid = "Dynamic registration invalid response", + CodeExchangeFailed = "Failed to exchange code for token", + InvalidBearerTokenResponse = "Invalid bearer token", } From 4382d2a425630c299370fa9a4bd8481dc5574b7a Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Tue, 27 Jun 2023 11:06:48 +0100 Subject: [PATCH 36/40] Increase another crypto test timeout (#3509) Followup to https://github.com/matrix-org/matrix-js-sdk/pull/3500: increase the timeout for another test which is also timing out. --- spec/unit/rust-crypto/rust-crypto.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 08bc5e397f6..e7a0a53c7c7 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -54,9 +54,13 @@ describe("RustCrypto", () => { describe(".importRoomKeys and .exportRoomKeys", () => { let rustCrypto: RustCrypto; - beforeEach(async () => { - rustCrypto = await makeTestRustCrypto(); - }); + beforeEach( + async () => { + rustCrypto = await makeTestRustCrypto(); + }, + /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */ + 10000, + ); it("should import and export keys", async () => { const someRoomKeys = [ From 2af0706b160a7464a1a3d95f8338c9c8fbb60f58 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 27 Jun 2023 11:57:15 +0100 Subject: [PATCH 37/40] Prepare changelog for v26.2.0-rc.1 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6481efff975..782604f15f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +Changes in [26.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0-rc.1) (2023-06-27) +============================================================================================================ + +## 🦖 Deprecations + * The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)). + * ElementR: Add `CryptoApi#bootstrapSecretStorage` ([\#3483](https://github.com/matrix-org/matrix-js-sdk/pull/3483)). Contributed by @florianduros. + * Deprecate `MatrixClient.findVerificationRequestDMInProgress`, `MatrixClient.getVerificationRequestsToDeviceInProgress`, and `MatrixClient.requestVerification`, in favour of methods in `CryptoApi`. ([\#3474](https://github.com/matrix-org/matrix-js-sdk/pull/3474)). + * Introduce a new `Crypto.VerificationRequest` interface, and deprecate direct access to the old `VerificationRequest` class. Also deprecate some related classes that were exported from `src/crypto/verification/request/VerificationRequest` ([\#3449](https://github.com/matrix-org/matrix-js-sdk/pull/3449)). + +## ✨ Features + * OIDC: navigate to authorization endpoint ([\#3499](https://github.com/matrix-org/matrix-js-sdk/pull/3499)). Contributed by @kerryarchibald. + * Support for interactive device verification in Element-R. ([\#3505](https://github.com/matrix-org/matrix-js-sdk/pull/3505)). + * Support for interactive device verification in Element-R. ([\#3508](https://github.com/matrix-org/matrix-js-sdk/pull/3508)). + * Support for interactive device verification in Element-R. ([\#3490](https://github.com/matrix-org/matrix-js-sdk/pull/3490)). Fixes vector-im/element-web#25316. + * Element-R: Store cross signing keys in secret storage ([\#3498](https://github.com/matrix-org/matrix-js-sdk/pull/3498)). Contributed by @florianduros. + * OIDC: add dynamic client registration util function ([\#3481](https://github.com/matrix-org/matrix-js-sdk/pull/3481)). Contributed by @kerryarchibald. + * Add getLastUnthreadedReceiptFor utility to Thread delegating to the underlying Room ([\#3493](https://github.com/matrix-org/matrix-js-sdk/pull/3493)). + * ElementR: Add `rust-crypto#createRecoveryKeyFromPassphrase` implementation ([\#3472](https://github.com/matrix-org/matrix-js-sdk/pull/3472)). Contributed by @florianduros. + +## 🐛 Bug Fixes + * Aggregate relations regardless of whether event fits into the timeline ([\#3496](https://github.com/matrix-org/matrix-js-sdk/pull/3496)). Fixes vector-im/element-web#25596. + * Fix bug where switching media caused media in subsequent calls to fail ([\#3489](https://github.com/matrix-org/matrix-js-sdk/pull/3489)). + * Fix: remove polls from room state on redaction ([\#3475](https://github.com/matrix-org/matrix-js-sdk/pull/3475)). Fixes vector-im/element-web#25573. Contributed by @kerryarchibald. + * Fix export type `GeneratedSecretStorageKey` ([\#3479](https://github.com/matrix-org/matrix-js-sdk/pull/3479)). Contributed by @florianduros. + * Close IDB database before deleting it to prevent spurious unexpected close errors ([\#3478](https://github.com/matrix-org/matrix-js-sdk/pull/3478)). Fixes vector-im/element-web#25597. + Changes in [26.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.1.0) (2023-06-20) ================================================================================================== From e285932776ca1b8d85466dc1928877245f764bc3 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 27 Jun 2023 11:57:18 +0100 Subject: [PATCH 38/40] v26.2.0-rc.1 --- package.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 03bfe44accd..678de4d4d70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "26.1.0", + "version": "26.2.0-rc.1", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=16.0.0" @@ -32,8 +32,8 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", - "browser": "./src/browser-index.ts", + "main": "./lib/index.js", + "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.ts", "matrix_lib_main": "./lib/index.js", @@ -156,5 +156,6 @@ "no-rust-crypto": { "src/rust-crypto/index.ts$": "./src/rust-crypto/browserify-index.ts" } - } + }, + "typings": "./lib/index.d.ts" } From 12a94bdd94e02fcb5ec484ddbeb40d0d69226cd3 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 4 Jul 2023 15:07:02 +0100 Subject: [PATCH 39/40] Prepare changelog for v26.2.0 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 782604f15f2..5a0e121a4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ -Changes in [26.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0-rc.1) (2023-06-27) -============================================================================================================ +Changes in [26.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.2.0) (2023-07-04) +================================================================================================== ## 🦖 Deprecations * The Browserify artifact is being deprecated, scheduled for removal in the October 10th release cycle. ([\#3189](https://github.com/matrix-org/matrix-js-sdk/issues/3189)). From 3f095caf2d7fac66e5d3c00aac1464dd8cffa457 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 4 Jul 2023 15:07:04 +0100 Subject: [PATCH 40/40] v26.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 678de4d4d70..5863cd81d22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "26.2.0-rc.1", + "version": "26.2.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=16.0.0"