pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
384 lines (322 loc) • 12.5 kB
text/typescript
/**
* Browser crypto module.
*/
import { AbstractCryptoModule, CryptorConfiguration } from '../../../core/interfaces/crypto-module';
import { PubNubFile, PubNubFileParameters } from '../../../file/modules/web';
import { PubNubFileConstructor } from '../../../core/types/file';
import { decode } from '../../../core/components/base64_codec';
import { PubNubError } from '../../../errors/pubnub-error';
import { EncryptedDataType, ICryptor } from './ICryptor';
import { ILegacyCryptor } from './ILegacyCryptor';
import AesCbcCryptor from './aesCbcCryptor';
import LegacyCryptor from './legacyCryptor';
import { LoggerManager } from '../../../core/components/logger-manager';
/**
* Re-export bundled cryptors.
*/
export { LegacyCryptor, AesCbcCryptor };
/**
* Crypto module cryptors interface.
*/
type CryptorType = ICryptor | ILegacyCryptor;
/**
* CryptoModule for browser platform.
*/
export class WebCryptoModule extends AbstractCryptoModule<CryptorType> {
/**
* {@link LegacyCryptor|Legacy} cryptor identifier.
*/
static LEGACY_IDENTIFIER = '';
/**
* Assign registered loggers' manager.
*
* @param logger - Registered loggers' manager.
*
* @internal
*/
set logger(logger: LoggerManager) {
if (this.defaultCryptor.identifier === WebCryptoModule.LEGACY_IDENTIFIER)
(this.defaultCryptor as LegacyCryptor).logger = logger;
else {
const cryptor = this.cryptors.find((cryptor) => cryptor.identifier === WebCryptoModule.LEGACY_IDENTIFIER);
if (cryptor) (cryptor as LegacyCryptor).logger = logger;
}
}
// --------------------------------------------------------
// --------------- Convenience functions ------------------
// -------------------------------------------------------
// region Convenience functions
static legacyCryptoModule(config: CryptorConfiguration) {
if (!config.cipherKey) throw new PubNubError('Crypto module error: cipher key not set.');
return new WebCryptoModule({
default: new LegacyCryptor({
...config,
useRandomIVs: config.useRandomIVs ?? true,
}),
cryptors: [new AesCbcCryptor({ cipherKey: config.cipherKey })],
});
}
static aesCbcCryptoModule(config: CryptorConfiguration) {
if (!config.cipherKey) throw new PubNubError('Crypto module error: cipher key not set.');
return new WebCryptoModule({
default: new AesCbcCryptor({ cipherKey: config.cipherKey }),
cryptors: [
new LegacyCryptor({
...config,
useRandomIVs: config.useRandomIVs ?? true,
}),
],
});
}
/**
* Construct crypto module with `cryptor` as default for data encryption and decryption.
*
* @param defaultCryptor - Default cryptor for data encryption and decryption.
*
* @returns Crypto module with pre-configured default cryptor.
*/
static withDefaultCryptor(defaultCryptor: CryptorType) {
return new this({ default: defaultCryptor });
}
// endregion
// --------------------------------------------------------
// --------------------- Encryption -----------------------
// --------------------------------------------------------
// region Encryption
encrypt(data: ArrayBuffer | string) {
// Encrypt data.
const encrypted =
data instanceof ArrayBuffer && this.defaultCryptor.identifier === WebCryptoModule.LEGACY_IDENTIFIER
? (this.defaultCryptor as ILegacyCryptor).encrypt(WebCryptoModule.decoder.decode(data))
: (this.defaultCryptor as ICryptor).encrypt(data);
if (!encrypted.metadata) return encrypted.data;
else if (typeof encrypted.data === 'string')
throw new Error('Encryption error: encrypted data should be ArrayBuffed.');
const headerData = this.getHeaderData(encrypted);
return this.concatArrayBuffer(headerData!, encrypted.data);
}
async encryptFile(file: PubNubFile, File: PubNubFileConstructor<PubNubFile, PubNubFileParameters>) {
/**
* Files handled differently in case of Legacy cryptor.
* (as long as we support legacy need to check on instance type)
*/
if (this.defaultCryptor.identifier === CryptorHeader.LEGACY_IDENTIFIER)
return (this.defaultCryptor as ILegacyCryptor).encryptFile(file, File);
const fileData = await this.getFileData(file);
const encrypted = await (this.defaultCryptor as ICryptor).encryptFileData(fileData);
if (typeof encrypted.data === 'string') throw new Error('Encryption error: encrypted data should be ArrayBuffed.');
return File.create({
name: file.name,
mimeType: 'application/octet-stream',
data: this.concatArrayBuffer(this.getHeaderData(encrypted)!, encrypted.data),
});
}
// endregion
// --------------------------------------------------------
// --------------------- Decryption -----------------------
// --------------------------------------------------------
// region Decryption
decrypt(data: ArrayBuffer | string) {
const encryptedData = typeof data === 'string' ? decode(data) : data;
const header = CryptorHeader.tryParse(encryptedData);
const cryptor = this.getCryptor(header);
const metadata =
header.length > 0
? encryptedData.slice(header.length - (header as CryptorHeaderV1).metadataLength, header.length)
: null;
if (encryptedData.slice(header.length).byteLength <= 0) throw new Error('Decryption error: empty content');
return cryptor!.decrypt({
data: encryptedData.slice(header.length),
metadata: metadata,
});
}
async decryptFile(file: PubNubFile, File: PubNubFileConstructor<PubNubFile, PubNubFileParameters>) {
const data = await file.data.arrayBuffer();
const header = CryptorHeader.tryParse(data);
const cryptor = this.getCryptor(header);
/**
* Files handled differently in case of Legacy cryptor.
* (as long as we support legacy need to check on instance type)
*/
if (cryptor?.identifier === CryptorHeader.LEGACY_IDENTIFIER)
return (cryptor as ILegacyCryptor).decryptFile(file, File);
const fileData = await this.getFileData(data);
const metadata = fileData.slice(header.length - (header as CryptorHeaderV1).metadataLength, header.length);
return File.create({
name: file.name,
data: await (this.defaultCryptor as ICryptor).decryptFileData({
data: data.slice(header.length),
metadata: metadata,
}),
});
}
// endregion
// --------------------------------------------------------
// ----------------------- Helpers ------------------------
// --------------------------------------------------------
// region Helpers
/**
* Retrieve registered cryptor by its identifier.
*
* @param id - Unique cryptor identifier.
*
* @returns Registered cryptor with specified identifier.
*
* @throws Error if cryptor with specified {@link id} can't be found.
*/
private getCryptorFromId(id: string) {
const cryptor = this.getAllCryptors().find((cryptor) => id === cryptor.identifier);
if (cryptor) return cryptor;
throw Error('Unknown cryptor error');
}
/**
* Retrieve cryptor by its identifier.
*
* @param header - Header with cryptor-defined data or raw cryptor identifier.
*
* @returns Cryptor which correspond to provided {@link header}.
*/
private getCryptor(header: CryptorHeader | string) {
if (typeof header === 'string') {
const cryptor = this.getAllCryptors().find((cryptor) => cryptor.identifier === header);
if (cryptor) return cryptor;
throw new Error('Unknown cryptor error');
} else if (header instanceof CryptorHeaderV1) {
return this.getCryptorFromId(header.identifier);
}
}
/**
* Create cryptor header data.
*
* @param encrypted - Encryption data object as source for header data.
*
* @returns Binary representation of the cryptor header data.
*/
private getHeaderData(encrypted: EncryptedDataType) {
if (!encrypted.metadata) return;
const header = CryptorHeader.from(this.defaultCryptor.identifier, encrypted.metadata);
const headerData = new Uint8Array(header!.length);
let pos = 0;
headerData.set(header!.data, pos);
pos += header!.length - encrypted.metadata.byteLength;
headerData.set(new Uint8Array(encrypted.metadata), pos);
return headerData.buffer;
}
/**
* Merge two {@link ArrayBuffer} instances.
*
* @param ab1 - First {@link ArrayBuffer}.
* @param ab2 - Second {@link ArrayBuffer}.
*
* @returns Merged data as {@link ArrayBuffer}.
*/
private concatArrayBuffer(ab1: ArrayBuffer, ab2: ArrayBuffer): ArrayBuffer {
const tmp = new Uint8Array(ab1.byteLength + ab2.byteLength);
tmp.set(new Uint8Array(ab1), 0);
tmp.set(new Uint8Array(ab2), ab1.byteLength);
return tmp.buffer;
}
/**
* Retrieve file content.
*
* @param file - Content of the {@link PubNub} File object.
*
* @returns Normalized file {@link data} as {@link ArrayBuffer};
*/
private async getFileData(file: PubNubFile | ArrayBuffer) {
if (file instanceof ArrayBuffer) return file;
else if (file instanceof PubNubFile) return file.toArrayBuffer();
throw new Error(
'Cannot decrypt/encrypt file. In browsers file encrypt/decrypt supported for string, ArrayBuffer or Blob',
);
}
}
/**
* CryptorHeader Utility
*/
class CryptorHeader {
static SENTINEL = 'PNED';
static LEGACY_IDENTIFIER = '';
static IDENTIFIER_LENGTH = 4;
static VERSION = 1;
static MAX_VERSION = 1;
static decoder = new TextDecoder();
static from(id: string, metadata: ArrayBuffer) {
if (id === CryptorHeader.LEGACY_IDENTIFIER) return;
return new CryptorHeaderV1(id, metadata.byteLength);
}
static tryParse(data: ArrayBuffer) {
const encryptedData = new Uint8Array(data);
let sentinel: Uint8Array;
let version = null;
if (encryptedData.byteLength >= 4) {
sentinel = encryptedData.slice(0, 4);
if (this.decoder.decode(sentinel) !== CryptorHeader.SENTINEL) return WebCryptoModule.LEGACY_IDENTIFIER;
}
if (encryptedData.byteLength >= 5) version = (encryptedData as Uint8Array)[4] as number;
else throw new Error('Decryption error: invalid header version');
if (version > CryptorHeader.MAX_VERSION) throw new Error('Decryption error: Unknown cryptor error');
let identifier: Uint8Array;
let pos = 5 + CryptorHeader.IDENTIFIER_LENGTH;
if (encryptedData.byteLength >= pos) identifier = encryptedData.slice(5, pos);
else throw new Error('Decryption error: invalid crypto identifier');
let metadataLength = null;
if (encryptedData.byteLength >= pos + 1) metadataLength = (encryptedData as Uint8Array)[pos];
else throw new Error('Decryption error: invalid metadata length');
pos += 1;
if (metadataLength === 255 && encryptedData.byteLength >= pos + 2) {
metadataLength = new Uint16Array(encryptedData.slice(pos, pos + 2)).reduce((acc, val) => (acc << 8) + val, 0);
}
return new CryptorHeaderV1(this.decoder.decode(identifier), metadataLength);
}
}
// v1 CryptorHeader
class CryptorHeaderV1 {
static IDENTIFIER_LENGTH = 4;
static SENTINEL = 'PNED';
_identifier;
_metadataLength;
constructor(id: string, metadataLength: number) {
this._identifier = id;
this._metadataLength = metadataLength;
}
get identifier() {
return this._identifier;
}
set identifier(value) {
this._identifier = value;
}
get metadataLength() {
return this._metadataLength;
}
set metadataLength(value) {
this._metadataLength = value;
}
get version() {
return CryptorHeader.VERSION;
}
get length() {
return (
CryptorHeader.SENTINEL.length +
1 +
CryptorHeader.IDENTIFIER_LENGTH +
(this.metadataLength < 255 ? 1 : 3) +
this.metadataLength
);
}
get data() {
let pos = 0;
const header = new Uint8Array(this.length);
const encoder = new TextEncoder();
header.set(encoder.encode(CryptorHeader.SENTINEL));
pos += CryptorHeader.SENTINEL.length;
header[pos] = this.version;
pos++;
if (this.identifier) header.set(encoder.encode(this.identifier), pos);
const metadataLength = this.metadataLength;
pos += CryptorHeader.IDENTIFIER_LENGTH;
if (metadataLength < 255) header[pos] = metadataLength;
else header.set([255, metadataLength >> 8, metadataLength & 0xff], pos);
return header;
}
}