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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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])),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user