1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-29 04:17:41 +02:00

[PM-5614] introduce SecretState wrapper (#7823)

Matt provided a ton of help on getting the state interactions right. Both he 
and Justin collaborated with me to write the core of of the secret classifier.

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
✨ Audrey ✨ 2024-02-27 11:40:32 -05:00 committed by GitHub
parent 5a1f09a568
commit 36116bddda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1198 additions and 290 deletions

View File

@ -7,6 +7,7 @@ export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options";
export { UserKeyDefinition } from "./user-key-definition";
export * from "./state-definitions";

View File

@ -0,0 +1,21 @@
import { Jsonify } from "type-fest";
/** A packing strategy that packs data into a string.
*/
export abstract class DataPacker {
/**
* Packs value into a string format.
* @type {Data} is the type of data being protected.
* @param value is packed into the string
* @returns the packed string
*/
abstract pack<Data>(value: Data): string;
/** Unpacks a string produced by pack.
* @param packedValue is the string to unpack
* @type {Data} is the type of data being protected.
* @returns the data stored within the secret.
* @throws when `packedValue` has an invalid format.
*/
abstract unpack<Data>(packedValue: string): Jsonify<Data>;
}

View File

@ -0,0 +1,101 @@
import { PaddedDataPacker } from "./padded-data-packer";
describe("UserKeyEncryptor", () => {
describe("pack", () => {
it("should pack a stringified value", () => {
const dataPacker = new PaddedDataPacker(32);
const packed = dataPacker.pack({ foo: true });
expect(packed).toEqual("32|eyJmb28iOnRydWV9|000000000000");
});
it("should pad to a multiple of the frame size", () => {
const dataPacker = new PaddedDataPacker(8);
const packed = dataPacker.pack({ foo: true });
expect(packed.length).toEqual(24);
});
it("should pad to a multiple of the frame size", () => {
const dataPacker = new PaddedDataPacker(8);
const packed = dataPacker.pack({ foo: true });
expect(packed.length).toEqual(24);
});
});
describe("unpack", () => {
it("should unpack a value with the same frame size", () => {
const dataPacker = new PaddedDataPacker(32);
const unpacked = dataPacker.unpack("32|eyJmb28iOnRydWV9|000000000000");
expect(unpacked).toEqual({ foo: true });
});
it("should unpack a value with a different frame size", () => {
const dataPacker = new PaddedDataPacker(32);
const unpacked = dataPacker.unpack("24|eyJmb28iOnRydWV9|0000");
expect(unpacked).toEqual({ foo: true });
});
it("should unpack a value whose length is a multiple of the frame size", () => {
const dataPacker = new PaddedDataPacker(32);
const unpacked = dataPacker.unpack("16|eyJmb28iOnRydWV9|000000000000");
expect(unpacked).toEqual({ foo: true });
});
it("should throw an error when the frame size is missing", () => {
const dataPacker = new PaddedDataPacker(512);
const packed = `|eyJmb28iOnRydWV9|${"0".repeat(16)}`;
expect(() => dataPacker.unpack(packed)).toThrow("missing frame size");
});
it("should throw an error when the length is not a multiple of the frame size", () => {
const dataPacker = new PaddedDataPacker(16);
const packed = "16|eyJmb28iOnRydWV9|0";
expect(() => dataPacker.unpack(packed)).toThrow("invalid length");
});
it("should throw an error when the padding divider is missing", () => {
const dataPacker = new PaddedDataPacker(16);
const packed = "16|eyJmb28iOnRydWV90000000000000";
expect(() => dataPacker.unpack(packed)).toThrow("missing json object");
});
it("should throw an error when the padding contains a non-0 character", () => {
const dataPacker = new PaddedDataPacker(16);
const packed = "16|eyJmb28iOnRydWV9|000000000001";
expect(() => dataPacker.unpack(packed)).toThrow("invalid padding");
});
});
it("should unpack a packed JSON-literal value", () => {
const dataPacker = new PaddedDataPacker(8);
const input = { foo: true };
const packed = dataPacker.pack(input);
const unpacked = dataPacker.unpack(packed);
expect(unpacked).toEqual(input);
});
it("should unpack a packed JSON-serializable value", () => {
const dataPacker = new PaddedDataPacker(8);
const input = { foo: new Date(100) };
const packed = dataPacker.pack(input);
const unpacked = dataPacker.unpack(packed);
expect(unpacked).toEqual({ foo: "1970-01-01T00:00:00.100Z" });
});
});

View File

@ -0,0 +1,94 @@
import { Jsonify } from "type-fest";
import { Utils } from "../../../platform/misc/utils";
import { DataPacker as DataPackerAbstraction } from "./data-packer.abstraction";
const DATA_PACKING = Object.freeze({
/** The character to use for padding. */
padding: "0",
/** The character dividing packed data. */
divider: "|",
/** A regular expression for detecting invalid padding. When the character
* changes, this should be updated to include the new padding pattern.
*/
hasInvalidPadding: /[^0]/,
});
/** A packing strategy that conceals the length of secret data by padding it
* to a multiple of the frame size.
* @example
* // packed === "24|e2Zvbzp0cnVlfQ==|0000"
* const packer = new SecretPacker(24);
* const packed = packer.pack({ foo: true });
*/
export class PaddedDataPacker extends DataPackerAbstraction {
/** Instantiates the padded data packer
* @param frameSize The size of the dataframe used to pad encrypted values.
*/
constructor(private readonly frameSize: number) {
super();
}
/**
* Packs value into a string format that conceals the length by obscuring it
* with the frameSize.
* @see {@link DataPackerAbstraction.unpack}
*/
pack<Secret>(value: Secret) {
// encode the value
const json = JSON.stringify(value);
const b64 = Utils.fromUtf8ToB64(json);
// calculate packing metadata
const frameSize = JSON.stringify(this.frameSize);
const separatorLength = 2 * DATA_PACKING.divider.length; // there are 2 separators
const payloadLength = b64.length + frameSize.length + separatorLength;
const paddingLength = this.frameSize - (payloadLength % this.frameSize);
// pack the data, thereby concealing its length
const padding = DATA_PACKING.padding.repeat(paddingLength);
const packed = `${frameSize}|${b64}|${padding}`;
return packed;
}
/** {@link DataPackerAbstraction.unpack} */
unpack<Secret>(secret: string): Jsonify<Secret> {
// frame size is stored before the JSON payload in base 10
const frameBreakpoint = secret.indexOf(DATA_PACKING.divider);
if (frameBreakpoint < 1) {
throw new Error("missing frame size");
}
const frameSize = parseInt(secret.slice(0, frameBreakpoint), 10);
// The decrypted string should be a multiple of the frame length
if (secret.length % frameSize > 0) {
throw new Error("invalid length");
}
// encoded data terminates with the divider, followed by the padding character
const jsonBreakpoint = secret.lastIndexOf(DATA_PACKING.divider);
if (jsonBreakpoint == frameBreakpoint) {
throw new Error("missing json object");
}
const paddingBegins = jsonBreakpoint + 1;
// If the padding contains invalid padding characters then the padding could be used
// as a side channel for arbitrary data.
if (secret.slice(paddingBegins).match(DATA_PACKING.hasInvalidPadding)) {
throw new Error("invalid padding");
}
// remove frame size and padding
const b64 = secret.substring(frameBreakpoint, paddingBegins);
// unpack the stored data
const json = Utils.fromB64ToUtf8(b64);
const unpacked = JSON.parse(json);
return unpacked;
}
}

View File

@ -0,0 +1,177 @@
import { SecretClassifier } from "./secret-classifier";
describe("SecretClassifier", () => {
describe("forSecret", () => {
it("classifies a property as secret by default", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
expect(classifier.disclosed).toEqual([]);
expect(classifier.excluded).toEqual([]);
});
});
describe("disclose", () => {
it("adds a property to the disclosed list", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const withDisclosedFoo = classifier.disclose("foo");
expect(withDisclosedFoo.disclosed).toEqual(["foo"]);
expect(withDisclosedFoo.excluded).toEqual([]);
});
it("chains calls with excluded", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>();
const withDisclosedFoo = classifier.disclose("foo").exclude("bar");
expect(withDisclosedFoo.disclosed).toEqual(["foo"]);
expect(withDisclosedFoo.excluded).toEqual(["bar"]);
});
it("returns a new classifier", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const withDisclosedFoo = classifier.disclose("foo");
expect(withDisclosedFoo).not.toBe(classifier);
});
});
describe("exclude", () => {
it("adds a property to the excluded list", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const withExcludedFoo = classifier.exclude("foo");
expect(withExcludedFoo.disclosed).toEqual([]);
expect(withExcludedFoo.excluded).toEqual(["foo"]);
});
it("chains calls with disclose", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>();
const withExcludedFoo = classifier.exclude("foo").disclose("bar");
expect(withExcludedFoo.disclosed).toEqual(["bar"]);
expect(withExcludedFoo.excluded).toEqual(["foo"]);
});
it("returns a new classifier", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const withExcludedFoo = classifier.exclude("foo");
expect(withExcludedFoo).not.toBe(classifier);
});
});
describe("classify", () => {
it("partitions disclosed properties into the disclosed member", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
"foo",
);
const classified = classifier.classify({ foo: true, bar: false });
expect(classified.disclosed).toEqual({ foo: true });
});
it("deletes disclosed properties from the secret member", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
"foo",
);
const classified = classifier.classify({ foo: true, bar: false });
expect(classified.secret).toEqual({ bar: false });
});
it("deletes excluded properties from the secret member", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude(
"foo",
);
const classified = classifier.classify({ foo: true, bar: false });
expect(classified.secret).toEqual({ bar: false });
});
it("excludes excluded properties from the disclosed member", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude(
"foo",
);
const classified = classifier.classify({ foo: true, bar: false });
expect(classified.disclosed).toEqual({});
});
it("returns its input as the secret member", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const input = { foo: true };
const classified = classifier.classify(input);
expect(classified.secret).toEqual(input);
});
});
describe("declassify", () => {
it("merges disclosed and secret members", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
"foo",
);
const declassified = classifier.declassify({ foo: true }, { bar: false });
expect(declassified).toEqual({ foo: true, bar: false });
});
it("omits unknown disclosed members", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>().disclose("foo");
// `any` is required here because Typescript knows `bar` is not a disclosed member,
// but the feautre assumes the disclosed data bypassed the typechecker (e.g. someone
// is trying to clobber secret data.)
const declassified = classifier.declassify({ foo: true, bar: false } as any, {});
expect(declassified).toEqual({ foo: true });
});
it("clobbers disclosed members with secret members", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
"foo",
);
// `any` is required here because `declassify` knows `bar` is supposed to be public,
// but the feature assumes the secret data bypassed the typechecker (e.g. migrated data)
const declassified = classifier.declassify({ foo: true }, { foo: false, bar: false } as any);
expect(declassified).toEqual({ foo: false, bar: false });
});
it("omits excluded secret members", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude(
"foo",
);
// `any` is required here because `declassify` knows `bar` isn't allowed, but the
// feature assumes the data bypassed the typechecker (e.g. omitted legacy data).
const declassified = classifier.declassify({}, { foo: false, bar: false } as any);
expect(declassified).toEqual({ bar: false });
});
it("returns a new object", () => {
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
const disclosed = {};
const secret = { foo: false };
const declassified = classifier.declassify(disclosed, secret);
expect(declassified).not.toBe(disclosed);
expect(declassified).not.toBe(secret);
});
});
});

