mirror of
https://github.com/bitwarden/browser
synced 2026-02-12 14:34:02 +00:00
Use tunneled communications for key connector
This commit is contained in:
@@ -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<void>;
|
||||
postResendSponsorshipOffer: (sponsoringOrgId: string) => Promise<void>;
|
||||
|
||||
getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise<KeyConnectorUserKeyResponse>;
|
||||
/**
|
||||
* 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<KeyConnectorGetUserKeyResponse>;
|
||||
postUserKeyToKeyConnector: (
|
||||
keyConnectorUrl: string,
|
||||
request: KeyConnectorUserKeyRequest,
|
||||
request: KeyConnectorSetUserKeyRequest,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Negotiate a tunneled communication protocol with the supplied url.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel";
|
||||
|
||||
export class InitTunnelRequest {
|
||||
constructor(readonly supportedTunnelVersions: TunnelVersion[]) {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
libs/common/src/auth/models/response/init-tunnel.response.ts
Normal file
13
libs/common/src/auth/models/response/init-tunnel.response.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LogService>();
|
||||
const organizationService = mock<OrganizationService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const communicationTunnelService = mock<CommunicationTunnelService>();
|
||||
|
||||
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<CommunicationTunnel>();
|
||||
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<CommunicationTunnel>();
|
||||
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<CommunicationTunnel>();
|
||||
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(
|
||||
|
||||
@@ -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<boolean | null>(
|
||||
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<void>,
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TunnelVersion> {
|
||||
this._tunnelVersion = response.communicationVersion;
|
||||
this._tunnelVersion = response.tunnelVersion;
|
||||
|
||||
if (!this.supportedTunnelVersions.includes(this.tunnelVersion)) {
|
||||
throw new Error("Unsupported communication version");
|
||||
|
||||
@@ -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<KeyConnectorUserKeyResponse> {
|
||||
request: KeyConnectorGetUserKeyRequest,
|
||||
): Promise<KeyConnectorGetUserKeyResponse> {
|
||||
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<void> {
|
||||
const authHeader = await this.getActiveBearerToken();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user