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

Establish communication tunnel protocol

This commit is contained in:
Matt Gibson
2024-11-19 12:26:05 -08:00
parent 42ebc23fd3
commit 376d7a88c1
5 changed files with 417 additions and 0 deletions

View File

@@ -502,6 +502,14 @@ export abstract class ApiService {
keyConnectorUrl: string,
request: KeyConnectorUserKeyRequest,
) => Promise<void>;
/**
* Negotiate a tunneled communication protocol with the supplied url.
*
* @param url The URL of the server to create a tunnel with.
* @param request The request to send to the server.
* @returns The response from the server.
*/
initCommunicationTunnel: (url: string, request: InitTunnelRequest) => Promise<InitTunnelResponse>;
getKeyConnectorAlive: (keyConnectorUrl: string) => Promise<void>;
getOrganizationExport: (organizationId: string) => Promise<OrganizationExportResponse>;
}

View File

@@ -0,0 +1,41 @@
import { ApiService } from "../../abstractions/api.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel";
export abstract class CommunicationTunnelService {
/**
*
* @param supportedTunnelVersions the allowed tunnel versions for the communication tunnel
* @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(
url: string,
supportedTunnelVersions: TunnelVersion[],
): Promise<CommunicationTunnel>;
}
export class DefaultCommunicationTunnelService implements CommunicationTunnelService {
constructor(
private readonly apiService: ApiService,
private readonly keyGenerationService: KeyGenerationService,
private readonly encryptService: EncryptService,
) {}
async createTunnel(
url: string,
supportedTunnelVersions: TunnelVersion[],
): Promise<CommunicationTunnel> {
const tunnel = new CommunicationTunnel(
this.apiService,
this.keyGenerationService,
this.encryptService,
supportedTunnelVersions,
);
await tunnel.negotiateTunnel(url);
return tunnel;
}
}

View File

@@ -0,0 +1,196 @@
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 { EncryptService } from "../abstractions/encrypt.service";
import { KeyGenerationService } from "../abstractions/key-generation.service";
import { CommunicationTunnel, TunnelVersion } from "./communication-tunnel";
describe("communicationTunnel", () => {
const url = "http://key-connector.example";
let apiService: MockProxy<ApiService>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let encryptService: MockProxy<EncryptService>;
let sut: CommunicationTunnel;
const sharedKey = makeSymmetricCryptoKey(32);
const encapsulationKey = makeStaticByteArray(32, 1);
const encapsulatedKey = makeEncString("encapsulatedKey");
beforeEach(() => {
apiService = mock<ApiService>();
keyGenerationService = mock<KeyGenerationService>();
encryptService = mock<EncryptService>();
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: encapsulationKey,
CommunicationVersion: TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
}),
);
keyGenerationService.createKey.mockResolvedValue(sharedKey);
encryptService.rsaEncrypt.mockResolvedValue(encapsulatedKey);
});
describe("negotiateTunnel", () => {
it("negotiates with the provided server", async () => {
const supportedTunnelVersions = [TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM];
sut = new CommunicationTunnel(
apiService,
keyGenerationService,
encryptService,
supportedTunnelVersions,
);
await sut.negotiateTunnel(url);
expect(apiService.initCommunicationTunnel).toHaveBeenCalledWith(
url,
expect.objectContaining({ supportedTunnelVersions }),
);
});
it("generates a shared key", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
await sut.negotiateTunnel(url);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(256);
});
it("encapsulates the shared key", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
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],
CommunicationVersion: TunnelVersion[tunnelVersion],
}),
);
await sut.negotiateTunnel(url);
expect(sut.tunnelVersion).toBe(TunnelVersion[tunnelVersion]);
},
);
it("throws an error if the communication version is not supported", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
apiService.initCommunicationTunnel.mockResolvedValue(
new InitTunnelResponse({
EncapsulationKey: [1, 2, 3],
CommunicationVersion: TunnelVersion.CLEAR_TEXT,
}),
);
await expect(sut.negotiateTunnel(url)).rejects.toThrow("Unsupported communication version");
});
});
describe("tunnel encryption", () => {
const clearText = makeStaticByteArray(32, 2);
const protectedText = makeStaticByteArray(32, 3);
beforeEach(() => {
encryptService.aesGcmEncryptToBytes.mockResolvedValue(protectedText);
encryptService.aesGcmDecryptToBytes.mockResolvedValue(clearText);
});
it("throws an error if the tunnel is not initialized", async () => {
sut = new CommunicationTunnel(apiService, keyGenerationService, encryptService, [
TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM,
]);
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({
CommunicationVersion: 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,
CommunicationVersion: 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])),
);
});
});
});
});
});

View File

@@ -0,0 +1,146 @@
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 { EncString } from "../models/domain/enc-string";
export enum TunnelVersion {
CLEAR_TEXT = 0,
/**
* Shared key is AES-256-GCM encapsulated by RSA. CipherText is formatted as:
* encryptedData + tag + iv
* additional data is:
* - the version of communication as 1 byte (0x00)
*/
RSA_ENCAPSULATED_AES_256_GCM = 1,
}
export class CommunicationTunnel {
private negotiationComplete: boolean = false;
private sharedKey: Uint8Array;
private _encapsulatedKey: EncString;
get encapsulatedKey(): EncString {
return this._encapsulatedKey;
}
private _tunnelVersion: TunnelVersion;
get tunnelVersion(): TunnelVersion {
return this._tunnelVersion;
}
constructor(
private readonly apiService: ApiService,
private readonly keyGenerationService: KeyGenerationService,
private readonly encryptService: EncryptService,
private readonly supportedTunnelVersions: TunnelVersion[],
) {}
/**
* Negotiate the communication protocol with the key connector.
*
* @param url the url of the key connector
* @returns the cleartext shared key, an encapsulated version of the shared key that can be shared with the key
* connector, and the version of the communication protocol.
* @throws errors in the key negotiation process, including unsupported communication versions
*/
async negotiateTunnel(url: string): Promise<TunnelVersion> {
let response: InitTunnelResponse;
try {
response = await this.apiService.initCommunicationTunnel(
url,
new InitTunnelRequest(this.supportedTunnelVersions),
);
} catch (e) {
// if the key connector does not support encrypted communication, fall back to clear text, as long as it's a supported version
if (
(e as ErrorResponse).statusCode === 404 &&
this.supportedTunnelVersions.includes(TunnelVersion.CLEAR_TEXT)
) {
response = new InitTunnelResponse({
CommunicationVersion: TunnelVersion.CLEAR_TEXT,
});
} else {
throw e;
}
}
return await this.initWithVersion(response);
}
async protect(clearText: Uint8Array): Promise<Uint8Array> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
let protectedText: Uint8Array;
switch (this.tunnelVersion) {
case TunnelVersion.CLEAR_TEXT:
protectedText = clearText;
break;
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM:
protectedText = await this.encryptService.aesGcmEncryptToBytes(
clearText,
this.sharedKey,
new Uint8Array([this.tunnelVersion]),
);
break;
default:
throw new Error("Unsupported communication version");
}
return protectedText;
}
async unprotect(protectedText: Uint8Array): Promise<Uint8Array> {
if (!this.negotiationComplete) {
throw new Error("Communication tunnel not initialized");
}
let clearText: Uint8Array;
switch (this.tunnelVersion) {
case TunnelVersion.CLEAR_TEXT:
clearText = protectedText;
break;
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM:
clearText = await this.encryptService.aesGcmDecryptToBytes(
protectedText,
this.sharedKey,
new Uint8Array([this.tunnelVersion]),
);
break;
default:
throw new Error("Unsupported communication version");
}
return clearText;
}
private async initWithVersion(response: InitTunnelResponse): Promise<TunnelVersion> {
this._tunnelVersion = response.communicationVersion;
if (!this.supportedTunnelVersions.includes(this.tunnelVersion)) {
throw new Error("Unsupported communication version");
}
switch (this.tunnelVersion) {
case TunnelVersion.CLEAR_TEXT:
break;
case TunnelVersion.RSA_ENCAPSULATED_AES_256_GCM: {
const encapsulationKey = response.encapsulationKey;
// use the encapsulation key to create an share a shared key
this.sharedKey = (await this.keyGenerationService.createKey(256)).key;
this._encapsulatedKey = await this.encryptService.rsaEncrypt(
this.sharedKey,
encapsulationKey,
);
break;
}
default:
throw new Error("Unsupported communication version");
}
this.negotiationComplete = true;
return this.tunnelVersion;
}
}

View File

@@ -1559,6 +1559,32 @@ export class ApiService implements ApiServiceAbstraction {
}
}
async initCommunicationTunnel(
url: string,
request: InitTunnelRequest,
): Promise<InitTunnelResponse> {
const authHeader = await this.getActiveBearerToken();
const response = await this.fetch(
new Request(url + "/init-communication", {
cache: "no-store",
method: "POST",
headers: new Headers({
Accept: "application/json",
Authorization: "Bearer " + authHeader,
}),
body: JSON.stringify(request),
}),
);
if (response.status !== 200) {
const error = await this.handleError(response, false, true);
return Promise.reject(error);
}
return new InitTunnelResponse(await response.json());
}
async getOrganizationExport(organizationId: string): Promise<OrganizationExportResponse> {
const r = await this.send(
"GET",