View File

@ -0,0 +1,137 @@
import { Jsonify } from "type-fest";
/** Classifies an object's JSON-serializable data by property into
* 3 categories:
* * Disclosed data MAY be stored in plaintext.
* * Excluded data MUST NOT be saved.
* * The remaining data is secret and MUST be stored using encryption.
*
* This type should not be used to classify functions.
* Data that cannot be serialized by JSON.stringify() should
* be excluded.
*/
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> {
private constructor(
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
excluded: readonly (keyof Plaintext)[],
) {
this.disclosed = disclosed;
this.excluded = excluded;
}
/** lists the disclosed properties. */
readonly disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[];
/** lists the excluded properties. */
readonly excluded: readonly (keyof Plaintext)[];
/** Creates a classifier where all properties are secret.
* @type {T} The type of secret being classified.
*/
static allSecret<T extends object>() {
const disclosed = Object.freeze([]);
const excluded = Object.freeze([]);
return new SecretClassifier<T, Record<keyof T, never>, T>(disclosed, excluded);
}
/** Classify a property as disclosed.
* @type {PropertyName} Available secrets to disclose.
* @param disclose The property name to disclose.
* @returns a new classifier
*/
disclose<const PropertyName extends keyof Jsonify<Secret>>(disclose: PropertyName) {
// move the property from the secret type to the disclose type
type NewDisclosed = Disclosed | Record<PropertyName, Jsonify<Secret>[PropertyName]>;
type NewSecret = Omit<Secret, PropertyName>;
// update the fluent interface
const newDisclosed = [...this.disclosed, disclose] as (keyof Jsonify<NewDisclosed> &
keyof Jsonify<Plaintext>)[];
const classifier = new SecretClassifier<Plaintext, NewDisclosed, NewSecret>(
// since `NewDisclosed` is opaque to the type checker, it's necessary
// to assert the type of the array here.
Object.freeze(newDisclosed),
this.excluded,
);
return classifier;
}
/** Classify a property as excluded.
* @type {PropertyName} Available secrets to exclude.
* @param exclude The property name to exclude.
* @returns a new classifier
*/
exclude<const PropertyName extends keyof Secret>(excludedPropertyName: PropertyName) {
// remove the property from the secret type
type NewConfidential = Omit<Secret, PropertyName>;
// update the fluent interface
const newExcluded = [...this.excluded, excludedPropertyName] as (keyof Plaintext)[];
const classifier = new SecretClassifier<Plaintext, Disclosed, NewConfidential>(
this.disclosed,
Object.freeze(newExcluded),
);
return classifier;
}
/** Partitions `secret` into its disclosed properties and secret properties.
* @param secret The object to partition
* @returns an object that classifies secrets.
* The `disclosed` member is new and contains disclosed properties.
* The `secret` member aliases the secret parameter, with all
* disclosed and excluded properties deleted.
*/
classify(secret: Plaintext): { disclosed: Disclosed; secret: Secret } {
const copy = { ...secret };
for (const excludedProp of this.excluded) {
delete copy[excludedProp];
}
const disclosed: Record<PropertyKey, unknown> = {};
for (const disclosedProp of this.disclosed) {
// disclosedProp is known to be a subset of the keys of `Plaintext`, so these
// type assertions are accurate.
// FIXME: prove it to the compiler
disclosed[disclosedProp] = copy[disclosedProp as unknown as keyof Plaintext];
delete copy[disclosedProp as unknown as keyof Plaintext];
}
return {
disclosed: disclosed as Disclosed,
secret: copy as unknown as Secret,
};
}
/** Merges the properties of `secret` and `disclosed`. When `secret` and
* `disclosed` contain the same property, the `secret` property overrides
* the `disclosed` property.
* @param disclosed an object whose disclosed properties are merged into
* the output. Unknown properties are ignored.
* @param secret an objects whose properties are merged into the output.
* Excluded properties are ignored. Unknown properties are retained.
* @returns a new object containing the merged data.
*/
// Declassified data is always jsonified--the purpose of classifying it is to Jsonify it,
// which causes type conversions.
declassify(disclosed: Jsonify<Disclosed>, secret: Jsonify<Secret>): Jsonify<Plaintext> {
// removed unknown keys from `disclosed` to prevent any old edit
// of plaintext data from being laundered though declassification.
const cleaned = {} as Partial<Jsonify<Disclosed>>;
for (const disclosedProp of this.disclosed) {
cleaned[disclosedProp] = disclosed[disclosedProp];
}
// merge decrypted into cleaned so that secret data clobbers public data
const merged: any = Object.assign(cleaned, secret);
// delete excluded props
for (const excludedProp of this.excluded) {
delete merged[excludedProp];
}
return merged as Jsonify<Plaintext>;
}
}

