Merge pull request #520 from vector-im/bwindels/fix-139

Keep backup of e2ee identity in localStorage when idb gets cleared
This commit is contained in:
Bruno Windels 2021-09-30 09:28:56 +02:00 committed by GitHub
commit f8f4bb4eac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 322 additions and 31 deletions

View file

@ -237,6 +237,10 @@ export class Session {
return this._sessionBackup;
}
get hasIdentity() {
return !!this._e2eeAccount;
}
/** @internal */
async createIdentity(log) {
if (this._olm) {

View file

@ -233,7 +233,7 @@ export class SessionContainer {
platform: this._platform,
});
await this._session.load(log);
if (isNewLogin) {
if (!this._session.hasIdentity) {
this._status.set(LoadStatus.SessionSetup);
await log.wrap("createIdentity", log => this._session.createIdentity(log));
}

View file

@ -15,12 +15,12 @@ limitations under the License.
*/
import anotherjson from "../../../lib/another-json/index.js";
import {SESSION_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
const ACCOUNT_SESSION_KEY = SESSION_KEY_PREFIX + "olmAccount";
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_KEY_PREFIX + "areDeviceKeysUploaded";
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_KEY_PREFIX + "serverOTKCount";
const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount";
const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "areDeviceKeysUploaded";
const SERVER_OTK_COUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "serverOTKCount";
export class Account {
static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) {

View file

@ -20,7 +20,7 @@ import {createEnum} from "../../utils/enum.js";
export const DecryptionSource = createEnum("Sync", "Timeline", "Retry");
// use common prefix so it's easy to clear properties that are not e2ee related during session clear
export const SESSION_KEY_PREFIX = "e2ee:";
export const SESSION_E2EE_KEY_PREFIX = "e2ee:";
export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";

View file

@ -17,6 +17,9 @@ limitations under the License.
import {KeyDescription, Key} from "./common.js";
import {keyFromPassphrase} from "./passphrase.js";
import {keyFromRecoveryKey} from "./recoveryKey.js";
import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js";
const SSSS_KEY = `${SESSION_E2EE_KEY_PREFIX}ssssKey`;
async function readDefaultKeyDescription(storage) {
const txn = await storage.readTxn([
@ -35,11 +38,11 @@ async function readDefaultKeyDescription(storage) {
}
export async function writeKey(key, txn) {
txn.session.set("ssssKey", {id: key.id, binaryKey: key.binaryKey});
txn.session.set(SSSS_KEY, {id: key.id, binaryKey: key.binaryKey});
}
export async function readKey(txn) {
const keyData = await txn.session.get("ssssKey");
const keyData = await txn.session.get(SSSS_KEY);
if (!keyData) {
return;
}

View file

@ -19,9 +19,12 @@ import {StorageError} from "../common";
import {LogItem} from "../../../logging/LogItem.js";
import {IDBKey} from "./Transaction";
// this is the part of the Transaction class API that is used here and in the Store subclass,
// to make it easier to replace it with alternative implementations in schema.ts and unit tests
export interface ITransaction {
idbFactory: IDBFactory;
IDBKeyRange: typeof IDBKeyRange;
databaseName: string;
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined);
}
@ -55,6 +58,10 @@ export class QueryTarget<T> {
return this._transaction.IDBKeyRange;
}
get databaseName(): string {
return this._transaction.databaseName;
}
_openCursor(range?: IDBQuery, direction?: IDBCursorDirection): IDBRequest<IDBCursorWithValue | null> {
if (range && direction) {
return this._target.openCursor(range, direction);
@ -269,6 +276,7 @@ import {QueryTargetWrapper, Store} from "./Store";
export function tests() {
class MockTransaction extends MockIDBImpl {
get databaseName(): string { return "mockdb"; }
addWriteError(error: StorageError, refItem: LogItem | undefined, operationName: string, keys: IDBKey[] | undefined) {}
}

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {IDOMStorage} from "./types";
import {Transaction} from "./Transaction";
import { STORE_NAMES, StoreNames, StorageError } from "../common";
import { reqAsPromise } from "./utils";
@ -29,13 +30,15 @@ export class Storage {
readonly idbFactory: IDBFactory
readonly IDBKeyRange: typeof IDBKeyRange;
readonly storeNames: typeof StoreNames;
readonly localStorage: IDOMStorage;
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, logger: BaseLogger) {
constructor(idbDatabase: IDBDatabase, idbFactory: IDBFactory, _IDBKeyRange: typeof IDBKeyRange, hasWebkitEarlyCloseTxnBug: boolean, localStorage: IDOMStorage, logger: BaseLogger) {
this._db = idbDatabase;
this.idbFactory = idbFactory;
this.IDBKeyRange = _IDBKeyRange;
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
this.storeNames = StoreNames;
this.localStorage = localStorage;
this.logger = logger;
}
@ -79,4 +82,8 @@ export class Storage {
close(): void {
this._db.close();
}
get databaseName(): string {
return this._db.name;
}
}

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {IDOMStorage} from "./types";
import {Storage} from "./Storage";
import { openDatabase, reqAsPromise } from "./utils";
import { exportSession, importSession, Export } from "./export";
@ -23,8 +24,8 @@ import { BaseLogger } from "../../../logging/BaseLogger.js";
import { LogItem } from "../../../logging/LogItem.js";
const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`;
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, log: LogItem) {
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log);
const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, localStorage: IDOMStorage, log: LogItem) {
const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, localStorage, log);
return openDatabase(sessionName(sessionId), create, schema.length, idbFactory);
}
@ -52,12 +53,14 @@ async function requestPersistedStorage(): Promise<boolean> {
export class StorageFactory {
private _serviceWorkerHandler: ServiceWorkerHandler;
private _idbFactory: IDBFactory;
private _IDBKeyRange: typeof IDBKeyRange
private _IDBKeyRange: typeof IDBKeyRange;
private _localStorage: IDOMStorage;
constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, _IDBKeyRange = window.IDBKeyRange) {
constructor(serviceWorkerHandler: ServiceWorkerHandler, idbFactory: IDBFactory = window.indexedDB, _IDBKeyRange = window.IDBKeyRange, localStorage: IDOMStorage = window.localStorage) {
this._serviceWorkerHandler = serviceWorkerHandler;
this._idbFactory = idbFactory;
this._IDBKeyRange = _IDBKeyRange;
this._localStorage = localStorage;
}
async create(sessionId: string, log: LogItem): Promise<Storage> {
@ -70,8 +73,8 @@ export class StorageFactory {
});
const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, log.logger);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, this._localStorage, log.logger);
}
delete(sessionId: string): Promise<IDBDatabase> {
@ -81,21 +84,22 @@ export class StorageFactory {
}
async export(sessionId: string, log: LogItem): Promise<Export> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return await exportSession(db);
}
async import(sessionId: string, data: Export, log: LogItem): Promise<void> {
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log);
const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, this._localStorage, log);
return await importSession(db, data);
}
}
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, log: LogItem): Promise<void> {
async function createStores(db: IDBDatabase, txn: IDBTransaction, oldVersion: number | null, version: number, localStorage: IDOMStorage, log: LogItem): Promise<void> {
const startIdx = oldVersion || 0;
return log.wrap({l: "storage migration", oldVersion, version}, async log => {
for(let i = startIdx; i < version; ++i) {
await log.wrap(`v${i + 1}`, log => schema[i](db, txn, log));
const migrationFunc = schema[i];
await log.wrap(`v${i + 1}`, log => migrationFunc(db, txn, localStorage, log));
}
});
}

View file

@ -73,6 +73,10 @@ export class Transaction {
return this._storage.IDBKeyRange;
}
get databaseName(): string {
return this._storage.databaseName;
}
get logger(): BaseLogger {
return this._storage.logger;
}
@ -94,7 +98,7 @@ export class Transaction {
}
get session(): SessionStore {
return this._store(StoreNames.session, idbStore => new SessionStore(idbStore));
return this._store(StoreNames.session, idbStore => new SessionStore(idbStore, this._storage.localStorage));
}
get roomSummary(): RoomSummaryStore {

View file

@ -33,10 +33,10 @@ export class IDBError extends StorageError {
storeName: string;
databaseName: string;
constructor(message: string, sourceOrCursor: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) {
const source = "source" in sourceOrCursor ? sourceOrCursor.source : sourceOrCursor;
const storeName = _sourceName(source);
const databaseName = _sourceDatabase(source);
constructor(message: string, sourceOrCursor: IDBIndex | IDBCursor | IDBObjectStore | null, cause: DOMException | null = null) {
const source = (sourceOrCursor && "source" in sourceOrCursor) ? sourceOrCursor.source : sourceOrCursor;
const storeName = source ? _sourceName(source) : "";
const databaseName = source ? _sourceDatabase(source) : "";
let fullMessage = `${message} on ${databaseName}.${storeName}`;
if (cause) {
fullMessage += ": ";

View file

@ -1,16 +1,23 @@
import {IDOMStorage} from "./types";
import {ITransaction} from "./QueryTarget";
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js";
import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js";
import {SummaryData} from "../../room/RoomSummary";
import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore";
import {RoomStateEntry} from "./stores/RoomStateStore";
import {SessionStore} from "./stores/SessionStore";
import {Store} from "./Store";
import {encodeScopeTypeKey} from "./stores/OperationStore";
import {MAX_UNICODE} from "./stores/common";
import {LogItem} from "../../../logging/LogItem.js";
export type MigrationFunc = (db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: LogItem) => Promise<void> | void;
// FUNCTIONS SHOULD ONLY BE APPENDED!!
// the index in the array is the database version
export const schema = [
export const schema: MigrationFunc[] = [
createInitialStores,
createMemberStore,
migrateSession,
@ -21,7 +28,9 @@ export const schema = [
createArchivedRoomSummaryStore,
migrateOperationScopeIndex,
createTimelineRelationsStore,
fixMissingRoomsInUserIdentities
fixMissingRoomsInUserIdentities,
changeSSSSKeyPrefix,
backupAndRestoreE2EEAccountToLocalStorage
];
// TODO: how to deal with git merge conflicts of this array?
@ -64,7 +73,7 @@ async function createMemberStore(db: IDBDatabase, txn: IDBTransaction): Promise<
});
}
//v3
async function migrateSession(db: IDBDatabase, txn: IDBTransaction): Promise<void> {
async function migrateSession(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage): Promise<void> {
const session = txn.objectStore("session");
try {
const PRE_MIGRATION_KEY = 1;
@ -73,7 +82,7 @@ async function migrateSession(db: IDBDatabase, txn: IDBTransaction): Promise<voi
session.delete(PRE_MIGRATION_KEY);
const {syncToken, syncFilterId, serverVersions} = entry.value;
// Cast ok here because only "set" is used and we don't look into return
const store = new SessionStore(session as any);
const store = new SessionStore(session as any, localStorage);
store.set("sync", {token: syncToken, filterId: syncFilterId});
store.set("serverVersions", serverVersions);
}
@ -156,7 +165,7 @@ function createTimelineRelationsStore(db: IDBDatabase) : void {
}
//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470)
async function fixMissingRoomsInUserIdentities(db, txn, log) {
async function fixMissingRoomsInUserIdentities(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: LogItem) {
const roomSummaryStore = txn.objectStore("roomSummary");
const trackedRoomIds: string[] = [];
await iterateCursor<SummaryData>(roomSummaryStore.openCursor(), roomSummary => {
@ -200,3 +209,37 @@ async function fixMissingRoomsInUserIdentities(db, txn, log) {
});
}
}
// v12 move ssssKey to e2ee:ssssKey so it will get backed up in the next step
async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) {
const session = txn.objectStore("session");
const ssssKey = await reqAsPromise(session.get("ssssKey"));
if (ssssKey) {
session.put({key: `${SESSION_E2EE_KEY_PREFIX}ssssKey`, value: ssssKey});
}
}
// v13
async function backupAndRestoreE2EEAccountToLocalStorage(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: LogItem) {
const session = txn.objectStore("session");
// the Store object gets passed in several things through the Transaction class (a wrapper around IDBTransaction),
// the only thing we should need here is the databaseName though, so we mock it out.
// ideally we should have an easier way to go from the idb primitive layer to the specific store classes where
// we implement logic, but for now we need this.
const databaseNameHelper: ITransaction = {
databaseName: db.name,
get idbFactory(): IDBFactory { throw new Error("unused");},
get IDBKeyRange(): typeof IDBKeyRange { throw new Error("unused");},
addWriteError() {},
};
const sessionStore = new SessionStore(new Store(session, databaseNameHelper), localStorage);
// if we already have an e2ee identity, write a backup to local storage.
// further updates to e2ee keys in the session store will also write to local storage from 0.2.15 on,
// but here we make sure a backup is immediately created after installing the update and we don't wait until
// the olm account needs to change
sessionStore.writeE2EEIdentityToLocalStorage();
// and if we already have a backup, restore it now for any missing key in idb.
// this will restore the backup every time the idb database is dropped as it will
// run through all the migration steps when recreating it.
const restored = await sessionStore.tryRestoreE2EEIdentityFromLocalStorage(log);
log.set("restored", restored);
}

View file

@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {Store} from "../Store";
import {IDOMStorage} from "../types";
import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common.js";
import {LogItem} from "../../../../logging/LogItem.js";
import {parse, stringify} from "../../../../utils/typedJSON";
export interface SessionEntry {
key: string;
@ -22,9 +26,15 @@ export interface SessionEntry {
export class SessionStore {
private _sessionStore: Store<SessionEntry>
private _localStorage: IDOMStorage;
constructor(sessionStore: Store<SessionEntry>) {
constructor(sessionStore: Store<SessionEntry>, localStorage: IDOMStorage) {
this._sessionStore = sessionStore;
this._localStorage = localStorage;
}
private get _localStorageKeyPrefix(): string {
return `${this._sessionStore.databaseName}.session.`;
}
async get(key: string): Promise<any> {
@ -34,15 +44,65 @@ export class SessionStore {
}
}
_writeKeyToLocalStorage(key: string, value: any) {
// we backup to localStorage so when idb gets cleared for some reason, we don't lose our e2ee identity
try {
const lsKey = this._localStorageKeyPrefix + key;
const lsValue = stringify(value);
this._localStorage.setItem(lsKey, lsValue);
} catch (err) {
console.error("could not write to localStorage", err);
}
}
writeE2EEIdentityToLocalStorage() {
this._sessionStore.iterateValues(undefined, (entry: SessionEntry, key: string) => {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, entry.value);
}
return false;
});
}
async tryRestoreE2EEIdentityFromLocalStorage(log: LogItem): Promise<boolean> {
let success = false;
const lsPrefix = this._localStorageKeyPrefix;
const prefix = lsPrefix + SESSION_E2EE_KEY_PREFIX;
for(let i = 0; i < this._localStorage.length; i += 1) {
const lsKey = this._localStorage.key(i)!;
if (lsKey.startsWith(prefix)) {
const value = parse(this._localStorage.getItem(lsKey)!);
const key = lsKey.substr(lsPrefix.length);
// we check if we don't have this key already, as we don't want to override anything
const hasKey = (await this._sessionStore.getKey(key)) === key;
log.set(key, !hasKey);
if (!hasKey) {
this._sessionStore.put({key, value});
success = true;
}
}
}
return success;
}
set(key: string, value: any): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, value);
}
this._sessionStore.put({key, value});
}
add(key: string, value: any): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._writeKeyToLocalStorage(key, value);
}
this._sessionStore.add({key, value});
}
remove(key: string): void {
if (key.startsWith(SESSION_E2EE_KEY_PREFIX)) {
this._localStorage.removeItem(this._localStorageKeyPrefix + key);
}
this._sessionStore.delete(key);
}
}

View file

@ -0,0 +1,23 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export interface IDOMStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
key(n: number): string | null;
readonly length: number;
}

View file

@ -16,12 +16,13 @@ limitations under the License.
import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js";
import {StorageFactory} from "../matrix/storage/idb/StorageFactory";
import {IDOMStorage} from "../matrix/storage/idb/types";
import {Storage} from "../matrix/storage/idb/Storage";
import {Instance as nullLogger} from "../logging/NullLogger.js";
import {openDatabase, CreateObjectStore} from "../matrix/storage/idb/utils";
export function createMockStorage(): Promise<Storage> {
return new StorageFactory(null as any, new FDBFactory(), FDBKeyRange).create("1", nullLogger.item);
return new StorageFactory(null as any, new FDBFactory(), FDBKeyRange, new MockLocalStorage()).create("1", nullLogger.item);
}
export function createMockDatabase(name: string, createObjectStore: CreateObjectStore, impl: MockIDBImpl): Promise<IDBDatabase> {
@ -39,3 +40,41 @@ export class MockIDBImpl {
return FDBKeyRange;
}
}
class MockLocalStorage implements IDOMStorage {
private _map: Map<string, string>;
constructor() {
this._map = new Map();
}
getItem(key: string): string | null {
return this._map.get(key) || null;
}
setItem(key: string, value: string) {
this._map.set(key, value);
}
removeItem(key: string): void {
this._map.delete(key);
}
get length(): number {
return this._map.size;
}
key(n: number): string | null {
const it = this._map.keys();
let i = -1;
let result;
while (i < n) {
result = it.next();
if (result.done) {
return null;
}
i += 1;
}
return result?.value || null;
}
}

96
src/utils/typedJSON.ts Normal file
View file

@ -0,0 +1,96 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function stringify(value: any): string {
return JSON.stringify(encodeValue(value));
}
export function parse(value: string): any {
return decodeValue(JSON.parse(value));
}
function encodeValue(value: any): any {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
// TypedArray
if (value.byteLength) {
return {_type: value.constructor.name, value: Array.from(value)};
}
let newObj = {};
for (const prop in value) {
if (value.hasOwnProperty(prop)) {
newObj[prop] = encodeValue(value[prop]);
}
}
return newObj;
} else {
return value;
}
}
function decodeValue(value: any): any {
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
if (typeof value._type === "string") {
switch (value._type) {
case "Int8Array": return Int8Array.from(value.value);
case "Uint8Array": return Uint8Array.from(value.value);
case "Uint8ClampedArray": return Uint8ClampedArray.from(value.value);
case "Int16Array": return Int16Array.from(value.value);
case "Uint16Array": return Uint16Array.from(value.value);
case "Int32Array": return Int32Array.from(value.value);
case "Uint32Array": return Uint32Array.from(value.value);
case "Float32Array": return Float32Array.from(value.value);
case "Float64Array": return Float64Array.from(value.value);
case "BigInt64Array": return BigInt64Array.from(value.value);
case "BigUint64Array": return BigUint64Array.from(value.value);
default:
return value.value;
}
}
let newObj = {};
for (const prop in value) {
if (value.hasOwnProperty(prop)) {
newObj[prop] = decodeValue(value[prop]);
}
}
return newObj;
} else {
return value;
}
}
export function tests() {
return {
"Uint8Array and primitives": assert => {
const value = {
foo: "bar",
bar: 5,
baz: false,
fuzz: new Uint8Array([3, 1, 2])
};
const serialized = stringify(value);
assert.strictEqual(typeof serialized, "string");
const deserialized = parse(serialized);
assert.strictEqual(deserialized.foo, "bar");
assert.strictEqual(deserialized.bar, 5);
assert.strictEqual(deserialized.baz, false);
assert(deserialized.fuzz instanceof Uint8Array);
assert.strictEqual(deserialized.fuzz.length, 3);
assert.strictEqual(deserialized.fuzz[0], 3);
assert.strictEqual(deserialized.fuzz[1], 1);
assert.strictEqual(deserialized.fuzz[2], 2);
}
}
}