diff --git a/spec/unit/rendezvous/ecdhv1.spec.ts b/spec/unit/rendezvous/ecdhv1.spec.ts new file mode 100644 index 000000000..f088977f0 --- /dev/null +++ b/spec/unit/rendezvous/ecdhv1.spec.ts @@ -0,0 +1,172 @@ +/* +Copyright 2022 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 "../../olm-loader"; +import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous"; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from "../../../src/rendezvous/channels"; +import { decodeBase64 } from "../../../src/crypto/olmlib"; +import { DummyTransport } from "./DummyTransport"; + +function makeTransport(name: string) { + return new DummyTransport(name, { type: "dummy" }); +} + +describe("ECDHv1", function () { + beforeAll(async function () { + await global.Olm.init(); + }); + + describe("with crypto", () => { + it("initiator wants to sign in", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + const message = { key: "xxx" }; + await alice.send(message); + const bobReceive = await bob.receive(); + expect(bobReceive).toEqual(message); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("initiator wants to reciprocate", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + const message = { key: "xxx" }; + await bob.send(message); + const aliceReceive = await alice.receive(); + expect(aliceReceive).toEqual(message); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("double connect", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + expect(alice.connect()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("closed", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + alice.close(); + + expect(alice.connect()).rejects.toThrow(); + expect(alice.send({})).rejects.toThrow(); + expect(alice.receive()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("require ciphertext", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + const aliceCode = await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + const bob = new MSC3903ECDHv1RendezvousChannel(bobTransport, decodeBase64(aliceCode.rendezvous.key)); + + const bobChecksum = await bob.connect(); + const aliceChecksum = await alice.connect(); + + expect(aliceChecksum).toEqual(bobChecksum); + + // send a message without encryption + await aliceTransport.send({ iv: "dummy", ciphertext: "dummy" }); + expect(bob.receive()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + await bob.cancel(RendezvousFailureReason.Unknown); + }); + + it("ciphertext before set up", async function () { + const aliceTransport = makeTransport("Alice"); + const bobTransport = makeTransport("Bob"); + aliceTransport.otherParty = bobTransport; + bobTransport.otherParty = aliceTransport; + + // alice is signing in initiates and generates a code + const alice = new MSC3903ECDHv1RendezvousChannel(aliceTransport); + await alice.generateCode(RendezvousIntent.LOGIN_ON_NEW_DEVICE); + + await bobTransport.send({ iv: "dummy", ciphertext: "dummy" }); + + expect(alice.receive()).rejects.toThrow(); + + await alice.cancel(RendezvousFailureReason.Unknown); + }); + }); +}); diff --git a/spec/unit/rendezvous/ecdh.spec.ts b/spec/unit/rendezvous/ecdhv2.spec.ts similarity index 100% rename from spec/unit/rendezvous/ecdh.spec.ts rename to spec/unit/rendezvous/ecdhv2.spec.ts diff --git a/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts new file mode 100644 index 000000000..573ae6f22 --- /dev/null +++ b/src/rendezvous/channels/MSC3903ECDHv1RendezvousChannel.ts @@ -0,0 +1,256 @@ +/* +Copyright 2022 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 { SAS } from "@matrix-org/olm"; + +import { + RendezvousError, + RendezvousCode, + RendezvousIntent, + RendezvousChannel, + RendezvousTransportDetails, + RendezvousTransport, + RendezvousFailureReason, +} from ".."; +import { encodeBase64, decodeBase64 } from "../../crypto/olmlib"; +import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; +import { generateDecimalSas } from "../../crypto/verification/SASDecimal"; +import { UnstableValue } from "../../NamespacedValue"; +import { EncryptedPayload, MSC3903ECDHPayload, PlainTextPayload } from "./MSC3903ECDHv2RendezvousChannel"; + +/** + * @deprecated Use ECDH_V2 instead + */ +export const ECDH_V1 = new UnstableValue( + "m.rendezvous.v1.curve25519-aes-sha256", + "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256", +); + +/** + * @deprecated Use ECDHv2RendezvousCode instead + */ +export interface ECDHv1RendezvousCode extends RendezvousCode { + rendezvous: { + transport: RendezvousTransportDetails; + algorithm: typeof ECDH_V1.name | typeof ECDH_V1.altName; + key: string; + }; +} + +async function importKey(key: Uint8Array): Promise { + if (!subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + + const imported = subtleCrypto.importKey("raw", key, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); + + return imported; +} + +/** + * Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903) + * X25519/ECDH key agreement based secure rendezvous channel. + * Note that this is UNSTABLE and may have breaking changes without notice. + * + * @deprecated Use MSC3903ECDHv2RendezvousChannel instead. This implementation will be removed. + */ +export class MSC3903ECDHv1RendezvousChannel implements RendezvousChannel { + private olmSAS?: SAS; + private ourPublicKey: Uint8Array; + private aesKey?: CryptoKey; + private connected = false; + + public constructor( + private transport: RendezvousTransport, + private theirPublicKey?: Uint8Array, + public onFailure?: (reason: RendezvousFailureReason) => void, + ) { + this.olmSAS = new global.Olm.SAS(); + this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey()); + } + + public async generateCode(intent: RendezvousIntent): Promise { + if (this.transport.ready) { + throw new Error("Code already generated"); + } + + await this.transport.send({ algorithm: ECDH_V1.name }); + + const rendezvous: ECDHv1RendezvousCode = { + rendezvous: { + algorithm: ECDH_V1.name, + key: encodeBase64(this.ourPublicKey), + transport: await this.transport.details(), + }, + intent, + }; + + return rendezvous; + } + + public async connect(): Promise { + if (this.connected) { + throw new Error("Channel already connected"); + } + + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + + const isInitiator = !this.theirPublicKey; + + if (isInitiator) { + // wait for the other side to send us their public key + const rawRes = await this.transport.receive(); + if (!rawRes) { + throw new Error("No response from other device"); + } + const res = rawRes as Partial; + const { key, algorithm } = res; + if (!algorithm || !ECDH_V1.matches(algorithm) || !key) { + throw new RendezvousError( + "Unsupported algorithm: " + algorithm, + RendezvousFailureReason.UnsupportedAlgorithm, + ); + } + + this.theirPublicKey = decodeBase64(key); + } else { + // send our public key unencrypted + await this.transport.send({ + algorithm: ECDH_V1.name, + key: encodeBase64(this.ourPublicKey), + }); + } + + this.connected = true; + + this.olmSAS.set_their_key(encodeBase64(this.theirPublicKey!)); + + const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey!; + const recipientKey = isInitiator ? this.theirPublicKey! : this.ourPublicKey; + let aesInfo = ECDH_V1.name; + aesInfo += `|${encodeBase64(initiatorKey)}`; + aesInfo += `|${encodeBase64(recipientKey)}`; + + const aesKeyBytes = this.olmSAS.generate_bytes(aesInfo, 32); + + this.aesKey = await importKey(aesKeyBytes); + + // blank the bytes out to make sure not kept in memory + aesKeyBytes.fill(0); + + const rawChecksum = this.olmSAS.generate_bytes(aesInfo, 5); + return generateDecimalSas(Array.from(rawChecksum)).join("-"); + } + + private async encrypt(data: T): Promise { + if (!subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + + const iv = new Uint8Array(32); + crypto.getRandomValues(iv); + + const encodedData = new TextEncoder().encode(JSON.stringify(data)); + + const ciphertext = await subtleCrypto.encrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + this.aesKey as CryptoKey, + encodedData, + ); + + return { + iv: encodeBase64(iv), + ciphertext: encodeBase64(ciphertext), + }; + } + + public async send(payload: T): Promise { + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + + if (!this.aesKey) { + throw new Error("Shared secret not set up"); + } + + return this.transport.send(await this.encrypt(payload)); + } + + private async decrypt({ iv, ciphertext }: EncryptedPayload): Promise> { + if (!ciphertext || !iv) { + throw new Error("Missing ciphertext and/or iv"); + } + + const ciphertextBytes = decodeBase64(ciphertext); + + if (!subtleCrypto) { + throw new Error("Web Crypto is not available"); + } + + const plaintext = await subtleCrypto.decrypt( + { + name: "AES-GCM", + iv: decodeBase64(iv), + tagLength: 128, + }, + this.aesKey as CryptoKey, + ciphertextBytes, + ); + + return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext))); + } + + public async receive(): Promise | undefined> { + if (!this.olmSAS) { + throw new Error("Channel closed"); + } + if (!this.aesKey) { + throw new Error("Shared secret not set up"); + } + + const rawData = await this.transport.receive(); + if (!rawData) { + return undefined; + } + const data = rawData as Partial; + if (data.ciphertext && data.iv) { + return this.decrypt(data as EncryptedPayload); + } + + throw new Error("Data received but no ciphertext"); + } + + public async close(): Promise { + if (this.olmSAS) { + this.olmSAS.free(); + this.olmSAS = undefined; + } + } + + public async cancel(reason: RendezvousFailureReason): Promise { + try { + await this.transport.cancel(reason); + } finally { + await this.close(); + } + } +} diff --git a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts index cd1ac47be..312aaa5c2 100644 --- a/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts +++ b/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts @@ -29,8 +29,9 @@ import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib"; import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto"; import { generateDecimalSas } from "../../crypto/verification/SASDecimal"; import { UnstableValue } from "../../NamespacedValue"; +import { ECDH_V1 } from "./MSC3903ECDHv1RendezvousChannel"; -const ECDH = new UnstableValue( +const ECDH_V2 = new UnstableValue( "m.rendezvous.v2.curve25519-aes-sha256", "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256", ); @@ -38,7 +39,7 @@ const ECDH = new UnstableValue( export interface ECDHv2RendezvousCode extends RendezvousCode { rendezvous: { transport: RendezvousTransportDetails; - algorithm: typeof ECDH.name | typeof ECDH.altName; + algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName | typeof ECDH_V1.name | typeof ECDH_V1.altName; key: string; }; } @@ -46,7 +47,7 @@ export interface ECDHv2RendezvousCode extends RendezvousCode { export type MSC3903ECDHPayload = PlainTextPayload | EncryptedPayload; export interface PlainTextPayload { - algorithm: typeof ECDH.name | typeof ECDH.altName; + algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName | typeof ECDH_V1.name | typeof ECDH_V1.altName; key?: string; } @@ -90,11 +91,11 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { throw new Error("Code already generated"); } - await this.transport.send({ algorithm: ECDH.name }); + await this.transport.send({ algorithm: ECDH_V2.name }); const rendezvous: ECDHv2RendezvousCode = { rendezvous: { - algorithm: ECDH.name, + algorithm: ECDH_V2.name, key: encodeUnpaddedBase64(this.ourPublicKey), transport: await this.transport.details(), }, @@ -123,7 +124,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { } const res = rawRes as Partial; const { key, algorithm } = res; - if (!algorithm || !ECDH.matches(algorithm) || !key) { + if (!algorithm || !ECDH_V2.matches(algorithm) || !key) { throw new RendezvousError( "Unsupported algorithm: " + algorithm, RendezvousFailureReason.UnsupportedAlgorithm, @@ -134,7 +135,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { } else { // send our public key unencrypted await this.transport.send({ - algorithm: ECDH.name, + algorithm: ECDH_V2.name, key: encodeUnpaddedBase64(this.ourPublicKey), }); } @@ -145,7 +146,7 @@ export class MSC3903ECDHv2RendezvousChannel implements RendezvousChannel { const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey!; const recipientKey = isInitiator ? this.theirPublicKey! : this.ourPublicKey; - let aesInfo = ECDH.name; + let aesInfo = ECDH_V2.name; aesInfo += `|${encodeUnpaddedBase64(initiatorKey)}`; aesInfo += `|${encodeUnpaddedBase64(recipientKey)}`; diff --git a/src/rendezvous/channels/index.ts b/src/rendezvous/channels/index.ts index f157bbeae..42e0ee15e 100644 --- a/src/rendezvous/channels/index.ts +++ b/src/rendezvous/channels/index.ts @@ -14,4 +14,5 @@ See the License for the specific language governing permissions and limitations under the License. */ +export * from "./MSC3903ECDHv1RendezvousChannel"; export * from "./MSC3903ECDHv2RendezvousChannel";