View File

@ -0,0 +1,207 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, from } from "rxjs";
import { Jsonify } from "type-fest";
import {
FakeStateProvider,
makeEncString,
mockAccountServiceWith,
awaitAsync,
} from "../../../../spec";
import { EncString } from "../../../platform/models/domain/enc-string";
import { KeyDefinition, GENERATOR_DISK } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { SecretState } from "./secret-state";
import { UserEncryptor } from "./user-encryptor.abstraction";
type FooBar = { foo: boolean; bar: boolean; date?: Date };
const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", {
deserializer: (fb) => {
const result: FooBar = { foo: fb.foo, bar: fb.bar };
if (fb.date) {
result.date = new Date(fb.date);
}
return result;
},
});
const SomeUser = "some user" as UserId;
function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<string, never>> {
// stores "encrypted values" so that they can be "decrypted" later
// while allowing the operations to be interleaved.
const encrypted = new Map<string, Jsonify<FooBar>>(
fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const),
);
const result = mock<UserEncryptor<FooBar, Record<string, never>>>({
encrypt(value: FooBar, user: UserId) {
const encString = toKey(value);
encrypted.set(encString.encryptedString, toValue(value));
return Promise.resolve({ secret: encString, disclosed: {} });
},
decrypt(secret: EncString, disclosed: Record<string, never>, userId: UserId) {
const decString = encrypted.get(toValue(secret.encryptedString));
return Promise.resolve(decString);
},
});
function toKey(value: FooBar) {
// `stringify` is only relevant for its uniqueness as a key
// to `encrypted`.
return makeEncString(JSON.stringify(value));
}
function toValue(value: any) {
// replace toJSON types with their round-trip equivalents
return JSON.parse(JSON.stringify(value));
}
// chromatic pops a false positive about missing `encrypt` and `decrypt`
// functions, so assert the type manually.
return result as unknown as UserEncryptor<FooBar, Record<string, never>>;
}
async function fakeStateProvider() {
const accountService = mockAccountServiceWith(SomeUser);
const stateProvider = new FakeStateProvider(accountService);
return stateProvider;
}
describe("UserEncryptor", () => {
describe("from", () => {
it("returns a state store", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const result = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
expect(result).toBeInstanceOf(SecretState);
});
});
describe("instance", () => {
it("gets a set value", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: false };
await state.update(() => value);
await awaitAsync();
const result = await firstValueFrom(state.state$);
expect(result).toEqual(value);
});
it("round-trips json-serializable values", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: true, date: new Date(1) };
await state.update(() => value);
await awaitAsync();
const result = await firstValueFrom(state.state$);
expect(result).toEqual(value);
});
it("gets the last set value", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const initialValue = { foo: true, bar: false };
const replacementValue = { foo: false, bar: false };
await state.update(() => initialValue);
await state.update(() => replacementValue);
await awaitAsync();
const result = await firstValueFrom(state.state$);
expect(result).toEqual(replacementValue);
});
it("interprets shouldUpdate option", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const initialValue = { foo: true, bar: false };
const replacementValue = { foo: false, bar: false };
await state.update(() => initialValue, { shouldUpdate: () => true });
await state.update(() => replacementValue, { shouldUpdate: () => false });
const result = await firstValueFrom(state.state$);
expect(result).toEqual(initialValue);
});
it("sets the state to `null` when `update` returns `null`", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: false };
await state.update(() => value);
await state.update(() => null);
await awaitAsync();
const result = await firstValueFrom(state.state$);
expect(result).toEqual(null);
});
it("sets the state to `null` when `update` returns `undefined`", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: false };
await state.update(() => value);
await state.update(() => undefined);
await awaitAsync();
const result = await firstValueFrom(state.state$);
expect(result).toEqual(null);
});
it("sends rxjs observables into the shouldUpdate method", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const combinedWith$ = from([1]);
let combinedShouldUpdate = 0;
await state.update((value) => value, {
shouldUpdate: (_, combined) => {
combinedShouldUpdate = combined;
return true;
},
combineLatestWith: combinedWith$,
});
expect(combinedShouldUpdate).toEqual(1);
});
it("sends rxjs observables into the update method", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const combinedWith$ = from([1]);
let combinedUpdate = 0;
await state.update(
(value, combined) => {
combinedUpdate = combined;
return value;
},
{
combineLatestWith: combinedWith$,
},
);
expect(combinedUpdate).toEqual(1);
});
});
});

