225 lines
8.1 KiB
TypeScript
225 lines
8.1 KiB
TypeScript
import { Serializable, stringToBytes, AsBytes, AsSerializable } from "./encoding";
|
|
import { Err, ErrorCode } from "./error";
|
|
import {
|
|
PBKDF2Params,
|
|
AESKey,
|
|
AESEncryptionParams,
|
|
AESKeyParams,
|
|
RSAEncryptionParams,
|
|
RSAPrivateKey,
|
|
RSAPublicKey,
|
|
} from "./crypto";
|
|
import { getCryptoProvider as getProvider } from "./platform";
|
|
|
|
/**
|
|
* Base class for all **Container** implementations. In general, a **Container** is
|
|
* an object for holding data encrypted using a symmetric cipher. Implementations
|
|
* vary mostly in how the encryption key is generated. Sub classes must implement
|
|
* the [[unlock]] method and may likely also want to augment [[lock]], [[validate]],
|
|
* [[_fromRaw]] and [[_toRaw]].
|
|
*/
|
|
export abstract class BaseContainer extends Serializable {
|
|
/** Parameters used for encryption of content data */
|
|
@AsSerializable(AESEncryptionParams)
|
|
encryptionParams: AESEncryptionParams = new AESEncryptionParams();
|
|
|
|
/** Encrypted data */
|
|
@AsBytes()
|
|
encryptedData?: Uint8Array;
|
|
|
|
/**
|
|
* The key used for encryption. Sub classes must set this property in the [[unlock]] method.
|
|
*/
|
|
protected _key?: AESKey;
|
|
|
|
/**
|
|
* Encrypts the provided `data` and stores it in the container
|
|
*/
|
|
async setData(data: Uint8Array) {
|
|
if (!this._key) {
|
|
throw new Err(ErrorCode.ENCRYPTION_FAILED, "No encryption key provided!");
|
|
}
|
|
|
|
// Generate random initialization vector
|
|
this.encryptionParams.iv = await getProvider().randomBytes(16);
|
|
|
|
// Generate additional authenticated data.
|
|
// Note: Without knowing anything about the nature of the encrypted data,
|
|
// we can't really choose a meaningful value for this. In the future,
|
|
// we may want to provide the option to pass this as an argument but for now
|
|
// a random value should be sufficient.
|
|
this.encryptionParams.additionalData = await getProvider().randomBytes(16);
|
|
|
|
// Encrypt the data and store it.
|
|
this.encryptedData = await getProvider().encrypt(this._key, data, this.encryptionParams);
|
|
}
|
|
|
|
/**
|
|
* Decrypts and extracts the plain text data from the container. This will
|
|
* usually require unlocking the container first.
|
|
*/
|
|
async getData(): Promise<Uint8Array> {
|
|
if (!this.encryptedData || !this._key) {
|
|
throw new Err(ErrorCode.DECRYPTION_FAILED);
|
|
}
|
|
return await getProvider().decrypt(this._key, this.encryptedData, this.encryptionParams);
|
|
}
|
|
|
|
/**
|
|
* Unlocks the container, making it possible to extract the plain text
|
|
* data via [[getData]]. The type of **secret** provided will differ based
|
|
* on the encryption scheme used by implemenations.
|
|
*/
|
|
abstract unlock(secret: unknown): Promise<void>;
|
|
|
|
/**
|
|
* Locks the container, removing the possibility to extract the plain text data
|
|
* via [[getData]] until the container is unlocked again. Subclasses extending
|
|
* this class must take care to delete any keys or other sensitive data
|
|
* that may have been stored temporarily after unlocking the container.
|
|
*/
|
|
lock() {
|
|
delete this._key;
|
|
}
|
|
|
|
clone() {
|
|
const clone = super.clone();
|
|
clone._key = this._key;
|
|
return clone;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Most basic **Container** implementation where the encryption key is
|
|
* simply passed explicitly.
|
|
*/
|
|
export class SimpleContainer extends BaseContainer {
|
|
async unlock(key: AESKey) {
|
|
this._key = key;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Password-based **Container** that uses the
|
|
* [PBES2](https://tools.ietf.org/html/rfc2898#section-6.2) encryption scheme,
|
|
* deriving the encryption key from a user-provided passphrase.
|
|
*/
|
|
export class PBES2Container extends BaseContainer {
|
|
/** Parameters used for key derivation */
|
|
@AsSerializable(PBKDF2Params)
|
|
keyParams: PBKDF2Params = new PBKDF2Params();
|
|
|
|
/**
|
|
* Unlocks the container using the given **password**
|
|
*/
|
|
async unlock(password: string) {
|
|
if (!this.keyParams.salt.length) {
|
|
this.keyParams.salt = await getProvider().randomBytes(16);
|
|
}
|
|
this._key = await getProvider().deriveKey(stringToBytes(password), this.keyParams);
|
|
// If this container has data already, make sure the derived key properly decrypts it.
|
|
if (this.encryptedData) {
|
|
await this.getData();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents an individual with access to a [[SharedContainer]]. Each accessor is mapped
|
|
* to an entity owning a public/private key pair via their `id`.
|
|
*/
|
|
export class Accessor extends Serializable {
|
|
/**
|
|
* Identifier used to map an `Accessor` to the owner of the public key used to encrypt the shared key.
|
|
*/
|
|
id: string = "";
|
|
|
|
/** Shared key encrypted with the public key of the entity associated with the `Accessor` object */
|
|
@AsBytes()
|
|
encryptedKey: Uint8Array = new Uint8Array();
|
|
|
|
/**
|
|
* Public key used to encrypt the shared key
|
|
*/
|
|
@AsBytes()
|
|
publicKey?: Uint8Array;
|
|
}
|
|
|
|
/**
|
|
* The `SharedContainer` is used to securely share data between a number of
|
|
* accessors using a shared-key encryption scheme where the content data is
|
|
* encrypted using a randomly generated shared key that is then encrypted with
|
|
* each accessors public key and stored along the encrypted data. Accessors can
|
|
* then retrieve the shared key by decrypting it using their private key and
|
|
* use it to recover the original data.
|
|
*/
|
|
export class SharedContainer extends BaseContainer {
|
|
/** Parameters used to wrap the shared encryption key */
|
|
@AsSerializable(RSAEncryptionParams)
|
|
keyParams: RSAEncryptionParams = new RSAEncryptionParams();
|
|
|
|
/** The ids and encrypted keys of all accessors */
|
|
@AsSerializable(Accessor)
|
|
accessors: Accessor[] = [];
|
|
|
|
/**
|
|
* Unlocks the container using the id and private key of a given accessor.
|
|
* The id is used to look up the corresponding encrypted key while the
|
|
* private key is used to decrypt it.
|
|
*/
|
|
async unlock({ id, privateKey }: { id: string; privateKey: RSAPrivateKey }) {
|
|
if (this._key) {
|
|
// Container is already unlocked, no need to unlock it again
|
|
return;
|
|
}
|
|
|
|
// Find accessor object with the same id
|
|
const accessor = this.accessors.find((a) => a.id === id);
|
|
|
|
if (!accessor || !accessor.encryptedKey) {
|
|
// No corresponding accessor found.
|
|
throw new Err(ErrorCode.MISSING_ACCESS, "You no longer have access to this vault.");
|
|
}
|
|
|
|
// Decrypt shared key using provided private key
|
|
this._key = await getProvider().decrypt(privateKey, accessor.encryptedKey, this.keyParams);
|
|
}
|
|
|
|
/**
|
|
* Updates the containers accessors, generating a new shared key and encrypting
|
|
* it with the public keys of the provided **subjects**. Non-empty containers
|
|
* need to be unlocked first.
|
|
*/
|
|
async updateAccessors(subjects: { id: string; publicKey: RSAPublicKey }[]) {
|
|
// Get existing data so we can reencrypt it after rotating the key
|
|
let data: Uint8Array | null = null;
|
|
|
|
// If the container already contains data, we need to reencrypt it after generating a new key
|
|
if (this.encryptedData) {
|
|
if (!this._key) {
|
|
throw "Non-empty containers need to be unlocked before accessors can be updated!";
|
|
}
|
|
data = await this.getData();
|
|
}
|
|
|
|
// Updating the accessors also requires generating a new shared key
|
|
this._key = await getProvider().generateKey(new AESKeyParams());
|
|
|
|
// Reencrypt data with new key
|
|
if (data) {
|
|
await this.setData(data);
|
|
}
|
|
|
|
// Encrypt the shared key with the public key of each accessor and store it along with their id
|
|
this.accessors = await Promise.all(
|
|
subjects.map(async ({ id, publicKey }) => {
|
|
const accessor = new Accessor();
|
|
accessor.id = id;
|
|
accessor.publicKey = publicKey;
|
|
accessor.encryptedKey = await getProvider().encrypt(publicKey, this._key!, this.keyParams);
|
|
return accessor;
|
|
})
|
|
);
|
|
}
|
|
}
|