Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Element-R: support for starting a SAS verification #3528

Merged
merged 3 commits into from
Jun 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 169 additions & 28 deletions spec/integ/crypto/verification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ limitations under the License.

import "fake-indexeddb/auto";

import anotherjson from "another-json";
import { MockResponse } from "fetch-mock";
import fetchMock from "fetch-mock-jest";
import { IDBFactory } from "fake-indexeddb";
import { createHash } from "crypto";

import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient } from "../../../src";
import {
Expand Down Expand Up @@ -235,13 +237,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
// 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(),
},
});
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));

// alice responds with a 'key' ...
requestBody = await expectSendToDeviceMessage("m.key.verification.key");
Expand All @@ -265,32 +261,15 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
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}`,
),
},
},
});
returnToDeviceMessageFromSync(
buildSasMacMessage(transactionId, olmSAS, TEST_USER_ID, aliceClient.deviceId!),
);

// 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,
},
});
returnToDeviceMessageFromSync(buildDoneMessage(transactionId));

// ... and the whole thing should be done!
await verificationPromise;
Expand All @@ -304,6 +283,102 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
olmSAS.free();
});

it("can initiate SAS verification ourselves", async () => {
aliceClient = await startTestClient();
await waitForDeviceList();

// Alice sends 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);
expect(request.phase).toEqual(VerificationPhase.Ready);
expect(request.otherPartySupportsMethod("m.sas.v1")).toBe(true);

// advance the clock, because the devicelist likes to sleep for 5ms during key downloads
await jest.advanceTimersByTimeAsync(10);

// And now Alice starts a SAS verification
let sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.start");
await request.startVerification("m.sas.v1");
let requestBody = await sendToDevicePromise;

let toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID];
expect(toDeviceMessage).toEqual({
from_device: aliceClient.deviceId,
method: "m.sas.v1",
transaction_id: transactionId,
hashes: ["sha256"],
key_agreement_protocols: expect.arrayContaining(["curve25519-hkdf-sha256"]),
message_authentication_codes: expect.arrayContaining(["hkdf-hmac-sha256.v2"]),
short_authentication_string: ["decimal", "emoji"],
});

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();
const verificationPromise = verifier.verify();

// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new global.Olm.SAS();
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);

sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
returnToDeviceMessageFromSync(buildSasAcceptMessage(transactionId, commitmentStr));

// alice responds with a 'key' ...
requestBody = await sendToDevicePromise;

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 dummy device also sends a 'key'
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));

// ... and the client is notified to show the emoji
const showSas = await new Promise<ShowSasCallbacks>((resolve) => {
verifier.once(VerifierEvent.ShowSas, resolve);
});

// `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
returnToDeviceMessageFromSync(
buildSasMacMessage(transactionId, olmSAS, TEST_USER_ID, aliceClient.deviceId!),
);

// that should satisfy Alice, who should reply with a 'done'
await expectSendToDeviceMessage("m.key.verification.done");

// the dummy device also confirms done-ness
returnToDeviceMessageFromSync(buildDoneMessage(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();
});

it("Can make a verification request to *all* devices", async () => {
aliceClient = await startTestClient();
// we need an existing cross-signing key for this
Expand Down Expand Up @@ -610,6 +685,11 @@ function calculateMAC(olmSAS: Olm.SAS, input: string, info: string): string {
return mac;
}

/** Calculate the sha256 hash of a string, encoding as unpadded base64 */
function sha256(commitmentStr: string): string {
return encodeUnpaddedBase64(createHash("sha256").update(commitmentStr, "utf8").digest());
}

function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
return Buffer.from(uint8Array).toString("base64").replace(/=+$/g, "");
}
Expand Down Expand Up @@ -642,3 +722,64 @@ function buildSasStartMessage(transactionId: string): { type: string; content: o
},
};
}

/** build an m.key.verification.accept to-device message suitable for the SAS flow */
function buildSasAcceptMessage(transactionId: string, commitmentStr: string) {
return {
type: "m.key.verification.accept",
content: {
transaction_id: transactionId,
commitment: sha256(commitmentStr),
hash: "sha256",
key_agreement_protocol: "curve25519-hkdf-sha256",
short_authentication_string: ["decimal", "emoji"],
message_authentication_code: "hkdf-hmac-sha256.v2",
},
};
}