View File

@ -0,0 +1,192 @@
import { Observable, concatMap, of, zip } from "rxjs";
import { Jsonify } from "type-fest";
import { EncString } from "../../../platform/models/domain/enc-string";
import {
DeriveDefinition,
DerivedState,
KeyDefinition,
SingleUserState,
StateProvider,
StateUpdateOptions,
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { UserEncryptor } from "./user-encryptor.abstraction";
/** Describes the structure of data stored by the SecretState's
* encrypted state. Notably, this interface ensures that `Disclosed`
* round trips through JSON serialization.
*/
type ClassifiedFormat<Disclosed> = {
/** Serialized {@link EncString} of the secret state's
* secret-level classified data.
*/
secret: string;
/** serialized representation of the secret state's
* disclosed-level classified data.
*/
disclosed: Jsonify<Disclosed>;
};
/** Stores account-specific secrets protected by a UserKeyEncryptor.
*
* @remarks This state store changes the structure of `Plaintext` during
* storage, and requires user keys to operate. It is incompatible with sync,
* which expects the disk storage format to be identical to the sync format.
*
* DO NOT USE THIS for synchronized data.
*/
export class SecretState<Plaintext extends object, Disclosed> {
// The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together.
private constructor(
private readonly encryptor: UserEncryptor<Plaintext, Disclosed>,
private readonly encrypted: SingleUserState<ClassifiedFormat<Disclosed>>,
private readonly plaintext: DerivedState<Plaintext>,
) {
this.state$ = plaintext.state$;
}
/** Creates a secret state bound to an account encryptor. The account must be unlocked
* when this method is called.
* @param userId: the user to which the secret state is bound.
* @param key Converts between a declassified secret and its formal type.
* @param provider constructs state objects.
* @param encryptor protects `Secret` data.
* @throws when `key.stateDefinition` is backed by memory storage.
* @remarks Secrets are written to a secret store as a named tuple. Data classification is
* determined by the encryptor's classifier. Secret-classification data is jsonified,
* encrypted, and stored in a `secret` property. Disclosed-classification data is stored
* in a `disclosed` property. Omitted-classification data is not stored.
*/
static from<TFrom extends object, Disclosed>(
userId: UserId,
key: KeyDefinition<TFrom>,
provider: StateProvider,
encryptor: UserEncryptor<TFrom, Disclosed>,
) {
// construct encrypted backing store while avoiding collisions between the derived key and the
// backing storage key.
const secretKey = new KeyDefinition<ClassifiedFormat<Disclosed>>(key.stateDefinition, key.key, {
cleanupDelayMs: key.cleanupDelayMs,
// FIXME: When the fakes run deserializers and serialization can be guaranteed through
// state providers, decode `jsonValue.secret` instead of it running in `derive`.
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Disclosed>,
});
const encryptedState = provider.getUser(userId, secretKey);
// construct plaintext store
const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>(
secretKey,
{
derive: async (from) => {
// fail fast if there's no value
if (from === null || from === undefined) {
return null;
}
// otherwise forward the decrypted data to the caller's derive implementation
const secret = EncString.fromJSON(from.secret);
const decrypted = await encryptor.decrypt(secret, from.disclosed, encryptedState.userId);
const result = key.deserializer(decrypted) as TFrom;
return result;
},
// wire in the caller's deserializer for memory serialization
deserializer: key.deserializer,
// cache the decrypted data in memory
cleanupDelayMs: key.cleanupDelayMs,
},
);
const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null);
// wrap the encrypted and plaintext states in a `SecretState` facade
const secretState = new SecretState(encryptor, encryptedState, plaintextState);
return secretState;
}
/** Observes changes to the decrypted secret state. The observer
* updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked.
*/
readonly state$: Observable<Plaintext>;
/** Updates the secret stored by this state.
* @param configureState a callback that returns an updated decrypted
* secret state. The callback receives the state's present value as its
* first argument and the dependencies listed in `options.combinedLatestWith`
* as its second argument.
* @param options configures how the update is applied. See {@link StateUpdateOptions}.
* @returns a promise that resolves with the updated value read from the state.
* The round-trip encrypts, decrypts, and deserializes the data, producing a new
* object.
* @remarks `configureState` must return a JSON-serializable object.
* If there are properties of your class which are not JSON-serializable,
* they can be lost when the secret state updates its backing store.
*/
async update<TCombine>(
configureState: (state: Plaintext, dependencies: TCombine) => Plaintext,
options: StateUpdateOptions<Plaintext, TCombine> = null,
): Promise<Plaintext> {
// reactively grab the latest state from the caller. `zip` requires each
// observable has a value, so `combined$` provides a default if necessary.
const combined$ = options?.combineLatestWith ?? of(undefined);
const newState$ = zip(this.plaintext.state$, combined$).pipe(
concatMap(([currentState, combined]) =>
this.prepareCryptoState(
currentState,
() => options?.shouldUpdate?.(currentState, combined) ?? true,
() => configureState(currentState, combined),
),
),
);
// update the backing store
let latestValue: Plaintext = null;
await this.encrypted.update((_, [, newStoredState]) => newStoredState, {
combineLatestWith: newState$,
shouldUpdate: (_, [shouldUpdate, , newState]) => {
// need to grab the latest value from the closure since the derived state
// could return its cached value, and this must be done in `shouldUpdate`
// because `configureState` may not run.
latestValue = newState;
return shouldUpdate;
},
});
return latestValue;
}
private async prepareCryptoState(
currentState: Plaintext,
shouldUpdate: () => boolean,
configureState: () => Plaintext,
): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> {
// determine whether an update is necessary
if (!shouldUpdate()) {
return [false, undefined, currentState];
}
// calculate the update
const newState = configureState();
if (newState === null || newState === undefined) {
return [true, newState as any, newState];
}
// the encrypt format *is* the storage format, so if the shape of that data changes,
// this needs to map it explicitly for compatibility purposes.
const newStoredState = await this.encryptor.encrypt(newState, this.encrypted.userId);
// the deserializer in the plaintextState's `derive` configuration always runs, but
// `encryptedState` is not guaranteed to serialize the data, so it's necessary to
// round-trip it proactively. This will cause some duplicate work in those situations
// where the backing store does deserialize the data.
//
// FIXME: Once there's a backing store configuration setting guaranteeing serialization,
// remove this code and configure the backing store as appropriate.
const serializedState = JSON.parse(JSON.stringify(newStoredState));
return [true, serializedState, newState];
}
}

