1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

Tunnels act on complete requests

This commit is contained in:
Matt Gibson
2024-11-20 19:21:20 -08:00
parent e114fdfcf1
commit 9649f80dd0
14 changed files with 339 additions and 331 deletions

View File

@@ -1,3 +1,5 @@
import type { JsonObject } from "type-fest";
import {
CollectionRequest,
CollectionAccessDetailsResponse,
@@ -74,7 +76,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { InitTunnelResponse } from "../auth/models/response/init-tunnel.response";
import { KeyConnectorGetUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -115,6 +116,7 @@ import { EventResponse } from "../models/response/event.response";
import { ListResponse } from "../models/response/list.response";
import { ProfileResponse } from "../models/response/profile.response";
import { UserKeyResponse } from "../models/response/user-key.response";
import { TunneledRequest } from "../platform/communication-tunnel/tunneled.request";
import { SyncResponse } from "../platform/sync";
import { UserId } from "../types/guid";
import { AttachmentRequest } from "../vault/models/request/attachment.request";
@@ -507,14 +509,15 @@ export abstract class ApiService {
*
* @param keyConnectorUrl The URL of the key connector.
* @param request The request to send to the key connector. If the shared key is null, falls back to an older GET endpoint that will respond in cleartext.
* @returns The raw json body of the response. The response must be passed through a tunnel to be decrypted.
*/
getMasterKeyFromKeyConnector: (
keyConnectorUrl: string,
request: KeyConnectorGetUserKeyRequest,
) => Promise<KeyConnectorGetUserKeyResponse>;
request: KeyConnectorGetUserKeyRequest | TunneledRequest<KeyConnectorGetUserKeyRequest>,
) => Promise<JsonObject>;
postUserKeyToKeyConnector: (
keyConnectorUrl: string,
request: KeyConnectorSetUserKeyRequest,
request: KeyConnectorSetUserKeyRequest | TunneledRequest<KeyConnectorSetUserKeyRequest>,
) => Promise<void>;
/**
* Negotiate a tunneled communication protocol with the supplied url.

View File

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

View File

@@ -1,92 +1,17 @@
import {
TunnelVersion,
CommunicationTunnel,
} from "../../../platform/communication-tunnel/communication-tunnel";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { UserId } from "../../../types/guid";
/**
* @typedef { import("../response/key-connector-init-communication.response").KeyConnectorInitCommunicationResponse } KeyConnectorInitCommunicationResponse
*/
export class KeyConnectorSetUserKeyRequest {
readonly key: string | null;
readonly encryptedKey: Uint8Array | null;
readonly sharedKey: Uint8Array | null;
readonly tunnelVersion: TunnelVersion;
/**
*
* @param key The key to store, encrypted by the shared key
* @param sharedKey The key used to encrypt {@link key}, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey} or null.
* If null, the communication is sent in cleartext.
* @param key The key to store
*/
constructor(
keyOrEncryptedKey:
| string
| {
key: Uint8Array;
sharedKey: Uint8Array;
tunnelVersion: TunnelVersion;
},
) {
if (typeof keyOrEncryptedKey === "string") {
this.key = keyOrEncryptedKey;
this.encryptedKey = undefined;
this.sharedKey = undefined;
this.tunnelVersion = undefined;
} else {
this.key = undefined;
this.encryptedKey = keyOrEncryptedKey.key;
this.sharedKey = keyOrEncryptedKey.sharedKey;
this.tunnelVersion = keyOrEncryptedKey.tunnelVersion;
}
}
static async BuildForTunnel(tunnel: CommunicationTunnel, masterKey: SymmetricCryptoKey) {
switch (tunnel.tunnelVersion) {
case TunnelVersion.CLEAR_TEXT:
return new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64);
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM:
return new KeyConnectorSetUserKeyRequest({
key: await tunnel.protect(masterKey.encKey),
sharedKey: tunnel.encapsulatedKey.dataBytes,
tunnelVersion: tunnel.tunnelVersion,
});
default:
}
}
constructor(readonly key: string) {}
}
export class KeyConnectorGetUserKeyRequest {
/**
* The key to use in encrypting the response, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey}
*/
readonly sharedKey: Uint8Array;
/**
* The version of communication to use in encrypting the response
*/
readonly tunnelVersion: TunnelVersion;
/**
*
* @fixme Once key connector server have been updated, this constructor should require a shared key and ApiService should drop support of the old GET request.
*
* @param sharedKey The key to use in encrypting the response, encapsulated by the {@link KeyConnectorInitCommunicationResponse.encapsulationKey}.
* @param tunnelVersion The version of communication to use in encrypting the response.
*/
constructor(tunneledCommunication?: { sharedKey: Uint8Array; tunnelVersion: TunnelVersion }) {
this.sharedKey = tunneledCommunication?.sharedKey;
this.tunnelVersion = tunneledCommunication?.tunnelVersion;
}
static BuildForTunnel(tunnel: CommunicationTunnel) {
switch (tunnel.tunnelVersion) {
case TunnelVersion.CLEAR_TEXT:
return new KeyConnectorGetUserKeyRequest();
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM:
return new KeyConnectorGetUserKeyRequest({
sharedKey: tunnel.encapsulatedKey.dataBytes,
tunnelVersion: tunnel.tunnelVersion,
});
default:
}
}
constructor(readonly userId: UserId) {}
}

View File

@@ -4,10 +4,14 @@ import { TunnelVersion } from "../../../platform/communication-tunnel/communicat
export class InitTunnelResponse extends BaseResponse {
readonly encapsulationKey: Uint8Array;
readonly tunnelVersion: TunnelVersion;
readonly tunnelIdentifier: string;
readonly tunnelDurationSeconds: number;
constructor(response: any) {
super(response);
this.encapsulationKey = new Uint8Array(this.getResponseProperty("EncapsulationKey"));
this.tunnelVersion = this.getResponseProperty("TunnelVersion");
this.tunnelIdentifier = this.getResponseProperty("TunnelIdentifier");
this.tunnelDurationSeconds = this.getResponseProperty("TunnelDurationSeconds");
}
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "../../../models/response/base.response";
export class KeyConnectorGetUserKeyResponse extends BaseResponse {
key: string;
constructor(response: any) {
super(response);
this.key = this.getResponseProperty("Key");
}
}

View File

@@ -1,20 +0,0 @@
import { BaseResponse } from "../../../models/response/base.response";
export class KeyConnectorGetUserKeyResponse extends BaseResponse {
// Base64 encoded key. This is present only in the clear text version of the response.
key: string;
// AES-256-GCM encrypted key. This does not exist in EncString and so is not sent or parsed as one.
// Expected format is data + tag + iv
encryptedKey: Uint8Array;
tunnelVersion: KeyConnectorGetUserKeyResponse;
constructor(response: any) {
super(response);
this.key = this.getResponseProperty("Key");
const responseEncryptedKey = this.getResponseProperty("EncryptedKey");
if (responseEncryptedKey) {
this.encryptedKey = new Uint8Array(responseEncryptedKey);
this.tunnelVersion = this.getResponseProperty("TunnelVersion");
}
}
}

View File

@@ -5,7 +5,6 @@ import {
FakeAccountService,
FakeStateProvider,
makeEncString,
makeStaticByteArray,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../spec";
@@ -27,7 +26,7 @@ import { OrganizationId, UserId } from "../../types/guid";
import { MasterKey, UserKey } from "../../types/key";
import { KeyConnectorSetUserKeyRequest } from "../models/request/key-connector-user-key.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-user-key.response";
import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-get-user-key.response";
import {
USES_KEY_CONNECTOR,
@@ -61,16 +60,16 @@ describe("KeyConnectorService", () => {
"eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==",
),
) as MasterKey;
let mockMasterKeyResponse: KeyConnectorGetUserKeyResponse;
let mockRawMasterKeyResponse: {
key: string;
};
beforeEach(() => {
jest.clearAllMocks();
mockMasterKeyResponse = new KeyConnectorGetUserKeyResponse({
mockRawMasterKeyResponse = {
key: mockClearTextMasterKey.keyB64,
encryptedKey: makeStaticByteArray(32),
tunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
});
};
masterPasswordService = new FakeMasterPasswordService();
accountService = mockAccountServiceWith(mockUserId);
@@ -228,10 +227,13 @@ describe("KeyConnectorService", () => {
});
describe("setMasterKeyFromUrl", () => {
const tunnel = mock<CommunicationTunnel>();
const tunnel = mock<CommunicationTunnel<any>>();
beforeEach(() => {
communicationTunnelService.createTunnel.mockResolvedValue(tunnel);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse);
tunnel.unprotect.mockResolvedValue(
new KeyConnectorGetUserKeyResponse(mockRawMasterKeyResponse),
);
});
afterEach(() => {
@@ -260,14 +262,16 @@ describe("KeyConnectorService", () => {
it("should set unprotect and set the master key", async () => {
// Arrange
const url = "https://key-connector-url.com";
mockMasterKeyResponse.key = null;
tunnel.unprotect.mockResolvedValue(mockClearTextMasterKey.key);
mockRawMasterKeyResponse.key = null;
// Act
await keyConnectorService.setMasterKeyFromUrl(url, mockUserId);
// Assert
expect(tunnel.unprotect).toHaveBeenCalledWith(mockMasterKeyResponse.encryptedKey);
expect(tunnel.unprotect).toHaveBeenCalledWith(
KeyConnectorGetUserKeyResponse,
mockRawMasterKeyResponse,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockClearTextMasterKey,
mockUserId,
@@ -278,7 +282,7 @@ describe("KeyConnectorService", () => {
// Arrange
const url = "https://key-connector-url.com";
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse);
// Act
await keyConnectorService.setMasterKeyFromUrl(url, mockUserId);
@@ -307,10 +311,12 @@ describe("KeyConnectorService", () => {
});
describe("migrateUser()", () => {
const tunnel = mock<CommunicationTunnel>();
const tunnel = mock<CommunicationTunnel<[TunnelVersion.CLEAR_TEXT]>>();
const keyConnectorRequest = new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.keyB64);
beforeEach(() => {
communicationTunnelService.createTunnel.mockResolvedValue(tunnel);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse);
tunnel.protect.mockResolvedValue(keyConnectorRequest);
});
afterEach(() => {
@@ -338,10 +344,6 @@ describe("KeyConnectorService", () => {
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = getMockMasterKey();
masterPasswordService.masterKeySubject.next(masterKey);
const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(
tunnel,
masterKey,
);
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
@@ -358,15 +360,11 @@ describe("KeyConnectorService", () => {
it("should set protect and share the master key", async () => {
// Arrange
mockMasterKeyResponse.key = null;
tunnel.unprotect.mockResolvedValue(mockClearTextMasterKey.key);
mockRawMasterKeyResponse.key = null;
tunnel.unprotectBytes.mockResolvedValue(mockClearTextMasterKey.key);
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = mockClearTextMasterKey;
masterPasswordService.masterKeySubject.next(masterKey);
const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(
tunnel,
masterKey,
);
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
@@ -386,10 +384,6 @@ describe("KeyConnectorService", () => {
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = getMockMasterKey();
masterPasswordService.masterKeySubject.next(masterKey);
const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(
tunnel,
masterKey,
);
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
@@ -410,10 +404,6 @@ describe("KeyConnectorService", () => {
// Arrange
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
const masterKey = getMockMasterKey();
const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(
tunnel,
masterKey,
);
const error = new Error("Failed to post user key to key connector");
organizationService.getAll.mockResolvedValue([organization]);
@@ -437,7 +427,7 @@ describe("KeyConnectorService", () => {
});
describe("convertNewSsoUserToKeyConnector()", () => {
const tunnel = mock<CommunicationTunnel>();
const tunnel = mock<CommunicationTunnel<[TunnelVersion.CLEAR_TEXT]>>();
const mockUserKey = makeSymmetricCryptoKey(64) as UserKey;
const mockEncryptedUserKey = makeEncString("encryptedUserKey");
const pubKey = "pubKey";
@@ -454,7 +444,10 @@ describe("KeyConnectorService", () => {
beforeEach(() => {
communicationTunnelService.createTunnel.mockResolvedValue(tunnel);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockRawMasterKeyResponse);
tunnel.protect.mockResolvedValue(
new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.keyB64),
);
keyGenerationService.createKey.mockResolvedValue(mockClearTextMasterKey);
keyService.makeMasterKey.mockResolvedValue(mockClearTextMasterKey);
@@ -478,13 +471,7 @@ describe("KeyConnectorService", () => {
expect(communicationTunnelService.createTunnel).toHaveBeenCalled();
});
it("should post user key to key connector", async () => {
// Arrange
const keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(
tunnel,
mockClearTextMasterKey,
);
it("should post user key to key connector through the tunnel", async () => {
// Act
await keyConnectorService.convertNewSsoUserToKeyConnector(
tokenResponse,
@@ -493,6 +480,9 @@ describe("KeyConnectorService", () => {
);
// Assert
expect(tunnel.protect).toHaveBeenCalledWith(
new KeyConnectorSetUserKeyRequest(mockClearTextMasterKey.encKeyB64),
);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
tokenResponse.userDecryptionOptions.keyConnectorOption.keyConnectorUrl,
keyConnectorRequest,
@@ -580,7 +570,7 @@ describe("KeyConnectorService", () => {
}
function getMockMasterKey(): MasterKey {
const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key);
const keyArr = Utils.fromB64ToArray(mockRawMasterKeyResponse.key);
const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey;
return masterKey;
}

View File

@@ -12,6 +12,7 @@ import { KeyGenerationService } from "../../platform/abstractions/key-generation
import { LogService } from "../../platform/abstractions/log.service";
import { TunnelVersion } from "../../platform/communication-tunnel/communication-tunnel";
import { CommunicationTunnelService } from "../../platform/communication-tunnel/communication-tunnel.service";
import { TunneledRequest } from "../../platform/communication-tunnel/tunneled.request";
import { KdfType } from "../../platform/enums/kdf-type.enum";
import { Utils } from "../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
@@ -34,6 +35,7 @@ import {
} from "../models/request/key-connector-user-key.request";
import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { KeyConnectorGetUserKeyResponse } from "../models/response/key-connector-get-user-key.response";
const SUPPORTED_COMMUNICATION_VERSIONS = [
TunnelVersion.CLEAR_TEXT,
@@ -109,7 +111,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
await this.apiService.postUserKeyToKeyConnector(
organization.keyConnectorUrl,
await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey),
await tunnel.protect(new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64)),
);
} catch (e) {
this.handleKeyConnectorError(e);
@@ -126,14 +128,14 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
SUPPORTED_COMMUNICATION_VERSIONS,
);
const response = await this.apiService.getMasterKeyFromKeyConnector(
const rawResponse = await this.apiService.getMasterKeyFromKeyConnector(
url,
KeyConnectorGetUserKeyRequest.BuildForTunnel(tunnel),
await tunnel.protect(new KeyConnectorGetUserKeyRequest(userId)),
);
// Decrypt the response with the shared key
const masterKeyArray =
Utils.fromB64ToArray(response.key) ?? (await tunnel.unprotect(response.encryptedKey));
const response = await tunnel.unprotect(KeyConnectorGetUserKeyResponse, rawResponse);
const masterKeyArray = Utils.fromB64ToArray(response.key);
const masterKey = new SymmetricCryptoKey(masterKeyArray) as MasterKey;
await this.masterPasswordService.setMasterKey(masterKey, userId);
@@ -182,14 +184,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
kdfConfig,
);
let keyConnectorRequest: KeyConnectorSetUserKeyRequest;
let keyConnectorRequest: TunneledRequest<KeyConnectorSetUserKeyRequest>;
try {
const tunnel = await this.communicationTunnelService.createTunnel(
keyConnectorUrl,
SUPPORTED_COMMUNICATION_VERSIONS,
);
keyConnectorRequest = await KeyConnectorSetUserKeyRequest.BuildForTunnel(tunnel, masterKey);
keyConnectorRequest = await tunnel.protect(
new KeyConnectorSetUserKeyRequest(masterKey.encKeyB64),
);
} catch (e) {
this.handleKeyConnectorError(e);
}

View File

@@ -11,10 +11,10 @@ export abstract class CommunicationTunnelService {
* @returns an object that stores the cryptographic secrets to protect and unprotect data for the communication tunnel
* @throws errors in the tunnel creation process, including unsupported communication versions or issues communicating with the server
*/
abstract createTunnel(
abstract createTunnel<const SupportedTunnelVersions extends readonly TunnelVersion[]>(
url: string,
supportedTunnelVersions: TunnelVersion[],
): Promise<CommunicationTunnel>;
supportedTunnelVersions: SupportedTunnelVersions,
): Promise<CommunicationTunnel<SupportedTunnelVersions>>;
}
export class DefaultCommunicationTunnelService implements CommunicationTunnelService {
@@ -23,10 +23,10 @@ export class DefaultCommunicationTunnelService implements CommunicationTunnelSer
private readonly keyGenerationService: KeyGenerationService,
private readonly encryptService: EncryptService,
) {}
async createTunnel(
async createTunnel<const SupportedTunnelVersions extends readonly TunnelVersion[]>(
url: string,
supportedTunnelVersions: TunnelVersion[],
): Promise<CommunicationTunnel> {
supportedTunnelVersions: SupportedTunnelVersions,
): Promise<CommunicationTunnel<SupportedTunnelVersions>> {
const tunnel = new CommunicationTunnel(
this.apiService,
this.keyGenerationService,

View File

@@ -3,194 +3,166 @@ import { mock, MockProxy } from "jest-mock-extended";
import { makeEncString, makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { InitTunnelResponse } from "../../auth/models/response/init-tunnel.response";
import { BaseResponse } from "../../models/response/base.response";
import { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { Utils } from "../misc/utils";
import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel";
import { TunneledRequest } from "./tunneled.request";
describe("communicationTunnel", () => {
class TestRequest {
constructor(readonly value: string) {}
}
class TestResponse extends BaseResponse {
readonly value: string;
constructor(response: any) {
super(response);
this.value = this.getResponseProperty("Value");
}
}
describe("communicationTunnel with cleartext", () => {
const url = "http://key-connector.example";
let apiService: MockProxy<ApiService>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let encryptService: MockProxy<EncryptService>;
let sut: CommunicationTunnel;
const supportedTunnelVersions = [
TunnelVersion.CLEAR_TEXT,
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
] as const;
let sut: CommunicationTunnel<typeof supportedTunnelVersions>;
const sharedKey = makeSymmetricCryptoKey(32);
const encapsulationKey = makeStaticByteArray(32, 1);
const encapsulatedKey = makeEncString("encapsulatedKey");
const tunnelResponse = new InitTunnelResponse({
EncapsulationKey: encapsulationKey,
TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
TunnelIdentifier: "tunnelIdentifier",
TunnelDurationSeconds: 60,
});
beforeEach(() => {
apiService = mock<ApiService>();
keyGenerationService = mock<KeyGenerationService>();
encryptService = mock<EncryptService>();
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: encapsulationKey,
TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
}),
);
apiService.initCommunicationTunnel.mockResolvedValue(tunnelResponse);
keyGenerationService.createKey.mockResolvedValue(sharedKey);
encryptService.rsaEncrypt.mockResolvedValue(encapsulatedKey);
sut = new CommunicationTunnel(
apiService,
keyGenerationService,
encryptService,
supportedTunnelVersions,
);
});
describe("negotiateTunnel", () => {
it("negotiates with the provided server", async () => {
const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM];
sut = new CommunicationTunnel(
apiService,
keyGenerationService,
encryptService,
supportedTunnelVersions,
);
it("negotiates with the provided server", async () => {
await sut.negotiateTunnel(url);
await sut.negotiateTunnel(url);
expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith(
url,
expect.objectContaining({
supportedTunnelVersions: expect.arrayContaining([TunnelVersion.CLEAR_TEXT]),
}),
);
expect(sut.tunnelVersion).toBe(TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM);
});
expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith(
url,
expect.objectContaining({ supportedTunnelVersions }),
);
it("allows cleartext requests", async () => {
await sut.negotiateTunnel(url);
// force to clear text tunnel
sut["_tunnelVersion"] = TunnelVersion.CLEAR_TEXT;
const request = new TestRequest("test");
const protectedRequest = (await sut.protect(request)) as TestRequest;
expect(protectedRequest).toBe(request);
});
it("encrypts the request", async () => {
const encryptedData = makeStaticByteArray(32, 3);
encryptService.aesGcmEncryptToBytes.mockResolvedValue(encryptedData);
await sut.negotiateTunnel(url);
const request = new TestRequest("test");
const protectedRequest = (await sut.protect(request)) as TunneledRequest<TestRequest>;
expect(protectedRequest).toEqual({
encryptedData: Utils.fromBufferToB64(encryptedData),
encapsulatedKey: Utils.fromBufferToB64(encapsulatedKey.dataBytes),
tunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
tunnelIdentifier: tunnelResponse.tunnelIdentifier,
});
});
it("generates a shared key", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
it("handles cleartext responses", async () => {
await sut.negotiateTunnel(url);
await sut.negotiateTunnel(url);
// force to clear text tunnel
sut["_tunnelVersion"] = TunnelVersion.CLEAR_TEXT;
expect(keyGenerationService.createKey).toHaveBeenCalledWith(256);
});
const response = { Value: "test" };
const unprotectedResponse = await sut.unprotect(TestResponse, response);
it("encapsulates the shared key", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
expect(unprotectedResponse).toEqual(new TestResponse(response));
});
await sut.negotiateTunnel(url);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(sharedKey.key, encapsulationKey);
expect(sut.encapsulatedKey).toBe(encapsulatedKey);
});
it.each(Object.values(TunnelVersion).filter((v) => typeof v !== "number"))(
"negotiates tunnel version %s",
async (tunnelVersion: keyof typeof TunnelVersion) => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion[tunnelVersion],
]);
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: [1, 2, 3],
TunnelVersion: TunnelVersion[tunnelVersion],
}),
);
await sut.negotiateTunnel(url);
expect(sut.tunnelVersion).toBe(TunnelVersion[tunnelVersion]);
},
it("decrypts the response", async () => {
const clearTextResponse = { Value: "test" };
const encryptedData = "encryptedData";
encryptService.aesGcmDecryptToBytes.mockResolvedValue(
Utils.fromUtf8ToArray(JSON.stringify(clearTextResponse)),
);
it("throws an error if the communication version is not supported", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
await sut.negotiateTunnel(url);
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: [1, 2, 3],
TunnelVersion: TunnelVersion.CLEAR_TEXT,
}),
);
const response = { EncryptedResponse: encryptedData };
const unprotectedResponse = await sut.unprotect(TestResponse, response);
await expect(sut.negotiateTunnel(url)).rejects.toThrow("Unsupported communication version");
});
expect(encryptService.aesGcmDecryptToBytes).toHaveBeenCalledWith(
Utils.fromB64ToArray(encryptedData),
expect.any(Uint8Array),
expect.any(Uint8Array),
);
expect(unprotectedResponse).toEqual(new TestResponse(clearTextResponse));
});
});
describe("communicationTunnel disallows cleartext", () => {
let apiService: MockProxy<ApiService>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let encryptService: MockProxy<EncryptService>;
const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM] as const;
let sut: CommunicationTunnel<typeof supportedTunnelVersions>;
beforeEach(() => {
apiService = mock<ApiService>();
keyGenerationService = mock<KeyGenerationService>();
encryptService = mock<EncryptService>();
sut = new CommunicationTunnel(
apiService,
keyGenerationService,
encryptService,
supportedTunnelVersions,
);
});
describe("tunnel encryption", () => {
const clearText = makeStaticByteArray(32, 2);
const protectedText = makeStaticByteArray(32, 3);
describe("protect", () => {
it("returns only a tunneled request", async () => {
return; // this is a compiler test
beforeEach(() => {
encryptService.aesGcmEncryptToBytes.mockResolvedValue(protectedText);
encryptService.aesGcmDecryptToBytes.mockResolvedValue(clearText);
});
const request = new TestRequest("test");
it("throws an error if the tunnel is not initialized", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
// @ts-expect-error -- this cast doesn't work because CLEAR_TEXT is not in the supported tunnel versions, so a TunneledRequest is always returned
(await sut.protect(request)) as TestRequest;
await expect(sut.protect(clearText)).rejects.toThrow("Communication tunnel not initialized");
await expect(sut.unprotect(protectedText)).rejects.toThrow(
"Communication tunnel not initialized",
);
});
describe("CLEAR_TEXT", () => {
beforeEach(async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.CLEAR_TEXT,
]);
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
TunnelVersion: TunnelVersion.CLEAR_TEXT,
}),
);
await sut.negotiateTunnel(url);
});
it("does not encrypt the clear text", async () => {
const result = await sut.protect(clearText);
expect(result).toBe(clearText);
expect(encryptService.aesGcmEncryptToBytes).not.toHaveBeenCalled();
});
it("does not decrypt the protected text", async () => {
const result = await sut.unprotect(protectedText);
expect(result).toBe(protectedText);
expect(encryptService.aesGcmDecryptToBytes).not.toHaveBeenCalled();
});
describe("RSA_ENCAPSULATED_AES_256_GCM", () => {
beforeEach(async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: encapsulationKey,
TunnelVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
}),
);
await sut.negotiateTunnel(url);
});
it("encrypts the clear text", async () => {
const result = await sut.protect(clearText);
expect(result).toBe(protectedText);
expect(encryptService.aesGcmEncryptToBytes).toHaveBeenCalledWith(
clearText,
sharedKey.key,
expect.toEqualBuffer(new Uint8Array([TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM])),
);
});
it("decrypts the protected text", async () => {
const result = await sut.unprotect(protectedText);
expect(result).toBe(clearText);
expect(encryptService.aesGcmDecryptToBytes).toHaveBeenCalledWith(
protectedText,
sharedKey.key,
expect.toEqualBuffer(new Uint8Array([TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM])),
);
});
});
// @ts-expect-no-error -- Must be a TunneledRequest
(await sut.protect(request)) as TunneledRequest<TestRequest>;
});
});
});

View File

@@ -1,11 +1,17 @@
import type { Includes, JsonObject } from "type-fest";
import { ApiService } from "../../abstractions/api.service";
import { InitTunnelRequest } from "../../auth/models/request/init-tunnel.request";
import { InitTunnelResponse } from "../../auth/models/response/init-tunnel.response";
import { ErrorResponse } from "../../models/response/error.response";
import { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { Utils } from "../misc/utils";
import { EncString } from "../models/domain/enc-string";
import { TunneledRequest } from "./tunneled.request";
import { TunneledResponse } from "./tunneled.response";
export enum TunnelVersion {
CLEAR_TEXT = 0,
/**
@@ -17,7 +23,12 @@ export enum TunnelVersion {
RSA_ENCAPSULATED_AES_256_GCM = 1,
}
export class CommunicationTunnel {
type SupportedRequestTypes<TRequest, SupportedTunnelVersions extends readonly TunnelVersion[]> =
Includes<SupportedTunnelVersions, TunnelVersion.CLEAR_TEXT> extends true
? TunneledRequest<TRequest> | TRequest
: TunneledRequest<TRequest>;
export class CommunicationTunnel<const TSupportedTunnelVersions extends readonly TunnelVersion[]> {
private negotiationComplete: boolean = false;
private sharedKey: Uint8Array;
private _encapsulatedKey: EncString;
@@ -28,12 +39,16 @@ export class CommunicationTunnel {
get tunnelVersion(): TunnelVersion {
return this._tunnelVersion;
}
private _tunnelIdentifier: string;
get tunnelIdentifier(): string {
return this._tunnelIdentifier;
}
constructor(
private readonly apiService: ApiService,
private readonly keyGenerationService: KeyGenerationService,
private readonly encryptService: EncryptService,
private readonly supportedTunnelVersions: TunnelVersion[],
private readonly supportedTunnelVersions: TSupportedTunnelVersions,
) {}
/**
@@ -69,7 +84,49 @@ export class CommunicationTunnel {
return await this.initWithVersion(response);
}
async protect(clearText: Uint8Array): Promise<Uint8Array> {
async protect<TRequest>(
request: TRequest,
): Promise<SupportedRequestTypes<TRequest, TSupportedTunnelVersions>> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
if (this.tunnelVersion === TunnelVersion.CLEAR_TEXT) {
// Should only be possible if the tunnel version is clear text
return request as SupportedRequestTypes<TRequest, TSupportedTunnelVersions>;
}
const requestBytes = Utils.fromUtf8ToArray(JSON.stringify(request));
const protectedText = await this.protectBytes(requestBytes);
return new TunneledRequest<TRequest>(
protectedText,
this.encapsulatedKey,
this.tunnelVersion,
this.tunnelIdentifier,
);
}
async unprotect<const TResponse>(
responseConstructor: new (response: any) => TResponse,
responseData: JsonObject,
): Promise<TResponse> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
if (this.tunnelVersion === TunnelVersion.CLEAR_TEXT) {
return new responseConstructor(responseData);
}
const tunneledResponse = new TunneledResponse<TResponse>(responseData);
const clearBytes = await this.unprotectBytes(
Utils.fromB64ToArray(tunneledResponse.encryptedResponse),
);
const clearText = Utils.fromBufferToUtf8(clearBytes);
return new responseConstructor(JSON.parse(clearText));
}
async protectBytes(clearText: Uint8Array): Promise<Uint8Array> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
@@ -92,7 +149,7 @@ export class CommunicationTunnel {
return protectedText;
}
async unprotect(protectedText: Uint8Array): Promise<Uint8Array> {
async unprotectBytes(protectedText: Uint8Array): Promise<Uint8Array> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
@@ -126,6 +183,7 @@ export class CommunicationTunnel {
case TunnelVersion.CLEAR_TEXT:
break;
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: {
this._tunnelIdentifier = response.tunnelIdentifier;
const encapsulationKey = response.encapsulationKey;
// use the encapsulation key to create an share a shared key

View File

@@ -0,0 +1,45 @@
import { Utils } from "../misc/utils";
import { EncString } from "../models/domain/enc-string";
import { TunnelVersion } from "./communication-tunnel";
declare const marker: unique symbol;
export class TunneledRequest<RequestType> {
[marker]: RequestType;
readonly encryptedData: string;
readonly encapsulatedKey: string;
constructor(
encryptedData: Uint8Array,
encapsulatedKey: EncString,
readonly tunnelVersion: TunnelVersion,
readonly tunnelIdentifier: string,
) {
if (encryptedData == null) {
throw new Error("encryptedData is required");
}
if (encapsulatedKey == null) {
throw new Error("encapsulatedKey is required");
}
if (tunnelVersion == null) {
throw new Error("tunnelVersion is required");
}
if (tunnelIdentifier == null) {
throw new Error("tunnelIdentifier is required");
}
this.encapsulatedKey = Utils.fromBufferToB64(encapsulatedKey.dataBytes);
this.encryptedData = Utils.fromBufferToB64(encryptedData);
}
}
export function isTunneledRequest<RequestType = any>(
request: any,
): request is TunneledRequest<RequestType> {
return (
request.encryptedData !== undefined &&
request.tunnelVersion !== undefined &&
request.tunnelIdentifier !== undefined
);
}

View File

@@ -0,0 +1,13 @@
import { BaseResponse } from "../../models/response/base.response";
declare const marker: unique symbol;
export class TunneledResponse<TResponse> extends BaseResponse {
[marker]: TResponse;
readonly encryptedResponse: string;
constructor(response: any) {
super(response);
this.encryptedResponse = this.getResponseProperty("EncryptedResponse");
}
}

View File

@@ -1,4 +1,5 @@
import { firstValueFrom } from "rxjs";
import type { JsonObject } from "type-fest";
import {
CollectionRequest,
@@ -82,7 +83,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { InitTunnelResponse } from "../auth/models/response/init-tunnel.response";
import { KeyConnectorGetUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -131,6 +131,10 @@ import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import {
isTunneledRequest,
TunneledRequest,
} from "../platform/communication-tunnel/tunneled.request";
import { flagEnabled } from "../platform/misc/flags";
import { Utils } from "../platform/misc/utils";
import { SyncResponse } from "../platform/sync";
@@ -1499,26 +1503,15 @@ export class ApiService implements ApiServiceAbstraction {
async getMasterKeyFromKeyConnector(
keyConnectorUrl: string,
request: KeyConnectorGetUserKeyRequest,
): Promise<KeyConnectorGetUserKeyResponse> {
request: KeyConnectorGetUserKeyRequest | TunneledRequest<KeyConnectorGetUserKeyRequest>,
): Promise<JsonObject> {
const authHeader = await this.getActiveBearerToken();
// If shared key is null, we use the old GET endpoint to support older key connectors
let response: Response;
if (request.sharedKey == null) {
if (isTunneledRequest(request)) {
response = await this.fetch(
new Request(keyConnectorUrl + "/user-keys", {
cache: "no-store",
method: "GET",
headers: new Headers({
Accept: "application/json",
Authorization: "Bearer " + authHeader,
}),
}),
);
} else {
response = await this.fetch(
new Request(keyConnectorUrl + "/user-keys", {
new Request(keyConnectorUrl + "/user-keys/get-user-key", {
cache: "no-store",
method: "POST",
headers: new Headers({
@@ -1529,6 +1522,17 @@ export class ApiService implements ApiServiceAbstraction {
body: JSON.stringify(request),
}),
);
} else {
response = await this.fetch(
new Request(keyConnectorUrl + "/user-keys", {
cache: "no-store",
method: "GET",
headers: new Headers({
Accept: "application/json",
Authorization: "Bearer " + authHeader,
}),
}),
);
}
if (response.status !== 200) {
@@ -1536,7 +1540,7 @@ export class ApiService implements ApiServiceAbstraction {
return Promise.reject(error);
}
return new KeyConnectorGetUserKeyResponse(await response.json());
return await response.json();
}
async postUserKeyToKeyConnector(