From 3d8291c1f296b3b443d8380e337ff3ea4c0cbaaa Mon Sep 17 00:00:00 2001 From: Jack Works Date: Tue, 22 Mar 2022 19:55:45 +0800 Subject: [PATCH 01/22] refactor: move backup code to backup-format package --- packages/backup-format/package.json | 1 + packages/backup-format/src/index.ts | 2 +- packages/backup-format/src/normalize/index.ts | 25 ++ packages/backup-format/src/normalize/type.ts | 96 +++++++ packages/backup-format/src/version-0/index.ts | 67 +++++ packages/backup-format/src/version-1/index.ts | 95 +++++++ packages/backup-format/src/version-2/index.ts | 261 ++++++++++++++++++ .../{v3-EncryptedJSON => version-3}/index.ts | 0 packages/backup-format/tsconfig.json | 2 +- .../mask/background/services/backup/create.ts | 1 + packages/mask/package.json | 1 + packages/mask/src/tsconfig.json | 1 + .../src/Identifier/IdentifierMap.ts | 2 +- packages/shared-base/src/crypto/JWKType.ts | 30 ++ pnpm-lock.yaml | 6 +- 15 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 packages/backup-format/src/normalize/index.ts create mode 100644 packages/backup-format/src/normalize/type.ts create mode 100644 packages/backup-format/src/version-0/index.ts create mode 100644 packages/backup-format/src/version-1/index.ts create mode 100644 packages/backup-format/src/version-2/index.ts rename packages/backup-format/src/{v3-EncryptedJSON => version-3}/index.ts (100%) create mode 100644 packages/mask/background/services/backup/create.ts diff --git a/packages/backup-format/package.json b/packages/backup-format/package.json index ec55c1cc7598..d4ba3fc7b5a3 100644 --- a/packages/backup-format/package.json +++ b/packages/backup-format/package.json @@ -5,6 +5,7 @@ "types": "./dist/index.d.ts", "type": "module", "dependencies": { + "@masknet/shared-base": "workspace:*", "@msgpack/msgpack": "^2.7.2" } } diff --git a/packages/backup-format/src/index.ts b/packages/backup-format/src/index.ts index a5d21d90d333..f14abd903bbd 100644 --- a/packages/backup-format/src/index.ts +++ b/packages/backup-format/src/index.ts @@ -1,2 +1,2 @@ -export * from './v3-EncryptedJSON' +export * from './version-3' export * from './BackupErrors' diff --git a/packages/backup-format/src/normalize/index.ts b/packages/backup-format/src/normalize/index.ts new file mode 100644 index 000000000000..e1860523a510 --- /dev/null +++ b/packages/backup-format/src/normalize/index.ts @@ -0,0 +1,25 @@ +import { ECKeyIdentifier, IdentifierMap, PostIVIdentifier, ProfileIdentifier } from '@masknet/shared-base' +import { BackupErrors } from '../BackupErrors' +import { isBackupVersion0, normalizeBackupVersion0 } from '../version-0' +import { isBackupVersion1, normalizeBackupVersion1 } from '../version-1' +import { isBackupVersion2, normalizeBackupVersion2 } from '../version-2' +import type { NormalizedBackup } from './type' + +export function normalizeBackup(data: unknown): NormalizedBackup.Data { + if (isBackupVersion2(data)) return normalizeBackupVersion2(data) + if (isBackupVersion1(data)) return normalizeBackupVersion1(data) + if (isBackupVersion0(data)) return normalizeBackupVersion0(data) + throw new TypeError(BackupErrors.UnknownFormat) +} +export function createEmptyNormalizedBackup(): NormalizedBackup.Data { + return { + meta: { version: 2 }, + personas: new IdentifierMap(new Map(), ECKeyIdentifier), + profiles: new IdentifierMap(new Map(), ProfileIdentifier), + posts: new IdentifierMap(new Map(), PostIVIdentifier), + relations: [], + settings: { grantedHostPermissions: [] }, + wallets: [], + plugins: {}, + } +} diff --git a/packages/backup-format/src/normalize/type.ts b/packages/backup-format/src/normalize/type.ts new file mode 100644 index 000000000000..02474666ea00 --- /dev/null +++ b/packages/backup-format/src/normalize/type.ts @@ -0,0 +1,96 @@ +import type { + PersonaIdentifier, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + AESJsonWebKey, + ProfileIdentifier, + RelationFavor, + PostIVIdentifier, + IdentifierMap, +} from '@masknet/shared-base' + +export namespace NormalizedBackup { + export interface Data { + /** Meta about this backup */ + meta: Meta + personas: IdentifierMap + profiles: IdentifierMap + relations: RelationBackup[] + posts: IdentifierMap + wallets: WalletBackup[] + settings: SettingsBackup + plugins: Record + } + export interface Meta { + /** Backup file version */ + version: 0 | 1 | 2 + /** Backup created by which Mask version */ + maskVersion?: string + createdAt?: Date + } + export interface PersonaBackup { + identifier: PersonaIdentifier + mnemonic?: Mnemonic + publicKey: EC_Public_JsonWebKey + privateKey?: EC_Private_JsonWebKey + localKey?: AESJsonWebKey + linkedProfiles: IdentifierMap + nickname?: string + createdAt?: Date + updatedAt?: Date + } + export interface Mnemonic { + words: string + path: string + hasPassword: boolean + } + export interface ProfileBackup { + identifier: ProfileIdentifier + nickname?: string + localKey?: AESJsonWebKey + linkedPersona?: PersonaIdentifier + createdAt?: Date + updatedAt?: Date + } + export interface RelationBackup { + profile: ProfileIdentifier + persona: PersonaIdentifier + favor: RelationFavor + } + export interface PostBackup { + identifier: PostIVIdentifier + postBy: ProfileIdentifier + postCryptoKey?: AESJsonWebKey + recipients?: PostReceiverPublic | PostReceiverE2E + foundAt: Date + encryptBy?: PersonaIdentifier + url?: string + summary?: string + interestedMeta: ReadonlyMap + } + export interface PostReceiverPublic { + type: 'public' + } + export interface PostReceiverE2E { + type: 'e2e' + receivers: IdentifierMap + } + export interface RecipientReason { + type: 'auto-share' | 'direct' | 'group' + group?: unknown + at: Date + } + export interface WalletBackup { + address: string + name: string + passphrase?: string + publicKey?: EC_Public_JsonWebKey + privateKey?: EC_Private_JsonWebKey + mnemonic?: Mnemonic + createdAt: Date + updatedAt: Date + } + export interface SettingsBackup { + grantedHostPermissions: string[] + } +} diff --git a/packages/backup-format/src/version-0/index.ts b/packages/backup-format/src/version-0/index.ts new file mode 100644 index 000000000000..39bcc09ac3f4 --- /dev/null +++ b/packages/backup-format/src/version-0/index.ts @@ -0,0 +1,67 @@ +import { + AESJsonWebKey, + ECKeyIdentifierFromJsonWebKey, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + IdentifierMap, + isAESJsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + ProfileIdentifier, +} from '@masknet/shared-base' +import { isObjectLike } from 'lodash-unified' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion0(obj: unknown): obj is BackupJSONFileVersion0 { + if (!isObjectLike(obj)) return false + try { + const data: BackupJSONFileVersion0 = obj as any + if (!data.local || !data.key || !data.key.key || !data.key.key.privateKey || !data.key.key.publicKey) + return false + return true + } catch { + return false + } +} +export function normalizeBackupVersion0(file: BackupJSONFileVersion0): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + backup.meta.version = 0 + backup.meta.maskVersion = '<=1.3.2' + + const { local } = file + const { username, key } = file.key + const { publicKey, privateKey } = key + + if (!isEC_Public_JsonWebKey(publicKey)) return backup + + const persona: NormalizedBackup.PersonaBackup = { + identifier: ECKeyIdentifierFromJsonWebKey(publicKey), + publicKey, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + } + if (isEC_Private_JsonWebKey(privateKey)) persona.privateKey = privateKey + if (isAESJsonWebKey(local)) persona.localKey = local + backup.personas.set(persona.identifier, persona) + + if (username && username !== '$unknown' && username !== '$local') { + const profile: NormalizedBackup.ProfileBackup = { + identifier: new ProfileIdentifier('facebook.com', username), + linkedPersona: persona.identifier, + } + if (isAESJsonWebKey(local)) persona.localKey = local + backup.profiles.set(profile.identifier, profile) + persona.linkedProfiles.set(profile.identifier, void 0) + } + + return backup +} +interface BackupJSONFileVersion0 { + key: { + username: string + key: { publicKey: EC_Public_JsonWebKey; privateKey?: EC_Private_JsonWebKey } + algor: unknown + usages: string[] + } + local: AESJsonWebKey +} diff --git a/packages/backup-format/src/version-1/index.ts b/packages/backup-format/src/version-1/index.ts new file mode 100644 index 000000000000..6e650f346671 --- /dev/null +++ b/packages/backup-format/src/version-1/index.ts @@ -0,0 +1,95 @@ +import { + AESJsonWebKey, + ECKeyIdentifierFromJsonWebKey, + EC_Private_JsonWebKey, + EC_Public_JsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + isAESJsonWebKey, + ProfileIdentifier, + IdentifierMap, +} from '@masknet/shared-base' +import { isObjectLike } from 'lodash-unified' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion1(obj: unknown): obj is BackupJSONFileVersion1 { + if (!isObjectLike(obj)) return false + try { + const data: BackupJSONFileVersion1 = obj as any + if (data.version !== 1) return false + if (!Array.isArray(data.whoami)) return false + if (!data.whoami) return false + return true + } catch { + return false + } +} +export function normalizeBackupVersion1(file: BackupJSONFileVersion1): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + + backup.meta.version = 1 + if (!file.grantedHostPermissions) backup.meta.maskVersion = '<=1.5.2' + else if (!file.maskbookVersion) backup.meta.maskVersion = '<=1.6.0' + + if (file.grantedHostPermissions) { + backup.settings.grantedHostPermissions = file.grantedHostPermissions + } + + const { whoami, people } = file + for (const { network, publicKey, userId, nickname, localKey, privateKey } of [...whoami, ...(people || [])]) { + const profile: NormalizedBackup.ProfileBackup = { + identifier: new ProfileIdentifier(network, userId), + nickname, + } + + if (isEC_Public_JsonWebKey(publicKey)) { + const personaID = ECKeyIdentifierFromJsonWebKey(publicKey) + const persona = backup.personas.get(personaID) || { + identifier: personaID, + publicKey, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + } + if (isEC_Private_JsonWebKey(privateKey)) { + persona.privateKey = privateKey + } + persona.linkedProfiles.set(profile.identifier, void 0) + profile.linkedPersona = personaID + } + if (isAESJsonWebKey(localKey)) { + profile.localKey = localKey + if (profile.linkedPersona && backup.personas.has(profile.linkedPersona)) { + backup.personas.get(profile.linkedPersona)!.localKey = localKey + } + } + } + + return backup +} + +interface BackupJSONFileVersion1 { + maskbookVersion?: string + version: 1 + whoami: Array<{ + network: string + userId: string + publicKey: EC_Public_JsonWebKey + privateKey: EC_Private_JsonWebKey + localKey: AESJsonWebKey + previousIdentifiers?: { network: string; userId: string }[] + nickname?: string + }> + people?: Array<{ + network: string + userId: string + publicKey: EC_Public_JsonWebKey + previousIdentifiers?: { network: string; userId: string }[] + nickname?: string + groups?: { network: string; groupID: string; virtualGroupOwner: string | null }[] + + // Note: those props are not existed in the backup, just to make the code more readable + privateKey?: EC_Private_JsonWebKey + localKey?: AESJsonWebKey + }> + grantedHostPermissions?: string[] +} diff --git a/packages/backup-format/src/version-2/index.ts b/packages/backup-format/src/version-2/index.ts new file mode 100644 index 000000000000..8d94dd233116 --- /dev/null +++ b/packages/backup-format/src/version-2/index.ts @@ -0,0 +1,261 @@ +import { decodeArrayBuffer } from '@dimensiondev/kit' +import { + ECKeyIdentifier, + ECKeyIdentifierFromJsonWebKey, + IdentifierMap, + isAESJsonWebKey, + isEC_Private_JsonWebKey, + isEC_Public_JsonWebKey, + PostIVIdentifier, + ProfileIdentifier, + RelationFavor, +} from '@masknet/shared-base' +import { decode } from '@msgpack/msgpack' +import { createEmptyNormalizedBackup } from '../normalize' +import type { NormalizedBackup } from '../normalize/type' + +export function isBackupVersion2(item: unknown): item is BackupJSONFileVersion2 { + try { + const x = item as BackupJSONFileVersion2 + return x._meta_.version === 2 + } catch {} + return false +} + +export function normalizeBackupVersion2(item: BackupJSONFileVersion2): NormalizedBackup.Data { + const backup = createEmptyNormalizedBackup() + + backup.meta.version = 2 + backup.meta.maskVersion = item._meta_.maskbookVersion + backup.meta.createdAt = new Date(item._meta_.createdAt) + backup.settings.grantedHostPermissions = item.grantedHostPermissions + + const { personas, posts, profiles, relations, wallets, plugin } = item + + for (const persona of personas) { + const { publicKey } = persona + if (!isEC_Public_JsonWebKey(publicKey)) continue + const identifier = ECKeyIdentifierFromJsonWebKey(publicKey) + const normalizedPersona: NormalizedBackup.PersonaBackup = { + identifier, + linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + publicKey, + createdAt: new Date(persona.createdAt), + updatedAt: new Date(persona.updatedAt), + nickname: persona.nickname, + } + if (persona.mnemonic) { + const { words, parameter } = persona.mnemonic + normalizedPersona.mnemonic = { words, hasPassword: parameter.withPassword, path: parameter.path } + } + if (isEC_Private_JsonWebKey(persona.privateKey)) normalizedPersona.privateKey = persona.privateKey + if (isAESJsonWebKey(persona.localKey)) normalizedPersona.localKey = persona.localKey + + backup.personas.set(identifier, normalizedPersona) + } + + for (const profile of profiles) { + const identifier = ProfileIdentifier.fromString(profile.identifier, ProfileIdentifier) + if (identifier.err) continue + const normalizedProfile: NormalizedBackup.ProfileBackup = { + identifier: identifier.val, + createdAt: new Date(profile.createdAt), + updatedAt: new Date(profile.updatedAt), + nickname: profile.nickname, + } + if (profile.linkedPersona) { + const id = ECKeyIdentifier.fromString(profile.linkedPersona, ECKeyIdentifier) + if (id.ok) { + if (backup.personas.has(id.val) && backup.personas.get(id.val)!.linkedProfiles.has(identifier.val)) { + normalizedProfile.linkedPersona = id.val + } + } + } + if (isAESJsonWebKey(profile.localKey)) normalizedProfile.localKey = profile.localKey + + backup.profiles.set(identifier.val, normalizedProfile) + } + + for (const persona of backup.personas.values()) { + const toRemove: ProfileIdentifier[] = [] + for (const profile of persona.linkedProfiles.keys()) { + if (backup.profiles.get(profile)?.linkedPersona?.equals(persona.identifier)) { + // do nothing + } else toRemove.push(profile) + } + for (const profile of toRemove) persona.linkedProfiles.delete(profile) + } + + for (const post of posts) { + const identifier = PostIVIdentifier.fromString(post.identifier, PostIVIdentifier) + const postBy = ProfileIdentifier.fromString(post.postBy, ProfileIdentifier) + const encryptBy = post.encryptBy ? ECKeyIdentifier.fromString(post.encryptBy, ECKeyIdentifier) : null + + if (identifier.err) continue + const interestedMeta = new Map() + const normalizedPost: NormalizedBackup.PostBackup = { + identifier: identifier.val, + foundAt: new Date(post.foundAt), + postBy: postBy.unwrapOr(ProfileIdentifier.unknown), + interestedMeta, + encryptBy: encryptBy?.unwrapOr(undefined), + summary: post.summary, + url: post.url, + } + + if (isAESJsonWebKey(post.postCryptoKey)) normalizedPost.postCryptoKey = post.postCryptoKey + if (post.recipients) { + if (post.recipients === 'everyone') normalizedPost.recipients = { type: 'public' } + else { + const map = new IdentifierMap( + new Map(), + ProfileIdentifier, + ) + for (const [recipient, { reason }] of post.recipients) { + const id = ProfileIdentifier.fromString(recipient, ProfileIdentifier) + if (id.err) continue + const reasons: NormalizedBackup.RecipientReason[] = [] + map.set(id.val, reasons) + for (const r of reason) { + // we ignore the original reason because we no longer support group / auto sharing + reasons.push({ type: 'direct', at: new Date(r.at) }) + } + } + normalizedPost.recipients = { type: 'e2e', receivers: map } + } + } + if (post.interestedMeta) normalizedPost.interestedMeta = MetaFromJson(post.interestedMeta) + + backup.posts.set(identifier.val, normalizedPost) + } + + for (const relation of relations) { + const { profile, persona, favor } = relation + const a = ProfileIdentifier.fromString(profile, ProfileIdentifier) + const b = ECKeyIdentifier.fromString(persona, ECKeyIdentifier) + if (a.ok && b.ok) { + backup.relations.push({ + profile: a.val, + persona: b.val, + favor: favor, + }) + } + } + + for (const wallet of wallets) { + const { publicKey, privateKey } = wallet + if (!isEC_Public_JsonWebKey(publicKey)) continue + if (!isEC_Private_JsonWebKey(privateKey)) continue + + const normalizedWallet: NormalizedBackup.WalletBackup = { + address: wallet.address, + name: wallet.name, + passphrase: wallet.passphrase, + publicKey, + privateKey, + mnemonic: wallet.mnemonic + ? { + words: wallet.mnemonic.words, + hasPassword: wallet.mnemonic.parameter.withPassword, + path: wallet.mnemonic.parameter.path, + } + : undefined, + createdAt: new Date(wallet.createdAt), + updatedAt: new Date(wallet.updatedAt), + } + backup.wallets.push(normalizedWallet) + } + + backup.plugins = plugin || {} + + return backup +} + +function MetaFromJson(meta: string | undefined): Map { + if (!meta) return new Map() + const raw = decode(decodeArrayBuffer(meta)) + if (typeof raw !== 'object' || !raw) return new Map() + return new Map(Object.entries(raw)) +} + +/** + * @see https://github.com/DimensionDev/Maskbook/issues/194 + */ +interface BackupJSONFileVersion2 { + _meta_: { + version: 2 + type: 'maskbook-backup' + maskbookVersion: string // e.g. "1.8.0" + createdAt: number // Unix timestamp + } + personas: Array<{ + // ? PersonaIdentifier can be infer from the publicKey + identifier: string // PersonaIdentifier.toText() + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + publicKey: JsonWebKey + privateKey?: JsonWebKey + localKey?: JsonWebKey + nickname?: string + linkedProfiles: [/** ProfileIdentifier.toText() */ string, LinkedProfileDetails][] + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + profiles: Array<{ + identifier: string // ProfileIdentifier.toText() + nickname?: string + localKey?: JsonWebKey + linkedPersona?: string // PersonaIdentifier.toText() + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + relations: Array<{ + profile: string // ProfileIdentifier.toText() + persona: string // PersonaIdentifier.toText() + favor: RelationFavor + }> + /** @deprecated */ + userGroups: never[] + posts: Array<{ + postBy: string // ProfileIdentifier.toText() + identifier: string // PostIVIdentifier.toText() + postCryptoKey?: JsonWebKey + recipients: 'everyone' | [/** ProfileIdentifier.toText() */ string, { reason: RecipientReasonJSON[] }][] + /** @deprecated */ + recipientGroups: never[] // Array + foundAt: number // Unix timestamp + encryptBy?: string // PersonaIdentifier.toText() + url?: string + summary?: string + interestedMeta?: string // encoded by MessagePack + }> + wallets: Array<{ + address: string + name: string + passphrase?: string + publicKey?: JsonWebKey + privateKey?: JsonWebKey + mnemonic?: { + words: string + parameter: { path: string; withPassword: boolean } + } + createdAt: number // Unix timestamp + updatedAt: number // Unix timestamp + }> + grantedHostPermissions: string[] + plugin?: Record +} + +interface LinkedProfileDetails { + connectionConfirmState: 'confirmed' | 'pending' | 'denied' +} + +type RecipientReasonJSON = ( + | { type: 'auto-share' } + | { type: 'direct' } + | { type: 'group'; /** GroupIdentifier */ group: string } +) & { + at: number +} diff --git a/packages/backup-format/src/v3-EncryptedJSON/index.ts b/packages/backup-format/src/version-3/index.ts similarity index 100% rename from packages/backup-format/src/v3-EncryptedJSON/index.ts rename to packages/backup-format/src/version-3/index.ts diff --git a/packages/backup-format/tsconfig.json b/packages/backup-format/tsconfig.json index 625eeee364ee..ecb7b18837fa 100644 --- a/packages/backup-format/tsconfig.json +++ b/packages/backup-format/tsconfig.json @@ -6,5 +6,5 @@ "tsBuildInfoFile": "./dist/.tsbuildinfo" }, "include": ["./src", "./src/**/*.json"], - "references": [] + "references": [{ "path": "../shared-base" }] } diff --git a/packages/mask/background/services/backup/create.ts b/packages/mask/background/services/backup/create.ts new file mode 100644 index 000000000000..336ce12bb910 --- /dev/null +++ b/packages/mask/background/services/backup/create.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/mask/package.json b/packages/mask/package.json index 2e6c8b94087c..b73d19e86d94 100644 --- a/packages/mask/package.json +++ b/packages/mask/package.json @@ -13,6 +13,7 @@ "@ethersproject/providers": "^5.0.9", "@ethersproject/solidity": "^5.0.4", "@hookform/resolvers": "2.8.8", + "@masknet/backup-format": "workspace:*", "@masknet/dashboard": "workspace:*", "@masknet/encryption": "workspace:^0.0.0", "@masknet/external-plugin-previewer": "workspace:*", diff --git a/packages/mask/src/tsconfig.json b/packages/mask/src/tsconfig.json index 40b24ac85751..c61900238630 100644 --- a/packages/mask/src/tsconfig.json +++ b/packages/mask/src/tsconfig.json @@ -16,6 +16,7 @@ "references": [ { "path": "../../public-api" }, { "path": "../../shared" }, + { "path": "../../backup-format" }, { "path": "../../shared-base" }, { "path": "../../shared-base-ui" }, { "path": "../../typed-message/base" }, diff --git a/packages/shared-base/src/Identifier/IdentifierMap.ts b/packages/shared-base/src/Identifier/IdentifierMap.ts index 095e57d1b2e8..2188d36cce74 100644 --- a/packages/shared-base/src/Identifier/IdentifierMap.ts +++ b/packages/shared-base/src/Identifier/IdentifierMap.ts @@ -94,7 +94,7 @@ export class IdentifierMap implements Map< *values() { for (const [k, v] of this.entries()) yield v } - public [Symbol.toStringTag]: string; + public [Symbol.toStringTag]!: string; [Symbol.iterator](): Generator<[IdentifierType, T], void, unknown> { return this.entries() } diff --git a/packages/shared-base/src/crypto/JWKType.ts b/packages/shared-base/src/crypto/JWKType.ts index 706d3b06fe8a..da9dbbb73346 100644 --- a/packages/shared-base/src/crypto/JWKType.ts +++ b/packages/shared-base/src/crypto/JWKType.ts @@ -10,6 +10,36 @@ export interface EC_Public_JsonWebKey extends JsonWebKey, Nominal<'EC public'> { export interface EC_Private_JsonWebKey extends JsonWebKey, Nominal<'EC private'> {} export interface AESJsonWebKey extends JsonWebKey, Nominal<'AES'> {} +export function isAESJsonWebKey(x: unknown): x is AESJsonWebKey { + if (typeof x !== 'object' || x === null) return false + const { alg, k, key_ops, kty } = x as JsonWebKey + if (!alg || !k || Array.isArray(key_ops) || kty !== 'oct') return false + return true +} +export function assertAESJsonWebKey(x: JsonWebKey): asserts x is AESJsonWebKey { + if (isAESJsonWebKey(x)) return + throw new Error('Assert failed.') +} +export function isEC_Public_JsonWebKey(o: unknown): o is EC_Public_JsonWebKey { + if (typeof o !== 'object' || o === null) return false + const { crv, key_ops, kty, x, y } = o as JsonWebKey + if (!crv || Array.isArray(key_ops) || !kty || !x || !y) return false + return true +} +export function assertEC_Public_JsonWebKey(o: JsonWebKey): asserts o is EC_Public_JsonWebKey { + if (isEC_Public_JsonWebKey(o)) return + throw new Error('Assert failed.') +} + +export function isEC_Private_JsonWebKey(o: unknown): o is EC_Private_JsonWebKey { + if (!isEC_Public_JsonWebKey(o)) return false + return !!o.d +} +export function assertEC_Private_JsonWebKey(o: JsonWebKey): asserts o is EC_Private_JsonWebKey { + if (isEC_Private_JsonWebKey(o)) return + throw new Error('Assert failed.') +} + declare class Nominal { /** Ghost property, don't use it! */ private __brand: T diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d32041caf43..74e98f54f70c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,8 +116,10 @@ importers: packages/backup-format: specifiers: + '@masknet/shared-base': workspace:* '@msgpack/msgpack': ^2.7.2 dependencies: + '@masknet/shared-base': link:../shared-base '@msgpack/msgpack': 2.7.2 packages/configuration: @@ -301,6 +303,7 @@ importers: '@ethersproject/providers': ^5.0.9 '@ethersproject/solidity': ^5.0.4 '@hookform/resolvers': 2.8.8 + '@masknet/backup-format': workspace:* '@masknet/dashboard': workspace:* '@masknet/encryption': workspace:^0.0.0 '@masknet/external-plugin-previewer': workspace:* @@ -439,6 +442,7 @@ importers: '@ethersproject/providers': 5.4.2 '@ethersproject/solidity': 5.4.0 '@hookform/resolvers': 2.8.8_react-hook-form@7.28.1 + '@masknet/backup-format': link:../backup-format '@masknet/dashboard': link:../dashboard '@masknet/encryption': link:../encryption '@masknet/external-plugin-previewer': link:../external-plugin-previewer @@ -23431,7 +23435,7 @@ packages: safe-buffer: 5.2.1 tough-cookie: 2.5.0 tunnel-agent: 0.6.0 - uuid: 3.3.2 + uuid: 3.4.0 dev: false /require-directory/2.1.1: From 41b867bcfad4d5580571e2c8cfcb081411073d9b Mon Sep 17 00:00:00 2001 From: Jack Works Date: Wed, 23 Mar 2022 15:59:07 +0800 Subject: [PATCH 02/22] wip --- packages/backup-format/src/index.ts | 12 +- packages/backup-format/src/normalize/index.ts | 18 +- packages/backup-format/src/normalize/type.ts | 47 ++--- .../backup-format/src/utils/backupPreview.ts | 31 +++ packages/backup-format/src/version-0/index.ts | 18 +- packages/backup-format/src/version-1/index.ts | 33 +++- packages/backup-format/src/version-2/index.ts | 185 ++++++++++++++---- .../components/Restore/RestoreFromCloud.tsx | 7 +- .../components/Restore/RestoreFromLocal.tsx | 4 +- .../components/dialogs/BackupDialog.tsx | 20 +- .../dialogs/CloudBackupMergeDialog.tsx | 4 +- .../components/dialogs/RestoreDialog.tsx | 2 +- .../mask/background/services/backup/create.ts | 33 +++- .../background/services/backup/internal.ts | 135 +++++++++++++ .../background/services/backup/restore.ts | 36 ++++ packages/mask/background/tsconfig.json | 1 + .../background-script/IdentityService.ts | 23 +-- .../background-script/WelcomeService.ts | 91 ++------- .../WelcomeServices/generateBackupJSON.ts | 165 ---------------- .../WelcomeServices/restoreBackup.ts | 164 ---------------- .../src/extension/background-script/legacy.ts | 123 ++++++++++++ .../pages/Wallet/WalletRecovery/index.tsx | 2 +- packages/mask/src/utils/native-rpc/Web.ts | 21 +- .../src/utils/type-transform/BackupFile.ts | 18 -- .../JSON/DBRecord-JSON/PersonaRecord.ts | 32 --- .../JSON/DBRecord-JSON/PostRecord.ts | 91 --------- .../JSON/DBRecord-JSON/ProfileRecord.ts | 27 --- .../JSON/DBRecord-JSON/RelationRecord.ts | 21 -- .../JSON/DBRecord-JSON/WalletRecord.ts | 95 --------- .../BackupFormat/JSON/latest.ts | 54 ----- .../BackupFormat/JSON/version-0.ts | 19 -- .../BackupFormat/JSON/version-1.ts | 71 ------- .../BackupFormat/JSON/version-2.ts | 185 ------------------ .../type-transform/BackupFormat/index.ts | 1 - .../mask/src/utils/type-transform/index.ts | 2 - 35 files changed, 639 insertions(+), 1152 deletions(-) create mode 100644 packages/backup-format/src/utils/backupPreview.ts create mode 100644 packages/mask/background/services/backup/internal.ts create mode 100644 packages/mask/background/services/backup/restore.ts delete mode 100644 packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts delete mode 100644 packages/mask/src/extension/background-script/WelcomeServices/restoreBackup.ts create mode 100644 packages/mask/src/extension/background-script/legacy.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFile.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/latest.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/version-0.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/version-1.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/JSON/version-2.ts delete mode 100644 packages/mask/src/utils/type-transform/BackupFormat/index.ts diff --git a/packages/backup-format/src/index.ts b/packages/backup-format/src/index.ts index f14abd903bbd..8e7c138b8716 100644 --- a/packages/backup-format/src/index.ts +++ b/packages/backup-format/src/index.ts @@ -1,2 +1,10 @@ -export * from './version-3' -export * from './BackupErrors' +export { decryptBackup, encryptBackup } from './version-3' +export { BackupErrors } from './BackupErrors' +export { + normalizeBackup, + createEmptyNormalizedBackup, + generateBackupFile, + generateBackupRAW, + type NormalizedBackup, +} from './normalize' +export { getBackupPreviewInfo, type BackupPreview } from './utils/backupPreview' diff --git a/packages/backup-format/src/normalize/index.ts b/packages/backup-format/src/normalize/index.ts index e1860523a510..af7bdafb4ca9 100644 --- a/packages/backup-format/src/normalize/index.ts +++ b/packages/backup-format/src/normalize/index.ts @@ -1,19 +1,33 @@ import { ECKeyIdentifier, IdentifierMap, PostIVIdentifier, ProfileIdentifier } from '@masknet/shared-base' +import { None } from 'ts-results' import { BackupErrors } from '../BackupErrors' import { isBackupVersion0, normalizeBackupVersion0 } from '../version-0' import { isBackupVersion1, normalizeBackupVersion1 } from '../version-1' -import { isBackupVersion2, normalizeBackupVersion2 } from '../version-2' +import { generateBackupVersion2, isBackupVersion2, normalizeBackupVersion2 } from '../version-2' import type { NormalizedBackup } from './type' +export * from './type' export function normalizeBackup(data: unknown): NormalizedBackup.Data { if (isBackupVersion2(data)) return normalizeBackupVersion2(data) if (isBackupVersion1(data)) return normalizeBackupVersion1(data) if (isBackupVersion0(data)) return normalizeBackupVersion0(data) throw new TypeError(BackupErrors.UnknownFormat) } + +/** It will return the internal format. DO NOT rely on the detail of it! */ +export function generateBackupRAW(data: NormalizedBackup.Data): unknown { + const result = generateBackupVersion2(data) + return result +} + +export function generateBackupFile(data: NormalizedBackup.Data): unknown { + const result = generateBackupRAW(data) + return JSON.stringify(result) +} + export function createEmptyNormalizedBackup(): NormalizedBackup.Data { return { - meta: { version: 2 }, + meta: { version: 2, createdAt: None, maskVersion: None }, personas: new IdentifierMap(new Map(), ECKeyIdentifier), profiles: new IdentifierMap(new Map(), ProfileIdentifier), posts: new IdentifierMap(new Map(), PostIVIdentifier), diff --git a/packages/backup-format/src/normalize/type.ts b/packages/backup-format/src/normalize/type.ts index 02474666ea00..c8aae5ea39a6 100644 --- a/packages/backup-format/src/normalize/type.ts +++ b/packages/backup-format/src/normalize/type.ts @@ -8,7 +8,9 @@ import type { PostIVIdentifier, IdentifierMap, } from '@masknet/shared-base' +import type { Option } from 'ts-results' +// All optional type in this file is marked by Option because we don't want to miss any field. export namespace NormalizedBackup { export interface Data { /** Meta about this backup */ @@ -25,19 +27,19 @@ export namespace NormalizedBackup { /** Backup file version */ version: 0 | 1 | 2 /** Backup created by which Mask version */ - maskVersion?: string - createdAt?: Date + maskVersion: Option + createdAt: Option } export interface PersonaBackup { identifier: PersonaIdentifier - mnemonic?: Mnemonic + mnemonic: Option publicKey: EC_Public_JsonWebKey - privateKey?: EC_Private_JsonWebKey - localKey?: AESJsonWebKey + privateKey: Option + localKey: Option linkedProfiles: IdentifierMap - nickname?: string - createdAt?: Date - updatedAt?: Date + nickname: Option + createdAt: Option + updatedAt: Option } export interface Mnemonic { words: string @@ -46,11 +48,11 @@ export namespace NormalizedBackup { } export interface ProfileBackup { identifier: ProfileIdentifier - nickname?: string - localKey?: AESJsonWebKey - linkedPersona?: PersonaIdentifier - createdAt?: Date - updatedAt?: Date + nickname: Option + localKey: Option + linkedPersona: Option + createdAt: Option + updatedAt: Option } export interface RelationBackup { profile: ProfileIdentifier @@ -60,12 +62,12 @@ export namespace NormalizedBackup { export interface PostBackup { identifier: PostIVIdentifier postBy: ProfileIdentifier - postCryptoKey?: AESJsonWebKey - recipients?: PostReceiverPublic | PostReceiverE2E + postCryptoKey: Option + recipients: Option foundAt: Date - encryptBy?: PersonaIdentifier - url?: string - summary?: string + encryptBy: Option + url: Option + summary: Option interestedMeta: ReadonlyMap } export interface PostReceiverPublic { @@ -77,16 +79,17 @@ export namespace NormalizedBackup { } export interface RecipientReason { type: 'auto-share' | 'direct' | 'group' + // We don't care about this field anymore. Do not wrap it with Option group?: unknown at: Date } export interface WalletBackup { address: string name: string - passphrase?: string - publicKey?: EC_Public_JsonWebKey - privateKey?: EC_Private_JsonWebKey - mnemonic?: Mnemonic + passphrase: Option + publicKey: Option + privateKey: Option + mnemonic: Option createdAt: Date updatedAt: Date } diff --git a/packages/backup-format/src/utils/backupPreview.ts b/packages/backup-format/src/utils/backupPreview.ts new file mode 100644 index 000000000000..7dc5abaaf0a9 --- /dev/null +++ b/packages/backup-format/src/utils/backupPreview.ts @@ -0,0 +1,31 @@ +import type { NormalizedBackup } from '@masknet/backup-format' + +export interface BackupPreview { + personas: number + accounts: number + posts: number + contacts: number + relations: number + files: number + wallets: number + createdAt: number +} + +export function getBackupPreviewInfo(json: NormalizedBackup.Data): BackupPreview { + let files: number = 0 + + try { + files = Number((json.plugins?.['com.maskbook.fileservice'] as any)?.length || 0) + } catch {} + + return { + personas: json.personas.size, + accounts: [...json.personas.values()].reduce((a, b) => a + b.linkedProfiles.size, 0), + posts: json.posts.size, + contacts: json.profiles.size, + relations: json.relations.length, + files, + wallets: json.wallets.length, + createdAt: Number(json.meta.createdAt), + } +} diff --git a/packages/backup-format/src/version-0/index.ts b/packages/backup-format/src/version-0/index.ts index 39bcc09ac3f4..d176ff110e08 100644 --- a/packages/backup-format/src/version-0/index.ts +++ b/packages/backup-format/src/version-0/index.ts @@ -10,6 +10,7 @@ import { ProfileIdentifier, } from '@masknet/shared-base' import { isObjectLike } from 'lodash-unified' +import { None, Some } from 'ts-results' import { createEmptyNormalizedBackup } from '../normalize' import type { NormalizedBackup } from '../normalize/type' @@ -27,7 +28,7 @@ export function isBackupVersion0(obj: unknown): obj is BackupJSONFileVersion0 { export function normalizeBackupVersion0(file: BackupJSONFileVersion0): NormalizedBackup.Data { const backup = createEmptyNormalizedBackup() backup.meta.version = 0 - backup.meta.maskVersion = '<=1.3.2' + backup.meta.maskVersion = Some('<=1.3.2') const { local } = file const { username, key } = file.key @@ -39,17 +40,24 @@ export function normalizeBackupVersion0(file: BackupJSONFileVersion0): Normalize identifier: ECKeyIdentifierFromJsonWebKey(publicKey), publicKey, linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + localKey: isAESJsonWebKey(local) ? Some(local) : None, + privateKey: isEC_Private_JsonWebKey(privateKey) ? Some(privateKey) : None, + mnemonic: None, + nickname: None, + createdAt: None, + updatedAt: None, } - if (isEC_Private_JsonWebKey(privateKey)) persona.privateKey = privateKey - if (isAESJsonWebKey(local)) persona.localKey = local backup.personas.set(persona.identifier, persona) if (username && username !== '$unknown' && username !== '$local') { const profile: NormalizedBackup.ProfileBackup = { identifier: new ProfileIdentifier('facebook.com', username), - linkedPersona: persona.identifier, + linkedPersona: Some(persona.identifier), + createdAt: None, + updatedAt: None, + localKey: isAESJsonWebKey(local) ? Some(local) : None, + nickname: None, } - if (isAESJsonWebKey(local)) persona.localKey = local backup.profiles.set(profile.identifier, profile) persona.linkedProfiles.set(profile.identifier, void 0) } diff --git a/packages/backup-format/src/version-1/index.ts b/packages/backup-format/src/version-1/index.ts index 6e650f346671..d4029cd046b4 100644 --- a/packages/backup-format/src/version-1/index.ts +++ b/packages/backup-format/src/version-1/index.ts @@ -10,6 +10,7 @@ import { IdentifierMap, } from '@masknet/shared-base' import { isObjectLike } from 'lodash-unified' +import { None, Some } from 'ts-results' import { createEmptyNormalizedBackup } from '../normalize' import type { NormalizedBackup } from '../normalize/type' @@ -29,8 +30,8 @@ export function normalizeBackupVersion1(file: BackupJSONFileVersion1): Normalize const backup = createEmptyNormalizedBackup() backup.meta.version = 1 - if (!file.grantedHostPermissions) backup.meta.maskVersion = '<=1.5.2' - else if (!file.maskbookVersion) backup.meta.maskVersion = '<=1.6.0' + if (!file.grantedHostPermissions) backup.meta.maskVersion = Some('<=1.5.2') + else if (!file.maskbookVersion) backup.meta.maskVersion = Some('<=1.6.0') if (file.grantedHostPermissions) { backup.settings.grantedHostPermissions = file.grantedHostPermissions @@ -40,26 +41,38 @@ export function normalizeBackupVersion1(file: BackupJSONFileVersion1): Normalize for (const { network, publicKey, userId, nickname, localKey, privateKey } of [...whoami, ...(people || [])]) { const profile: NormalizedBackup.ProfileBackup = { identifier: new ProfileIdentifier(network, userId), - nickname, + nickname: nickname ? Some(nickname) : None, + createdAt: None, + updatedAt: None, + localKey: None, + linkedPersona: None, } if (isEC_Public_JsonWebKey(publicKey)) { const personaID = ECKeyIdentifierFromJsonWebKey(publicKey) - const persona = backup.personas.get(personaID) || { + const persona: NormalizedBackup.PersonaBackup = backup.personas.get(personaID) || { identifier: personaID, - publicKey, + nickname: None, linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), + publicKey, + privateKey: None, + localKey: None, + mnemonic: None, + createdAt: None, + updatedAt: None, } + profile.linkedPersona = Some(personaID) + if (isEC_Private_JsonWebKey(privateKey)) { - persona.privateKey = privateKey + persona.privateKey = Some(privateKey) } + backup.personas.set(personaID, persona) persona.linkedProfiles.set(profile.identifier, void 0) - profile.linkedPersona = personaID } if (isAESJsonWebKey(localKey)) { - profile.localKey = localKey - if (profile.linkedPersona && backup.personas.has(profile.linkedPersona)) { - backup.personas.get(profile.linkedPersona)!.localKey = localKey + profile.localKey = Some(localKey) + if (profile.linkedPersona.some && backup.personas.has(profile.linkedPersona.val)) { + backup.personas.get(profile.linkedPersona.val)!.localKey = Some(localKey) } } } diff --git a/packages/backup-format/src/version-2/index.ts b/packages/backup-format/src/version-2/index.ts index 8d94dd233116..863a4376035b 100644 --- a/packages/backup-format/src/version-2/index.ts +++ b/packages/backup-format/src/version-2/index.ts @@ -1,4 +1,4 @@ -import { decodeArrayBuffer } from '@dimensiondev/kit' +import { decodeArrayBuffer, encodeArrayBuffer, safeUnreachable } from '@dimensiondev/kit' import { ECKeyIdentifier, ECKeyIdentifierFromJsonWebKey, @@ -10,7 +10,8 @@ import { ProfileIdentifier, RelationFavor, } from '@masknet/shared-base' -import { decode } from '@msgpack/msgpack' +import { decode, encode } from '@msgpack/msgpack' +import { Err, None, Some } from 'ts-results' import { createEmptyNormalizedBackup } from '../normalize' import type { NormalizedBackup } from '../normalize/type' @@ -26,8 +27,8 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize const backup = createEmptyNormalizedBackup() backup.meta.version = 2 - backup.meta.maskVersion = item._meta_.maskbookVersion - backup.meta.createdAt = new Date(item._meta_.createdAt) + backup.meta.maskVersion = Some(item._meta_.maskbookVersion) + backup.meta.createdAt = Some(new Date(item._meta_.createdAt)) backup.settings.grantedHostPermissions = item.grantedHostPermissions const { personas, posts, profiles, relations, wallets, plugin } = item @@ -40,16 +41,17 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize identifier, linkedProfiles: new IdentifierMap(new Map(), ProfileIdentifier), publicKey, - createdAt: new Date(persona.createdAt), - updatedAt: new Date(persona.updatedAt), - nickname: persona.nickname, + privateKey: isEC_Private_JsonWebKey(persona.privateKey) ? Some(persona.privateKey) : None, + localKey: isAESJsonWebKey(persona.localKey) ? Some(persona.localKey) : None, + createdAt: Some(new Date(persona.createdAt)), + updatedAt: Some(new Date(persona.updatedAt)), + nickname: persona.nickname ? Some(persona.nickname) : None, + mnemonic: None, } if (persona.mnemonic) { const { words, parameter } = persona.mnemonic - normalizedPersona.mnemonic = { words, hasPassword: parameter.withPassword, path: parameter.path } + normalizedPersona.mnemonic = Some({ words, hasPassword: parameter.withPassword, path: parameter.path }) } - if (isEC_Private_JsonWebKey(persona.privateKey)) normalizedPersona.privateKey = persona.privateKey - if (isAESJsonWebKey(persona.localKey)) normalizedPersona.localKey = persona.localKey backup.personas.set(identifier, normalizedPersona) } @@ -59,27 +61,27 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize if (identifier.err) continue const normalizedProfile: NormalizedBackup.ProfileBackup = { identifier: identifier.val, - createdAt: new Date(profile.createdAt), - updatedAt: new Date(profile.updatedAt), - nickname: profile.nickname, + createdAt: Some(new Date(profile.createdAt)), + updatedAt: Some(new Date(profile.updatedAt)), + nickname: profile.nickname ? Some(profile.nickname) : None, + linkedPersona: None, + localKey: isAESJsonWebKey(profile.localKey) ? Some(profile.localKey) : None, } if (profile.linkedPersona) { const id = ECKeyIdentifier.fromString(profile.linkedPersona, ECKeyIdentifier) if (id.ok) { if (backup.personas.has(id.val) && backup.personas.get(id.val)!.linkedProfiles.has(identifier.val)) { - normalizedProfile.linkedPersona = id.val + normalizedProfile.linkedPersona = Some(id.val) } } } - if (isAESJsonWebKey(profile.localKey)) normalizedProfile.localKey = profile.localKey - backup.profiles.set(identifier.val, normalizedProfile) } for (const persona of backup.personas.values()) { const toRemove: ProfileIdentifier[] = [] for (const profile of persona.linkedProfiles.keys()) { - if (backup.profiles.get(profile)?.linkedPersona?.equals(persona.identifier)) { + if (backup.profiles.get(profile)?.linkedPersona?.unwrapOr(undefined)?.equals(persona.identifier)) { // do nothing } else toRemove.push(profile) } @@ -89,7 +91,7 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize for (const post of posts) { const identifier = PostIVIdentifier.fromString(post.identifier, PostIVIdentifier) const postBy = ProfileIdentifier.fromString(post.postBy, ProfileIdentifier) - const encryptBy = post.encryptBy ? ECKeyIdentifier.fromString(post.encryptBy, ECKeyIdentifier) : null + const encryptBy = post.encryptBy ? ECKeyIdentifier.fromString(post.encryptBy, ECKeyIdentifier) : Err.EMPTY if (identifier.err) continue const interestedMeta = new Map() @@ -98,14 +100,16 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize foundAt: new Date(post.foundAt), postBy: postBy.unwrapOr(ProfileIdentifier.unknown), interestedMeta, - encryptBy: encryptBy?.unwrapOr(undefined), - summary: post.summary, - url: post.url, + encryptBy: encryptBy.toOption(), + summary: post.summary ? Some(post.summary) : None, + url: post.url ? Some(post.url) : None, + postCryptoKey: isAESJsonWebKey(post.postCryptoKey) ? Some(post.postCryptoKey) : None, + recipients: None, } - if (isAESJsonWebKey(post.postCryptoKey)) normalizedPost.postCryptoKey = post.postCryptoKey if (post.recipients) { - if (post.recipients === 'everyone') normalizedPost.recipients = { type: 'public' } + if (post.recipients === 'everyone') + normalizedPost.recipients = Some({ type: 'public' }) else { const map = new IdentifierMap( new Map(), @@ -121,7 +125,7 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize reasons.push({ type: 'direct', at: new Date(r.at) }) } } - normalizedPost.recipients = { type: 'e2e', receivers: map } + normalizedPost.recipients = Some({ type: 'e2e', receivers: map }) } } if (post.interestedMeta) normalizedPost.interestedMeta = MetaFromJson(post.interestedMeta) @@ -143,23 +147,19 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize } for (const wallet of wallets) { - const { publicKey, privateKey } = wallet - if (!isEC_Public_JsonWebKey(publicKey)) continue - if (!isEC_Private_JsonWebKey(privateKey)) continue - const normalizedWallet: NormalizedBackup.WalletBackup = { address: wallet.address, name: wallet.name, - passphrase: wallet.passphrase, - publicKey, - privateKey, + passphrase: wallet.passphrase ? Some(wallet.passphrase) : None, + publicKey: isEC_Public_JsonWebKey(wallet.publicKey) ? Some(wallet.publicKey) : None, + privateKey: isEC_Private_JsonWebKey(wallet.privateKey) ? Some(wallet.privateKey) : None, mnemonic: wallet.mnemonic - ? { + ? Some({ words: wallet.mnemonic.words, hasPassword: wallet.mnemonic.parameter.withPassword, path: wallet.mnemonic.parameter.path, - } - : undefined, + }) + : None, createdAt: new Date(wallet.createdAt), updatedAt: new Date(wallet.updatedAt), } @@ -171,12 +171,129 @@ export function normalizeBackupVersion2(item: BackupJSONFileVersion2): Normalize return backup } +export function generateBackupVersion2(item: NormalizedBackup.Data): BackupJSONFileVersion2 { + const result: BackupJSONFileVersion2 = { + _meta_: { + maskbookVersion: item.meta.maskVersion.unwrapOr('>=2.5.0'), + createdAt: Number(item.meta.createdAt), + type: 'maskbook-backup', + version: 2, + }, + grantedHostPermissions: item.settings.grantedHostPermissions, + plugin: item.plugins, + personas: [], + posts: [], + profiles: [], + relations: [], + wallets: [], + userGroups: [], + } + for (const [id, data] of item.personas) { + result.personas.push({ + identifier: id.toText(), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + nickname: data.nickname.unwrapOr(undefined), + linkedProfiles: [...data.linkedProfiles.keys()].map((id) => [ + id.toText(), + { connectionConfirmState: 'confirmed' } as LinkedProfileDetails, + ]), + publicKey: data.publicKey, + privateKey: data.privateKey.unwrapOr(undefined), + mnemonic: data.mnemonic + .map((data) => ({ + words: data.words, + parameter: { path: data.path, withPassword: data.hasPassword }, + })) + .unwrapOr(undefined), + localKey: data.localKey.unwrapOr(undefined), + }) + } + + for (const [id, data] of item.profiles) { + result.profiles.push({ + identifier: id.toText(), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + nickname: data.nickname.unwrapOr(undefined), + linkedPersona: data.linkedPersona.unwrapOr(undefined)?.toText(), + localKey: data.localKey.unwrapOr(undefined), + }) + } + + for (const [id, data] of item.posts) { + const item: BackupJSONFileVersion2['posts'][0] = { + identifier: id.toText(), + foundAt: Number(data.foundAt), + postBy: data.postBy.toText(), + interestedMeta: MetaToJson(data.interestedMeta), + encryptBy: data.encryptBy.unwrapOr(undefined)?.toText(), + summary: data.summary.unwrapOr(undefined), + url: data.url.unwrapOr(undefined), + postCryptoKey: data.postCryptoKey.unwrapOr(undefined), + recipientGroups: [], + recipients: [], + } + result.posts.push(item) + if (data.recipients.some) { + if (data.recipients.val.type === 'public') item.recipients = 'everyone' + else if (data.recipients.val.type === 'e2e') { + item.recipients = [] + for (const [recipient, reasons] of data.recipients.val.receivers) { + if (!reasons.length) continue + item.recipients.push([ + recipient.toText(), + { + reason: [ + { + at: Number(reasons[0].at), + type: 'direct', + }, + ], + }, + ]) + } + } else safeUnreachable(data.recipients.val) + } + } + + for (const data of item.relations) { + result.relations.push({ + profile: data.profile.toText(), + persona: data.persona.toText(), + favor: data.favor, + }) + } + + for (const data of item.wallets) { + result.wallets.push({ + address: data.address, + name: data.name, + passphrase: data.passphrase.unwrapOr(undefined), + publicKey: data.publicKey.unwrapOr(undefined), + privateKey: data.privateKey.unwrapOr(undefined), + mnemonic: data.mnemonic + .map((data) => ({ + words: data.words, + parameter: { path: data.path, withPassword: data.hasPassword }, + })) + .unwrapOr(undefined), + createdAt: Number(data.createdAt), + updatedAt: Number(data.updatedAt), + }) + } + return result +} + function MetaFromJson(meta: string | undefined): Map { if (!meta) return new Map() const raw = decode(decodeArrayBuffer(meta)) if (typeof raw !== 'object' || !raw) return new Map() return new Map(Object.entries(raw)) } +function MetaToJson(meta: ReadonlyMap) { + return encodeArrayBuffer(encode(Object.fromEntries(meta.entries()))) +} /** * @see https://github.com/DimensionDev/Maskbook/issues/194 diff --git a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx index 2a32d31fefe6..ee793e23d7d4 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx @@ -13,14 +13,13 @@ import { useNavigate } from 'react-router-dom' import { DashboardRoutes } from '@masknet/shared-base' import { Step, Stepper } from '../Stepper' import { LoadingCard } from './steps/LoadingCard' -import { decryptBackup } from '@masknet/backup-format' +import { BackupPreview, decryptBackup } from '@masknet/backup-format' import { decode, encode } from '@msgpack/msgpack' import { PersonaContext } from '../../pages/Personas/hooks/usePersonaContext' import { AccountType } from '../../pages/Settings/type' import { UserContext } from '../../pages/Settings/hooks/UserContext' import { ConfirmSynchronizePasswordDialog } from './ConfirmSynchronizePasswordDialog' import { LoadingButton } from '../LoadingButton' -import type { BackupPreview } from '../../../../mask/src/utils' export const RestoreFromCloud = memo(() => { const t = useDashboardI18N() @@ -105,10 +104,10 @@ export const RestoreFromCloud = memo(() => { async (backupInfo: { info: BackupPreview; id: string }, account: any) => { try { if (backupInfo.info?.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(backupInfo.id) + await Services.Welcome.restoreBackupWithIDAndPermissionAndWallet({ id: backupInfo.id }) return } else { - await Services.Welcome.checkPermissionsAndRestore(backupInfo.id) + await Services.Welcome.restoreBackupWithIDAndPermission({ id: backupInfo.id }) await restoreCallback() } diff --git a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx index 8ef85e403af1..500a7e2b5289 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx @@ -105,10 +105,10 @@ export const RestoreFromLocal = memo(() => { try { // If json has wallets, restore in popup. if (json?.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(backupId) + await Services.Welcome.restoreBackupWithIDAndPermissionAndWallet({ id: backupId }) return } else { - await Services.Welcome.checkPermissionsAndRestore(backupId) + await Services.Welcome.restoreBackupWithIDAndPermission({ id: backupId }) await restoreCallback() } diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx index 3e3def1a2eb8..22e8e47d2045 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/BackupDialog.tsx @@ -54,26 +54,20 @@ export default function BackupDialog({ local = true, params, open, merged, onClo } } - const fileJson = await Services.Welcome.createBackupFile({ - noPosts: !showPassword.base, - noPersonas: !showPassword.base, - noProfiles: !showPassword.base, - noWallets: !showPassword.wallet, - download: false, - onlyBackupWhoAmI: false, + const { file, personaNickNames } = await Services.Welcome.createBackupFile({ + excludeBase: !showPassword, + excludeWallet: !showPassword.wallet, }) + // TODO: move this to background if (local) { // local backup, no account - const encrypted = await encryptBackup(encode(backupPassword), encode(fileJson)) + const encrypted = await encryptBackup(encode(backupPassword), encode(file)) await Services.Welcome.downloadBackupV2(encrypted) } else if (params) { - const abstract = fileJson.personas - .filter((x) => x.nickname) - .map((x) => x.nickname) - .join(', ') + const abstract = personaNickNames.join(', ') const uploadUrl = await fetchUploadLink({ ...params, abstract }) - const encrypted = await encryptBackup(encode(params.account + backupPassword), encode(fileJson)) + const encrypted = await encryptBackup(encode(params.account + backupPassword), encode(file)) uploadBackupValue(uploadUrl, encrypted).then(() => { showSnackbar(t.settings_alert_backup_success(), { variant: 'success' }) diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx index bcb1a3740721..b7a03fcdad38 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx @@ -60,11 +60,11 @@ export function CloudBackupMergeDialog({ account, info, open, onClose, onMerged const data = await Services.Welcome.parseBackupStr(backupText) if (data?.info.wallets) { - await Services.Welcome.checkPermissionAndOpenWalletRecovery(data.id) + await Services.Welcome.restoreBackupWithIDAndPermissionAndWallet({ id: data.id }) return } else { if (data?.id) { - await Services.Welcome.checkPermissionsAndRestore(data.id) + await Services.Welcome.restoreBackupWithIDAndPermission({ id: data.id }) } restoreCallback() diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx index 65665c181e97..fb19671d2390 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx @@ -85,7 +85,7 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { const handleConfirm = async () => { if (!json) return - await Services.Welcome.checkPermissionsAndRestore(id) + await Services.Welcome.restoreBackupWithIDAndPermission({ id }) } useAsync(async () => { diff --git a/packages/mask/background/services/backup/create.ts b/packages/mask/background/services/backup/create.ts index 336ce12bb910..08fb9875e8de 100644 --- a/packages/mask/background/services/backup/create.ts +++ b/packages/mask/background/services/backup/create.ts @@ -1 +1,32 @@ -export {} +import type { PersonaIdentifier } from '@masknet/shared-base' +import { BackupPreview, createEmptyNormalizedBackup, generateBackupRAW } from '@masknet/backup-format' + +export interface MobileBackupOptions { + noPosts?: boolean + noWallets?: boolean + noPersonas?: boolean + noProfiles?: boolean + hasPrivateKeyOnly?: boolean +} +export async function mobile_generateBackupJSON(options: MobileBackupOptions): Promise { + if (process.env.architecture !== 'app') throw new TypeError('This function is only available in app environment') + const file = createEmptyNormalizedBackup() + return generateBackupRAW(file) +} +export async function mobile_generateBackupJSONOnlyForPersona(persona: PersonaIdentifier): Promise { + if (process.env.architecture !== 'app') throw new TypeError('This function is only available in app environment') + throw new TypeError('Not implemented') +} + +export async function generateBackupPreviewInfo(): Promise { + throw new TypeError('Not implemented') +} + +export interface BackupOptions { + excludeWallet?: boolean + /** Includes persona, relations, posts and profiles. */ + excludeBase?: boolean +} +export async function createBackupFile(options: BackupOptions): Promise<{ file: unknown; personaNickNames: string[] }> { + throw new TypeError('Not implemented') +} diff --git a/packages/mask/background/services/backup/internal.ts b/packages/mask/background/services/backup/internal.ts new file mode 100644 index 000000000000..6a515e6651f8 --- /dev/null +++ b/packages/mask/background/services/backup/internal.ts @@ -0,0 +1,135 @@ +import { createEmptyNormalizedBackup, NormalizedBackup } from '@masknet/backup-format' +import { IdentifierMap, PersonaIdentifier, ProfileIdentifier } from '@masknet/shared-base' +import { None, Some } from 'ts-results' +import { queryPersonasDB, queryProfilesDB, queryRelations } from '../../database/persona/db' +import { queryPostsDB } from '../../database/post' + +// Well, this is a bit of a hack, because we have not move those two parts into this project yet. +let backupPlugins: () => Promise +let backupWallets: () => Promise +export function delegateWalletBackup(f: typeof backupWallets) { + backupWallets = f +} +export function delegatePluginBackup(f: typeof backupPlugins) { + backupPlugins = f +} + +/** @internal */ +export interface InternalBackupOptions { + hasPrivateKeyOnly?: boolean +} +/** + * @internal + * DO NOT expose this function as a service. + */ +// TODO: use a single readonly transaction in this operation. +export async function createNewBackup(options: InternalBackupOptions): Promise { + const file = createEmptyNormalizedBackup() + const { meta, personas, posts, profiles, relations, settings } = file + + meta.version = 2 + meta.maskVersion = Some(process.env.VERSION) + meta.createdAt = Some(new Date()) + + settings.grantedHostPermissions = (await browser.permissions.getAll()).origins || [] + + await Promise.allSettled([ + backupPersonas(), + backupAllRelations(), + backupPosts(), + backupProfiles(), + backupPlugins().then((p) => (file.plugins = p)), + backupWallets().then((w) => (file.wallets = w)), + ]) + + return file + + async function backupPersonas(of?: PersonaIdentifier[]) { + const data = await queryPersonasDB( + { + initialized: true, + hasPrivateKey: options.hasPrivateKeyOnly, + identifiers: of, + }, + undefined, + true, + ) + for (const persona of data) { + personas.set(persona.identifier, { + identifier: persona.identifier, + nickname: persona.nickname ? Some(persona.nickname) : None, + publicKey: persona.publicKey, + privateKey: persona.privateKey ? Some(persona.privateKey) : None, + localKey: persona.localKey ? Some(persona.localKey) : None, + createdAt: persona.createdAt ? Some(persona.createdAt) : None, + updatedAt: persona.updatedAt ? Some(persona.updatedAt) : None, + mnemonic: persona.mnemonic + ? Some({ + hasPassword: persona.mnemonic.parameter.withPassword, + path: persona.mnemonic.parameter.path, + words: persona.mnemonic.words, + }) + : None, + linkedProfiles: persona.linkedProfiles, + }) + } + } + + async function backupPosts() { + const data = await queryPostsDB(() => true) + for (const post of data) { + let recipients: NormalizedBackup.PostReceiverE2E | NormalizedBackup.PostReceiverPublic = { + type: 'public', + } + if (post.recipients !== 'everyone') { + recipients = { + type: 'e2e', + receivers: new IdentifierMap(new Map(), ProfileIdentifier), + } + for (const [recipient, reason] of post.recipients) { + if (reason.reason.length === 0) continue + recipients.receivers.set(recipient, [{ at: reason.reason[0].at, type: 'direct' }]) + } + } + posts.set(post.identifier, { + identifier: post.identifier, + foundAt: post.foundAt, + interestedMeta: post.interestedMeta || new Map(), + postBy: post.postBy, + encryptBy: post.encryptBy ? Some(post.encryptBy) : None, + postCryptoKey: post.postCryptoKey ? Some(post.postCryptoKey) : None, + summary: post.summary ? Some(post.summary) : None, + url: post.url ? Some(post.url) : None, + recipients: Some(recipients), + }) + } + } + + async function backupProfiles(of?: ProfileIdentifier[]) { + const data = await queryProfilesDB({ + identifiers: of, + hasLinkedPersona: true, + }) + for (const profile of data) { + profiles.set(profile.identifier, { + identifier: profile.identifier, + nickname: profile.nickname ? Some(profile.nickname) : None, + localKey: profile.localKey ? Some(profile.localKey) : None, + createdAt: profile.createdAt ? Some(profile.createdAt) : None, + updatedAt: profile.updatedAt ? Some(profile.updatedAt) : None, + linkedPersona: profile.linkedPersona ? Some(profile.linkedPersona) : None, + }) + } + } + + async function backupAllRelations() { + const data = await queryRelations(() => true) + for (const relation of data) { + relations.push({ + favor: relation.favor, + persona: relation.linked, + profile: relation.profile, + }) + } + } +} diff --git a/packages/mask/background/services/backup/restore.ts b/packages/mask/background/services/backup/restore.ts new file mode 100644 index 000000000000..26d3910d2af7 --- /dev/null +++ b/packages/mask/background/services/backup/restore.ts @@ -0,0 +1,36 @@ +import type { BackupPreview, NormalizedBackup } from '@masknet/backup-format' + +/** + * Only for mobile. Throw when no Persona is restored + */ +export async function restoreFromBase64(backup: string): Promise { + if (process.env.architecture !== 'app') throw new Error('Only for mobile') + throw new TypeError('Not implemented') +} + +/** + * Only for mobile. Throw when no Persona is restored. + */ +export async function restoreFromBackup(backup: string): Promise { + if (process.env.architecture !== 'app') throw new Error('Only for mobile') + throw new TypeError('Not implemented') +} + +/** + * Throw when no Persona is restored. + */ +export async function restoreBackup(backup: string): Promise { + throw new TypeError('Not implemented') +} + +export async function restoreBackupWithID({ id }: { id: string }): Promise {} +export async function restoreBackupWithIDAndPermission({ id }: { id: string }): Promise {} +export async function restoreBackupWithIDAndPermissionAndWallet({ id }: { id: string }): Promise {} + +export async function parseBackupStr(backup: string): Promise<{ info: BackupPreview; id: string }> { + throw new TypeError('Not implemented') +} + +export async function getUnconfirmedBackup(id: string): Promise<{ wallets: NormalizedBackup.WalletBackup[] }> { + throw new TypeError('Not implemented') +} diff --git a/packages/mask/background/tsconfig.json b/packages/mask/background/tsconfig.json index b97bc2510f7c..ca2ab8364445 100644 --- a/packages/mask/background/tsconfig.json +++ b/packages/mask/background/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../shared" }, { "path": "../utils-pure" }, { "path": "../../encryption" }, + { "path": "../../backup-format" }, { "path": "../../gun-utils" } ] } diff --git a/packages/mask/src/extension/background-script/IdentityService.ts b/packages/mask/src/extension/background-script/IdentityService.ts index 9668da327efb..7e6282475916 100644 --- a/packages/mask/src/extension/background-script/IdentityService.ts +++ b/packages/mask/src/extension/background-script/IdentityService.ts @@ -1,7 +1,7 @@ import * as bip39 from 'bip39' import { validateMnemonic } from 'bip39' import { decode } from '@msgpack/msgpack' -import { decodeArrayBuffer, decodeText } from '@dimensiondev/kit' +import { decodeArrayBuffer } from '@dimensiondev/kit' import { loginPersona, personaRecordToPersona, @@ -13,8 +13,6 @@ import { storeAvatar, } from '../../database' import { - ECKeyIdentifier, - Identifier, PersonaIdentifier, ProfileIdentifier, ECKeyIdentifierFromJsonWebKey, @@ -48,10 +46,7 @@ import { queryProfilesDB as queryProfilesFromIndexedDB, queryRelations as queryRelationsFromIndexedDB, } from '../../../background/database/persona/web' -import { BackupJSONFileLatest, UpgradeBackupJSONFile } from '../../utils/type-transform/BackupFormat/JSON/latest' -import { restoreBackup } from './WelcomeServices/restoreBackup' import { restoreNewIdentityWithMnemonicWord } from './WelcomeService' -import { convertBackupFileToObject, fixBackupFilePermission } from '../../utils/type-transform/BackupFile' import { assertEnvironment, Environment } from '@dimensiondev/holoflows-kit' import { getCurrentPersonaIdentifier } from './SettingsService' @@ -60,6 +55,8 @@ import { first, orderBy } from 'lodash-unified' import { recover_ECDH_256k1_KeyPair_ByMnemonicWord } from '../../utils/mnemonic-code' import { bindProof } from '@masknet/web3-providers' +export { restoreFromBase64, restoreFromBackup } from '../../../background/services/backup/restore' + assertEnvironment(Environment.ManifestBackground) export { validateMnemonic } from '../../utils/mnemonic-code' @@ -179,14 +176,6 @@ export async function queryOwnedPersonaInformation(): Promise { - if (!object) return null - await restoreBackup(object) - if (object?.personas?.length) { - return queryPersona(Identifier.fromString(object.personas[0].identifier, ECKeyIdentifier).unwrap()) - } - return null -} export async function restoreFromMnemonicWords( mnemonicWords: string, nickname: string, @@ -199,12 +188,6 @@ export async function restoreFromMnemonicWords( return queryPersona(identifier) } -export async function restoreFromBase64(base64: string): Promise { - return restoreFromObject(JSON.parse(decodeText(decodeArrayBuffer(base64))) as BackupJSONFileLatest) -} -export async function restoreFromBackup(backup: string): Promise { - return restoreFromObject(fixBackupFilePermission(UpgradeBackupJSONFile(convertBackupFileToObject(backup)))) -} // #endregion // #region Profile & Persona diff --git a/packages/mask/src/extension/background-script/WelcomeService.ts b/packages/mask/src/extension/background-script/WelcomeService.ts index 65a4160c4298..bd2a5a2ebc31 100644 --- a/packages/mask/src/extension/background-script/WelcomeService.ts +++ b/packages/mask/src/extension/background-script/WelcomeService.ts @@ -1,27 +1,30 @@ -import { encodeText, delay } from '@dimensiondev/kit' -import { type DashboardRoutes, PopupRoutes } from '@masknet/shared-base' +import { encodeText } from '@dimensiondev/kit' +import type { DashboardRoutes, PersonaIdentifier, ProfileIdentifier, AESJsonWebKey } from '@masknet/shared-base' import { recover_ECDH_256k1_KeyPair_ByMnemonicWord } from '../../utils/mnemonic-code' import { createPersonaByJsonWebKey } from '../../../background/database/persona/helper' import { attachProfileDB, LinkedProfileDetails } from '../../../background/database/persona/db' import { deriveLocalKeyFromECDHKey } from '../../utils/mnemonic-code/localKeyGenerate' -import type { PersonaIdentifier, ProfileIdentifier, AESJsonWebKey } from '@masknet/shared-base' -import { BackupOptions, generateBackupJSON } from './WelcomeServices/generateBackupJSON' -import { requestExtensionPermission, openPopupWindow } from './../../../background/services/helper' import { saveFileFromBuffer } from '../../../shared' -import { - BackupJSONFileLatest, - getBackupPreviewInfo, - UpgradeBackupJSONFile, -} from '../../utils/type-transform/BackupFormat/JSON/latest' import { assertEnvironment, Environment } from '@dimensiondev/holoflows-kit' -import { convertBackupFileToObject, extraPermissions, fixBackupFilePermission } from '../../utils' -import { v4 as uuid } from 'uuid' -import { getUnconfirmedBackup, restoreBackup, setUnconfirmedBackup } from './WelcomeServices/restoreBackup' import formatDateTime from 'date-fns/format' +import './legacy' + +export { + mobile_generateBackupJSON, + mobile_generateBackupJSONOnlyForPersona, + generateBackupPreviewInfo, + createBackupFile, +} from '../../../background/services/backup/create' +export { + restoreBackup, + parseBackupStr, + getUnconfirmedBackup, + restoreBackupWithID, + restoreBackupWithIDAndPermission, + restoreBackupWithIDAndPermissionAndWallet, +} from '../../../background/services/backup/restore' -export { generateBackupJSON, generateBackupPreviewInfo } from './WelcomeServices/generateBackupJSON' -export * from './WelcomeServices/restoreBackup' assertEnvironment(Environment.ManifestBackground) /** @@ -68,26 +71,6 @@ export async function downloadBackupV2(buffer: ArrayBuffer) { saveFileFromBuffer(buffer, 'application/octet-stream', makeBackupName('bin')) } -export async function createBackupFile( - options: { download: boolean; onlyBackupWhoAmI: boolean } & Partial, -): Promise { - const obj = await generateBackupJSON(options) - if (!options.download) return obj - // Don't make the download pop so fast - await delay(1000) - return downloadBackup(obj) -} - -export async function createBackupUrl( - options: { download: boolean; onlyBackupWhoAmI: boolean } & Partial, -) { - const obj = await generateBackupJSON(options) - const { buffer, mimeType, fileName } = await createBackupInfo(obj) - const blob = new Blob([buffer], { type: mimeType }) - const url = URL.createObjectURL(blob) - return { url, fileName } -} - async function createBackupInfo(obj: T, type?: 'txt' | 'json') { const string = typeof obj === 'string' ? obj : JSON.stringify(obj) const buffer = encodeText(string) @@ -104,44 +87,6 @@ export async function openOptionsPage(route?: DashboardRoutes, search?: string) export { createPersonaByMnemonic } from '../../database' -export function parseBackupStr(str: string) { - const json = fixBackupFilePermission(UpgradeBackupJSONFile(convertBackupFileToObject(str))) - if (json) { - const info = getBackupPreviewInfo(json) - const id = uuid() - setUnconfirmedBackup(id, json) - return { info, id } - } else { - return null - } -} - -export async function checkPermissionsAndRestore(id: string) { - const json = await getUnconfirmedBackup(id) - if (json) { - const permissions = await extraPermissions(json.grantedHostPermissions) - if (permissions.length) { - const granted = await requestExtensionPermission({ origins: permissions }) - if (!granted) return - } - - await restoreBackup(json) - } -} - -export async function checkPermissionAndOpenWalletRecovery(id: string) { - const json = await getUnconfirmedBackup(id) - if (json) { - const permissions = await extraPermissions(json.grantedHostPermissions) - if (permissions.length) { - const granted = await requestExtensionPermission({ origins: permissions }) - if (!granted) return - } - - await openPopupWindow(PopupRoutes.WalletRecovered, { backupId: id }) - } -} - function makeBackupName(extension: string) { const now = formatDateTime(Date.now(), 'yyyy-MM-dd') return `mask-network-keystore-backup-${now}.${extension}` diff --git a/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts b/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts deleted file mode 100644 index 2becd9acf9af..000000000000 --- a/packages/mask/src/extension/background-script/WelcomeServices/generateBackupJSON.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ProviderType } from '@masknet/web3-shared-evm' -import { - BackupJSONFileLatest, - BackupPreview, - getBackupPreviewInfo, -} from '../../../utils/type-transform/BackupFormat/JSON/latest' -import { queryPersonasDB, queryProfilesDB, queryRelations } from '../../../../background/database/persona/db' -import { queryPostsDB } from '../../../../background/database/post' -import { PersonaRecordToJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord' -import { ProfileRecordToJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord' -import { PostRecordToJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord' -import { Identifier, PersonaIdentifier, ProfileIdentifier } from '@masknet/shared-base' -import { exportMnemonic, exportPrivateKey, getLegacyWallets, getWallets } from '../../../plugins/Wallet/services' -import { - LegacyWalletRecordToJSONFormat, - WalletRecordToJSONFormat, -} from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord' -import { activatedPluginsWorker } from '@masknet/plugin-infra' -import { RelationRecordToJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord' -import { timeout } from '@dimensiondev/kit' - -export type { BackupPreview } from '../../../utils/type-transform/BackupFormat/JSON/latest' -export interface BackupOptions { - noPosts: boolean - noWallets: boolean - noPersonas: boolean - noProfiles: boolean - noRelations: boolean - noPlugins: boolean - hasPrivateKeyOnly: boolean - filter: { type: 'persona'; wanted: PersonaIdentifier[] } -} -export async function generateBackupJSON(opts: Partial = {}): Promise { - const personas: BackupJSONFileLatest['personas'] = [] - const posts: BackupJSONFileLatest['posts'] = [] - const wallets: BackupJSONFileLatest['wallets'] = [] - const profiles: BackupJSONFileLatest['profiles'] = [] - const relations: BackupJSONFileLatest['relations'] = [] - const plugins: NonNullable = {} - - if (!opts.filter) { - if (!opts.noPersonas) await backupPersonas() - if (!opts.noProfiles) await backProfiles() - } else if (opts.filter.type === 'persona') { - if (opts.noPersonas) throw new TypeError('Invalid opts') - await backupPersonas(opts.filter.wanted) - const wantedProfiles: ProfileIdentifier[] = personas.flatMap((q) => - q.linkedProfiles - .map((y) => Identifier.fromString(y[0], ProfileIdentifier)) - .filter((k) => k.ok) - .map((x) => x.val as ProfileIdentifier), - ) - if (!opts.noProfiles) await backProfiles(wantedProfiles) - } - if (!opts.noPosts) await backupAllPosts() - if (!opts.noRelations) await backupAllRelations() - if (!opts.noWallets) { - await backupAllWallets() - await backupAllLegacyWallets() - } - if (!opts.noPlugins) await backupAllPlugins() - - const file: BackupJSONFileLatest = { - _meta_: { - createdAt: Date.now(), - maskbookVersion: browser.runtime.getManifest().version, - version: 2, - type: 'maskbook-backup', - }, - grantedHostPermissions: (await browser.permissions.getAll()).origins || [], - personas, - posts, - wallets, - profiles, - relations, - userGroups: [], - } - if (Object.keys(plugins).length) file.plugin = plugins - - return file - - async function backupAllPosts() { - posts.push(...(await queryPostsDB(() => true)).map(PostRecordToJSONFormat)) - } - - async function backProfiles(of?: ProfileIdentifier[]) { - const data = ( - await queryProfilesDB({ - identifiers: of, - hasLinkedPersona: true, - }) - ).map(ProfileRecordToJSONFormat) - profiles.push(...data) - } - - async function backupPersonas(of?: PersonaIdentifier[]) { - const data = ( - await queryPersonasDB( - { - initialized: true, - hasPrivateKey: opts.hasPrivateKeyOnly, - identifiers: of, - }, - undefined, - true, - ) - ).map(PersonaRecordToJSONFormat) - personas.push(...data) - } - - async function backupAllRelations() { - const data = (await queryRelations(() => true)).map(RelationRecordToJSONFormat) - relations.push(...data) - } - - async function backupAllWallets() { - const allSettled = await Promise.allSettled( - ( - await getWallets(ProviderType.MaskWallet) - ).map(async (wallet) => { - return { - ...wallet, - mnemonic: wallet.derivationPath ? await exportMnemonic(wallet.address) : undefined, - privateKey: wallet.derivationPath ? undefined : await exportPrivateKey(wallet.address), - } - }), - ) - const wallets_ = allSettled.map((x) => (x.status === 'fulfilled' ? WalletRecordToJSONFormat(x.value) : null)) - if (wallets_.some((x) => !x)) throw new Error('Failed to backup wallets.') - wallets.push(...(wallets_ as BackupJSONFileLatest['wallets'])) - } - - async function backupAllLegacyWallets() { - const wallets_ = (await getLegacyWallets(ProviderType.MaskWallet)).map(LegacyWalletRecordToJSONFormat) - wallets.push(...wallets_) - } - - async function backupAllPlugins() { - await Promise.all( - [...activatedPluginsWorker] - // generate backup - .map(async (plugin) => { - const backupCreator = plugin.backup?.onBackup - if (!backupCreator) return - - async function backupPlugin() { - const result = await timeout(backupCreator!(), 3000) - if (result.none) return - // We limit the plugin contributed backups must be simple objects. - // We may allow plugin to store binary if we're moving to binary backup format like MessagePack. - plugins[plugin.ID] = result.map(JSON.stringify).map(JSON.parse).val - } - if (process.env.NODE_ENV === 'development') return backupPlugin() - return backupPlugin().catch((error) => - console.error(`[@masknet/plugin-infra] Plugin ${plugin.ID} failed to backup`, error), - ) - }), - ) - } -} - -export async function generateBackupPreviewInfo(opts: Partial = {}): Promise { - const json = await generateBackupJSON(opts) - return getBackupPreviewInfo(json) -} diff --git a/packages/mask/src/extension/background-script/WelcomeServices/restoreBackup.ts b/packages/mask/src/extension/background-script/WelcomeServices/restoreBackup.ts deleted file mode 100644 index 1117c89495d6..000000000000 --- a/packages/mask/src/extension/background-script/WelcomeServices/restoreBackup.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { ProfileIdentifier } from '@masknet/shared-base' -import { RelationFavor } from '@masknet/shared-base' -import { BackupJSONFileLatest, UpgradeBackupJSONFile } from '../../../utils' -import { i18n } from '../../../../shared-ui/locales_legacy' -import { - attachProfileDB, - consistentPersonaDBWriteAccess, - createOrUpdatePersonaDB, - createOrUpdateProfileDB, -} from '../../../../background/database/persona/db' -import { currySameAddress } from '@masknet/web3-shared-evm' -import { PersonaRecordFromJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord' -import { ProfileRecordFromJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord' -import { PostRecordFromJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord' -import { createPostDB, PostDBAccess, updatePostDB } from '../../../database' -import { WalletRecordFromJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord' -import { - getDerivableAccounts, - recoverWalletFromMnemonic, - recoverWalletFromPrivateKey, -} from '../../../plugins/Wallet/services' -import { activatedPluginsWorker, registeredPluginIDs } from '@masknet/plugin-infra' -import { Result } from 'ts-results' -import { addWallet } from '../../../plugins/Wallet/services/wallet/database' -import { patchCreateNewRelation, patchCreateOrUpdateRelation } from '../IdentityService' -import { RelationRecordFromJSONFormat } from '../../../utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord' -import { HD_PATH_WITHOUT_INDEX_ETHEREUM } from '@masknet/plugin-wallet' -import { createTransaction } from '../../../../background/database/utils/openDB' - -/** - * Restore the backup - */ -export async function restoreBackup(json: object, whoAmI?: ProfileIdentifier) { - const data = UpgradeBackupJSONFile(json, whoAmI) - if (!data) throw new TypeError(i18n.t('service_invalid_backup_file')) - - { - await consistentPersonaDBWriteAccess(async (t) => { - for (const x of data.personas) { - await createOrUpdatePersonaDB( - PersonaRecordFromJSONFormat(x), - { explicitUndefinedField: 'ignore', linkedProfiles: 'merge', protectPrivateKey: true }, - t, - ) - } - - for (const x of data.profiles) { - const { linkedPersona, ...record } = ProfileRecordFromJSONFormat(x) - await createOrUpdateProfileDB(record, t) - if (linkedPersona) { - await attachProfileDB(record.identifier, linkedPersona, { connectionConfirmState: 'confirmed' }, t) - } - } - }) - } - - for (const [_, x] of data.wallets.entries()) { - try { - const record = WalletRecordFromJSONFormat(x) - const name = record.name - - if (record.storedKeyInfo && record.derivationPath) - await addWallet(record.address, name, record.derivationPath, record.storedKeyInfo) - else if (record.privateKey) await recoverWalletFromPrivateKey(name, record.privateKey) - else if (record.mnemonic) { - // fix a backup bug of pre-v2.2.2 versions - const accounts = await getDerivableAccounts(record.mnemonic, 1, 5) - const index = accounts.findIndex(currySameAddress(record.address)) - await recoverWalletFromMnemonic( - name, - record.mnemonic, - index > -1 ? `${HD_PATH_WITHOUT_INDEX_ETHEREUM}/${index}` : record.derivationPath, - ) - } - } catch (error) { - console.error(error) - continue - } - } - { - const t = createTransaction(await PostDBAccess(), 'readwrite')('post') - for (const x of data.posts) { - const { val, err } = Result.wrap(() => PostRecordFromJSONFormat(x)) - if (err) continue - if (await t.objectStore('post').get(val.identifier.toText())) { - await updatePostDB(val, 'append', t) - } else await createPostDB(val, t) - } - } - - if (data.relations?.length) { - const relations = data.relations.map(RelationRecordFromJSONFormat) - await patchCreateNewRelation(relations) - } else { - // For 1.x backups - const personas = data.personas - .map(PersonaRecordFromJSONFormat) - .filter((x) => x.privateKey) - .map((x) => x.identifier) - const profiles = data.profiles.map(ProfileRecordFromJSONFormat).map((x) => x.identifier) - await patchCreateOrUpdateRelation(profiles, personas, RelationFavor.UNCOLLECTED) - } - - const plugins = [...activatedPluginsWorker] - const works = new Set>>() - for (const [pluginID, item] of Object.entries(data.plugin || {})) { - const plugin = plugins.find((x) => x.ID === pluginID) - // should we warn user here? - if (!plugin) { - if ([...registeredPluginIDs].includes(pluginID)) - console.warn(`[@masknet/plugin-infra] Found a backup of a not enabled plugin ${plugin}`, item) - else console.warn(`[@masknet/plugin-infra] Found an unknown plugin backup of ${plugin}`, item) - continue - } - - const f = plugin.backup?.onRestore - if (!f) { - console.warn( - `[@masknet/plugin-infra] Found a backup of plugin ${plugin} but it did not register a onRestore callback.`, - item, - ) - continue - } - const x = Result.wrapAsync(async () => { - const x = await f(item) - if (x.err) console.error(`[@masknet/plugin-infra] Plugin ${plugin} failed to restore its backup.`, item) - return x.unwrap() - }) - works.add(x) - } - await Promise.all(works) -} - -const unconfirmedBackup = new Map() - -/** - * Restore backup step 1: store the unconfirmed backup in cached - * @param id the uuid for each restoration - * @param json the backup to be cached - */ -export async function setUnconfirmedBackup(id: string, json: BackupJSONFileLatest) { - unconfirmedBackup.set(id, json) -} - -/** - * Get the unconfirmed backup with uuid - * @param id the uuid for each restoration - */ -export async function getUnconfirmedBackup(id: string) { - return unconfirmedBackup.get(id) -} - -/** - * Restore backup step 2: restore the unconfirmed backup with uuid - * @param id the uuid for each restoration - */ -export async function confirmBackup(id: string, whoAmI?: ProfileIdentifier) { - if (unconfirmedBackup.has(id)) { - await restoreBackup(unconfirmedBackup.get(id)!, whoAmI) - unconfirmedBackup.delete(id) - } else { - throw new Error('cannot find backup') - } -} diff --git a/packages/mask/src/extension/background-script/legacy.ts b/packages/mask/src/extension/background-script/legacy.ts new file mode 100644 index 000000000000..ac4a021c0427 --- /dev/null +++ b/packages/mask/src/extension/background-script/legacy.ts @@ -0,0 +1,123 @@ +import { isSameAddress, ProviderType } from '@masknet/web3-shared-evm' +import { exportMnemonic, exportPrivateKey, getLegacyWallets, getWallets } from '../../plugins/Wallet/services' +import { activatedPluginsWorker } from '@masknet/plugin-infra' +import { Some, None } from 'ts-results' +import { isNonNull, timeout } from '@dimensiondev/kit' +import { delegatePluginBackup, delegateWalletBackup } from '../../../background/services/backup/internal' +import type { NormalizedBackup } from '@masknet/backup-format' +import type { LegacyWalletRecord } from '../../plugins/Wallet/database/types' +import { keyToAddr, keyToJWK } from '../../utils/type-transform/SECP256k1-ETH' +import type { WalletRecord } from '../../plugins/Wallet/services/wallet/type' + +delegatePluginBackup(backupAllPlugins) +delegateWalletBackup(async function () { + const wallet = await Promise.all([backupAllWallets(), backupAllLegacyWallets()]) + return wallet.flat() +}) +async function backupAllWallets(): Promise { + const allSettled = await Promise.allSettled( + ( + await getWallets(ProviderType.MaskWallet) + ).map(async (wallet) => { + return { + ...wallet, + mnemonic: wallet.derivationPath ? await exportMnemonic(wallet.address) : undefined, + privateKey: wallet.derivationPath ? undefined : await exportPrivateKey(wallet.address), + } + }), + ) + const wallets_ = allSettled.map((x) => (x.status === 'fulfilled' ? WalletRecordToJSONFormat(x.value) : null)) + if (wallets_.some((x) => !x)) throw new Error('Failed to backup wallets.') + return wallets_.filter(isNonNull) +} + +async function backupAllLegacyWallets(): Promise { + const x = await getLegacyWallets(ProviderType.MaskWallet) + return x.map(LegacyWalletRecordToJSONFormat) +} + +export function WalletRecordToJSONFormat( + wallet: Omit & { + mnemonic?: string + privateKey?: string + }, +): NormalizedBackup.WalletBackup { + const backup: NormalizedBackup.WalletBackup = { + name: wallet.name ?? '', + address: wallet.address, + createdAt: wallet.createdAt, + updatedAt: wallet.updatedAt, + mnemonic: None, + passphrase: None, + publicKey: None, + privateKey: None, + } + if (wallet.mnemonic && wallet.derivationPath) { + backup.mnemonic = Some({ + words: wallet.mnemonic, + path: wallet.derivationPath, + hasPassword: false, + }) + } + + if (wallet.privateKey) backup.privateKey = Some(keyToJWK(wallet.privateKey, 'private')) + + return backup +} + +function LegacyWalletRecordToJSONFormat(wallet: LegacyWalletRecord): NormalizedBackup.WalletBackup { + const backup: NormalizedBackup.WalletBackup = { + name: wallet.name ?? '', + address: wallet.address, + createdAt: wallet.createdAt, + updatedAt: wallet.updatedAt, + mnemonic: None, + passphrase: None, + privateKey: None, + publicKey: None, + } + + // generate keys for managed wallet + try { + const wallet_ = wallet as LegacyWalletRecord + backup.passphrase = Some(wallet_.passphrase) + if (wallet_.mnemonic?.length) + backup.mnemonic = Some({ + words: wallet_.mnemonic.join(' '), + path: "m/44'/60'/0'/0/0", + hasPassword: false, + }) + if (wallet_._public_key_ && isSameAddress(keyToAddr(wallet_._public_key_, 'public'), wallet.address)) + backup.publicKey = Some(keyToJWK(wallet_._public_key_, 'public')) + if (wallet_._private_key_ && isSameAddress(keyToAddr(wallet_._private_key_, 'private'), wallet.address)) + backup.privateKey = Some(keyToJWK(wallet_._private_key_, 'private')) + } catch (error) { + console.error(error) + } + return backup +} + +async function backupAllPlugins() { + const plugins = Object.create(null) as Record + const allPlugins = [...activatedPluginsWorker] + + async function backup(plugin: typeof allPlugins[0]): Promise { + const backupCreator = plugin.backup?.onBackup + if (!backupCreator) return + + async function backupPlugin() { + const result = await timeout(backupCreator!(), 3000) + if (result.none) return + // We limit the plugin contributed backups must be simple objects. + // We may allow plugin to store binary if we're moving to binary backup format like MessagePack. + plugins[plugin.ID] = result.map(JSON.stringify).map(JSON.parse).val + } + if (process.env.NODE_ENV === 'development') return backupPlugin() + return backupPlugin().catch((error) => + console.error(`[@masknet/plugin-infra] Plugin ${plugin.ID} failed to backup`, error), + ) + } + + await Promise.all(allPlugins.map(backup)) + return plugins +} diff --git a/packages/mask/src/extension/popups/pages/Wallet/WalletRecovery/index.tsx b/packages/mask/src/extension/popups/pages/Wallet/WalletRecovery/index.tsx index 0c02a4cf8b33..19165daecb21 100644 --- a/packages/mask/src/extension/popups/pages/Wallet/WalletRecovery/index.tsx +++ b/packages/mask/src/extension/popups/pages/Wallet/WalletRecovery/index.tsx @@ -120,7 +120,7 @@ const WalletRecovery = memo(() => { if (backupId) { const json = await Services.Welcome.getUnconfirmedBackup(backupId) if (json) { - await Services.Welcome.restoreBackup(json) + await Services.Welcome.restoreBackupWithID({ id: backupId }) // Set default wallet if (json.wallets) await WalletRPC.setDefaultWallet() diff --git a/packages/mask/src/utils/native-rpc/Web.ts b/packages/mask/src/utils/native-rpc/Web.ts index 99de4f155aca..95e05cbbf295 100644 --- a/packages/mask/src/utils/native-rpc/Web.ts +++ b/packages/mask/src/utils/native-rpc/Web.ts @@ -116,15 +116,15 @@ export const MaskNetworkAPI: MaskNetworkAPIs = { settings_setTheme: ({ theme }) => Services.Settings.setTheme(theme), settings_getLanguage: () => Services.Settings.getLanguage(), settings_setLanguage: ({ language }) => Services.Settings.setLanguage(language), - settings_createBackupJson: (options) => Services.Welcome.generateBackupJSON(options), + settings_createBackupJson: (options) => Services.Welcome.mobile_generateBackupJSON(options), settings_getBackupPreviewInfo: async ({ backupInfo }) => { const data = await Services.Welcome.parseBackupStr(backupInfo) return data?.info }, - settings_restoreBackup: ({ backupInfo }) => { + settings_restoreBackup: async ({ backupInfo }) => { try { const json = JSON.parse(backupInfo) - return Services.Welcome.restoreBackup(json) + await Services.Welcome.restoreBackup(json) } catch (error) { throw new Error('invalid json') } @@ -151,14 +151,10 @@ export const MaskNetworkAPI: MaskNetworkAPIs = { persona_removePersona: ({ identifier }) => Services.Identity.deletePersona(stringToPersonaIdentifier(identifier), 'delete even with private'), persona_restoreFromJson: async ({ backup }) => { - const result = await Services.Identity.restoreFromBackup(backup) - - if (!result) throw new Error('invalid json') + await Services.Identity.restoreFromBackup(backup) }, persona_restoreFromBase64: async ({ backup }) => { - const result = await Services.Identity.restoreFromBase64(backup) - - if (!result) throw new Error('invalid base64') + await Services.Identity.restoreFromBase64(backup) }, persona_connectProfile: async ({ profileIdentifier, personaIdentifier }) => { const profileId = stringToProfileIdentifier(profileIdentifier) @@ -179,12 +175,7 @@ export const MaskNetworkAPI: MaskNetworkAPIs = { return persona.mnemonic?.words }, persona_backupJson: async ({ identifier }) => { - const persona = await Services.Identity.queryPersona(stringToPersonaIdentifier(identifier)) - return Services.Welcome.generateBackupJSON({ - noPosts: true, - noWallets: true, - filter: { type: 'persona', wanted: [persona.identifier] }, - }) + return Services.Welcome.mobile_generateBackupJSONOnlyForPersona(stringToPersonaIdentifier(identifier)) }, persona_restoreFromPrivateKey: async ({ privateKey, nickname }) => { const identifier = await Services.Identity.createPersonaByPrivateKey(privateKey, nickname) diff --git a/packages/mask/src/utils/type-transform/BackupFile.ts b/packages/mask/src/utils/type-transform/BackupFile.ts deleted file mode 100644 index 0067a9154519..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFile.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BackupJSONFileLatest } from './BackupFormat/JSON/latest' - -export function fixBackupFilePermission(backup: BackupJSONFileLatest): BackupJSONFileLatest -export function fixBackupFilePermission(backup: null | BackupJSONFileLatest): BackupJSONFileLatest | null -export function fixBackupFilePermission(backup: null | BackupJSONFileLatest): BackupJSONFileLatest | null { - if (!backup) return null - return { - ...backup, - grantedHostPermissions: backup.grantedHostPermissions.filter((url) => /^(http|)/.test(url)), - } -} - -export function convertBackupFileToObject(short: string): BackupJSONFileLatest { - const compressed = JSON.parse(short) - // TODO: maybe schema validate? - if (typeof compressed !== 'object') throw new TypeError('Invalid backup') - return compressed -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord.ts deleted file mode 100644 index cec9e048f37b..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PersonaRecord.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { BackupJSONFileLatest } from '../latest' -import type { PersonaRecord } from '../../../../../../background/database/persona/db' -import { Identifier, ECKeyIdentifier, IdentifierMap, ProfileIdentifier } from '@masknet/shared-base' -export function PersonaRecordToJSONFormat(persona: PersonaRecord): BackupJSONFileLatest['personas'][0] { - return { - createdAt: persona.createdAt.getTime(), - updatedAt: persona.updatedAt.getTime(), - identifier: persona.identifier.toText(), - publicKey: persona.publicKey, - privateKey: persona.privateKey, - nickname: persona.nickname, - mnemonic: persona.mnemonic, - localKey: persona.localKey, - linkedProfiles: Array.from(persona.linkedProfiles).map(([x, y]) => [x.toText(), y]), - } -} - -export function PersonaRecordFromJSONFormat(persona: BackupJSONFileLatest['personas'][0]): PersonaRecord { - if (persona.privateKey && !persona.privateKey.d) throw new Error('Private have no secret') - return { - createdAt: new Date(persona.createdAt), - updatedAt: new Date(persona.updatedAt), - identifier: Identifier.fromString(persona.identifier, ECKeyIdentifier).unwrap(), - publicKey: persona.publicKey, - privateKey: persona.privateKey, - nickname: persona.nickname, - mnemonic: persona.mnemonic, - localKey: persona.localKey, - hasLogout: false, - linkedProfiles: new IdentifierMap(new Map(persona.linkedProfiles), ProfileIdentifier), - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord.ts deleted file mode 100644 index 53f082dbcbfa..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/PostRecord.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { PostRecord, RecipientDetail, RecipientReason } from '../../../../../../background/database/post' -import type { BackupJSONFileLatest } from '../latest' -import type { RecipientReasonJSON } from '../version-2' -import { encodeArrayBuffer, decodeArrayBuffer, unreachable } from '@dimensiondev/kit' -import { encode, decode } from '@msgpack/msgpack' -import type { TypedMessage } from '@masknet/typed-message' -import { - IdentifierMap, - ECKeyIdentifier, - GroupIdentifier, - Identifier, - PostIVIdentifier, - ProfileIdentifier, -} from '@masknet/shared-base' - -export function PostRecordToJSONFormat(post: PostRecord): BackupJSONFileLatest['posts'][0] { - return { - postCryptoKey: post.postCryptoKey, - foundAt: post.foundAt.getTime(), - identifier: post.identifier.toText(), - postBy: post.postBy.toText(), - recipientGroups: [], - recipients: - post.recipients === 'everyone' - ? 'everyone' - : Array.from(post.recipients).map( - ([identifier, detail]): [string, { reason: RecipientReasonJSON[] }] => [ - identifier.toText(), - { - reason: Array.from(detail.reason).map(RecipientReasonToJSON), - }, - ], - ), - encryptBy: post.encryptBy?.toText(), - interestedMeta: MetaToJson(post.interestedMeta), - summary: post.summary, - url: post.url, - } -} - -export function PostRecordFromJSONFormat(post: BackupJSONFileLatest['posts'][0]): PostRecord { - return { - postCryptoKey: post.postCryptoKey, - foundAt: new Date(post.foundAt), - identifier: Identifier.fromString(post.identifier, PostIVIdentifier).unwrap(), - postBy: Identifier.fromString(post.postBy, ProfileIdentifier).unwrap(), - recipients: - post.recipients === 'everyone' - ? 'everyone' - : new IdentifierMap( - new Map( - post.recipients.map(([x, y]) => [ - x, - { reason: y.reason.map(RecipientReasonFromJSON) } as RecipientDetail, - ]), - ), - ), - encryptBy: post.encryptBy ? Identifier.fromString(post.encryptBy, ECKeyIdentifier).unwrap() : undefined, - interestedMeta: MetaFromJson(post.interestedMeta), - summary: post.summary, - url: post.url, - } -} - -function RecipientReasonToJSON(y: RecipientReason): RecipientReasonJSON { - if (y.type === 'direct' || y.type === 'auto-share') - return { at: y.at.getTime(), type: y.type } as RecipientReasonJSON - else if (y.type === 'group') return { at: y.at.getTime(), group: y.group.toText(), type: y.type } - return unreachable(y) -} -function RecipientReasonFromJSON(y: RecipientReasonJSON): RecipientReason { - if (y.type === 'direct' || y.type === 'auto-share') return { at: new Date(y.at), type: y.type } as RecipientReason - else if (y.type === 'group') - return { - type: 'group', - at: new Date(y.at), - group: Identifier.fromString(y.group, GroupIdentifier).unwrap(), - } - return unreachable(y) -} -function MetaToJson(meta: undefined | TypedMessage['meta']): undefined | string { - if (!meta) return undefined - const obj = Object.fromEntries(meta) - return encodeArrayBuffer(encode(obj)) -} -function MetaFromJson(meta: string | undefined): undefined | TypedMessage['meta'] { - if (!meta) return undefined - const raw = decode(decodeArrayBuffer(meta)) - if (typeof raw !== 'object' || !raw) return undefined - return new Map(Object.entries(raw)) -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord.ts deleted file mode 100644 index 6b03a90bb974..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/ProfileRecord.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ProfileRecord } from '../../../../../../background/database/persona/db' -import type { BackupJSONFileLatest } from '../latest' -import { ProfileIdentifier, Identifier, ECKeyIdentifier } from '@masknet/shared-base' - -export function ProfileRecordToJSONFormat(profile: ProfileRecord): BackupJSONFileLatest['profiles'][0] { - return { - createdAt: profile.createdAt.getTime(), - updatedAt: profile.updatedAt.getTime(), - identifier: profile.identifier.toText(), - nickname: profile.nickname, - localKey: profile.localKey, - linkedPersona: profile.linkedPersona?.toText(), - } -} - -export function ProfileRecordFromJSONFormat(profile: BackupJSONFileLatest['profiles'][0]): ProfileRecord { - return { - createdAt: new Date(profile.createdAt), - updatedAt: new Date(profile.updatedAt), - identifier: Identifier.fromString(profile.identifier, ProfileIdentifier).unwrap(), - nickname: profile.nickname, - localKey: profile.localKey, - linkedPersona: profile.linkedPersona - ? Identifier.fromString(profile.linkedPersona, ECKeyIdentifier).unwrap() - : undefined, - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord.ts deleted file mode 100644 index 39638fef8d48..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/RelationRecord.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ECKeyIdentifier, Identifier, ProfileIdentifier } from '@masknet/shared-base' -import type { BackupJSONFileLatest } from '../latest' -import type { RelationRecord } from '../../../../../../background/database/persona/db' - -export function RelationRecordToJSONFormat(relation: RelationRecord): BackupJSONFileLatest['relations'][0] { - return { - favor: relation.favor, - persona: relation.linked.toText(), - profile: relation.profile.toText(), - } -} - -export function RelationRecordFromJSONFormat( - relation: BackupJSONFileLatest['relations'][0], -): Omit { - return { - favor: relation.favor, - profile: Identifier.fromString(relation.profile, ProfileIdentifier).unwrap(), - linked: Identifier.fromString(relation.persona, ECKeyIdentifier).unwrap(), - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord.ts deleted file mode 100644 index 6c8fdc85d3fd..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/DBRecord-JSON/WalletRecord.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { omit } from 'lodash-unified' -import { isSameAddress } from '@masknet/web3-shared-evm' -import type { BackupJSONFileLatest } from '../latest' -import type { WalletRecord } from '../../../../../plugins/Wallet/services/wallet/type' -import { JWKToKey, keyToAddr, keyToJWK } from '../../../SECP256k1-ETH' -import type { LegacyWalletRecord } from '../../../../../plugins/Wallet/database/types' - -type WalletBackup = BackupJSONFileLatest['wallets'][0] - -export function WalletRecordToJSONFormat( - wallet: Omit & { - mnemonic?: string - privateKey?: string - }, -): WalletBackup { - return { - ...omit( - wallet, - 'id', - 'erc20_token_whitelist', - 'erc20_token_blacklist', - 'erc721_token_whitelist', - 'erc721_token_blacklist', - 'erc1155_token_whitelist', - 'erc1155_token_blacklist', - 'derivationPath', - 'storedKeyInfo', - ), - mnemonic: - wallet.mnemonic && wallet.derivationPath - ? { - words: wallet.mnemonic, - parameter: { - path: wallet.derivationPath, - withPassword: false, - }, - } - : undefined, - privateKey: wallet.privateKey ? keyToJWK(wallet.privateKey, 'private') : undefined, - createdAt: wallet.createdAt.getTime(), - updatedAt: wallet.updatedAt.getTime(), - } -} - -export function LegacyWalletRecordToJSONFormat(wallet: LegacyWalletRecord): WalletBackup { - const backup: Partial = { - name: wallet.name ?? '', - address: wallet.address, - createdAt: wallet.createdAt.getTime(), - updatedAt: wallet.updatedAt.getTime(), - } - - // generate keys for managed wallet - try { - const wallet_ = wallet as LegacyWalletRecord - backup.passphrase = wallet_.passphrase - if (wallet_.mnemonic?.length) - backup.mnemonic = { - words: wallet_.mnemonic.join(' '), - parameter: { - path: "m/44'/60'/0'/0/0", - withPassword: false, - }, - } - if (wallet_._public_key_ && isSameAddress(keyToAddr(wallet_._public_key_, 'public'), wallet.address)) - backup.publicKey = keyToJWK(wallet_._public_key_, 'public') - if (wallet_._private_key_ && isSameAddress(keyToAddr(wallet_._private_key_, 'private'), wallet.address)) - backup.privateKey = keyToJWK(wallet_._private_key_, 'private') - } catch (error) { - console.error(error) - } - return backup as WalletBackup -} - -export function WalletRecordFromJSONFormat(wallet: WalletBackup): WalletRecord & { - mnemonic?: string - privateKey?: string -} { - return { - ...omit(wallet, 'mnemonic', 'privateKey'), - id: wallet.address, - type: 'wallet', - erc20_token_whitelist: new Set(), - erc20_token_blacklist: new Set(), - erc721_token_whitelist: new Set(), - erc721_token_blacklist: new Set(), - erc1155_token_whitelist: new Set(), - erc1155_token_blacklist: new Set(), - createdAt: new Date(wallet.createdAt), - updatedAt: new Date(wallet.updatedAt), - mnemonic: wallet.mnemonic?.words, - derivationPath: wallet.mnemonic?.parameter.path, - privateKey: wallet.privateKey ? JWKToKey(wallet.privateKey, 'private') : undefined, - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/latest.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/latest.ts deleted file mode 100644 index b67e401d5e20..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/latest.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - isBackupJSONFileVersion1, - patchNonBreakingUpgradeForBackupJSONFileVersion1, - upgradeFromBackupJSONFileVersion0, -} from './version-1' -import { PluginId } from '@masknet/plugin-infra' -import type { ProfileIdentifier } from '@masknet/shared-base' -import { isBackupJSONFileVersion0 } from './version-0' -import { - BackupJSONFileVersion2, - isBackupJSONFileVersion2, - upgradeFromBackupJSONFileVersion1, - patchNonBreakingUpgradeForBackupJSONFileVersion2, -} from './version-2' - -export interface BackupPreview { - personas: number - accounts: number - posts: number - contacts: number - relations: number - files: number - wallets: number - createdAt: number -} - -/** - * Always use this interface in other code. - */ -export interface BackupJSONFileLatest extends BackupJSONFileVersion2 {} -export function UpgradeBackupJSONFile(json: object, identity?: ProfileIdentifier): BackupJSONFileLatest | null { - if (isBackupJSONFileVersion2(json)) return patchNonBreakingUpgradeForBackupJSONFileVersion2(json) - if (isBackupJSONFileVersion1(json)) - return upgradeFromBackupJSONFileVersion1(patchNonBreakingUpgradeForBackupJSONFileVersion1(json)) - if (isBackupJSONFileVersion0(json) && identity) { - const upgraded = upgradeFromBackupJSONFileVersion0(json, identity) - if (upgraded === null) return null - return upgradeFromBackupJSONFileVersion1(upgraded) - } - return null -} - -export function getBackupPreviewInfo(json: BackupJSONFileLatest): BackupPreview { - return { - personas: json.personas.length, - accounts: json.personas.reduce((a, b) => a + b.linkedProfiles.length, 0), - posts: json.posts.length, - contacts: json.profiles.length, - relations: json.relations.length, - files: json.plugin?.[PluginId.FileService]?.length || 0, - wallets: json.wallets.length, - createdAt: json._meta_.createdAt, - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-0.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-0.ts deleted file mode 100644 index 251ab90ea385..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-0.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { EC_Public_JsonWebKey, EC_Private_JsonWebKey, AESJsonWebKey } from '@masknet/shared-base' - -/** - * @deprecated History JSON backup file - */ -export interface BackupJSONFileVersion0 { - key: { - username: string - key: { publicKey: EC_Public_JsonWebKey; privateKey?: EC_Private_JsonWebKey } - algor: unknown - usages: string[] - } - local: AESJsonWebKey -} -export function isBackupJSONFileVersion0(obj: object): obj is BackupJSONFileVersion0 { - const data: BackupJSONFileVersion0 = obj as any - if (!data.local || !data.key || !data.key.key || !data.key.key.privateKey || !data.key.key.publicKey) return false - return true -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-1.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-1.ts deleted file mode 100644 index e820767e5182..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-1.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { BackupJSONFileVersion0 } from './version-0' -import type { - ProfileIdentifier, - EC_Public_JsonWebKey, - EC_Private_JsonWebKey, - AESJsonWebKey, -} from '@masknet/shared-base' - -/** - * @deprecated The old version 1 backup file before persona db was done. - */ -export interface BackupJSONFileVersion1 { - maskbookVersion: string - version: 1 - whoami: Array<{ - network: string - userId: string - publicKey: EC_Public_JsonWebKey - privateKey: EC_Private_JsonWebKey - localKey: AESJsonWebKey - previousIdentifiers?: { network: string; userId: string }[] - nickname?: string - }> - people?: Array<{ - network: string - userId: string - publicKey: EC_Public_JsonWebKey - previousIdentifiers?: { network: string; userId: string }[] - nickname?: string - groups?: { network: string; groupID: string; virtualGroupOwner: string | null }[] - }> - grantedHostPermissions: string[] -} -export function isBackupJSONFileVersion1(obj: object): obj is BackupJSONFileVersion1 { - const data: BackupJSONFileVersion1 = obj as any - if (data.version !== 1) return false - if (!Array.isArray(data.whoami)) return false - if (!data.whoami) return false - return true -} - -// Since 8/21/2019, every backup file of version 1 should have grantedHostPermissions -// Before 8/21/2019, we only support facebook, so we can auto upgrade the backup file -const facebookHost = ['https://m.facebook.com/*', 'https://www.facebook.com/*'] -export function patchNonBreakingUpgradeForBackupJSONFileVersion1(json: BackupJSONFileVersion1): BackupJSONFileVersion1 { - if (json.grantedHostPermissions === undefined) { - json.grantedHostPermissions = facebookHost - json.maskbookVersion = '1.5.2' - } - if (!json.maskbookVersion) json.maskbookVersion = '1.6.0' - return json -} -export function upgradeFromBackupJSONFileVersion0( - json: BackupJSONFileVersion0, - identity: ProfileIdentifier, -): BackupJSONFileVersion1 | null { - return { - maskbookVersion: '1.3.2', - version: 1, - whoami: [ - { - localKey: json.local, - network: 'facebook.com', - publicKey: json.key.key.publicKey, - privateKey: json.key.key.privateKey!, - userId: identity.userId || '$self', - }, - ], - grantedHostPermissions: facebookHost, - } -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-2.ts b/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-2.ts deleted file mode 100644 index a2a56c5bdeff..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/JSON/version-2.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { LinkedProfileDetails } from '../../../../../background/database/persona/db' -import type { BackupJSONFileVersion1 } from './version-1' -import { - type AESJsonWebKey, - type EC_Public_JsonWebKey, - type EC_Private_JsonWebKey, - ProfileIdentifier, - type RelationFavor, - ECKeyIdentifierFromJsonWebKey, -} from '@masknet/shared-base' -import { twitterBase } from '../../../../social-network-adaptor/twitter.com/base' -import { facebookBase } from '../../../../social-network-adaptor/facebook.com/base' - -export type RecipientReasonJSON = ( - | { type: 'auto-share' } - | { type: 'direct' } - | { type: 'group'; group: string /** GroupIdentifier */ } -) & { - at: number -} -/** - * @see https://github.com/DimensionDev/Maskbook/issues/194 - * - * @deprecated Since this is the latest format, you should not use it. - * Use BackupJSONFileVersionLatest please. - */ -export interface BackupJSONFileVersion2 { - _meta_: { - version: 2 - type: 'maskbook-backup' - maskbookVersion: string // e.g. "1.8.0" - createdAt: number // Unix timestamp - } - personas: Array<{ - // ? PersonaIdentifier can be infer from the publicKey - identifier: string // PersonaIdentifier.toText() - mnemonic?: { - words: string - parameter: { path: string; withPassword: boolean } - } - publicKey: EC_Public_JsonWebKey - privateKey?: EC_Private_JsonWebKey - localKey?: AESJsonWebKey - nickname?: string - linkedProfiles: [/** ProfileIdentifier.toText() */ string, LinkedProfileDetails][] - createdAt: number // Unix timestamp - updatedAt: number // Unix timestamp - }> - profiles: Array<{ - identifier: string // ProfileIdentifier.toText() - nickname?: string - localKey?: AESJsonWebKey - linkedPersona?: string // PersonaIdentifier.toText() - createdAt: number // Unix timestamp - updatedAt: number // Unix timestamp - }> - relations: Array<{ - profile: string // ProfileIdentifier.toText() - persona: string // PersonaIdentifier.toText() - favor: RelationFavor - }> - /** @deprecated */ - userGroups: never[] - posts: Array<{ - postBy: string // ProfileIdentifier.toText() - identifier: string // PostIVIdentifier.toText() - postCryptoKey?: AESJsonWebKey - recipients: 'everyone' | [/** ProfileIdentifier.toText() */ string, { reason: RecipientReasonJSON[] }][] - /** @deprecated */ - recipientGroups: never[] // Array - foundAt: number // Unix timestamp - encryptBy?: string // PersonaIdentifier.toText() - url?: string - summary?: string - interestedMeta?: string // encoded by MessagePack - }> - wallets: Array<{ - address: string - name: string - passphrase?: string - publicKey?: EC_Public_JsonWebKey - privateKey?: EC_Private_JsonWebKey - mnemonic?: { - words: string - parameter: { path: string; withPassword: boolean } - } - createdAt: number // Unix timestamp - updatedAt: number // Unix timestamp - }> - grantedHostPermissions: string[] - plugin?: Record -} - -export function isBackupJSONFileVersion2(obj: object): obj is BackupJSONFileVersion2 { - return ( - (obj as BackupJSONFileVersion2)?._meta_?.version === 2 && - (obj as BackupJSONFileVersion2)?._meta_?.type === 'maskbook-backup' - ) -} - -export function upgradeFromBackupJSONFileVersion1(json: BackupJSONFileVersion1): BackupJSONFileVersion2 { - const personas: BackupJSONFileVersion2['personas'] = [] - const profiles: BackupJSONFileVersion2['profiles'] = [] - - function addPersona(record: Omit) { - const prev = personas.find((x) => x.identifier === record.identifier) - if (prev) { - Object.assign(prev, record) - prev.linkedProfiles.push(...record.linkedProfiles) - } else personas.push({ ...record, updatedAt: 0, createdAt: 0 }) - } - - function addProfile(record: Omit) { - const prev = profiles.find((x) => x.identifier === record.identifier) - if (prev) { - Object.assign(prev, record) - } else profiles.push({ ...record, updatedAt: 0, createdAt: 0 }) - } - - for (const x of json.whoami) { - const profile = new ProfileIdentifier(x.network, x.userId).toText() - const persona = ECKeyIdentifierFromJsonWebKey(x.publicKey).toText() - addProfile({ - identifier: profile, - linkedPersona: persona, - localKey: x.localKey, - nickname: x.nickname, - }) - addPersona({ - identifier: persona, - linkedProfiles: [[profile, { connectionConfirmState: 'confirmed' }]], - publicKey: x.publicKey, - privateKey: x.privateKey, - localKey: x.localKey, - nickname: x.nickname, - }) - } - - for (const x of json.people || []) { - const profile = new ProfileIdentifier(x.network, x.userId) - const persona = ECKeyIdentifierFromJsonWebKey(x.publicKey).toText() - addProfile({ - identifier: profile.toText(), - linkedPersona: persona, - nickname: x.nickname, - }) - addPersona({ - identifier: persona, - linkedProfiles: [[profile.toText(), { connectionConfirmState: 'confirmed' }]], - publicKey: x.publicKey, - nickname: x.nickname, - }) - } - - return { - _meta_: { - version: 2, - type: 'maskbook-backup', - maskbookVersion: json.maskbookVersion, - createdAt: 0, - }, - posts: [], - wallets: [], - personas, - profiles, - relations: [], - userGroups: [], - grantedHostPermissions: json.grantedHostPermissions, - } -} - -export function patchNonBreakingUpgradeForBackupJSONFileVersion2(json: BackupJSONFileVersion2): BackupJSONFileVersion2 { - json.wallets = json.wallets ?? [] - json.relations = json.relations ?? [] - const permissions = new Set(json.grantedHostPermissions) - if (json.grantedHostPermissions.some((x) => x.includes('twitter.com'))) { - const a = twitterBase.declarativePermissions.origins - a.forEach((x) => permissions.add(x)) - } - if (json.grantedHostPermissions.some((x) => x.includes('facebook.com'))) { - const a = facebookBase.declarativePermissions.origins - a.forEach((x) => permissions.add(x)) - } - return json -} diff --git a/packages/mask/src/utils/type-transform/BackupFormat/index.ts b/packages/mask/src/utils/type-transform/BackupFormat/index.ts deleted file mode 100644 index f2c073b42172..000000000000 --- a/packages/mask/src/utils/type-transform/BackupFormat/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './JSON/latest' diff --git a/packages/mask/src/utils/type-transform/index.ts b/packages/mask/src/utils/type-transform/index.ts index a7b5a5bed1ca..7139a874b01e 100644 --- a/packages/mask/src/utils/type-transform/index.ts +++ b/packages/mask/src/utils/type-transform/index.ts @@ -1,5 +1,3 @@ export * from './asyncIteratorHelpers' -export * from './BackupFile' -export * from './BackupFormat' export * from './Payload' export * from './SECP256k1-ETH' From c7aed2568154381a82f001c1fb7877a3226de46c Mon Sep 17 00:00:00 2001 From: Jack Works Date: Thu, 24 Mar 2022 14:05:06 +0800 Subject: [PATCH 03/22] feat: impl generate backup --- .../mask/background/services/backup/create.ts | 36 +++++++++++++++---- .../background/services/backup/internal.ts | 25 ++++++++----- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/mask/background/services/backup/create.ts b/packages/mask/background/services/backup/create.ts index 08fb9875e8de..ad2ad0897b52 100644 --- a/packages/mask/background/services/backup/create.ts +++ b/packages/mask/background/services/backup/create.ts @@ -1,5 +1,6 @@ import type { PersonaIdentifier } from '@masknet/shared-base' -import { BackupPreview, createEmptyNormalizedBackup, generateBackupRAW } from '@masknet/backup-format' +import { BackupPreview, generateBackupRAW, getBackupPreviewInfo } from '@masknet/backup-format' +import { createNewBackup } from './internal' export interface MobileBackupOptions { noPosts?: boolean @@ -10,16 +11,30 @@ export interface MobileBackupOptions { } export async function mobile_generateBackupJSON(options: MobileBackupOptions): Promise { if (process.env.architecture !== 'app') throw new TypeError('This function is only available in app environment') - const file = createEmptyNormalizedBackup() - return generateBackupRAW(file) + const { hasPrivateKeyOnly, noPersonas, noPosts, noProfiles, noWallets } = options + const backup = await createNewBackup({ + hasPrivateKeyOnly, + noPersonas, + noPosts, + noProfiles, + noWallets, + }) + return generateBackupRAW(backup) } export async function mobile_generateBackupJSONOnlyForPersona(persona: PersonaIdentifier): Promise { if (process.env.architecture !== 'app') throw new TypeError('This function is only available in app environment') - throw new TypeError('Not implemented') + const backup = await createNewBackup({ + noPosts: true, + noWallets: true, + onlyForPersona: persona, + }) + return generateBackupRAW(backup) } export async function generateBackupPreviewInfo(): Promise { - throw new TypeError('Not implemented') + // can we avoid create a full backup? + const backup = await createNewBackup({}) + return getBackupPreviewInfo(backup) } export interface BackupOptions { @@ -28,5 +43,14 @@ export interface BackupOptions { excludeBase?: boolean } export async function createBackupFile(options: BackupOptions): Promise<{ file: unknown; personaNickNames: string[] }> { - throw new TypeError('Not implemented') + const { excludeBase, excludeWallet } = options + const backup = await createNewBackup({ + noPersonas: excludeBase, + noPosts: excludeBase, + noProfiles: excludeBase, + noWallets: excludeWallet, + }) + const file = generateBackupRAW(backup) + const personaNickNames = [...backup.personas.values()].map((p) => p.nickname.unwrapOr('')).filter((x) => x) + return { file, personaNickNames } } diff --git a/packages/mask/background/services/backup/internal.ts b/packages/mask/background/services/backup/internal.ts index 6a515e6651f8..4fc4672b6340 100644 --- a/packages/mask/background/services/backup/internal.ts +++ b/packages/mask/background/services/backup/internal.ts @@ -17,6 +17,11 @@ export function delegatePluginBackup(f: typeof backupPlugins) { /** @internal */ export interface InternalBackupOptions { hasPrivateKeyOnly?: boolean + noPosts?: boolean + noWallets?: boolean + noPersonas?: boolean + noProfiles?: boolean + onlyForPersona?: PersonaIdentifier } /** * @internal @@ -24,22 +29,23 @@ export interface InternalBackupOptions { */ // TODO: use a single readonly transaction in this operation. export async function createNewBackup(options: InternalBackupOptions): Promise { + const { noPersonas, noPosts, noProfiles, noWallets, onlyForPersona } = options const file = createEmptyNormalizedBackup() const { meta, personas, posts, profiles, relations, settings } = file meta.version = 2 - meta.maskVersion = Some(process.env.VERSION) + meta.maskVersion = Some(process.env.VERSION || '>=2.5.0') meta.createdAt = Some(new Date()) settings.grantedHostPermissions = (await browser.permissions.getAll()).origins || [] await Promise.allSettled([ - backupPersonas(), - backupAllRelations(), - backupPosts(), - backupProfiles(), + noPersonas || backupPersonas(onlyForPersona ? [onlyForPersona] : undefined), + noProfiles || backupProfiles(onlyForPersona), + (noPersonas && noProfiles) || backupAllRelations(), + noPosts || backupPosts(), + noWallets || backupWallets().then((w) => (file.wallets = w)), backupPlugins().then((p) => (file.plugins = p)), - backupWallets().then((w) => (file.wallets = w)), ]) return file @@ -105,12 +111,15 @@ export async function createNewBackup(options: InternalBackupOptions): Promise Date: Thu, 24 Mar 2022 14:10:08 +0800 Subject: [PATCH 04/22] refactor: rename file --- packages/mask/background/services/backup/create.ts | 2 +- .../services/backup/{internal.ts => internal_create.ts} | 0 packages/mask/src/extension/background-script/legacy.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/mask/background/services/backup/{internal.ts => internal_create.ts} (100%) diff --git a/packages/mask/background/services/backup/create.ts b/packages/mask/background/services/backup/create.ts index ad2ad0897b52..bf78f4c8e4f7 100644 --- a/packages/mask/background/services/backup/create.ts +++ b/packages/mask/background/services/backup/create.ts @@ -1,6 +1,6 @@ import type { PersonaIdentifier } from '@masknet/shared-base' import { BackupPreview, generateBackupRAW, getBackupPreviewInfo } from '@masknet/backup-format' -import { createNewBackup } from './internal' +import { createNewBackup } from './internal_create' export interface MobileBackupOptions { noPosts?: boolean diff --git a/packages/mask/background/services/backup/internal.ts b/packages/mask/background/services/backup/internal_create.ts similarity index 100% rename from packages/mask/background/services/backup/internal.ts rename to packages/mask/background/services/backup/internal_create.ts diff --git a/packages/mask/src/extension/background-script/legacy.ts b/packages/mask/src/extension/background-script/legacy.ts index ac4a021c0427..d05289b0a759 100644 --- a/packages/mask/src/extension/background-script/legacy.ts +++ b/packages/mask/src/extension/background-script/legacy.ts @@ -3,7 +3,7 @@ import { exportMnemonic, exportPrivateKey, getLegacyWallets, getWallets } from ' import { activatedPluginsWorker } from '@masknet/plugin-infra' import { Some, None } from 'ts-results' import { isNonNull, timeout } from '@dimensiondev/kit' -import { delegatePluginBackup, delegateWalletBackup } from '../../../background/services/backup/internal' +import { delegatePluginBackup, delegateWalletBackup } from '../../../background/services/backup/internal_create' import type { NormalizedBackup } from '@masknet/backup-format' import type { LegacyWalletRecord } from '../../plugins/Wallet/database/types' import { keyToAddr, keyToJWK } from '../../utils/type-transform/SECP256k1-ETH' From cc5c5f641d6285e2a439bb6aae5e1c71edd40674 Mon Sep 17 00:00:00 2001 From: Jack Works Date: Thu, 24 Mar 2022 14:23:17 +0800 Subject: [PATCH 05/22] store --- packages/backup-format/src/normalize/index.ts | 12 +++++++++++- .../background/services/backup/internal_restore.ts | 11 +++++++++++ .../mask/background/services/backup/restore.ts | 14 +++++++++++--- .../extension/background-script/IdentityService.ts | 2 +- packages/mask/src/utils/native-rpc/Web.ts | 4 ++-- 5 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 packages/mask/background/services/backup/internal_restore.ts diff --git a/packages/backup-format/src/normalize/index.ts b/packages/backup-format/src/normalize/index.ts index af7bdafb4ca9..842da630ddb8 100644 --- a/packages/backup-format/src/normalize/index.ts +++ b/packages/backup-format/src/normalize/index.ts @@ -7,13 +7,23 @@ import { generateBackupVersion2, isBackupVersion2, normalizeBackupVersion2 } fro import type { NormalizedBackup } from './type' export * from './type' -export function normalizeBackup(data: unknown): NormalizedBackup.Data { +function __normalizeBackup(data: unknown): NormalizedBackup.Data { if (isBackupVersion2(data)) return normalizeBackupVersion2(data) if (isBackupVersion1(data)) return normalizeBackupVersion1(data) if (isBackupVersion0(data)) return normalizeBackupVersion0(data) throw new TypeError(BackupErrors.UnknownFormat) } +export function normalizeBackup(data: unknown): NormalizedBackup.Data { + const normalized = __normalizeBackup(data) + + // fix invalid URL + normalized.settings.grantedHostPermissions = normalized.settings.grantedHostPermissions.filter((url) => + /^(http|)/.test(url), + ) + return normalized +} + /** It will return the internal format. DO NOT rely on the detail of it! */ export function generateBackupRAW(data: NormalizedBackup.Data): unknown { const result = generateBackupVersion2(data) diff --git a/packages/mask/background/services/backup/internal_restore.ts b/packages/mask/background/services/backup/internal_restore.ts new file mode 100644 index 000000000000..dff9c41eb0a2 --- /dev/null +++ b/packages/mask/background/services/backup/internal_restore.ts @@ -0,0 +1,11 @@ +import type { NormalizedBackup } from '@masknet/backup-format' + +// Well, this is a bit of a hack, because we have not move those two parts into this project yet. +let restorePlugins: (backup: NormalizedBackup.Data['plugins']) => Promise +let restoreWallets: (backup: NormalizedBackup.WalletBackup[]) => Promise +export function delegateWalletRestore(f: typeof restoreWallets) { + restoreWallets = f +} +export function delegatePluginRestore(f: typeof restorePlugins) { + restorePlugins = f +} diff --git a/packages/mask/background/services/backup/restore.ts b/packages/mask/background/services/backup/restore.ts index 26d3910d2af7..a7e132c2680e 100644 --- a/packages/mask/background/services/backup/restore.ts +++ b/packages/mask/background/services/backup/restore.ts @@ -1,18 +1,26 @@ -import type { BackupPreview, NormalizedBackup } from '@masknet/backup-format' +import { decodeArrayBuffer, decodeText } from '@dimensiondev/kit' +import { BackupPreview, normalizeBackup, NormalizedBackup } from '@masknet/backup-format' /** * Only for mobile. Throw when no Persona is restored */ -export async function restoreFromBase64(backup: string): Promise { +export async function mobile_restoreFromBase64(rawString: string): Promise { if (process.env.architecture !== 'app') throw new Error('Only for mobile') + + const raw = JSON.parse(decodeText(decodeArrayBuffer(rawString))) + const backup = normalizeBackup(raw) + // restore backup throw new TypeError('Not implemented') } /** * Only for mobile. Throw when no Persona is restored. */ -export async function restoreFromBackup(backup: string): Promise { +export async function mobile_restoreFromBackup(rawString: string): Promise { if (process.env.architecture !== 'app') throw new Error('Only for mobile') + + const backup = normalizeBackup(JSON.parse(rawString)) + // restore backup throw new TypeError('Not implemented') } diff --git a/packages/mask/src/extension/background-script/IdentityService.ts b/packages/mask/src/extension/background-script/IdentityService.ts index 7e6282475916..262c56ae5c33 100644 --- a/packages/mask/src/extension/background-script/IdentityService.ts +++ b/packages/mask/src/extension/background-script/IdentityService.ts @@ -55,7 +55,7 @@ import { first, orderBy } from 'lodash-unified' import { recover_ECDH_256k1_KeyPair_ByMnemonicWord } from '../../utils/mnemonic-code' import { bindProof } from '@masknet/web3-providers' -export { restoreFromBase64, restoreFromBackup } from '../../../background/services/backup/restore' +export { mobile_restoreFromBase64, mobile_restoreFromBackup } from '../../../background/services/backup/restore' assertEnvironment(Environment.ManifestBackground) diff --git a/packages/mask/src/utils/native-rpc/Web.ts b/packages/mask/src/utils/native-rpc/Web.ts index 95e05cbbf295..b7e3f7411c07 100644 --- a/packages/mask/src/utils/native-rpc/Web.ts +++ b/packages/mask/src/utils/native-rpc/Web.ts @@ -151,10 +151,10 @@ export const MaskNetworkAPI: MaskNetworkAPIs = { persona_removePersona: ({ identifier }) => Services.Identity.deletePersona(stringToPersonaIdentifier(identifier), 'delete even with private'), persona_restoreFromJson: async ({ backup }) => { - await Services.Identity.restoreFromBackup(backup) + await Services.Identity.mobile_restoreFromBackup(backup) }, persona_restoreFromBase64: async ({ backup }) => { - await Services.Identity.restoreFromBase64(backup) + await Services.Identity.mobile_restoreFromBase64(backup) }, persona_connectProfile: async ({ profileIdentifier, personaIdentifier }) => { const profileId = stringToProfileIdentifier(profileIdentifier) From db29f5ab178b4912d47caaf5d2707d7b7581f144 Mon Sep 17 00:00:00 2001 From: Jack Works Date: Mon, 28 Mar 2022 13:29:25 +0800 Subject: [PATCH 06/22] refactor: impl addUnconfirmedBackup --- .../components/Restore/RestoreFromCloud.tsx | 36 +++++++++---------- .../components/Restore/RestoreFromLocal.tsx | 18 ++++------ .../dialogs/CloudBackupMergeDialog.tsx | 9 ++--- .../components/dialogs/RestoreDialog.tsx | 27 +++++++------- .../background/services/backup/restore.ts | 16 +++++++-- .../services/helper/request-permission.ts | 8 +++++ .../background-script/WelcomeService.ts | 2 +- packages/mask/src/utils/native-rpc/Web.ts | 4 +-- packages/public-api/src/web.ts | 2 +- 9 files changed, 65 insertions(+), 57 deletions(-) diff --git a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx index ee793e23d7d4..d3a3d29f99a4 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromCloud.tsx @@ -57,27 +57,27 @@ export const RestoreFromCloud = memo(() => { const onValidated = useCallback( async (downloadLink: string, accountValue: string, password: string, type: AccountType) => { - const backupValue = await fetchBackupValueFn(downloadLink) - const backupText = await decryptBackupFn(accountValue, password, backupValue) + const backupEncrypted = await fetchBackupValueFn(downloadLink) + const backupDecrypted = await decryptBackupFn(accountValue, password, backupEncrypted) - if (!backupText) { + if (!backupDecrypted) { return t.sign_in_account_cloud_backup_decrypt_failed() } - const backupInfo = await Services.Welcome.parseBackupStr(backupText) - if (backupInfo) { - setBackupId(backupInfo.id) - setAccount({ type, value: accountValue, password }) - setStep({ - name: 'restore', - params: { - backupJson: backupInfo.info, - handleRestore: () => onRestore(backupInfo, { type, value: accountValue, password }), - }, - }) - return null - } - return t.sign_in_account_cloud_backup_decrypt_failed() + const backupNormalized = await Services.Welcome.addUnconfirmedBackup(backupDecrypted) + if (backupNormalized.err) return t.sign_in_account_cloud_backup_decrypt_failed() + + const { id, info } = backupNormalized.val + setBackupId(id) + setAccount({ type, value: accountValue, password }) + setStep({ + name: 'restore', + params: { + backupJson: info, + handleRestore: () => onRestore(backupNormalized.val), + }, + }) + return null }, [], ) @@ -101,7 +101,7 @@ export const RestoreFromCloud = memo(() => { }, [currentPersona, account, user, toggleSynchronizePasswordDialog]) const onRestore = useCallback( - async (backupInfo: { info: BackupPreview; id: string }, account: any) => { + async (backupInfo: { info: BackupPreview; id: string }) => { try { if (backupInfo.info?.wallets) { await Services.Welcome.restoreBackupWithIDAndPermissionAndWallet({ id: backupInfo.id }) diff --git a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx index 500a7e2b5289..d8eff64598c2 100644 --- a/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx +++ b/packages/dashboard/src/components/Restore/RestoreFromLocal.tsx @@ -62,18 +62,12 @@ export const RestoreFromLocal = memo(() => { if (!backupValue) return setRestoreStatus(RestoreStatus.Verifying) - try { - const backupInfo = await Services.Welcome.parseBackupStr(backupValue) - - if (backupInfo) { - setJSON(backupInfo.info) - setBackupId(backupInfo.id) - setRestoreStatus(RestoreStatus.Verified) - } else { - setRestoreStatus(RestoreStatus.WaitingInput) - setBackupValue('') - } - } catch { + const backupInfo = await Services.Welcome.addUnconfirmedBackup(backupValue) + if (backupInfo.ok) { + setJSON(backupInfo.val.info) + setBackupId(backupInfo.val.id) + setRestoreStatus(RestoreStatus.Verified) + } else { showSnackbar(t.sign_in_account_cloud_backup_not_support(), { variant: 'error' }) setRestoreStatus(RestoreStatus.WaitingInput) setBackupValue('') diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx index b7a03fcdad38..970bf8466581 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/CloudBackupMergeDialog.tsx @@ -57,15 +57,12 @@ export function CloudBackupMergeDialog({ account, info, open, onClose, onMerged const encrypted = await fetchBackupValue(info.downloadURL) const decrypted = await decryptBackup(encode(account + backupPassword), encrypted) const backupText = JSON.stringify(decode(decrypted)) - const data = await Services.Welcome.parseBackupStr(backupText) + const data = (await Services.Welcome.addUnconfirmedBackup(backupText)).unwrap() - if (data?.info.wallets) { + if (data.info.wallets) { await Services.Welcome.restoreBackupWithIDAndPermissionAndWallet({ id: data.id }) - return } else { - if (data?.id) { - await Services.Welcome.restoreBackupWithIDAndPermission({ id: data.id }) - } + await Services.Welcome.restoreBackupWithIDAndPermission({ id: data.id }) restoreCallback() setBackupPassword('') diff --git a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx index fb19671d2390..af023879f329 100644 --- a/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx +++ b/packages/dashboard/src/pages/Settings/components/dialogs/RestoreDialog.tsx @@ -70,8 +70,7 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { const [text, setText] = useState('') // file content const [content, setContent] = useState('') - // parsed json - const [json, setJSON] = useState(null) + const [preview, setPreview] = useState(null) // backup id const [id, setId] = useState('') @@ -80,10 +79,10 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { setTab('file') setText('') setContent('') - setJSON(null) + setPreview(null) } const handleConfirm = async () => { - if (!json) return + if (!preview) return await Services.Welcome.restoreBackupWithIDAndPermission({ id }) } @@ -92,13 +91,13 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { const str = tab === 'file' ? content : text if (str) { - const obj = await Services.Welcome.parseBackupStr(str) - if (obj) { - setJSON(obj.info) - setId(obj.id) + const obj = await Services.Welcome.addUnconfirmedBackup(str) + if (obj.ok) { + setPreview(obj.val.info) + setId(obj.val.id) } } else { - setJSON(null) + setPreview(null) setId('') } }, [tab, text, content]) @@ -108,7 +107,7 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { title="Restore Backups" confirmText="Restore" open={open} - confirmDisabled={!json} + confirmDisabled={!preview} onClose={handleClose} onConfirm={handleConfirm}>
@@ -118,13 +117,13 @@ export default function RestoreDialog({ open, onClose }: RestoreDialogProps) { -
+
setContent(content || '')} />
- {json && content ? : null} + {preview && content ? : null} -
+