View File

@ -0,0 +1,42 @@
import { Jsonify } from "type-fest";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
/** A classification strategy that protects a type's secrets with
* user-specific information. The specific kind of information is
* determined by the classification strategy.
*/
export abstract class UserEncryptor<State extends object, Disclosed> {
/** Protects secrets in `value` with a user-specific key.
* @param value the object to protect. This object is mutated during encryption.
* @param userId identifies the user-specific information used to protect
* the secret.
* @returns a promise that resolves to a tuple. The tuple's first property contains
* the encrypted secret and whose second property contains an object w/ disclosed
* properties.
* @throws If `value` is `null` or `undefined`, the promise rejects with an error.
*/
abstract encrypt(
value: State,
userId: UserId,
): Promise<{ secret: EncString; disclosed: Disclosed }>;
/** Combines protected secrets and disclosed data into a type that can be
* rehydrated into a domain object.
* @param secret an encrypted JSON payload containing State's secrets.
* @param disclosed a data object containing State's disclosed properties.
* @param userId identifies the user-specific information used to protect
* the secret.
* @returns a promise that resolves to the raw state. This state *is not* a
* class. It contains only data that can be round-tripped through JSON,
* and lacks members such as a prototype or bound functions.
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
* rejects with an error.
*/
abstract decrypt(
secret: EncString,
disclosed: Jsonify<Disclosed>,
userId: UserId,
): Promise<Jsonify<State>>;
}

