mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 09:43:23 +00:00
* [deps] Autofill: Update prettier to v3.6.2 * fix: [PM-23425] Fix prettier issues related to dependency updte Signed-off-by: Ben Brooks <bbrooks@bitwarden.com> * [deps] Autofill: Update prettier to v3.6.2 * [deps] Autofill: Update prettier to v3.7.3 * [PM-29379] Fix prettier issues found with the updated Prettier 3.7.3 Signed-off-by: Ben Brooks <bbrooks@bitwarden.com> --------- Signed-off-by: Ben Brooks <bbrooks@bitwarden.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ben Brooks <bbrooks@bitwarden.com>
575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
// FIXME: Update this file to be type safe and remove this and next line
|
|
// @ts-strict-ignore
|
|
import {
|
|
Observer,
|
|
SubjectLike,
|
|
Unsubscribable,
|
|
ReplaySubject,
|
|
filter,
|
|
map,
|
|
takeUntil,
|
|
distinctUntilChanged,
|
|
BehaviorSubject,
|
|
Observable,
|
|
Subscription,
|
|
last,
|
|
concat,
|
|
combineLatestWith,
|
|
catchError,
|
|
EMPTY,
|
|
concatMap,
|
|
OperatorFunction,
|
|
pipe,
|
|
first,
|
|
withLatestFrom,
|
|
scan,
|
|
skip,
|
|
shareReplay,
|
|
tap,
|
|
switchMap,
|
|
} from "rxjs";
|
|
|
|
import { Account } from "../../auth/abstractions/account.service";
|
|
import { EncString } from "../../key-management/crypto/models/enc-string";
|
|
import { SingleUserState, UserKeyDefinition } from "../../platform/state";
|
|
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
|
|
import { SemanticLogger } from "../log";
|
|
import { anyComplete, pin, ready, withLatestReady } from "../rx";
|
|
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
|
|
|
|
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
|
|
import { unconstrained$ } from "./identity-state-constraint";
|
|
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
|
|
import { isDynamic } from "./state-constraints-dependency";
|
|
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
|
import { UserStateSubjectDependencyProvider } from "./user-state-subject-dependency-provider";
|
|
|
|
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
|
|
|
|
// FIXME: The subject should always repeat the value when it's own `next` method is called.
|
|
//
|
|
// Chrome StateService only calls `next` when the underlying values changes. When enforcing,
|
|
// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents
|
|
// invalid data received in sequence from calling `next` because the state provider doesn't
|
|
// emit.
|
|
//
|
|
// The hack is pretty simple. Insert arbitrary data into the saved data to ensure
|
|
// that it *always* changes.
|
|
//
|
|
// Any real fix will be fairly complex because it needs to recognize *fast* when it
|
|
// is waiting. Alternatively, the kludge could become a format properly fed by random noise.
|
|
//
|
|
// NOTE: this only matters for plaintext objects; encrypted fields change with every
|
|
// update b/c their IVs change.
|
|
const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$";
|
|
|
|
/** Default frame size for data packing */
|
|
const DEFAULT_FRAME_SIZE = 32;
|
|
|
|
/**
|
|
* Adapt a state provider to an rxjs subject.
|
|
*
|
|
* This subject buffers the last value it received in memory. The buffer is erased
|
|
* if the subject receives a complete or error event. It does not persist the buffer.
|
|
*
|
|
* Warning! The user state subject has a synchronous interface, but subscriptions are
|
|
* always asynchronous.
|
|
*
|
|
* @template State the state stored by the subject
|
|
* @template Dependencies use-specific dependencies provided by the user.
|
|
*/
|
|
export class UserStateSubject<
|
|
State extends object,
|
|
Secret = State,
|
|
Disclosed = Record<string, never>,
|
|
Dependencies = null,
|
|
>
|
|
extends Observable<State>
|
|
implements SubjectLike<State>
|
|
{
|
|
/**
|
|
* Instantiates the user state subject bound to a persistent backing store
|
|
* @param key identifies the persistent backing store
|
|
* @param getState creates a persistent backing store using a key
|
|
* @param context tailor the subject's behavior for a particular
|
|
* purpose.
|
|
* @param dependencies.when$ blocks updates to the state subject until
|
|
* this becomes true. When this occurs, only the last-received update
|
|
* is applied. The blocked update is kept in memory. It does not persist
|
|
* to disk.
|
|
* @param dependencies.account$ writes block until the account$
|
|
* is available.
|
|
*/
|
|
constructor(
|
|
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
|
|
private providers: UserStateSubjectDependencyProvider,
|
|
private context: UserStateSubjectDependencies<State, Dependencies>,
|
|
) {
|
|
super();
|
|
|
|
if (isObjectKey(this.key)) {
|
|
// classification and encryption only supported with `ObjectKey`
|
|
this.objectKey = this.key;
|
|
this.stateKey = toUserKeyDefinition(this.key);
|
|
} else {
|
|
// raw state access granted with `UserKeyDefinition`
|
|
this.objectKey = null;
|
|
this.stateKey = this.key as UserKeyDefinition<State>;
|
|
}
|
|
|
|
this.log = this.providers.log({
|
|
contextId: this.contextId,
|
|
type: "UserStateSubject",
|
|
storage: {
|
|
state: this.stateKey.stateDefinition.name,
|
|
key: this.stateKey.key,
|
|
},
|
|
});
|
|
|
|
// normalize dependencies
|
|
const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
|
|
const account$ = context.account$.pipe(
|
|
pin({
|
|
name: () => `${this.contextId} { account$ }`,
|
|
distinct(prev, current) {
|
|
return prev.id === current.id;
|
|
},
|
|
}),
|
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
);
|
|
const encryptor$ = this.encryptor(account$);
|
|
const constraints$ = (this.context.constraints$ ?? unconstrained$<State>()).pipe(
|
|
catchError((e: unknown) => {
|
|
this.log.error(e as object, "constraints$ dependency failed; using last-known constraints");
|
|
return EMPTY;
|
|
}),
|
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
);
|
|
const dependencies$ = (
|
|
this.context.dependencies$ ?? new BehaviorSubject<Dependencies>(null)
|
|
).pipe(shareReplay({ refCount: true, bufferSize: 1 }));
|
|
|
|
// load state once the account becomes available
|
|
const userState$ = account$.pipe(
|
|
tap((account) => this.log.debug({ accountId: account.id }, "loading user state")),
|
|
map((account) => this.providers.state.getUser(account.id, this.stateKey)),
|
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
);
|
|
|
|
// wire output before input so that output normalizes the current state
|
|
// before any `next` value is processed
|
|
this.outputSubscription = userState$
|
|
.pipe(
|
|
switchMap((userState) => userState.state$),
|
|
map((stored) => {
|
|
if (stored && typeof stored === "object" && ALWAYS_UPDATE_KLUDGE in stored) {
|
|
// related: ALWAYS_UPDATE_KLUDGE FIXME
|
|
delete stored[ALWAYS_UPDATE_KLUDGE];
|
|
}
|
|
|
|
return stored;
|
|
}),
|
|
this.declassify(encryptor$),
|
|
this.adjust(combineLatestWith(constraints$)),
|
|
takeUntil(anyComplete(account$)),
|
|
)
|
|
.subscribe(this.output);
|
|
|
|
const last$ = new ReplaySubject<State>(1);
|
|
this.output
|
|
.pipe(
|
|
last(),
|
|
map((o) => o.state),
|
|
)
|
|
.subscribe(last$);
|
|
|
|
// the update stream simulates the stateProvider's "shouldUpdate"
|
|
// functionality & applies policy
|
|
const updates$ = concat(
|
|
this.input.pipe(
|
|
this.when(when$),
|
|
this.adjust(withLatestReady(constraints$)),
|
|
this.prepareUpdate(this, dependencies$),
|
|
),
|
|
// when the output subscription completes, its last-emitted value
|
|
// loops around to the input for finalization
|
|
last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)),
|
|
);
|
|
|
|
// classification/encryption bound to the input subscription's lifetime
|
|
// to ensure that `fix` has access to the encryptor key
|
|
//
|
|
// FIXME: this should probably timeout when a lock occurs
|
|
this.inputSubscription = updates$
|
|
.pipe(
|
|
this.classify(encryptor$),
|
|
withLatestFrom(userState$),
|
|
takeUntil(anyComplete([when$, this.input, encryptor$])),
|
|
)
|
|
.subscribe({
|
|
next: ([input, state]) => this.onNext(input, state),
|
|
error: (e: unknown) => this.onError(e),
|
|
complete: () => this.onComplete(),
|
|
});
|
|
}
|
|
|
|
private get contextId() {
|
|
return `UserStateSubject(${this.stateKey.stateDefinition.name}, ${this.stateKey.key})`;
|
|
}
|
|
|
|
private readonly log: SemanticLogger;
|
|
|
|
private readonly stateKey: UserKeyDefinition<unknown>;
|
|
private readonly objectKey: ObjectKey<State, Secret, Disclosed>;
|
|
|
|
private encryptor(account$: Observable<Account>): Observable<UserEncryptor> {
|
|
const singleUserId$ = account$.pipe(map((account) => account.id));
|
|
const frameSize = this.objectKey?.frame ?? DEFAULT_FRAME_SIZE;
|
|
const encryptor$ = this.providers.encryptor.userEncryptor$(frameSize, { singleUserId$ }).pipe(
|
|
tap(() => this.log.debug("encryptor constructed")),
|
|
map(({ encryptor }) => encryptor),
|
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
);
|
|
return encryptor$;
|
|
}
|
|
|
|
private when(when$: Observable<boolean>): OperatorFunction<State, State> {
|
|
return pipe(
|
|
combineLatestWith(when$.pipe(distinctUntilChanged())),
|
|
tap(([_, when]) => this.log.debug({ when }, "when status")),
|
|
filter(([_, when]) => !!when),
|
|
map(([input]) => input),
|
|
);
|
|
}
|
|
|
|
private prepareUpdate(
|
|
init$: Observable<State>,
|
|
dependencies$: Observable<Dependencies>,
|
|
): OperatorFunction<Constrained<State>, State> {
|
|
return (input$) =>
|
|
concat(
|
|
// `init$` becomes the accumulator for `scan`
|
|
init$.pipe(
|
|
first(),
|
|
map((init) => [init, null] as [State, Dependencies]),
|
|
),
|
|
input$.pipe(
|
|
map((constrained) => constrained.state),
|
|
withLatestFrom(dependencies$),
|
|
),
|
|
).pipe(
|
|
// scan only emits values that can cause updates
|
|
scan(([prev], [pending, dependencies]) => {
|
|
const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true;
|
|
if (shouldUpdate) {
|
|
// actual update
|
|
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
|
return [next, dependencies] as const;
|
|
} else {
|
|
// false update
|
|
this.log.debug("shouldUpdate prevented write");
|
|
return [prev, null];
|
|
}
|
|
}),
|
|
// the first emission primes `scan`s aggregator
|
|
skip(1),
|
|
map(([state]) => state),
|
|
|
|
// clean up false updates
|
|
distinctUntilChanged(),
|
|
);
|
|
}
|
|
|
|
private adjust(
|
|
withConstraints: OperatorFunction<State, [State, SubjectConstraints<State>]>,
|
|
): OperatorFunction<State, Constrained<State>> {
|
|
return pipe(
|
|
// how constraints are blended with incoming emissions varies:
|
|
// * `output` needs to emit when constraints update
|
|
// * `input` needs to wait until a message flows through the pipe
|
|
withConstraints,
|
|
map(([loadedState, constraints]) => {
|
|
if (!loadedState && !this.objectKey?.initial) {
|
|
this.log.debug("no value; bypassing adjustment");
|
|
return {
|
|
constraints: {} as Constraints<State>,
|
|
state: null,
|
|
} satisfies Constrained<State>;
|
|
}
|
|
|
|
this.log.debug("adjusting");
|
|
const unconstrained = loadedState ?? structuredClone(this.objectKey.initial);
|
|
const calibration = isDynamic(constraints)
|
|
? constraints.calibrate(unconstrained)
|
|
: constraints;
|
|
const adjusted = calibration.adjust(unconstrained);
|
|
|
|
this.log.debug("adjusted");
|
|
return {
|
|
constraints: calibration.constraints,
|
|
state: adjusted,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
private fix(
|
|
constraints$: Observable<SubjectConstraints<State>>,
|
|
): OperatorFunction<State, Constrained<State>> {
|
|
return pipe(
|
|
combineLatestWith(constraints$),
|
|
map(([loadedState, constraints]) => {
|
|
this.log.debug("fixing");
|
|
|
|
const calibration = isDynamic(constraints)
|
|
? constraints.calibrate(loadedState)
|
|
: constraints;
|
|
const fixed = calibration.fix(loadedState);
|
|
|
|
this.log.debug("fixed");
|
|
return {
|
|
constraints: calibration.constraints,
|
|
state: fixed,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
|
|
// short-circuit if they key lacks encryption support
|
|
if (!this.objectKey || this.objectKey.format === "plain") {
|
|
this.log.debug("key uses plain format; bypassing declassification");
|
|
return (input$) => input$ as Observable<State>;
|
|
}
|
|
|
|
// all other keys support encryption; enable encryptor support
|
|
return pipe(
|
|
this.mapToClassifiedFormat(),
|
|
combineLatestWith(encryptor$),
|
|
concatMap(async ([input, encryptor]) => {
|
|
// pass through null values
|
|
if (input === null || input === undefined) {
|
|
this.log.debug("no value; bypassing declassification");
|
|
return null;
|
|
}
|
|
|
|
this.log.debug("declassifying");
|
|
|
|
// decrypt classified data
|
|
const { secret, disclosed } = input;
|
|
const encrypted = EncString.fromJSON(secret);
|
|
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
|
|
|
|
// assemble into proper state
|
|
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
|
|
const state = this.objectKey.options.deserializer(declassified);
|
|
|
|
this.log.debug("declassified");
|
|
return state;
|
|
}),
|
|
);
|
|
}
|
|
|
|
private mapToClassifiedFormat(): OperatorFunction<unknown, ClassifiedFormat<unknown, unknown>> {
|
|
// FIXME: warn when data is dropped in the console and/or report an error
|
|
// through the observable; consider redirecting dropped data to a recovery
|
|
// location
|
|
|
|
// user-state subject's default format is object-aware
|
|
if (this.objectKey && this.objectKey.format === "classified") {
|
|
return map((input) => {
|
|
if (!isClassifiedFormat(input)) {
|
|
this.log.warn("classified data must be in classified format; dropping");
|
|
return null;
|
|
}
|
|
|
|
return input;
|
|
});
|
|
}
|
|
|
|
// secret state's format wraps objects in an array
|
|
if (this.objectKey && this.objectKey.format === "secret-state") {
|
|
return map((input) => {
|
|
if (!Array.isArray(input)) {
|
|
this.log.warn("secret-state requires array formatting; dropping");
|
|
return null;
|
|
}
|
|
|
|
const [unwrapped] = input;
|
|
if (!isClassifiedFormat(unwrapped)) {
|
|
this.log.warn("unwrapped secret-state must be in classified format; dropping");
|
|
return null;
|
|
}
|
|
|
|
return unwrapped;
|
|
});
|
|
}
|
|
|
|
this.log.panic({ format: this.objectKey.format }, "unsupported serialization format");
|
|
}
|
|
|
|
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
|
|
// short-circuit if they key lacks encryption support; `encryptor` is
|
|
// readied to preserve `dependencies.singleUserId$` emission contract
|
|
if (!this.objectKey || this.objectKey.format === "plain") {
|
|
this.log.debug("key uses plain format; bypassing classification");
|
|
return pipe(
|
|
ready(encryptor$),
|
|
map((input) => input as unknown),
|
|
);
|
|
}
|
|
|
|
// all other keys support encryption; enable encryptor support
|
|
return pipe(
|
|
withLatestReady(encryptor$),
|
|
concatMap(async ([input, encryptor]) => {
|
|
// fail fast if there's no value
|
|
if (input === null || input === undefined) {
|
|
this.log.debug("no value; bypassing classification");
|
|
return null;
|
|
}
|
|
|
|
this.log.debug("classifying");
|
|
|
|
// split data by classification level
|
|
const serialized = JSON.parse(JSON.stringify(input));
|
|
const classified = this.objectKey.classifier.classify(serialized);
|
|
|
|
// protect data
|
|
const encrypted = await encryptor.encrypt(classified.secret);
|
|
const secret = JSON.parse(JSON.stringify(encrypted));
|
|
|
|
// wrap result in classified format envelope for storage
|
|
const envelope = {
|
|
id: null as void,
|
|
secret,
|
|
disclosed: classified.disclosed,
|
|
} satisfies ClassifiedFormat<void, Disclosed>;
|
|
|
|
this.log.debug("classified");
|
|
// deliberate type erasure; the type is restored during `declassify`
|
|
return envelope as ClassifiedFormat<unknown, unknown>;
|
|
}),
|
|
this.mapToStorageFormat(),
|
|
);
|
|
}
|
|
|
|
private mapToStorageFormat(): OperatorFunction<ClassifiedFormat<unknown, unknown>, unknown> {
|
|
// user-state subject's default format is object-aware
|
|
if (this.objectKey && this.objectKey.format === "classified") {
|
|
return map((input) => input as unknown);
|
|
}
|
|
|
|
// secret state's format wraps objects in an array
|
|
if (this.objectKey && this.objectKey.format === "secret-state") {
|
|
return map((input) => [input] as unknown);
|
|
}
|
|
|
|
this.log.panic({ format: this.objectKey.format }, "unsupported serialization format");
|
|
}
|
|
|
|
next(value: State) {
|
|
this.input?.next(value);
|
|
}
|
|
|
|
error(err: any) {
|
|
this.input?.error(err);
|
|
}
|
|
|
|
complete() {
|
|
this.input?.complete();
|
|
}
|
|
|
|
/** Subscribe to the subject's event stream
|
|
* @param observer listening for events
|
|
* @returns the subscription
|
|
*/
|
|
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
|
|
return this.output
|
|
.pipe(
|
|
map((wc) => wc.state),
|
|
distinctUntilChanged(),
|
|
)
|
|
.subscribe(observer);
|
|
}
|
|
|
|
// using subjects to ensure the right semantics are followed;
|
|
// if greater efficiency becomes desirable, consider implementing
|
|
// `SubjectLike` directly
|
|
private input = new ReplaySubject<State>(1);
|
|
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
|
|
|
/** A stream containing settings and their last-applied constraints. */
|
|
get withConstraints$() {
|
|
return this.output.asObservable();
|
|
}
|
|
|
|
private inputSubscription: Unsubscribable;
|
|
private outputSubscription: Unsubscribable;
|
|
|
|
private counter = 0;
|
|
|
|
private onNext(value: unknown, state: SingleUserState<unknown>) {
|
|
state
|
|
.update(() => {
|
|
this.log.debug("updating");
|
|
|
|
if (typeof value === "object") {
|
|
// related: ALWAYS_UPDATE_KLUDGE FIXME
|
|
const counter = this.counter++;
|
|
if (counter > Number.MAX_SAFE_INTEGER) {
|
|
this.counter = 0;
|
|
}
|
|
|
|
const kludge = { ...value } as any;
|
|
kludge[ALWAYS_UPDATE_KLUDGE] = counter;
|
|
}
|
|
|
|
this.log.debug("updated");
|
|
return value;
|
|
})
|
|
.catch((e: any) => {
|
|
this.log.error(e as object, "updating failed");
|
|
this.onError(e);
|
|
});
|
|
}
|
|
|
|
private onError(value: any) {
|
|
if (!this.isDisposed) {
|
|
this.log.debug(value, "forwarding error to subscribers");
|
|
this.output.error(value);
|
|
}
|
|
|
|
this.dispose();
|
|
}
|
|
|
|
private onComplete() {
|
|
if (!this.isDisposed) {
|
|
this.output.complete();
|
|
}
|
|
|
|
this.dispose();
|
|
}
|
|
|
|
private get isDisposed() {
|
|
return this.input === null;
|
|
}
|
|
|
|
private dispose() {
|
|
if (!this.isDisposed) {
|
|
this.log.debug("disposing");
|
|
|
|
// clean up internal subscriptions
|
|
this.inputSubscription?.unsubscribe();
|
|
this.outputSubscription?.unsubscribe();
|
|
this.inputSubscription = null;
|
|
this.outputSubscription = null;
|
|
|
|
// drop input to ensure its value is removed from memory
|
|
this.input = null;
|
|
|
|
this.log.debug("disposed");
|
|
}
|
|
}
|
|
}
|