From 376d7a88c1161fea417087232b27627f0db3e163 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 19 Nov 2024 12:26:05 -0800 Subject: [PATCH] Establish communication tunnel protocol --- libs/common/src/abstractions/api.service.ts | 8 + .../communication-tunnel.service.ts | 41 ++++ .../communication-tunnel.spec.ts | 196 ++++++++++++++++++ .../communication-tunnel.ts | 146 +++++++++++++ libs/common/src/services/api.service.ts | 26 +++ 5 files changed, 417 insertions(+) create mode 100644 libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts create mode 100644 libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts create mode 100644 libs/common/src/platform/communication-tunnel/communication-tunnel.ts diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 9834116c9fc..dec25ac60a0 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -502,6 +502,14 @@ export abstract class ApiService { keyConnectorUrl: string, request: KeyConnectorUserKeyRequest, ) => Promise; + /** + * Negotiate a tunneled communication protocol with the supplied url. + * + * @param url The URL of the server to create a tunnel with. + * @param request The request to send to the server. + * @returns The response from the server. + */ + initCommunicationTunnel: (url: string, request: InitTunnelRequest) => Promise; getKeyConnectorAlive: (keyConnectorUrl: string) => Promise; getOrganizationExport: (organizationId: string) => Promise; } diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts new file mode 100644 index 00000000000..59ef182c3a4 --- /dev/null +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts @@ -0,0 +1,41 @@ +import { ApiService } from "../../abstractions/api.service"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { KeyGenerationService } from "../abstractions/key-generation.service"; + +import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel"; + +export abstract class CommunicationTunnelService { + /** + * + * @param supportedTunnelVersions the allowed tunnel versions for the communication tunnel + * @returns an object that stores the cryptographic secrets to protect and unprotect data for the communication tunnel + * @throws errors in the tunnel creation process, including unsupported communication versions or issues communicating with the server + */ + abstract createTunnel( + url: string, + supportedTunnelVersions: TunnelVersion[], + ): Promise; +} + +export class DefaultCommunicationTunnelService implements CommunicationTunnelService { + constructor( + private readonly apiService: ApiService, + private readonly keyGenerationService: KeyGenerationService, + private readonly encryptService: EncryptService, + ) {} + async createTunnel( + url: string, + supportedTunnelVersions: TunnelVersion[], + ): Promise { + const tunnel = new CommunicationTunnel( + this.apiService, + this.keyGenerationService, + this.encryptService, + supportedTunnelVersions, + ); + + await tunnel.negotiateTunnel(url); + + return tunnel; + } +} diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts new file mode 100644 index 00000000000..6984661d787 --- /dev/null +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts @@ -0,0 +1,196 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { makeEncString, makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec"; +import { ApiService } from "../../abstractions/api.service"; +import { InitTunnelResponse } from "../../auth/models/response/init-tunnel.response"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { KeyGenerationService } from "../abstractions/key-generation.service"; + +import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel"; + +describe("communicationTunnel", () => { + const url = "http://key-connector.example"; + let apiService: MockProxy; + let keyGenerationService: MockProxy; + let encryptService: MockProxy; + let sut: CommunicationTunnel; + const sharedKey = makeSymmetricCryptoKey(32); + const encapsulationKey = makeStaticByteArray(32, 1); + const encapsulatedKey = makeEncString("encapsulatedKey"); + + beforeEach(() => { + apiService = mock(); + keyGenerationService = mock(); + encryptService = mock(); + + apiService.initCommunicationTunnel.mockResolvedValue( + new InitTunnelResponse({ + EncapsulationKey: encapsulationKey, + CommunicationVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + }), + ); + keyGenerationService.createKey.mockResolvedValue(sharedKey); + encryptService.rsaEncrypt.mockResolvedValue(encapsulatedKey); + }); + + describe("negotiateTunnel", () => { + it("negotiates with the provided server", async () => { + const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM]; + sut = new CommunicationTunnel( + apiService, + keyGenerationService, + encryptService, + supportedTunnelVersions, + ); + + await sut.negotiateTunnel(url); + + expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith( + url, + expect.objectContaining({ supportedTunnelVersions }), + ); + }); + + it("generates a shared key", async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ]); + + await sut.negotiateTunnel(url); + + expect(keyGenerationService.createKey).toHaveBeenCalledWith(256); + }); + + it("encapsulates the shared key", async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ]); + + await sut.negotiateTunnel(url); + + expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(sharedKey.key, encapsulationKey); + expect(sut.encapsulatedKey).toBe(encapsulatedKey); + }); + + it.each(Object.values(TunnelVersion).filter((v) => typeof v !== "number"))( + "negotiates tunnel version %s", + async (tunnelVersion: keyof typeof TunnelVersion) => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion[tunnelVersion], + ]); + + apiService.initCommunicationTunnel.mockResolvedValue( + new InitTunnelResponse({ + EncapsulationKey: [1, 2, 3], + CommunicationVersion: TunnelVersion[tunnelVersion], + }), + ); + + await sut.negotiateTunnel(url); + + expect(sut.tunnelVersion).toBe(TunnelVersion[tunnelVersion]); + }, + ); + + it("throws an error if the communication version is not supported", async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ]); + + apiService.initCommunicationTunnel.mockResolvedValue( + new InitTunnelResponse({ + EncapsulationKey: [1, 2, 3], + CommunicationVersion: TunnelVersion.CLEAR_TEXT, + }), + ); + + await expect(sut.negotiateTunnel(url)).rejects.toThrow("Unsupported communication version"); + }); + }); + + describe("tunnel encryption", () => { + const clearText = makeStaticByteArray(32, 2); + const protectedText = makeStaticByteArray(32, 3); + + beforeEach(() => { + encryptService.aesGcmEncryptToBytes.mockResolvedValue(protectedText); + encryptService.aesGcmDecryptToBytes.mockResolvedValue(clearText); + }); + + it("throws an error if the tunnel is not initialized", async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ]); + + await expect(sut.protect(clearText)).rejects.toThrow("Communication tunnel not initialized"); + await expect(sut.unprotect(protectedText)).rejects.toThrow( + "Communication tunnel not initialized", + ); + }); + + describe("CLEAR_TEXT", () => { + beforeEach(async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.CLEAR_TEXT, + ]); + apiService.initCommunicationTunnel.mockResolvedValue( + new InitTunnelResponse({ + CommunicationVersion: TunnelVersion.CLEAR_TEXT, + }), + ); + await sut.negotiateTunnel(url); + }); + + it("does not encrypt the clear text", async () => { + const result = await sut.protect(clearText); + + expect(result).toBe(clearText); + expect(encryptService.aesGcmEncryptToBytes).not.toHaveBeenCalled(); + }); + + it("does not decrypt the protected text", async () => { + const result = await sut.unprotect(protectedText); + + expect(result).toBe(protectedText); + expect(encryptService.aesGcmDecryptToBytes).not.toHaveBeenCalled(); + }); + + describe("RSA_ENCAPSULATED_AES_256_GCM", () => { + beforeEach(async () => { + sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ]); + apiService.initCommunicationTunnel.mockResolvedValue( + new InitTunnelResponse({ + EncapsulationKey: encapsulationKey, + CommunicationVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + }), + ); + await sut.negotiateTunnel(url); + }); + + it("encrypts the clear text", async () => { + const result = await sut.protect(clearText); + + expect(result).toBe(protectedText); + expect(encryptService.aesGcmEncryptToBytes).toHaveBeenCalledWith( + clearText, + sharedKey.key, + expect.toEqualBuffer(new Uint8Array([TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM])), + ); + }); + + it("decrypts the protected text", async () => { + const result = await sut.unprotect(protectedText); + + expect(result).toBe(clearText); + expect(encryptService.aesGcmDecryptToBytes).toHaveBeenCalledWith( + protectedText, + sharedKey.key, + expect.toEqualBuffer(new Uint8Array([TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM])), + ); + }); + }); + }); + }); +}); diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts new file mode 100644 index 00000000000..671e4cd78b8 --- /dev/null +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts @@ -0,0 +1,146 @@ +import { ApiService } from "../../abstractions/api.service"; +import { InitTunnelRequest } from "../../auth/models/request/init-tunnel.request"; +import { InitTunnelResponse } from "../../auth/models/response/init-tunnel.response"; +import { ErrorResponse } from "../../models/response/error.response"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { KeyGenerationService } from "../abstractions/key-generation.service"; +import { EncString } from "../models/domain/enc-string"; + +export enum TunnelVersion { + CLEAR_TEXT = 0, + /** + * Shared key is AES-256-GCM encapsulated by RSA. CipherText is formatted as: + * encryptedData + tag + iv + * additional data is: + * - the version of communication as 1 byte (0x00) + */ + RSA_ENCAPSULATED_AES_256_GCM = 1, +} + +export class CommunicationTunnel { + private negotiationComplete: boolean = false; + private sharedKey: Uint8Array; + private _encapsulatedKey: EncString; + get encapsulatedKey(): EncString { + return this._encapsulatedKey; + } + private _tunnelVersion: TunnelVersion; + get tunnelVersion(): TunnelVersion { + return this._tunnelVersion; + } + + constructor( + private readonly apiService: ApiService, + private readonly keyGenerationService: KeyGenerationService, + private readonly encryptService: EncryptService, + private readonly supportedTunnelVersions: TunnelVersion[], + ) {} + + /** + * Negotiate the communication protocol with the key connector. + * + * @param url the url of the key connector + * @returns the cleartext shared key, an encapsulated version of the shared key that can be shared with the key + * connector, and the version of the communication protocol. + * @throws errors in the key negotiation process, including unsupported communication versions + */ + async negotiateTunnel(url: string): Promise { + let response: InitTunnelResponse; + + try { + response = await this.apiService.initCommunicationTunnel( + url, + new InitTunnelRequest(this.supportedTunnelVersions), + ); + } catch (e) { + // if the key connector does not support encrypted communication, fall back to clear text, as long as it's a supported version + if ( + (e as ErrorResponse).statusCode === 404 && + this.supportedTunnelVersions.includes(TunnelVersion.CLEAR_TEXT) + ) { + response = new InitTunnelResponse({ + CommunicationVersion: TunnelVersion.CLEAR_TEXT, + }); + } else { + throw e; + } + } + + return await this.initWithVersion(response); + } + + async protect(clearText: Uint8Array): Promise { + if (!this.negotiationComplete) { + throw new Error("Communication tunnel not initialized"); + } + + let protectedText: Uint8Array; + switch (this.tunnelVersion) { + case TunnelVersion.CLEAR_TEXT: + protectedText = clearText; + break; + case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: + protectedText = await this.encryptService.aesGcmEncryptToBytes( + clearText, + this.sharedKey, + new Uint8Array([this.tunnelVersion]), + ); + break; + default: + throw new Error("Unsupported communication version"); + } + return protectedText; + } + + async unprotect(protectedText: Uint8Array): Promise { + if (!this.negotiationComplete) { + throw new Error("Communication tunnel not initialized"); + } + + let clearText: Uint8Array; + switch (this.tunnelVersion) { + case TunnelVersion.CLEAR_TEXT: + clearText = protectedText; + break; + case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: + clearText = await this.encryptService.aesGcmDecryptToBytes( + protectedText, + this.sharedKey, + new Uint8Array([this.tunnelVersion]), + ); + break; + default: + throw new Error("Unsupported communication version"); + } + return clearText; + } + + private async initWithVersion(response: InitTunnelResponse): Promise { + this._tunnelVersion = response.communicationVersion; + + if (!this.supportedTunnelVersions.includes(this.tunnelVersion)) { + throw new Error("Unsupported communication version"); + } + + switch (this.tunnelVersion) { + case TunnelVersion.CLEAR_TEXT: + break; + case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: { + const encapsulationKey = response.encapsulationKey; + + // use the encapsulation key to create an share a shared key + this.sharedKey = (await this.keyGenerationService.createKey(256)).key; + this._encapsulatedKey = await this.encryptService.rsaEncrypt( + this.sharedKey, + encapsulationKey, + ); + break; + } + default: + throw new Error("Unsupported communication version"); + } + + this.negotiationComplete = true; + return this.tunnelVersion; + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 0c508bfeb88..71915dbf14b 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1559,6 +1559,32 @@ export class ApiService implements ApiServiceAbstraction { } } + async initCommunicationTunnel( + url: string, + request: InitTunnelRequest, + ): Promise { + const authHeader = await this.getActiveBearerToken(); + + const response = await this.fetch( + new Request(url + "/init-communication", { + cache: "no-store", + method: "POST", + headers: new Headers({ + Accept: "application/json", + Authorization: "Bearer " + authHeader, + }), + body: JSON.stringify(request), + }), + ); + + if (response.status !== 200) { + const error = await this.handleError(response, false, true); + return Promise.reject(error); + } + + return new InitTunnelResponse(await response.json()); + } + async getOrganizationExport(organizationId: string): Promise { const r = await this.send( "GET",