View File

@ -0,0 +1,140 @@
import { mock } from "jest-mock-extended";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../../types/csprng";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { DataPacker } from "./data-packer.abstraction";
import { SecretClassifier } from "./secret-classifier";
import { UserKeyEncryptor } from "./user-key-encryptor";
describe("UserKeyEncryptor", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const dataPacker = mock<DataPacker>();
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
const anyUserId = "foo" as UserId;
beforeEach(() => {
// The UserKeyEncryptor is, in large part, a facade coordinating a handful of worker
// objects, so its tests focus on how data flows between components. The defaults rely
// on this property--that the facade treats its data like a opaque objects--to trace
// the data through several function calls. Should the encryptor interact with the
// objects themselves, it will break.
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string));
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("encrypt", () => {
it("should throw if value was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>();
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow(
"value cannot be null or undefined",
);
await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow(
"value cannot be null or undefined",
);
});
it("should throw if userId was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>();
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.encrypt({} as any, null)).rejects.toThrow(
"userId cannot be null or undefined",
);
await expect(encryptor.encrypt({} as any, undefined)).rejects.toThrow(
"userId cannot be null or undefined",
);
});
it("should classify data into a disclosed value and an encrypted packed value using the user's key", async () => {
const classifier = SecretClassifier.allSecret<object>();
const classifierClassify = jest.spyOn(classifier, "classify");
const disclosed = {} as any;
const secret = {} as any;
classifierClassify.mockReturnValue({ disclosed, secret });
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
const value = { foo: true };
const result = await encryptor.encrypt(value, anyUserId);
expect(classifierClassify).toHaveBeenCalledWith(value);
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(dataPacker.pack).toHaveBeenCalledWith(secret);
expect(encryptService.encrypt).toHaveBeenCalledWith(secret, userKey);
expect(result.secret).toBe(secret);
expect(result.disclosed).toBe(disclosed);
});
});
describe("decrypt", () => {
it("should throw if secret was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>();
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.decrypt(null, {} as any, anyUserId)).rejects.toThrow(
"secret cannot be null or undefined",
);
await expect(encryptor.decrypt(undefined, {} as any, anyUserId)).rejects.toThrow(
"secret cannot be null or undefined",
);
});
it("should throw if disclosed was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>();
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.decrypt({} as any, null, anyUserId)).rejects.toThrow(
"disclosed cannot be null or undefined",
);
await expect(encryptor.decrypt({} as any, undefined, anyUserId)).rejects.toThrow(
"disclosed cannot be null or undefined",
);
});
it("should throw if userId was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>();
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.decrypt({} as any, {} as any, null)).rejects.toThrow(
"userId cannot be null or undefined",
);
await expect(encryptor.decrypt({} as any, {} as any, undefined)).rejects.toThrow(
"userId cannot be null or undefined",
);
});
it("should declassify a decrypted packed value using the user's key", async () => {
const classifier = SecretClassifier.allSecret<object>();
const classifierDeclassify = jest.spyOn(classifier, "declassify");
const declassified = {} as any;
classifierDeclassify.mockReturnValue(declassified);
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
const secret = "encrypted" as any;
const disclosed = {} as any;
const result = await encryptor.decrypt(secret, disclosed, anyUserId);
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(classifierDeclassify).toHaveBeenCalledWith(disclosed, secret);
expect(result).toBe(declassified);
});
});
});

View File

@ -0,0 +1,83 @@
import { Jsonify } from "type-fest";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
import { DataPacker } from "./data-packer.abstraction";
import { SecretClassifier } from "./secret-classifier";
import { UserEncryptor } from "./user-encryptor.abstraction";
/** A classification strategy that protects a type's secrets by encrypting them
* with a `UserKey`
*/
export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends UserEncryptor<
State,
Disclosed
> {
/** Instantiates the encryptor
* @param encryptService protects properties of `Secret`.
* @param keyService looks up the user key when protecting data.
* @param classifier partitions secrets and disclosed information.
* @param dataPacker packs and unpacks data classified as secrets.
*/
constructor(
private readonly encryptService: EncryptService,
private readonly keyService: CryptoService,
private readonly classifier: SecretClassifier<State, Disclosed, Secret>,
private readonly dataPacker: DataPacker,
) {
super();
}
/** {@link UserEncryptor.encrypt} */
async encrypt(
value: State,
userId: UserId,
): Promise<{ secret: EncString; disclosed: Disclosed }> {
this.assertHasValue("value", value);
this.assertHasValue("userId", userId);
const classified = this.classifier.classify(value);
let packed = this.dataPacker.pack(classified.secret);
// encrypt the data and drop the key
let key = await this.keyService.getUserKey(userId);
const secret = await this.encryptService.encrypt(packed, key);
packed = null;
key = null;
return { ...classified, secret };
}
/** {@link UserEncryptor.decrypt} */
async decrypt(
secret: EncString,
disclosed: Jsonify<Disclosed>,
userId: UserId,
): Promise<Jsonify<State>> {
this.assertHasValue("secret", secret);
this.assertHasValue("disclosed", disclosed);
this.assertHasValue("userId", userId);
// decrypt the data and drop the key
let key = await this.keyService.getUserKey(userId);
let decrypted = await this.encryptService.decryptToUtf8(secret, key);
key = null;
// reconstruct TFrom's data
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;
const jsonValue = this.classifier.declassify(disclosed, unpacked);
return jsonValue;
}
private assertHasValue(name: string, value: any) {
if (value === undefined || value === null) {
throw new Error(`${name} cannot be null or undefined`);
}
}
}

