1
0
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:
Matt Gibson
2024-11-19 16:36:19 -08:00
parent 7d6e742625
commit 88cce8cf91
11 changed files with 525 additions and 63 deletions

View File

@@ -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.

View File

@@ -0,0 +1,5 @@
import { TunnelVersion } from "../../../platform/communication-tunnel/communication-tunnel";
export class InitTunnelRequest {
constructor(readonly supportedTunnelVersions: TunnelVersion[]) {}
}

View File

@@ -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);
});
});

View File

@@ -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:
}
}
}

View 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");
}
}

View File

@@ -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");
}
}
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");

View File

@@ -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();