diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index dec25ac60a0..9484887faad 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -44,7 +44,11 @@ import { PasswordTokenRequest } from "../auth/models/request/identity-token/pass import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request"; import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request"; import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request"; -import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request"; +import { InitTunnelRequest } from "../auth/models/request/init-tunnel.request"; +import { + KeyConnectorGetUserKeyRequest, + KeyConnectorSetUserKeyRequest, +} from "../auth/models/request/key-connector-user-key.request"; import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordRequest } from "../auth/models/request/password.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; @@ -69,7 +73,8 @@ import { DeviceVerificationResponse } from "../auth/models/response/device-verif import { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; -import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.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"; @@ -497,10 +502,19 @@ export abstract class ApiService { ) => Promise; postResendSponsorshipOffer: (sponsoringOrgId: string) => Promise; - getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise; + /** + * Get the master key from the key connector. + * + * @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. + */ + getMasterKeyFromKeyConnector: ( + keyConnectorUrl: string, + request: KeyConnectorGetUserKeyRequest, + ) => Promise; postUserKeyToKeyConnector: ( keyConnectorUrl: string, - request: KeyConnectorUserKeyRequest, + request: KeyConnectorSetUserKeyRequest, ) => 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 new file mode 100644 index 00000000000..2c00e1eda60 --- /dev/null +++ b/libs/common/src/auth/models/request/init-tunnel.request.ts @@ -0,0 +1,5 @@ +import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel"; + +export class InitTunnelRequest { + constructor(readonly supportedTunnelVersions: TunnelVersion[]) {} +} diff --git a/libs/common/src/auth/models/request/key-connector-user-key.request.spec.ts b/libs/common/src/auth/models/request/key-connector-user-key.request.spec.ts new file mode 100644 index 00000000000..1e6d93ae630 --- /dev/null +++ b/libs/common/src/auth/models/request/key-connector-user-key.request.spec.ts @@ -0,0 +1,66 @@ +import { makeEncString, makeStaticByteArray, makeSymmetricCryptoKey } from "../../../../spec"; +import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel"; + +import { + KeyConnectorGetUserKeyRequest, + KeyConnectorSetUserKeyRequest, +} from "./key-connector-user-key.request"; + +describe("KeyConnectorSetUserKeyRequest", () => { + const masterKey = makeSymmetricCryptoKey(64); + const tunnel = { + protect: jest.fn(), + encapsulatedKey: makeEncString("encapsulatedKey"), + } as any; + const protectedKey = makeStaticByteArray(32, 100); + + it("creates a cleartext instance", async () => { + tunnel.tunnelVersion = TunnelVersion.CLEAR_TEXT; + + const request = await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey); + expect(request).toBeInstanceOf(KeyConnectorSetUserKeyRequest); + expect(request.key).toBe(masterKey.encKeyB64); + expect(request.encryptedKey).toBeUndefined(); + expect(request.sharedKey).toBeUndefined(); + expect(request.tunnelVersion).toBeUndefined(); + }); + + it("creates an encapsulated instance", async () => { + tunnel.tunnelVersion = TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM; + tunnel.protect.mockResolvedValue(protectedKey); + + const request = await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey); + expect(request).toBeInstanceOf(KeyConnectorSetUserKeyRequest); + expect(request.key).toBeUndefined(); + expect(request.sharedKey).toEqualBuffer(tunnel.encapsulatedKey.dataBytes); + expect(request.encryptedKey).toEqualBuffer(protectedKey); + expect(request.tunnelVersion).toBe(TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM); + + expect(tunnel.protect).toHaveBeenCalledWith(masterKey.encKey); + }); +}); + +describe("KeyConnectorGetUserKeyRequest", () => { + const tunnel = { + protect: jest.fn(), + encapsulatedKey: makeEncString("encapsulatedKey"), + } as any; + + it("creates a cleartext instance", async () => { + tunnel.tunnelVersion = TunnelVersion.CLEAR_TEXT; + const request = KeyConnectorGetUserKeyRequest.BuildForTunnel(tunnel); + + expect(request).toBeInstanceOf(KeyConnectorGetUserKeyRequest); + expect(request.tunnelVersion).toBeUndefined(); + expect(request.sharedKey).toBeUndefined(); + }); + + it("creates an encapsulated instance", async () => { + tunnel.tunnelVersion = TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM; + const request = KeyConnectorGetUserKeyRequest.BuildForTunnel(tunnel); + + expect(request).toBeInstanceOf(KeyConnectorGetUserKeyRequest); + expect(request.tunnelVersion).toBe(TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM); + expect(request.sharedKey).toEqualBuffer(tunnel.encapsulatedKey.dataBytes); + }); +}); 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 3df2db82cb4..3c35fa3fef4 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,7 +1,92 @@ -export class KeyConnectorUserKeyRequest { - key: string; +import { + TunnelVersion, + CommunicationTunnel, +} from "../../../platform/communication-tunnel/communication-tunnel"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; - constructor(key: string) { - this.key = key; +/** + * @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. + */ + 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: + } + } +} + +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: + } } } diff --git a/libs/common/src/auth/models/response/init-tunnel.response.ts b/libs/common/src/auth/models/response/init-tunnel.response.ts new file mode 100644 index 00000000000..3f9466daa9f --- /dev/null +++ b/libs/common/src/auth/models/response/init-tunnel.response.ts @@ -0,0 +1,13 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel"; + +export class InitTunnelResponse extends BaseResponse { + readonly encapsulationKey: Uint8Array; + readonly tunnelVersion: TunnelVersion; + + constructor(response: any) { + super(response); + this.encapsulationKey = new Uint8Array(this.getResponseProperty("EncapsulationKey")); + this.tunnelVersion = this.getResponseProperty("TunnelVersion"); + } +} 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 index 873f68a0067..87da614c1e8 100644 --- 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 @@ -1,10 +1,20 @@ import { BaseResponse } from "../../../models/response/base.response"; -export class KeyConnectorUserKeyResponse extends BaseResponse { +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 b1bf87693c1..43a0c259eac 100644 --- a/libs/common/src/auth/services/key-connector.service.spec.ts +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -1,20 +1,33 @@ import { mock } from "jest-mock-extended"; import { KeyService } from "../../../../key-management/src/abstractions/key.service"; -import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; +import { + FakeAccountService, + FakeStateProvider, + makeEncString, + makeStaticByteArray, + makeSymmetricCryptoKey, + mockAccountServiceWith, +} from "../../../spec"; import { ApiService } from "../../abstractions/api.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationData } from "../../admin-console/models/data/organization.data"; import { Organization } from "../../admin-console/models/domain/organization"; import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { LogService } from "../../platform/abstractions/log.service"; +import { + CommunicationTunnel, + TunnelVersion, +} from "../../platform/communication-tunnel/communication-tunnel"; +import { CommunicationTunnelService } from "../../platform/communication-tunnel/communication-tunnel.service"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { KeyGenerationService } from "../../platform/services/key-generation.service"; import { OrganizationId, UserId } from "../../types/guid"; -import { MasterKey } from "../../types/key"; -import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; -import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response"; +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 { USES_KEY_CONNECTOR, @@ -33,6 +46,7 @@ describe("KeyConnectorService", () => { const logService = mock(); const organizationService = mock(); const keyGenerationService = mock(); + const communicationTunnelService = mock(); let stateProvider: FakeStateProvider; @@ -42,13 +56,22 @@ describe("KeyConnectorService", () => { const mockUserId = Utils.newGuid() as UserId; const mockOrgId = Utils.newGuid() as OrganizationId; - const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({ - key: "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==", - }); + const mockClearTextMasterKey = new SymmetricCryptoKey( + Utils.fromB64ToArray( + "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==", + ), + ) as MasterKey; + let mockMasterKeyResponse: KeyConnectorGetUserKeyResponse; beforeEach(() => { jest.clearAllMocks(); + mockMasterKeyResponse = new KeyConnectorGetUserKeyResponse({ + key: mockClearTextMasterKey.keyB64, + encryptedKey: makeStaticByteArray(32), + tunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + }); + masterPasswordService = new FakeMasterPasswordService(); accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); @@ -62,6 +85,7 @@ describe("KeyConnectorService", () => { logService, organizationService, keyGenerationService, + communicationTunnelService, async () => {}, stateProvider, ); @@ -204,24 +228,65 @@ describe("KeyConnectorService", () => { }); describe("setMasterKeyFromUrl", () => { + const tunnel = mock(); + beforeEach(() => { + communicationTunnelService.createTunnel.mockResolvedValue(tunnel); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should negotiate an encrypted communication tunnel with Key Connector", async () => { + await keyConnectorService.setMasterKeyFromUrl("https://key-connector-url.com", mockUserId); + expect(communicationTunnelService.createTunnel).toHaveBeenCalled(); + }); + + it("should downgrade to cleartext communication if Key Connector does not support encryption", async () => { + // Arrange + const url = "https://key-connector-url.com"; + + // Act + await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); + + // Assert + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockClearTextMasterKey, + mockUserId, + ); + }); + + 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); + + // Act + await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); + + // Assert + expect(tunnel.unprotect).toHaveBeenCalledWith(mockMasterKeyResponse.encryptedKey); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockClearTextMasterKey, + mockUserId, + ); + }); + it("should set the master key from the provided URL", async () => { // Arrange const url = "https://key-connector-url.com"; apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); - // Hard to mock these, but we can generate the same keys - const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); - const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; - // Act await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); // Assert - expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( - masterKey, - expect.any(String), + mockClearTextMasterKey, + mockUserId, ); }); @@ -233,24 +298,98 @@ describe("KeyConnectorService", () => { apiService.getMasterKeyFromKeyConnector.mockRejectedValue(error); jest.spyOn(logService, "error"); - try { - // Act - await keyConnectorService.setMasterKeyFromUrl(url, mockUserId); - } catch { - // Assert - expect(logService.error).toHaveBeenCalledWith(error); - expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); - } + await expect(() => keyConnectorService.setMasterKeyFromUrl(url, mockUserId)).rejects.toThrow( + "Key Connector error", + ); + + expect(logService.error).toHaveBeenCalledWith(error); }); }); describe("migrateUser()", () => { + const tunnel = mock(); + beforeEach(() => { + communicationTunnelService.createTunnel.mockResolvedValue(tunnel); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should negotiate an encrypted communication tunnel with Key Connector", async () => { + // Arrange + const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); + const masterKey = getMockMasterKey(); + masterPasswordService.masterKeySubject.next(masterKey); + + jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); + jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); + + // Act + await keyConnectorService.migrateUser(mockUserId); + + // Assert + expect(communicationTunnelService.createTunnel).toHaveBeenCalled(); + }); + + it("should downgrade to cleartext communication if Key Connector does not support encryption", async () => { + // Arrange + 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(); + + // Act + await keyConnectorService.migrateUser(mockUserId); + + // Assert + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + }); + + it("should set protect and share the master key", async () => { + // Arrange + mockMasterKeyResponse.key = null; + tunnel.unprotect.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(); + + // Act + await keyConnectorService.migrateUser(mockUserId); + + // Assert + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + }); + it("should migrate the user to the key connector", async () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); masterPasswordService.masterKeySubject.next(masterKey); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( + tunnel, + masterKey, + ); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); @@ -271,7 +410,10 @@ describe("KeyConnectorService", () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( + tunnel, + masterKey, + ); const error = new Error("Failed to post user key to key connector"); organizationService.getAll.mockResolvedValue([organization]); @@ -293,6 +435,70 @@ describe("KeyConnectorService", () => { ); } }); + + describe("convertNewSsoUserToKeyConnector()", () => { + const tunnel = mock(); + const mockUserKey = makeSymmetricCryptoKey(64) as UserKey; + const mockEncryptedUserKey = makeEncString("encryptedUserKey"); + const pubKey = "pubKey"; + const privKey = makeEncString("privKey"); + const tokenResponse = { + kdf: "pbkdf2", + kdfIterations: 100000, + userDecryptionOptions: { + keyConnectorOption: { + keyConnectorUrl: "https://key-connector-url.com", + }, + }, + } as any as IdentityTokenResponse; + + beforeEach(() => { + communicationTunnelService.createTunnel.mockResolvedValue(tunnel); + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + + keyGenerationService.createKey.mockResolvedValue(mockClearTextMasterKey); + keyService.makeMasterKey.mockResolvedValue(mockClearTextMasterKey); + keyService.makeUserKey.mockResolvedValue([mockUserKey, mockEncryptedUserKey]); + keyService.makeKeyPair.mockResolvedValue([pubKey, privKey]); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should negotiate an encrypted communication tunnel with Key Connector", async () => { + // Act + await keyConnectorService.convertNewSsoUserToKeyConnector( + tokenResponse, + mockOrgId, + mockUserId, + ); + + // Assert + expect(communicationTunnelService.createTunnel).toHaveBeenCalled(); + }); + + it("should post user key to key connector", async () => { + // Arrange + const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel( + tunnel, + mockClearTextMasterKey, + ); + + // Act + await keyConnectorService.convertNewSsoUserToKeyConnector( + tokenResponse, + mockOrgId, + mockUserId, + ); + + // Assert + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + tokenResponse.userDecryptionOptions.keyConnectorOption.keyConnectorUrl, + keyConnectorRequest, + ); + }); + }); }); function organizationData( diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index 111f82e6e52..9c222c5b669 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -10,6 +10,8 @@ import { Organization } from "../../admin-console/models/domain/organization"; import { KeysRequest } from "../../models/request/keys.request"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; 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 { KdfType } from "../../platform/enums/kdf-type.enum"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -26,10 +28,18 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstra import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { Argon2KdfConfig, KdfConfig, PBKDF2KdfConfig } from "../models/domain/kdf-config"; -import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; +import { + KeyConnectorGetUserKeyRequest, + KeyConnectorSetUserKeyRequest, +} 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"; +const SUPPORTED_COMMUNICATION_VERSIONS = [ + TunnelVersion.CLEAR_TEXT, + TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, +]; + export const USES_KEY_CONNECTOR = new UserKeyDefinition( KEY_CONNECTOR_DISK, "usesKeyConnector", @@ -60,6 +70,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private logService: LogService, private organizationService: OrganizationService, private keyGenerationService: KeyGenerationService, + private communicationTunnelService: CommunicationTunnelService, private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise, private stateProvider: StateProvider, ) { @@ -89,12 +100,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; const organization = await this.getManagingOrganization(userId); const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); try { + const tunnel = await this.communicationTunnelService.createTunnel( + organization.keyConnectorUrl, + SUPPORTED_COMMUNICATION_VERSIONS, + ); + await this.apiService.postUserKeyToKeyConnector( organization.keyConnectorUrl, - keyConnectorRequest, + await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey), ); } catch (e) { this.handleKeyConnectorError(e); @@ -106,9 +121,21 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { // TODO: UserKey should be renamed to MasterKey and typed accordingly async setMasterKeyFromUrl(url: string, userId: UserId) { try { - const masterKeyResponse = await this.apiService.getMasterKeyFromKeyConnector(url); - const keyArr = Utils.fromB64ToArray(masterKeyResponse.key); - const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + const tunnel = await this.communicationTunnelService.createTunnel( + url, + SUPPORTED_COMMUNICATION_VERSIONS, + ); + + const response = await this.apiService.getMasterKeyFromKeyConnector( + url, + KeyConnectorGetUserKeyRequest.BuildForTunnel(tunnel), + ); + + // Decrypt the response with the shared key + const masterKeyArray = + Utils.fromB64ToArray(response.key) ?? (await tunnel.unprotect(response.encryptedKey)); + + const masterKey = new SymmetricCryptoKey(masterKeyArray) as MasterKey; await this.masterPasswordService.setMasterKey(masterKey, userId); } catch (e) { this.handleKeyConnectorError(e); @@ -140,6 +167,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { keyConnectorUrl: legacyKeyConnectorUrl, userDecryptionOptions, } = tokenResponse; + const keyConnectorUrl = + legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl; + const password = await this.keyGenerationService.createKey(512); const kdfConfig: KdfConfig = kdf === KdfType.PBKDF2_SHA256 @@ -151,7 +181,19 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { await this.tokenService.getEmail(), kdfConfig, ); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + + let keyConnectorRequest: KeyConnectorSetUserKeyRequest; + try { + const tunnel = await this.communicationTunnelService.createTunnel( + keyConnectorUrl, + SUPPORTED_COMMUNICATION_VERSIONS, + ); + + keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey); + } catch (e) { + this.handleKeyConnectorError(e); + } + await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.keyService.makeUserKey(masterKey); @@ -161,8 +203,6 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]); try { - const keyConnectorUrl = - legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl; await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest); } catch (e) { this.handleKeyConnectorError(e); 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 6984661d787..9740c2aecf0 100644 --- a/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.spec.ts @@ -26,7 +26,7 @@ describe("communicationTunnel", () => { apiService.initCommunicationTunnel.mockResolvedValue( new InitTunnelResponse({ EncapsulationKey: encapsulationKey, - CommunicationVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, }), ); keyGenerationService.createKey.mockResolvedValue(sharedKey); @@ -82,7 +82,7 @@ describe("communicationTunnel", () => { apiService.initCommunicationTunnel.mockResolvedValue( new InitTunnelResponse({ EncapsulationKey: [1, 2, 3], - CommunicationVersion: TunnelVersion[tunnelVersion], + TunnelVersion: TunnelVersion[tunnelVersion], }), ); @@ -100,7 +100,7 @@ describe("communicationTunnel", () => { apiService.initCommunicationTunnel.mockResolvedValue( new InitTunnelResponse({ EncapsulationKey: [1, 2, 3], - CommunicationVersion: TunnelVersion.CLEAR_TEXT, + TunnelVersion: TunnelVersion.CLEAR_TEXT, }), ); @@ -135,7 +135,7 @@ describe("communicationTunnel", () => { ]); apiService.initCommunicationTunnel.mockResolvedValue( new InitTunnelResponse({ - CommunicationVersion: TunnelVersion.CLEAR_TEXT, + TunnelVersion: TunnelVersion.CLEAR_TEXT, }), ); await sut.negotiateTunnel(url); @@ -163,7 +163,7 @@ describe("communicationTunnel", () => { apiService.initCommunicationTunnel.mockResolvedValue( new InitTunnelResponse({ EncapsulationKey: encapsulationKey, - CommunicationVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, + TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM, }), ); await sut.negotiateTunnel(url); diff --git a/libs/common/src/platform/communication-tunnel/communication-tunnel.ts b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts index 671e4cd78b8..fb4307b52c8 100644 --- a/libs/common/src/platform/communication-tunnel/communication-tunnel.ts +++ b/libs/common/src/platform/communication-tunnel/communication-tunnel.ts @@ -59,7 +59,7 @@ export class CommunicationTunnel { this.supportedTunnelVersions.includes(TunnelVersion.CLEAR_TEXT) ) { response = new InitTunnelResponse({ - CommunicationVersion: TunnelVersion.CLEAR_TEXT, + TunnelVersion: TunnelVersion.CLEAR_TEXT, }); } else { throw e; @@ -116,7 +116,7 @@ export class CommunicationTunnel { } private async initWithVersion(response: InitTunnelResponse): Promise { - this._tunnelVersion = response.communicationVersion; + this._tunnelVersion = response.tunnelVersion; if (!this.supportedTunnelVersions.includes(this.tunnelVersion)) { throw new Error("Unsupported communication version"); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 71915dbf14b..bd2924e1c6a 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -52,7 +52,11 @@ import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token import { TokenTwoFactorRequest } from "../auth/models/request/identity-token/token-two-factor.request"; import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request"; import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request"; -import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request"; +import { InitTunnelRequest } from "../auth/models/request/init-tunnel.request"; +import { + KeyConnectorGetUserKeyRequest, + KeyConnectorSetUserKeyRequest, +} from "../auth/models/request/key-connector-user-key.request"; import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordRequest } from "../auth/models/request/password.request"; import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request"; @@ -77,7 +81,8 @@ import { DeviceVerificationResponse } from "../auth/models/response/device-verif import { IdentityCaptchaResponse } from "../auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; -import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.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"; @@ -1494,31 +1499,49 @@ export class ApiService implements ApiServiceAbstraction { async getMasterKeyFromKeyConnector( keyConnectorUrl: string, - ): Promise { + request: KeyConnectorGetUserKeyRequest, + ): Promise { const authHeader = await this.getActiveBearerToken(); - const response = await this.fetch( - new Request(keyConnectorUrl + "/user-keys", { - cache: "no-store", - method: "GET", - headers: new Headers({ - Accept: "application/json", - Authorization: "Bearer " + authHeader, + // If shared key is null, we use the old GET endpoint to support older key connectors + let response: Response; + if (request.sharedKey == null) { + 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", { + cache: "no-store", + method: "POST", + headers: new Headers({ + Accept: "application/json", + Authorization: "Bearer " + authHeader, + "Content-Type": "application/json; charset=utf-8", + }), + body: JSON.stringify(request), + }), + ); + } if (response.status !== 200) { const error = await this.handleError(response, false, true); return Promise.reject(error); } - return new KeyConnectorUserKeyResponse(await response.json()); + return new KeyConnectorGetUserKeyResponse(await response.json()); } async postUserKeyToKeyConnector( keyConnectorUrl: string, - request: KeyConnectorUserKeyRequest, + request: KeyConnectorSetUserKeyRequest, ): Promise { const authHeader = await this.getActiveBearerToken();