View File

@ -2,20 +2,9 @@
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { DefaultOptions, Forwarders } from "./constants";
import { ApiOptions } from "./forwarder-options";
import { UsernameGeneratorOptions, MaybeLeakedOptions } from "./generator-options";
import {
getForwarderOptions,
falsyDefault,
encryptInPlace,
decryptInPlace,
forAllForwarders,
} from "./utilities";
import { UsernameGeneratorOptions } from "./generator-options";
import { getForwarderOptions, falsyDefault, forAllForwarders } from "./utilities";
const TestOptions: UsernameGeneratorOptions = {
type: "word",
@ -61,17 +50,6 @@ const TestOptions: UsernameGeneratorOptions = {
},
};
function mockEncryptService(): EncryptService {
return {
encrypt: jest
.fn()
.mockImplementation((plainText: string, _key: SymmetricCryptoKey) => plainText),
decryptToUtf8: jest
.fn()
.mockImplementation((cryptoText: string, _key: SymmetricCryptoKey) => cryptoText),
} as unknown as EncryptService;
}
describe("Username Generation Options", () => {
describe("forAllForwarders", () => {
it("runs the function on every forwarder.", () => {
@ -256,153 +234,4 @@ describe("Username Generation Options", () => {
});
});
});
describe("encryptInPlace", () => {
it("should return without encrypting if a token was not supplied", async () => {
const encryptService = mockEncryptService();
// throws if modified, failing the test
const options = Object.freeze({});
await encryptInPlace(encryptService, null, options);
expect(encryptService.encrypt).toBeCalledTimes(0);
});
it.each([
["a token", { token: "a token" }, `{"token":"a token"}${"0".repeat(493)}`, "a key"],
[
"a token and wasPlainText",
{ token: "a token", wasPlainText: true },
`{"token":"a token","wasPlainText":true}${"0".repeat(473)}`,
"another key",
],
[
"a really long token",
{ token: `a ${"really ".repeat(50)}long token` },
`{"token":"a ${"really ".repeat(50)}long token"}${"0".repeat(138)}`,
"a third key",
],
[
"a really long token and wasPlainText",
{ token: `a ${"really ".repeat(50)}long token`, wasPlainText: true },
`{"token":"a ${"really ".repeat(50)}long token","wasPlainText":true}${"0".repeat(118)}`,
"a key",
],
] as unknown as [string, ApiOptions & MaybeLeakedOptions, string, SymmetricCryptoKey][])(
"encrypts %s and removes encrypted values",
async (_description, options, encryptedToken, key) => {
const encryptService = mockEncryptService();
await encryptInPlace(encryptService, key, options);
expect(options.encryptedToken).toEqual(encryptedToken);
expect(options).not.toHaveProperty("token");
expect(options).not.toHaveProperty("wasPlainText");
// Why `encryptedToken`? The mock outputs its input without encryption.
expect(encryptService.encrypt).toBeCalledWith(encryptedToken, key);
},
);
});
describe("decryptInPlace", () => {
it("should return without decrypting if an encryptedToken was not supplied", async () => {
const encryptService = mockEncryptService();
// throws if modified, failing the test
const options = Object.freeze({});
await decryptInPlace(encryptService, null, options);
expect(encryptService.decryptToUtf8).toBeCalledTimes(0);
});
it.each([
["a simple token", `{"token":"a token"}${"0".repeat(493)}`, { token: "a token" }, "a key"],
[
"a simple leaked token",
`{"token":"a token","wasPlainText":true}${"0".repeat(473)}`,
{ token: "a token", wasPlainText: true },
"another key",
],
[
"a long token",
`{"token":"a ${"really ".repeat(50)}long token"}${"0".repeat(138)}`,
{ token: `a ${"really ".repeat(50)}long token` },
"a third key",
],
[
"a long leaked token",
`{"token":"a ${"really ".repeat(50)}long token","wasPlainText":true}${"0".repeat(118)}`,
{ token: `a ${"really ".repeat(50)}long token`, wasPlainText: true },
"a key",
],
] as [string, string, ApiOptions & MaybeLeakedOptions, string][])(
"decrypts %s and removes encrypted values",
async (_description, encryptedTokenString, expectedOptions, keyString) => {
const encryptService = mockEncryptService();
// cast through unknown to avoid type errors; the mock doesn't need the real types
// since it just outputs its input
const key = keyString as unknown as SymmetricCryptoKey;
const encryptedToken = encryptedTokenString as unknown as EncString;
const actualOptions = { encryptedToken } as any;
await decryptInPlace(encryptService, key, actualOptions);
expect(actualOptions.token).toEqual(expectedOptions.token);
expect(actualOptions.wasPlainText).toEqual(expectedOptions.wasPlainText);
expect(actualOptions).not.toHaveProperty("encryptedToken");
// Why `encryptedToken`? The mock outputs its input without encryption.
expect(encryptService.decryptToUtf8).toBeCalledWith(encryptedToken, key);
},
);
it.each([
["invalid length", "invalid length", "invalid"],
["all padding", "missing json object", `${"0".repeat(512)}`],
[
"invalid padding",
"invalid padding",
`{"token":"a token","wasPlainText":true} ${"0".repeat(472)}`,
],
["only closing brace", "invalid json", `}${"0".repeat(511)}`],
["token is NaN", "invalid json", `{"token":NaN}${"0".repeat(499)}`],
["only unknown key", "unknown keys", `{"unknown":"key"}${"0".repeat(495)}`],
["unknown key", "unknown keys", `{"token":"some token","unknown":"key"}${"0".repeat(474)}`],
[
"unknown key with wasPlainText",
"unknown keys",
`{"token":"some token","wasPlainText":true,"unknown":"key"}${"0".repeat(454)}`,
],
["empty json object", "invalid token", `{}${"0".repeat(510)}`],
["token is a number", "invalid token", `{"token":5}${"0".repeat(501)}`],
[
"wasPlainText is false",
"invalid wasPlainText",
`{"token":"foo","wasPlainText":false}${"0".repeat(476)}`,
],
[
"wasPlainText is string",
"invalid wasPlainText",
`{"token":"foo","wasPlainText":"fal"}${"0".repeat(476)}`,
],
])(
"should delete untrusted encrypted values (description %s, reason: %s) ",
async (_description, expectedReason, encryptedToken) => {
const encryptService = mockEncryptService();
// cast through unknown to avoid type errors; the mock doesn't need the real types
// since it just outputs its input
const key: SymmetricCryptoKey = "a key" as unknown as SymmetricCryptoKey;
const options = { encryptedToken: encryptedToken as unknown as EncString };
const reason = await decryptInPlace(encryptService, key, options);
expect(options).not.toHaveProperty("encryptedToken");
expect(reason).toEqual(expectedReason);
},
);
});
});

