diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 9484887faad..cc665dd706d 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -1,3 +1,5 @@ +import type { JsonObject } from "type-fest"; + import { CollectionRequest, CollectionAccessDetailsResponse, @@ -74,7 +76,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { InitTunnelResponse } from "../auth/models/response/init-tunnel.response"; -import { KeyConnectorGetUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { RegisterResponse } from "../auth/models/response/register.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; @@ -115,6 +116,7 @@ import { EventResponse } from "../models/response/event.response"; import { ListResponse } from "../models/response/list.response"; import { ProfileResponse } from "../models/response/profile.response"; import { UserKeyResponse } from "../models/response/user-key.response"; +import { TunneledRequest } from "../platform/communication-tunnel/tunneled.request"; import { SyncResponse } from "../platform/sync"; import { UserId } from "../types/guid"; import { AttachmentRequest } from "../vault/models/request/attachment.request"; @@ -507,14 +509,15 @@ export abstract class ApiService { * * @param keyConnectorUrl The URL of the key connector. * @param request The request to send to the key connector. If the shared key is null, falls back to an older GET endpoint that will respond in cleartext. + * @returns The raw json body of the response. The response must be passed through a tunnel to be decrypted. */ getMasterKeyFromKeyConnector: ( keyConnectorUrl: string, - request: KeyConnectorGetUserKeyRequest, - ) => Promise; + request: KeyConnectorGetUserKeyRequest | TunneledRequest, + ) => Promise; postUserKeyToKeyConnector: ( keyConnectorUrl: string, - request: KeyConnectorSetUserKeyRequest, + request: KeyConnectorSetUserKeyRequest | TunneledRequest, ) => Promise; /** * Negotiate a tunneled communication protocol with the supplied url. diff --git a/libs/common/src/auth/models/request/init-tunnel.request.ts b/libs/common/src/auth/models/request/init-tunnel.request.ts index 2c00e1eda60..7e569670089 100644 --- a/libs/common/src/auth/models/request/init-tunnel.request.ts +++ b/libs/common/src/auth/models/request/init-tunnel.request.ts @@ -1,5 +1,5 @@ import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel"; export class InitTunnelRequest { - constructor(readonly supportedTunnelVersions: TunnelVersion[]) {} + constructor(readonly supportedTunnelVersions: readonly TunnelVersion[]) {} } diff --git a/libs/common/src/auth/models/request/key-connector-user-key.request.ts b/libs/common/src/auth/models/request/key-connector-user-key.request.ts index 3c35fa3fef4..48c0d6a87fb 100644 --- a/libs/common/src/auth/models/request/key-connector-user-key.request.ts +++ b/libs/common/src/auth/models/request/key-connector-user-key.request.ts @@ -1,92 +1,17 @@ -import { - TunnelVersion, - CommunicationTunnel, -} from "../../../platform/communication-tunnel/communication-tunnel"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../../types/guid"; /** * @typedef { import("../response/key-connector-init-communication.response").KeyConnectorInitCommunicationResponse } KeyConnectorInitCommunicationResponse */ export class KeyConnectorSetUserKeyRequest { - readonly key: string | null; - readonly encryptedKey: Uint8Array | null; - readonly sharedKey: Uint8Array | null; - readonly tunnelVersion: TunnelVersion; /** * - * @param key The key to store, encrypted by the shared key - * @param sharedKey The key used to encrypt {@link key}, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey} or null. - * If null, the communication is sent in cleartext. + * @param key The key to store */ - constructor( - keyOrEncryptedKey: - | string - | { - key: Uint8Array; - sharedKey: Uint8Array; - tunnelVersion: TunnelVersion; - }, - ) { - if (typeof keyOrEncryptedKey === "string") { - this.key = keyOrEncryptedKey; - this.encryptedKey = undefined; - this.sharedKey = undefined; - this.tunnelVersion = undefined; - } else { - this.key = undefined; - this.encryptedKey = keyOrEncryptedKey.key; - this.sharedKey = keyOrEncryptedKey.sharedKey; - this.tunnelVersion = keyOrEncryptedKey.tunnelVersion; - } - } - - static async BuildForTunnel(tunnel: CommunicationTunnel, masterKey: SymmetricCryptoKey) { - switch (tunnel.tunnelVersion) { - case TunnelVersion.CLEAR_TEXT: - return new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64); - case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: - return new KeyConnectorSetUserKeyRequest({ - key: await tunnel.protect(masterKey.encKey), - sharedKey: tunnel.encapsulatedKey.dataBytes, - tunnelVersion: tunnel.tunnelVersion, - }); - default: - } - } + constructor(readonly key: string) {} } export class KeyConnectorGetUserKeyRequest { - /** - * The key to use in encrypting the response, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey} - */ - readonly sharedKey: Uint8Array; - /** - * The version of communication to use in encrypting the response - */ - readonly tunnelVersion: TunnelVersion; - /** - * - * @fixme Once key connector server have been updated, this constructor should require a shared key and ApiService should drop support of the old GET request. - * - * @param sharedKey The key to use in encrypting the response, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey}. - * @param tunnelVersion The version of communication to use in encrypting the response. - */ - constructor(tunneledCommunication?: { sharedKey: Uint8Array; tunnelVersion: TunnelVersion }) { - this.sharedKey = tunneledCommunication?.sharedKey; - this.tunnelVersion = tunneledCommunication?.tunnelVersion; - } - - static BuildForTunnel(tunnel: CommunicationTunnel) { - switch (tunnel.tunnelVersion) { - case TunnelVersion.CLEAR_TEXT: - return new KeyConnectorGetUserKeyRequest(); - case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: - return new KeyConnectorGetUserKeyRequest({ - sharedKey: tunnel.encapsulatedKey.dataBytes, - tunnelVersion: tunnel.tunnelVersion, - }); - default: - } - } + constructor(readonly userId: UserId) {} } diff --git a/libs/common/src/auth/models/response/init-tunnel.response.ts b/libs/common/src/auth/models/response/init-tunnel.response.ts index 3f9466daa9f..83168485df7 100644 --- a/libs/common/src/auth/models/response/init-tunnel.response.ts +++ b/libs/common/src/auth/models/response/init-tunnel.response.ts @@ -4,10 +4,14 @@ import { TunnelVersion } from "../../../platform/communication-tunnel/communicat export class InitTunnelResponse extends BaseResponse { readonly encapsulationKey: Uint8Array; readonly tunnelVersion: TunnelVersion; + readonly tunnelIdentifier: string; + readonly tunnelDurationSeconds: number; constructor(response: any) { super(response); this.encapsulationKey = new Uint8Array(this.getResponseProperty("EncapsulationKey")); this.tunnelVersion = this.getResponseProperty("TunnelVersion"); + this.tunnelIdentifier = this.getResponseProperty("TunnelIdentifier"); + this.tunnelDurationSeconds = this.getResponseProperty("TunnelDurationSeconds"); } } diff --git a/libs/common/src/auth/models/response/key-connector-get-user-key.response.ts b/libs/common/src/auth/models/response/key-connector-get-user-key.response.ts new file mode 100644 index 00000000000..6731136100e --- /dev/null +++ b/libs/common/src/auth/models/response/key-connector-get-user-key.response.ts @@ -0,0 +1,10 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class KeyConnectorGetUserKeyResponse extends BaseResponse { + key: string; + + constructor(response: any) { + super(response); + this.key = this.getResponseProperty("Key"); + } +} diff --git a/libs/common/src/auth/models/response/key-connector-user-key.response.ts b/libs/common/src/auth/models/response/key-connector-user-key.response.ts deleted file mode 100644 index 87da614c1e8..00000000000 --- a/libs/common/src/auth/models/response/key-connector-user-key.response.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -export class KeyConnectorGetUserKeyResponse extends BaseResponse { - // Base64 encoded key. This is present only in the clear text version of the response. - key: string; - // AES-256-GCM encrypted key. This does not exist in EncString and so is not sent or parsed as one. - // Expected format is data + tag + iv - encryptedKey: Uint8Array; - tunnelVersion: KeyConnectorGetUserKeyResponse; - - constructor(response: any) { - super(response); - this.key = this.getResponseProperty("Key"); - const responseEncryptedKey = this.getResponseProperty("EncryptedKey"); - if (responseEncryptedKey) { - this.encryptedKey = new Uint8Array(responseEncryptedKey); - this.tunnelVersion = this.getResponseProperty("TunnelVersion"); - } - } -} diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts index 43a0c259eac..b2094ab3ef4 100644 --- a/libs/common/src/auth/services/key-connector.service.spec.ts +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -5,7 +5,6 @@ import { FakeAccountService, FakeStateProvider, makeEncString, - makeStaticByteArray, makeSymmetricCryptoKey, mockAccountServiceWith, } from "../../../spec"; @@ -27,7 +26,7 @@ import { OrganizationId, UserId } from "../../types/guid"; import { MasterKey, UserKey } from "../../types/key"; import { KeyConnectorSetUserKeyRequest } from "../models/request/key-connector-user-key.request"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; -import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-user-key.response"; +import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-get-user-key.response"; import { USES_KEY_CONNECTOR, @@ -61,16 +60,16 @@ describe("KeyConnectorService", () => { "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==", ), ) as MasterKey; - let mockMasterKeyResponse: KeyConnectorGetUserKeyResponse; + let mockRawMasterKeyResponse: { + key: string; + }; beforeEach(() => { jest.clearAllMocks(); - mockMasterKeyResponse = new KeyConnectorGetUserKeyResponse({ + mockRawMasterKeyResponse = { key: mockClearTextMasterKey.keyB64, - encryptedKey: makeStaticByteArray(32), - tunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - }); + }; masterPasswordService = new FakeMasterPasswordService(); accountService = mockAccountServiceWith(mockUserId); @@ -228,10 +227,13 @@ describe("KeyConnectorService", () => { }); describe("setMasterKeyFromUrl", () => { - const tunnel = mock(); + const tunnel = mock>(); beforeEach(() => { communicationTunnelService.createTunnel.mockResolvedValue(tunnel); - apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse); + tunnel.unprotect.mockResolvedValue( + new KeyConnectorGetUserKeyResponse(mockRawMasterKeyResponse), + ); }); afterEach(() => { @@ -260,14 +262,16 @@ describe("KeyConnectorService", () => { it("should set unprotect and set the master key", async () => { // Arrange const url = "https://key-connector-url.com"; - mockMasterKeyResponse.key = null; - tunnel.unprotect.mockResolvedValue(mockClearTextMasterKey.key); + mockRawMasterKeyResponse.key = null; // Act await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); // Assert - expect(tunnel.unprotect).toHaveBeenCalledWith(mockMasterKeyResponse.encryptedKey); + expect(tunnel.unprotect).toHaveBeenCalledWith( + KeyConnectorGetUserKeyResponse, + mockRawMasterKeyResponse, + ); expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( mockClearTextMasterKey, mockUserId, @@ -278,7 +282,7 @@ describe("KeyConnectorService", () => { // Arrange const url = "https://key-connector-url.com"; - apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse); // Act await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); @@ -307,10 +311,12 @@ describe("KeyConnectorService", () => { }); describe("migrateUser()", () => { - const tunnel = mock(); + const tunnel = mock>(); + const keyConnectorRequest = new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.keyB64); beforeEach(() => { communicationTunnelService.createTunnel.mockResolvedValue(tunnel); - apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse); + tunnel.protect.mockResolvedValue(keyConnectorRequest); }); afterEach(() => { @@ -338,10 +344,6 @@ describe("KeyConnectorService", () => { const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); masterPasswordService.masterKeySubject.next(masterKey); - const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( - tunnel, - masterKey, - ); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); @@ -358,15 +360,11 @@ describe("KeyConnectorService", () => { it("should set protect and share the master key", async () => { // Arrange - mockMasterKeyResponse.key = null; - tunnel.unprotect.mockResolvedValue(mockClearTextMasterKey.key); + mockRawMasterKeyResponse.key = null; + tunnel.unprotectBytes.mockResolvedValue(mockClearTextMasterKey.key); const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = mockClearTextMasterKey; masterPasswordService.masterKeySubject.next(masterKey); - const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( - tunnel, - masterKey, - ); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); @@ -386,10 +384,6 @@ describe("KeyConnectorService", () => { const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); masterPasswordService.masterKeySubject.next(masterKey); - const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( - tunnel, - masterKey, - ); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); @@ -410,10 +404,6 @@ describe("KeyConnectorService", () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); - const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( - tunnel, - masterKey, - ); const error = new Error("Failed to post user key to key connector"); organizationService.getAll.mockResolvedValue([organization]); @@ -437,7 +427,7 @@ describe("KeyConnectorService", () => { }); describe("convertNewSsoUserToKeyConnector()", () => { - const tunnel = mock(); + const tunnel = mock>(); const mockUserKey = makeSymmetricCryptoKey(64) as UserKey; const mockEncryptedUserKey = makeEncString("encryptedUserKey"); const pubKey = "pubKey"; @@ -454,7 +444,10 @@ describe("KeyConnectorService", () => { beforeEach(() => { communicationTunnelService.createTunnel.mockResolvedValue(tunnel); - apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse); + tunnel.protect.mockResolvedValue( + new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.keyB64), + ); keyGenerationService.createKey.mockResolvedValue(mockClearTextMasterKey); keyService.makeMasterKey.mockResolvedValue(mockClearTextMasterKey); @@ -478,13 +471,7 @@ describe("KeyConnectorService", () => { expect(communicationTunnelService.createTunnel).toHaveBeenCalled(); }); - it("should post user key to key connector", async () => { - // Arrange - const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( - tunnel, - mockClearTextMasterKey, - ); - + it("should post user key to key connector through the tunnel", async () => { // Act await keyConnectorService.convertNewSsoUserToKeyConnector( tokenResponse, @@ -493,6 +480,9 @@ describe("KeyConnectorService", () => { ); // Assert + expect(tunnel.protect).toHaveBeenCalledWith( + new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.encKeyB64), + ); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( tokenResponse.userDecryptionOptions.keyConnectorOption.keyConnectorUrl, keyConnectorRequest, @@ -580,7 +570,7 @@ describe("KeyConnectorService", () => { } function getMockMasterKey(): MasterKey { - const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); + const keyArr = Utils.fromB64ToArray(mockRawMasterKeyResponse.key); const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; return masterKey; } diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index 9c222c5b669..4d52cb75b9e 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -12,6 +12,7 @@ import { KeyGenerationService } from "../../platform/abstractions/key-generation import { LogService } from "../../platform/abstractions/log.service"; import { TunnelVersion } from "../../platform/communication-tunnel/communication-tunnel"; import { CommunicationTunnelService } from "../../platform/communication-tunnel/communication-tunnel.service"; +import { TunneledRequest } from "../../platform/communication-tunnel/tunneled.request"; import { KdfType } from "../../platform/enums/kdf-type.enum"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -34,6 +35,7 @@ import { } from "../models/request/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-get-user-key.response"; const SUPPORTED_COMMUNICATION_VERSIONS = [ TunnelVersion.CLEAR_TEXT, @@ -109,7 +111,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { await this.apiService.postUserKeyToKeyConnector( organization.keyConnectorUrl, - await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey), + await tunnel.protect(new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64)), ); } catch (e) { this.handleKeyConnectorError(e); @@ -126,14 +128,14 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { SUPPORTED_COMMUNICATION_VERSIONS, ); - const response = await this.apiService.getMasterKeyFromKeyConnector( + const rawResponse = await this.apiService.getMasterKeyFromKeyConnector( url, - KeyConnectorGetUserKeyRequest.BuildForTunnel(tunnel), + await tunnel.protect(new KeyConnectorGetUserKeyRequest(userId)), ); - // Decrypt the response with the shared key - const masterKeyArray = - Utils.fromB64ToArray(response.key) ?? (await tunnel.unprotect(response.encryptedKey)); + const response = await tunnel.unprotect(KeyConnectorGetUserKeyResponse, rawResponse); + + const masterKeyArray = Utils.fromB64ToArray(response.key); const masterKey = new SymmetricCryptoKey(masterKeyArray) as MasterKey; await this.masterPasswordService.setMasterKey(masterKey, userId); @@ -182,14 +184,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { kdfConfig, ); - let keyConnectorRequest: KeyConnectorSetUserKeyRequest; + let keyConnectorRequest: TunneledRequest; try { const tunnel = await this.communicationTunnelService.createTunnel( keyConnectorUrl, SUPPORTED_COMMUNICATION_VERSIONS, ); - keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey); + keyConnectorRequest = await tunnel.protect( + new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64), + ); } catch (e) { this.handleKeyConnectorError(e); } diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts index 59ef182c3a4..d0ca222b92a 100644 --- a/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.service.ts @@ -11,10 +11,10 @@ export abstract class CommunicationTunnelService { * @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( + abstract createTunnel( url: string, - supportedTunnelVersions: TunnelVersion[], - ): Promise; + supportedTunnelVersions: SupportedTunnelVersions, + ): Promise>; } export class DefaultCommunicationTunnelService implements CommunicationTunnelService { @@ -23,10 +23,10 @@ export class DefaultCommunicationTunnelService implements CommunicationTunnelSer private readonly keyGenerationService: KeyGenerationService, private readonly encryptService: EncryptService, ) {} - async createTunnel( + async createTunnel( url: string, - supportedTunnelVersions: TunnelVersion[], - ): Promise { + supportedTunnelVersions: SupportedTunnelVersions, + ): Promise> { const tunnel = new CommunicationTunnel( this.apiService, this.keyGenerationService, diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts index 9740c2aecf0..a47e7297587 100644 --- a/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts @@ -3,194 +3,166 @@ 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 { BaseResponse } from "../../models/response/base.response"; import { EncryptService } from "../abstractions/encrypt.service"; import { KeyGenerationService } from "../abstractions/key-generation.service"; +import { Utils } from "../misc/utils"; import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel"; +import { TunneledRequest } from "./tunneled.request"; -describe("communicationTunnel", () => { +class TestRequest { + constructor(readonly value: string) {} +} + +class TestResponse extends BaseResponse { + readonly value: string; + constructor(response: any) { + super(response); + this.value = this.getResponseProperty("Value"); + } +} + +describe("communicationTunnel with cleartext", () => { const url = "http://key-connector.example"; let apiService: MockProxy; let keyGenerationService: MockProxy; let encryptService: MockProxy; - let sut: CommunicationTunnel; + const supportedTunnelVersions = [ + TunnelVersion.CLEAR_TEXT, + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + ] as const; + let sut: CommunicationTunnel; const sharedKey = makeSymmetricCryptoKey(32); const encapsulationKey = makeStaticByteArray(32, 1); const encapsulatedKey = makeEncString("encapsulatedKey"); + const tunnelResponse = new InitTunnelResponse({ + EncapsulationKey: encapsulationKey, + TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + TunnelIdentifier: "tunnelIdentifier", + TunnelDurationSeconds: 60, + }); beforeEach(() => { apiService = mock(); keyGenerationService = mock(); encryptService = mock(); - apiService.initCommunicationTunnel.mockResolvedValue( - new InitTunnelResponse({ - EncapsulationKey: encapsulationKey, - TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - }), - ); + apiService.initCommunicationTunnel.mockResolvedValue(tunnelResponse); keyGenerationService.createKey.mockResolvedValue(sharedKey); encryptService.rsaEncrypt.mockResolvedValue(encapsulatedKey); + + sut = new CommunicationTunnel( + apiService, + keyGenerationService, + encryptService, + supportedTunnelVersions, + ); }); - describe("negotiateTunnel", () => { - it("negotiates with the provided server", async () => { - const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM]; - sut = new CommunicationTunnel( - apiService, - keyGenerationService, - encryptService, - supportedTunnelVersions, - ); + it("negotiates with the provided server", async () => { + await sut.negotiateTunnel(url); - await sut.negotiateTunnel(url); + expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith( + url, + expect.objectContaining({ + supportedTunnelVersions: expect.arrayContaining([TunnelVersion.CLEAR_TEXT]), + }), + ); + expect(sut.tunnelVersion).toBe(TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM); + }); - expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith( - url, - expect.objectContaining({ supportedTunnelVersions }), - ); + it("allows cleartext requests", async () => { + await sut.negotiateTunnel(url); + + // force to clear text tunnel + sut["_tunnelVersion"] = TunnelVersion.CLEAR_TEXT; + + const request = new TestRequest("test"); + const protectedRequest = (await sut.protect(request)) as TestRequest; + expect(protectedRequest).toBe(request); + }); + + it("encrypts the request", async () => { + const encryptedData = makeStaticByteArray(32, 3); + encryptService.aesGcmEncryptToBytes.mockResolvedValue(encryptedData); + + await sut.negotiateTunnel(url); + + const request = new TestRequest("test"); + const protectedRequest = (await sut.protect(request)) as TunneledRequest; + expect(protectedRequest).toEqual({ + encryptedData: Utils.fromBufferToB64(encryptedData), + encapsulatedKey: Utils.fromBufferToB64(encapsulatedKey.dataBytes), + tunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + tunnelIdentifier: tunnelResponse.tunnelIdentifier, }); + }); - it("generates a shared key", async () => { - sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ - TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - ]); + it("handles cleartext responses", async () => { + await sut.negotiateTunnel(url); - await sut.negotiateTunnel(url); + // force to clear text tunnel + sut["_tunnelVersion"] = TunnelVersion.CLEAR_TEXT; - expect(keyGenerationService.createKey).toHaveBeenCalledWith(256); - }); + const response = { Value: "test" }; + const unprotectedResponse = await sut.unprotect(TestResponse, response); - it("encapsulates the shared key", async () => { - sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ - TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - ]); + expect(unprotectedResponse).toEqual(new TestResponse(response)); + }); - 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], - TunnelVersion: TunnelVersion[tunnelVersion], - }), - ); - - await sut.negotiateTunnel(url); - - expect(sut.tunnelVersion).toBe(TunnelVersion[tunnelVersion]); - }, + it("decrypts the response", async () => { + const clearTextResponse = { Value: "test" }; + const encryptedData = "encryptedData"; + encryptService.aesGcmDecryptToBytes.mockResolvedValue( + Utils.fromUtf8ToArray(JSON.stringify(clearTextResponse)), ); - it("throws an error if the communication version is not supported", async () => { - sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ - TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - ]); + await sut.negotiateTunnel(url); - apiService.initCommunicationTunnel.mockResolvedValue( - new InitTunnelResponse({ - EncapsulationKey: [1, 2, 3], - TunnelVersion: TunnelVersion.CLEAR_TEXT, - }), - ); + const response = { EncryptedResponse: encryptedData }; + const unprotectedResponse = await sut.unprotect(TestResponse, response); - await expect(sut.negotiateTunnel(url)).rejects.toThrow("Unsupported communication version"); - }); + expect(encryptService.aesGcmDecryptToBytes).toHaveBeenCalledWith( + Utils.fromB64ToArray(encryptedData), + expect.any(Uint8Array), + expect.any(Uint8Array), + ); + expect(unprotectedResponse).toEqual(new TestResponse(clearTextResponse)); + }); +}); + +describe("communicationTunnel disallows cleartext", () => { + let apiService: MockProxy; + let keyGenerationService: MockProxy; + let encryptService: MockProxy; + const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM] as const; + let sut: CommunicationTunnel; + + beforeEach(() => { + apiService = mock(); + keyGenerationService = mock(); + encryptService = mock(); + + sut = new CommunicationTunnel( + apiService, + keyGenerationService, + encryptService, + supportedTunnelVersions, + ); }); - describe("tunnel encryption", () => { - const clearText = makeStaticByteArray(32, 2); - const protectedText = makeStaticByteArray(32, 3); + describe("protect", () => { + it("returns only a tunneled request", async () => { + return; // this is a compiler test - beforeEach(() => { - encryptService.aesGcmEncryptToBytes.mockResolvedValue(protectedText); - encryptService.aesGcmDecryptToBytes.mockResolvedValue(clearText); - }); + const request = new TestRequest("test"); - it("throws an error if the tunnel is not initialized", async () => { - sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [ - TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, - ]); + // @ts-expect-error -- this cast doesn't work because CLEAR_TEXT is not in the supported tunnel versions, so a TunneledRequest is always returned + (await sut.protect(request)) as TestRequest; - 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({ - TunnelVersion: 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, - TunnelVersion: 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])), - ); - }); - }); + // @ts-expect-no-error -- Must be a TunneledRequest + (await sut.protect(request)) as TunneledRequest; }); }); }); diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts index fb4307b52c8..b9259b73519 100644 --- a/libs/common/src/platform/communication-tunnel/communication-tunnel.ts +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts @@ -1,11 +1,17 @@ +import type { Includes, JsonObject } from "type-fest"; + 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 { Utils } from "../misc/utils"; import { EncString } from "../models/domain/enc-string"; +import { TunneledRequest } from "./tunneled.request"; +import { TunneledResponse } from "./tunneled.response"; + export enum TunnelVersion { CLEAR_TEXT = 0, /** @@ -17,7 +23,12 @@ export enum TunnelVersion { RSA_ENCAPSULATED_AES_256_GCM = 1, } -export class CommunicationTunnel { +type SupportedRequestTypes = + Includes extends true + ? TunneledRequest | TRequest + : TunneledRequest; + +export class CommunicationTunnel { private negotiationComplete: boolean = false; private sharedKey: Uint8Array; private _encapsulatedKey: EncString; @@ -28,12 +39,16 @@ export class CommunicationTunnel { get tunnelVersion(): TunnelVersion { return this._tunnelVersion; } + private _tunnelIdentifier: string; + get tunnelIdentifier(): string { + return this._tunnelIdentifier; + } constructor( private readonly apiService: ApiService, private readonly keyGenerationService: KeyGenerationService, private readonly encryptService: EncryptService, - private readonly supportedTunnelVersions: TunnelVersion[], + private readonly supportedTunnelVersions: TSupportedTunnelVersions, ) {} /** @@ -69,7 +84,49 @@ export class CommunicationTunnel { return await this.initWithVersion(response); } - async protect(clearText: Uint8Array): Promise { + async protect( + request: TRequest, + ): Promise> { + if (!this.negotiationComplete) { + throw new Error("Communication tunnel not initialized"); + } + + if (this.tunnelVersion === TunnelVersion.CLEAR_TEXT) { + // Should only be possible if the tunnel version is clear text + return request as SupportedRequestTypes; + } + + const requestBytes = Utils.fromUtf8ToArray(JSON.stringify(request)); + const protectedText = await this.protectBytes(requestBytes); + return new TunneledRequest( + protectedText, + this.encapsulatedKey, + this.tunnelVersion, + this.tunnelIdentifier, + ); + } + + async unprotect( + responseConstructor: new (response: any) => TResponse, + responseData: JsonObject, + ): Promise { + if (!this.negotiationComplete) { + throw new Error("Communication tunnel not initialized"); + } + + if (this.tunnelVersion === TunnelVersion.CLEAR_TEXT) { + return new responseConstructor(responseData); + } + + const tunneledResponse = new TunneledResponse(responseData); + const clearBytes = await this.unprotectBytes( + Utils.fromB64ToArray(tunneledResponse.encryptedResponse), + ); + const clearText = Utils.fromBufferToUtf8(clearBytes); + return new responseConstructor(JSON.parse(clearText)); + } + + async protectBytes(clearText: Uint8Array): Promise { if (!this.negotiationComplete) { throw new Error("Communication tunnel not initialized"); } @@ -92,7 +149,7 @@ export class CommunicationTunnel { return protectedText; } - async unprotect(protectedText: Uint8Array): Promise { + async unprotectBytes(protectedText: Uint8Array): Promise { if (!this.negotiationComplete) { throw new Error("Communication tunnel not initialized"); } @@ -126,6 +183,7 @@ export class CommunicationTunnel { case TunnelVersion.CLEAR_TEXT: break; case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: { + this._tunnelIdentifier = response.tunnelIdentifier; const encapsulationKey = response.encapsulationKey; // use the encapsulation key to create an share a shared key diff --git a/libs/common/src/platform/communication-tunnel/tunneled.request.ts b/libs/common/src/platform/communication-tunnel/tunneled.request.ts new file mode 100644 index 00000000000..8c51831d63a --- /dev/null +++ b/libs/common/src/platform/communication-tunnel/tunneled.request.ts @@ -0,0 +1,45 @@ +import { Utils } from "../misc/utils"; +import { EncString } from "../models/domain/enc-string"; + +import { TunnelVersion } from "./communication-tunnel"; + +declare const marker: unique symbol; + +export class TunneledRequest { + [marker]: RequestType; + + readonly encryptedData: string; + readonly encapsulatedKey: string; + + constructor( + encryptedData: Uint8Array, + encapsulatedKey: EncString, + readonly tunnelVersion: TunnelVersion, + readonly tunnelIdentifier: string, + ) { + if (encryptedData == null) { + throw new Error("encryptedData is required"); + } + if (encapsulatedKey == null) { + throw new Error("encapsulatedKey is required"); + } + if (tunnelVersion == null) { + throw new Error("tunnelVersion is required"); + } + if (tunnelIdentifier == null) { + throw new Error("tunnelIdentifier is required"); + } + this.encapsulatedKey = Utils.fromBufferToB64(encapsulatedKey.dataBytes); + this.encryptedData = Utils.fromBufferToB64(encryptedData); + } +} + +export function isTunneledRequest( + request: any, +): request is TunneledRequest { + return ( + request.encryptedData !== undefined && + request.tunnelVersion !== undefined && + request.tunnelIdentifier !== undefined + ); +} diff --git a/libs/common/src/platform/communication-tunnel/tunneled.response.ts b/libs/common/src/platform/communication-tunnel/tunneled.response.ts new file mode 100644 index 00000000000..c28121cbd04 --- /dev/null +++ b/libs/common/src/platform/communication-tunnel/tunneled.response.ts @@ -0,0 +1,13 @@ +import { BaseResponse } from "../../models/response/base.response"; + +declare const marker: unique symbol; + +export class TunneledResponse extends BaseResponse { + [marker]: TResponse; + readonly encryptedResponse: string; + + constructor(response: any) { + super(response); + this.encryptedResponse = this.getResponseProperty("EncryptedResponse"); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index c2fb9868994..d7cc7e3687c 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,4 +1,5 @@ import { firstValueFrom } from "rxjs"; +import type { JsonObject } from "type-fest"; import { CollectionRequest, @@ -82,7 +83,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { InitTunnelResponse } from "../auth/models/response/init-tunnel.response"; -import { KeyConnectorGetUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; import { PreloginResponse } from "../auth/models/response/prelogin.response"; import { RegisterResponse } from "../auth/models/response/register.response"; import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response"; @@ -131,6 +131,10 @@ import { AppIdService } from "../platform/abstractions/app-id.service"; import { EnvironmentService } from "../platform/abstractions/environment.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { + isTunneledRequest, + TunneledRequest, +} from "../platform/communication-tunnel/tunneled.request"; import { flagEnabled } from "../platform/misc/flags"; import { Utils } from "../platform/misc/utils"; import { SyncResponse } from "../platform/sync"; @@ -1499,26 +1503,15 @@ export class ApiService implements ApiServiceAbstraction { async getMasterKeyFromKeyConnector( keyConnectorUrl: string, - request: KeyConnectorGetUserKeyRequest, - ): Promise { + request: KeyConnectorGetUserKeyRequest | TunneledRequest, + ): Promise { const authHeader = await this.getActiveBearerToken(); // If shared key is null, we use the old GET endpoint to support older key connectors let response: Response; - if (request.sharedKey == null) { + if (isTunneledRequest(request)) { response = await this.fetch( - new Request(keyConnectorUrl + "/user-keys", { - cache: "no-store", - method: "GET", - headers: new Headers({ - Accept: "application/json", - Authorization: "Bearer " + authHeader, - }), - }), - ); - } else { - response = await this.fetch( - new Request(keyConnectorUrl + "/user-keys", { + new Request(keyConnectorUrl + "/user-keys/get-user-key", { cache: "no-store", method: "POST", headers: new Headers({ @@ -1529,6 +1522,17 @@ export class ApiService implements ApiServiceAbstraction { body: JSON.stringify(request), }), ); + } else { + response = await this.fetch( + new Request(keyConnectorUrl + "/user-keys", { + cache: "no-store", + method: "GET", + headers: new Headers({ + Accept: "application/json", + Authorization: "Bearer " + authHeader, + }), + }), + ); } if (response.status !== 200) { @@ -1536,7 +1540,7 @@ export class ApiService implements ApiServiceAbstraction { return Promise.reject(error); } - return new KeyConnectorGetUserKeyResponse(await response.json()); + return await response.json(); } async postUserKeyToKeyConnector(