1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

move state provider extensions to tools folder (#9436)

This commit is contained in:
✨ Audrey ✨
2024-05-30 15:37:40 -04:00
committed by GitHub
parent 41fbac7151
commit 90e7e16df6
21 changed files with 46 additions and 46 deletions

View File

@@ -0,0 +1,196 @@
import { Observable, map, concatMap, share, ReplaySubject, timer } from "rxjs";
import { EncString } from "../../platform/models/domain/enc-string";
import {
SingleUserState,
StateProvider,
StateUpdateOptions,
CombinedState,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { ClassifiedFormat } from "./classified-format";
import { SecretKeyDefinition } from "./secret-key-definition";
import { UserEncryptor } from "./user-encryptor.abstraction";
const ONE_MINUTE = 1000 * 60;
/** 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<Outer, Id, Plaintext extends object, Disclosed, Secret>
implements SingleUserState<Outer>
{
// The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together.
private constructor(
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
private readonly encryptor: UserEncryptor,
userId: UserId,
provider: StateProvider,
) {
// construct the backing store
this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey());
// cache plaintext
this.combinedState$ = this.encryptedState.combinedState$.pipe(
concatMap(
async ([userId, state]) => [userId, await this.declassifyAll(state)] as [UserId, Outer],
),
share({
connector: () => {
return new ReplaySubject<[UserId, Outer]>(1);
},
resetOnRefCountZero: () => timer(key.options.cleanupDelayMs ?? ONE_MINUTE),
}),
);
this.state$ = this.combinedState$.pipe(map(([, state]) => state));
}
private readonly encryptedState: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>;
/** {@link SingleUserState.userId} */
get userId() {
return this.encryptedState.userId;
}
/** 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<Outer>;
/** {@link SingleUserState.combinedState$} */
readonly combinedState$: Observable<CombinedState<Outer>>;
/** 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<Outer, Id, TFrom extends object, Disclosed, Secret>(
userId: UserId,
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
provider: StateProvider,
encryptor: UserEncryptor,
) {
const secretState = new SecretState(key, encryptor, userId, provider);
return secretState;
}
private async declassifyItem({ id, secret, disclosed }: ClassifiedFormat<Id, Disclosed>) {
const encrypted = EncString.fromJSON(secret);
const decrypted = await this.encryptor.decrypt(encrypted, this.encryptedState.userId);
const declassified = this.key.classifier.declassify(disclosed, decrypted);
const result = [id, this.key.options.deserializer(declassified)] as const;
return result;
}
private async declassifyAll(data: ClassifiedFormat<Id, Disclosed>[]) {
// fail fast if there's no value
if (data === null || data === undefined) {
return null;
}
// decrypt each item
const decryptTasks = data.map(async (item) => this.declassifyItem(item));
// reconstruct expected type
const results = await Promise.all(decryptTasks);
const result = this.key.reconstruct(results);
return result;
}
private async classifyItem([id, item]: [Id, Plaintext]) {
const classified = this.key.classifier.classify(item);
const encrypted = await this.encryptor.encrypt(classified.secret, this.encryptedState.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 `encrypted` proactively.
const serialized = {
id,
secret: JSON.parse(JSON.stringify(encrypted)),
disclosed: classified.disclosed,
} as ClassifiedFormat<Id, Disclosed>;
return serialized;
}
private async classifyAll(data: Outer) {
// fail fast if there's no value
if (data === null || data === undefined) {
return null;
}
// convert the object to a list format so that all encrypt and decrypt
// operations are self-similar
const desconstructed = this.key.deconstruct(data);
// encrypt each value individually
const classifyTasks = desconstructed.map(async (item) => this.classifyItem(item));
const classified = await Promise.all(classifyTasks);
return classified;
}
/** 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: Outer, dependencies: TCombine) => Outer,
options: StateUpdateOptions<Outer, TCombine> = null,
): Promise<Outer> {
// read the backing store
let latestClassified: ClassifiedFormat<Id, Disclosed>[];
let latestCombined: TCombine;
await this.encryptedState.update((c) => c, {
shouldUpdate: (latest, combined) => {
latestClassified = latest;
latestCombined = combined;
return false;
},
combineLatestWith: options?.combineLatestWith,
});
// exit early if there's no update to apply
const latestDeclassified = await this.declassifyAll(latestClassified);
const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true;
if (!shouldUpdate) {
return latestDeclassified;
}
// apply the update
const updatedDeclassified = configureState(latestDeclassified, latestCombined);
const updatedClassified = await this.classifyAll(updatedDeclassified);
await this.encryptedState.update(() => updatedClassified);
return updatedDeclassified;
}
}