/** build an m.key.verification.key to-device message suitable for the SAS flow */
function buildSasKeyMessage(transactionId: string, key: string): { type: string; content: object } {
return {
type: "m.key.verification.key",
content: {
transaction_id: transactionId,
key: key,
},
};
}

/** build an m.key.verification.mac to-device message suitable for the SAS flow, originating from the dummy device */
function buildSasMacMessage(
transactionId: string,
olmSAS: Olm.SAS,
recipientUserId: string,
recipientDeviceId: string,
): { type: string; content: object } {
const macInfoBase = `MATRIX_KEY_VERIFICATION_MAC${TEST_USER_ID}${TEST_DEVICE_ID}${recipientUserId}${recipientDeviceId}${transactionId}`;

return {
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}`,
),
},
},
};
}

/** build an m.key.verification.done to-device message */
function buildDoneMessage(transactionId: string) {
return {
type: "m.key.verification.done",
content: {
transaction_id: transactionId,
},
};
}
50 changes: 50 additions & 0 deletions spec/unit/rust-crypto/verification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
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 { Mocked } from "jest-mock";

import { RustVerificationRequest } from "../../../src/rust-crypto/verification";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";

describe("VerificationRequest", () => {
describe("startVerification", () => {
let mockedInner: Mocked<RustSdkCryptoJs.VerificationRequest>;
let mockedOutgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
let request: RustVerificationRequest;

beforeEach(() => {
mockedInner = {
registerChangesCallback: jest.fn(),
startSas: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.VerificationRequest>;
mockedOutgoingRequestProcessor = {} as Mocked<OutgoingRequestProcessor>;
request = new RustVerificationRequest(mockedInner, mockedOutgoingRequestProcessor);
});

it("does not permit methods other than SAS", async () => {
await expect(request.startVerification("m.reciprocate.v1")).rejects.toThrow(
"Unsupported verification method",
);
});

it("raises an error if starting verification does not produce a verifier", async () => {
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
"Still no verifier after startSas() call",
);
});
});
});
11 changes: 11 additions & 0 deletions src/crypto-api/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,20 @@ export interface VerificationRequest
* @param targetDevice - details of where to send the request to.
*
* @returns The verifier which will do the actual verification.
*
* @deprecated Use {@link VerificationRequest#startVerification} instead.
*/
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;

/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
* @param method - the name of the verification method to use.
*
* @returns The verifier which will do the actual verification.
*/
startVerification(method: string): Promise<Verifier>;

/**
* The verifier which is doing the actual verification, once the method has been established.
* Only defined when the `phase` is Started.
Expand Down
8 changes: 8 additions & 0 deletions src/crypto/verification/request/VerificationRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
VerificationRequest as IVerificationRequest,
VerificationRequestEvent,
VerificationRequestEventHandlerMap,
Verifier,
} from "../../../crypto-api/verification";

// backwards-compatibility exports
Expand Down Expand Up @@ -458,6 +459,13 @@ export class VerificationRequest<C extends IVerificationChannel = IVerificationC
return this._verifier!;
}

public async startVerification(method: string): Promise<Verifier> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it desirable to have a list of supported methods here, rather than using string as a type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly, though we do have to support strings on all the other methods for backwards compatibility, so it's not an obvious win.

const verifier = this.beginKeyVerification(method);
// kick off the verification in the background, but *don't* wait for to complete: we need to return the `Verifier`.
verifier.verify();
return verifier;
}

/**
* sends the initial .request event.
* @returns resolves when the event has been sent.
Expand Down
29 changes: 29 additions & 0 deletions src/rust-crypto/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,35 @@ export class RustVerificationRequest
throw new Error("not implemented");
}

/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
* Implementation of {@link Crypto.VerificationRequest#startVerification}.
*
* @param method - the name of the verification method to use.
*/
public async startVerification(method: string): Promise<Verifier> {
if (method !== "m.sas.v1") {
throw new Error(`Unsupported verification method ${method}`);
}

const res:
| [RustSdkCryptoJs.Sas, RustSdkCryptoJs.RoomMessageRequest | RustSdkCryptoJs.ToDeviceRequest]
| undefined = await this.inner.startSas();

if (res) {
const [, req] = res;
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
}

// this should have triggered the onChange callback, and we should now have a verifier
if (!this._verifier) {
throw new Error("Still no verifier after startSas() call");
}

return this._verifier;
}

/**
* The verifier which is doing the actual verification, once the method has been established.
* Only defined when the `phase` is Started.
Expand Down