View File

@ -1,7 +1,4 @@
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { DefaultOptions, Forwarders, SecretPadding } from "./constants";
import { DefaultOptions, Forwarders } from "./constants";
import { ApiOptions, ForwarderId } from "./forwarder-options";
import { MaybeLeakedOptions, UsernameGeneratorOptions } from "./generator-options";
@ -73,116 +70,3 @@ export function falsyDefault<T>(value: T, defaults: Partial<T>): T {
return value;
}
/** encrypts sensitive options and stores them in-place.
* @param encryptService The service used to encrypt the options.
* @param key The key used to encrypt the options.
* @param options The options to encrypt. The encrypted members are
* removed from the options and the decrypted members
* are added to the options.
*/
export async function encryptInPlace(
encryptService: EncryptService,
key: SymmetricCryptoKey,
options: ApiOptions & MaybeLeakedOptions,
) {
if (!options.token) {
return;
}
// pick the options that require encryption
const encryptOptions = (({ token, wasPlainText }) => ({ token, wasPlainText }))(options);
delete options.token;
delete options.wasPlainText;
// don't leak whether a leak was possible by padding the encrypted string.
// without this, it could be possible to determine whether the token was
// encrypted by checking the length of the encrypted string.
const toEncrypt = JSON.stringify(encryptOptions).padEnd(
SecretPadding.length,
SecretPadding.character,
);
const encrypted = await encryptService.encrypt(toEncrypt, key);
options.encryptedToken = encrypted;
}
/** decrypts sensitive options and stores them in-place.
* @param encryptService The service used to decrypt the options.
* @param key The key used to decrypt the options.
* @param options The options to decrypt. The encrypted members are
* removed from the options and the decrypted members
* are added to the options.
* @returns null if the options were decrypted successfully, otherwise
* a string describing why the options could not be decrypted.
* The return values are intended to be used for logging and debugging.
* @remarks This method does not throw if the options could not be decrypted
* because in such cases there's nothing the user can do to fix it.
*/
export async function decryptInPlace(
encryptService: EncryptService,
key: SymmetricCryptoKey,
options: ApiOptions & MaybeLeakedOptions,
) {
if (!options.encryptedToken) {
return "missing encryptedToken";
}
const decrypted = await encryptService.decryptToUtf8(options.encryptedToken, key);
delete options.encryptedToken;
// If the decrypted string is not exactly the padding length, it could be compromised
// and shouldn't be trusted.
if (decrypted.length !== SecretPadding.length) {
return "invalid length";
}
// JSON terminates with a closing brace, after which the plaintext repeats `character`
// If the closing brace is not found, then it could be compromised and shouldn't be trusted.
const jsonBreakpoint = decrypted.lastIndexOf("}") + 1;
if (jsonBreakpoint < 1) {
return "missing json object";
}
// If the padding contains invalid padding characters then the padding could be used
// as a side channel for arbitrary data.
if (decrypted.substring(jsonBreakpoint).match(SecretPadding.hasInvalidPadding)) {
return "invalid padding";
}
// remove padding and parse the JSON
const json = decrypted.substring(0, jsonBreakpoint);
const { decryptedOptions, error } = parseOptions(json);
if (error) {
return error;
}
Object.assign(options, decryptedOptions);
}
function parseOptions(json: string) {
let decryptedOptions = null;
try {
decryptedOptions = JSON.parse(json);
} catch {
return { decryptedOptions: undefined as string, error: "invalid json" };
}
// If the decrypted options contain any property that is not in the original
// options, then the object could be used as a side channel for arbitrary data.
if (Object.keys(decryptedOptions).some((key) => key !== "token" && key !== "wasPlainText")) {
return { decryptedOptions: undefined as string, error: "unknown keys" };
}
// If the decrypted properties are not the expected type, then the object could
// be compromised and shouldn't be trusted.
if (typeof decryptedOptions.token !== "string") {
return { decryptedOptions: undefined as string, error: "invalid token" };
}
if (decryptedOptions.wasPlainText !== undefined && decryptedOptions.wasPlainText !== true) {
return { decryptedOptions: undefined as string, error: "invalid wasPlainText" };
}
return { decryptedOptions, error: undefined as string };
}