diff --git a/packages/encryption/src/encryption/Decryption.ts b/packages/encryption/src/encryption/Decryption.ts index fb0b92c8baa4..e4141bb8462f 100644 --- a/packages/encryption/src/encryption/Decryption.ts +++ b/packages/encryption/src/encryption/Decryption.ts @@ -1,5 +1,5 @@ import { unreachable } from '@dimensiondev/kit' -import { decodeTypedMessageFromDocument, decodeTypedMessageV38ToV40Format } from '@masknet/typed-message' +import { decodeTypedMessageFromDocument, decodeTypedMessageV38ToV40Format, TypedMessage } from '@masknet/typed-message' import { AESCryptoKey, EC_Public_CryptoKey, andThenAsync } from '@masknet/shared-base' import { None, Result } from 'ts-results' import { AESAlgorithmEnum, PayloadParseResult } from '../payload' @@ -12,6 +12,8 @@ import { DecryptError, DecryptEphemeralECDH_PostKey, DecryptSuccess, + DecryptIntermediateProgress, + DecryptIntermediateProgressKind, } from './DecryptionTypes' import { deriveAESByECDH_version38OrOlderExtraSteps } from './v38-ecdh' export * from './DecryptionTypes' @@ -30,7 +32,7 @@ export async function* decrypt(options: DecryptOptions, io: DecryptIO): AsyncIte { const cacheKey = await io.getPostKeyCache().catch(() => null) const iv = encryption.iv.unwrapOr(null) - if (cacheKey && iv) return yield* decryptWithPostAESKey(version, cacheKey, iv, encrypted) + if (cacheKey && iv) return yield* decryptWithPostAESKey(version, cacheKey, iv, encrypted, options.onDecrypted) } if (encryption.type === 'public') { @@ -38,7 +40,13 @@ export async function* decrypt(options: DecryptOptions, io: DecryptIO): AsyncIte if (AESKey.err) return yield new DecryptError(ErrorReasons.PayloadBroken, AESKey.val) if (iv.err) return yield new DecryptError(ErrorReasons.PayloadBroken, iv.val) // Not calling setPostCache here. It's public post and saving key is wasting storage space. - return yield* decryptWithPostAESKey(version, AESKey.val.key as AESCryptoKey, iv.val, encrypted) + return yield* decryptWithPostAESKey( + version, + AESKey.val.key as AESCryptoKey, + iv.val, + encrypted, + options.onDecrypted, + ) } else if (encryption.type === 'E2E') { const { iv: _iv, ownersAESKeyEncrypted } = encryption if (_iv.err) return yield new DecryptError(ErrorReasons.PayloadBroken, _iv.val) @@ -55,7 +63,7 @@ export async function* decrypt(options: DecryptOptions, io: DecryptIO): AsyncIte const aes_raw = await io.decryptByLocalKey(author.unwrapOr(null), ownersAESKeyEncrypted.val, iv) const aes = await importAESKeyFromJWKFromTextEncoder(aes_raw) io.setPostKeyCache(aes.unwrap()).catch(() => {}) - return yield* decryptWithPostAESKey(version, aes.unwrap(), iv, encrypted) + return yield* decryptWithPostAESKey(version, aes.unwrap(), iv, encrypted, options.onDecrypted) } catch (err) { if (await hasAuthorLocalKey) { // If we fall into this branch, it means we failed to decrypt as author. @@ -77,8 +85,9 @@ export async function* decrypt(options: DecryptOptions, io: DecryptIO): AsyncIte // #region // ! decrypt by ECDH const authorPublicKey = _authorPublicKey.unwrapOr(None) + yield progress(DecryptProgressKind.Progress, { event: DecryptIntermediateProgressKind.TryDecryptByE2E }) if (version === -37) { - return yield* v37ECDHE(io, encryption, encrypted, options.signal) + return yield* v37ECDHE(io, encryption, encrypted, options.signal, options.onDecrypted) } else { // Static ECDH // to do static ECDH, we need to have the authors public key first. bail if not found. @@ -88,7 +97,15 @@ export async function* decrypt(options: DecryptOptions, io: DecryptIO): AsyncIte }) if (authorECPub.err) return yield new DecryptError(ErrorReasons.AuthorPublicKeyNotFound, authorECPub.val) if (!authorECPub.val) return yield new DecryptError(ErrorReasons.AuthorPublicKeyNotFound, undefined) - return yield* v38To40StaticECDH(version, io, authorECPub.val, iv, encrypted, options.signal) + return yield* v38To40StaticECDH( + version, + io, + authorECPub.val, + iv, + encrypted, + options.signal, + options.onDecrypted, + ) } // #endregion } @@ -100,6 +117,7 @@ async function* v37ECDHE( encryption: PayloadParseResult.EndToEndEncryption, encrypted: Uint8Array, signal: AbortSignal | undefined, + report: ((message: TypedMessage) => void) | undefined, ) { // checked before const iv = encryption.iv.unwrap() @@ -126,7 +144,7 @@ async function* v37ECDHE( type: 'ephemeral', derive: (key) => (key ? io.deriveAESKey(key) : inlinedECDHE_derived), } - return yield* decryptByECDH(-37, io, postKey(), ecdh, importAESKeyFromRaw, iv, encrypted) + return yield* decryptByECDH(-37, io, postKey(), ecdh, importAESKeyFromRaw, iv, encrypted, report) } async function* v38To40StaticECDH( @@ -136,6 +154,7 @@ async function* v38To40StaticECDH( iv: Uint8Array, encrypted: Uint8Array, signal: AbortSignal | undefined, + report: ((message: TypedMessage) => void) | undefined, ): AsyncIterableIterator { const postKey = { '-40': async function* (iv: Uint8Array) { @@ -150,7 +169,7 @@ async function* v38To40StaticECDH( type: 'static-v38-or-older', derive: (postKeyIV) => deriveAESByECDH_version38OrOlderExtraSteps(io.deriveAESKey, authorECPub, postKeyIV), } - return yield* decryptByECDH(version, io, postKey, ecdh, importAESKeyFromJWKFromTextEncoder, iv, encrypted) + return yield* decryptByECDH(version, io, postKey, ecdh, importAESKeyFromJWKFromTextEncoder, iv, encrypted, report) } type StaticV38OrOlderECDH = { type: 'static-v38-or-older' @@ -170,6 +189,7 @@ async function* decryptByECDH( postKeyDecoder: (raw: Uint8Array) => Promise>, iv: Uint8Array, encrypted: Uint8Array, + report: ((message: TypedMessage) => void) | undefined, ) { const { derive, type } = ecdhProvider for await (const _ of possiblePostKeyIterator) { @@ -192,7 +212,7 @@ async function* decryptByECDH( io.setPostKeyCache(possiblePostKey.val).catch(() => {}) // If we'd able to decrypt the raw message, we will stop here. // because try further key cannot resolve the problem of parseTypedMessage failed. - return yield* parseTypedMessage(version, decrypted.val) + return yield* parseTypedMessage(version, decrypted.val, report) } } return void (yield new DecryptError(ErrorReasons.NotShareTarget, undefined)) @@ -203,16 +223,24 @@ async function* decryptWithPostAESKey( postAESKey: AESCryptoKey, iv: Uint8Array, encrypted: Uint8Array, + report: ((message: TypedMessage) => void) | undefined, ): AsyncIterableIterator { const { err, val } = await decryptWithAES(AESAlgorithmEnum.A256GCM, postAESKey, iv, encrypted) if (err) return yield new DecryptError(ErrorReasons.DecryptFailed, val) - return yield* parseTypedMessage(version, val) + return yield* parseTypedMessage(version, val, report) } -async function* parseTypedMessage(version: Version, raw: Uint8Array): AsyncIterableIterator { +async function* parseTypedMessage( + version: Version, + raw: Uint8Array, + report: ((message: TypedMessage) => void) | undefined, +): AsyncIterableIterator { const { err, val } = version === -37 ? decodeTypedMessageFromDocument(raw) : decodeTypedMessageV38ToV40Format(raw, version) if (err) return yield new DecryptError(ErrorReasons.PayloadDecryptedButTypedMessageBroken, val) + try { + report?.(val) + } catch {} return yield progress(DecryptProgressKind.Success, { content: val }) } @@ -235,6 +263,7 @@ function importAESKeyFromRaw(aes_raw: Uint8Array) { } function progress(kind: DecryptProgressKind.Success, rest: Omit): DecryptSuccess +function progress(kind: DecryptProgressKind.Progress, rest: Omit): DecryptSuccess function progress(kind: DecryptProgressKind, rest?: object): DecryptProgress { return { type: kind, ...rest } as any } diff --git a/packages/encryption/src/encryption/DecryptionTypes.ts b/packages/encryption/src/encryption/DecryptionTypes.ts index 98a2d30e825a..032b1678eb15 100644 --- a/packages/encryption/src/encryption/DecryptionTypes.ts +++ b/packages/encryption/src/encryption/DecryptionTypes.ts @@ -5,6 +5,7 @@ import { registerSerializableClass } from '@masknet/shared-base' export interface DecryptOptions { message: PayloadParseResult.Payload + onDecrypted?(message: TypedMessage): void signal?: AbortSignal } export interface DecryptIO { diff --git a/packages/encryption/tests/__snapshots__/encryption.ts.snap b/packages/encryption/tests/__snapshots__/encryption.ts.snap index 24512ab2f93a..1eb5c2486eb2 100644 --- a/packages/encryption/tests/__snapshots__/encryption.ts.snap +++ b/packages/encryption/tests/__snapshots__/encryption.ts.snap @@ -2,6 +2,10 @@ exports[`v37 E2E encryption: decrypted as author 1`] = ` Array [ + Object { + "event": "E2E", + "type": "progress", + }, Object { "content": Object { "content": "hello world", @@ -17,6 +21,10 @@ Array [ exports[`v37 E2E encryption: decrypted as jack 1`] = ` Array [ + Object { + "event": "E2E", + "type": "progress", + }, Object { "content": Object { "content": "hello world", @@ -242,6 +250,10 @@ Array [ exports[`v38 E2E encryption: decrypted as jack 1`] = ` Array [ + Object { + "event": "E2E", + "type": "progress", + }, Object { "content": Object { "content": "hello world", diff --git a/packages/mask/background/services/crypto/decryption.ts b/packages/mask/background/services/crypto/decryption.ts index 6b5af69a5902..cc1c661dd07f 100644 --- a/packages/mask/background/services/crypto/decryption.ts +++ b/packages/mask/background/services/crypto/decryption.ts @@ -22,6 +22,7 @@ import { PostIVIdentifier, ProfileIdentifier, } from '@masknet/shared-base' +import type { TypedMessage } from '@masknet/typed-message' import { noop } from 'lodash-unified' import { queryPersonaByProfileDB } from '../../database/persona/db' import { @@ -72,6 +73,8 @@ export async function* decryptionWithSocialNetworkDecoding( if (!decoded) return yield new DecryptError(ErrorReasons.NoPayloadFound, undefined) yield* decryption(decoded, context) } + +const inMemoryCache = new IdentifierMap(new Map(), PostIVIdentifier) async function* decryption(payload: string | Uint8Array, context: DecryptionContext) { const parse = await parsePayload(payload) if (parse.err) return null @@ -105,7 +108,10 @@ async function* decryption(payload: string | Uint8Array, context: DecryptionCont ) // #endregion - // TODO: read in-memory cache to avoid db lookup + if (inMemoryCache.has(id)) { + const p: DecryptProgress = { type: DecryptProgressKind.Success, content: inMemoryCache.get(id)! } + return void (yield p) + } // #region store author public key into the database try { @@ -119,7 +125,12 @@ async function* decryption(payload: string | Uint8Array, context: DecryptionCont // #endregion const progress = decrypt( - { message: parse.val }, + { + message: parse.val, + onDecrypted(message) { + inMemoryCache.set(id, message) + }, + }, { getPostKeyCache: getPostKeyCache.bind(null, id), setPostKeyCache: (key) => { diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json index 5858f2017d8a..60e40f45df72 100644 --- a/packages/mask/shared-ui/locales/en-US.json +++ b/packages/mask/shared-ui/locales/en-US.json @@ -150,11 +150,7 @@ "select_specific_friends_dialog__title": "Select Specific Contacts", "service_decryption_failed": "Decryption failed.", "service_invalid_backup_file": "This does not seem like a backup of Mask.", - "service_not_share_target": "Mask does not find the post key. This post may not be intended to share with you.", - "service_others_key_not_found": "The public key of {{name}} cannot be found!", "service_publish_post_aes_key_failed": "Failed to publish the AES key!", - "service_self_key_decryption_failed": "Decryption failed. Maybe you selected a wrong identity or imported a key that was not used to encrypt this post.", - "service_decode_image_payload_failed": "Decode image failed.", "service_unknown_payload": "Unknown post version. You may need to update Mask.", "service_username_invalid": "Invalid Username", "settings_enable_debug": "Debug Mode", diff --git a/packages/mask/shared-ui/locales/ja-JP.json b/packages/mask/shared-ui/locales/ja-JP.json index aec1b8e2e302..d524c836b238 100644 --- a/packages/mask/shared-ui/locales/ja-JP.json +++ b/packages/mask/shared-ui/locales/ja-JP.json @@ -66,11 +66,7 @@ "select_specific_friends_dialog__title": "特定の連絡先を選ぶ", "service_decryption_failed": "復号に失敗", "service_invalid_backup_file": "これは Mask のバックアップではないかもしれません。", - "service_not_share_target": " Mask は投稿鍵を発見できませんでした。この投稿はあなたに共有されていない可能性があります。", - "service_others_key_not_found": "{{name}} の公開鍵が見つかりません!", "service_publish_post_aes_key_failed": "AES キーを公開するのに失敗しました。", - "service_self_key_decryption_failed": "復号に失敗しました。間違ったIDを選択したか、この投稿の暗号化に使用されていない鍵をインポートしたのかもしれません。", - "service_decode_image_payload_failed": "画像のデコードに失敗しました。", "service_unknown_payload": "投稿のバージョンが不明です。Mask のアップデートが必要かもしれません。", "service_username_invalid": "無効なユーザー名", "settings_enable_debug": "デバッグモード", diff --git a/packages/mask/shared-ui/locales/ko-KR.json b/packages/mask/shared-ui/locales/ko-KR.json index c65c704bf6d4..3161d8c48c3c 100644 --- a/packages/mask/shared-ui/locales/ko-KR.json +++ b/packages/mask/shared-ui/locales/ko-KR.json @@ -125,11 +125,7 @@ "select_specific_friends_dialog__title": "지정 연락처 선택", "service_decryption_failed": "해독 실패", "service_invalid_backup_file": "Mask의 백업이 아닌 것 같습니다.", - "service_not_share_target": "Mask가 이 게시물의 키를 찾지 못합니다. 이 게시물은 당신에게 공유하는 것이 아닌 것 같습니다.", - "service_others_key_not_found": "찾을 수 없는 {{name}}의 공개 키입니다.", "service_publish_post_aes_key_failed": "AES 키 공유 실패", - "service_self_key_decryption_failed": "해독 실패했습니다. 잘못된 신분이나 키를 불러온 것 같습니다.", - "service_decode_image_payload_failed": "이미지 디코딩 실패.", "service_unknown_payload": "알 수 없는 게시물 버전입니다. Mask를 업데이트하세요.", "service_username_invalid": "무효한 아이디입니다", "settings_enable_debug": "디버그 모드", diff --git a/packages/mask/shared-ui/locales/qya-AA.json b/packages/mask/shared-ui/locales/qya-AA.json index 10d133b5a232..c9f261d0bfe7 100644 --- a/packages/mask/shared-ui/locales/qya-AA.json +++ b/packages/mask/shared-ui/locales/qya-AA.json @@ -150,11 +150,7 @@ "select_specific_friends_dialog__title": "crwdns4341:0crwdne4341:0", "service_decryption_failed": "crwdns4343:0crwdne4343:0", "service_invalid_backup_file": "crwdns4345:0crwdne4345:0", - "service_not_share_target": "crwdns4347:0crwdne4347:0", - "service_others_key_not_found": "crwdns4349:0{{name}}crwdne4349:0", "service_publish_post_aes_key_failed": "crwdns4351:0crwdne4351:0", - "service_self_key_decryption_failed": "crwdns4353:0crwdne4353:0", - "service_decode_image_payload_failed": "crwdns4355:0crwdne4355:0", "service_unknown_payload": "crwdns4357:0crwdne4357:0", "service_username_invalid": "crwdns4359:0crwdne4359:0", "settings_enable_debug": "crwdns4427:0crwdne4427:0", diff --git a/packages/mask/shared-ui/locales/zh-CN.json b/packages/mask/shared-ui/locales/zh-CN.json index e5b445da1f4c..1a190aba9051 100644 --- a/packages/mask/shared-ui/locales/zh-CN.json +++ b/packages/mask/shared-ui/locales/zh-CN.json @@ -142,11 +142,7 @@ "select_specific_friends_dialog__title": "选择特定联系人", "service_decryption_failed": "解密失败", "service_invalid_backup_file": "这似乎不是Mask的备份。", - "service_not_share_target": "Mask没有找到该贴文的密钥。这个贴文可能没有分享给您查看。", - "service_others_key_not_found": "无法找到 {{name}} 的公钥 !", "service_publish_post_aes_key_failed": "AES密钥发布失败!", - "service_self_key_decryption_failed": "贴文解密失败。或许您选择了错误的身份或者导入的是未用于加密此贴文的密钥。", - "service_decode_image_payload_failed": "图片解码失败。", "service_unknown_payload": "未知的贴文版本。您可能需要更新Mask。", "service_username_invalid": "无效的用户名", "settings_enable_debug": "调试模式", diff --git a/packages/mask/shared-ui/locales/zh-TW.json b/packages/mask/shared-ui/locales/zh-TW.json index 8d1c48cdc0dd..878d1517ad88 100644 --- a/packages/mask/shared-ui/locales/zh-TW.json +++ b/packages/mask/shared-ui/locales/zh-TW.json @@ -98,11 +98,7 @@ "select_specific_friends_dialog__title": "選擇特定聯絡人", "service_decryption_failed": "解密失敗。", "service_invalid_backup_file": "這不是一個 Mask 的備份檔。", - "service_not_share_target": "Mask 無發找到貼文金鑰。 這篇文章或許不是要分享給你的。", - "service_others_key_not_found": "無法找到 {{name}} 的公鑰!", "service_publish_post_aes_key_failed": "發佈 AES 金鑰失敗!", - "service_self_key_decryption_failed": "解密失敗。可能您選擇了錯誤的身分或導入了一個不用於加密此貼文的金鑰。", - "service_decode_image_payload_failed": "圖片解碼失敗", "service_unknown_payload": "未知的貼文版本。您或許需要升級 Mask。", "service_username_invalid": "錯誤的使用者名稱", "settings_enable_debug": "除錯模式", diff --git a/packages/mask/src/components/DataSource/usePostInfo.ts b/packages/mask/src/components/DataSource/usePostInfo.ts index 51e502f65e66..43fdc666e360 100644 --- a/packages/mask/src/components/DataSource/usePostInfo.ts +++ b/packages/mask/src/components/DataSource/usePostInfo.ts @@ -2,7 +2,7 @@ import { activatedSocialNetworkUI } from '../../social-network' import { usePostInfoDetails } from '@masknet/plugin-infra' import type { ProfileIdentifier } from '@masknet/shared-base' -export { usePostInfo, PostInfoProvider, usePostInfoDetails, usePostInfoSharedPublic } from '@masknet/plugin-infra' +export { usePostInfo, PostInfoProvider, usePostInfoDetails } from '@masknet/plugin-infra' export function usePostLink() { const id = usePostInfoDetails.snsID() const identifier = usePostInfoDetails.identifier() diff --git a/packages/mask/src/components/InjectedComponents/DecryptedPost/DecryptPostFailed.tsx b/packages/mask/src/components/InjectedComponents/DecryptedPost/DecryptPostFailed.tsx index 860f5b03a5a6..87451cb073f7 100644 --- a/packages/mask/src/components/InjectedComponents/DecryptedPost/DecryptPostFailed.tsx +++ b/packages/mask/src/components/InjectedComponents/DecryptedPost/DecryptPostFailed.tsx @@ -1,10 +1,8 @@ import { memo } from 'react' import { useI18N } from '../../../utils' import { AdditionalContent } from '../AdditionalPostContent' -import { DecryptFailedReason } from '../../../utils/constants' import type { ProfileIdentifier } from '@masknet/shared-base' import { wrapAuthorDifferentMessage } from './authorDifferentMessage' -import MaskPostExtraInfoWrapper from '../../../plugins/MaskPluginWrapper' export interface DecryptPostFailedProps { error: Error @@ -17,9 +15,6 @@ export const DecryptPostFailed = memo(function DecryptPostFailed(props: DecryptP const { author, postedBy, error } = props const { t } = useI18N() - if (error?.message === DecryptFailedReason.MyCryptoKeyNotFound) { - return - } return ( { const signal = new AbortController() - async function makeProgress( - key: string, - decryptionProcess: ReturnType, - ) { - const refreshProgress = (progress: SuccessDecryption | FailureDecryption | DecryptionProgress) => - dispatch({ - type: 'refresh', - key, - progress, - }) - for await (const process of asyncIteratorWithResult(decryptionProcess)) { - if (signal.signal.aborted) - return decryptionProcess.return?.({ type: 'error', internal: true, error: 'aborted' }) - if (process.done) { - if (process.value.type === 'success') { - current.iv.value = process.value.iv - current.decryptedPayloadForImage.value = process.value.decryptedPayloadForImage - } - return refreshProgress(process.value) - } - const status = process.value - refreshProgress(status) - if (status.type === 'progress') { - if (status.progress === 'intermediate_success') refreshProgress(status.data) - else if (status.progress === 'iv_decrypted') current.iv.value = status.iv - else if (status.progress === 'payload_decrypted') - current.decryptedPayloadForImage.value = status.decryptedPayloadForImage + const postURL = current.url.getCurrentValue()?.toString() + const report = + (key: string): ReportProgress => + (kind, message) => { + if (kind === 'e2e') { + dispatch({ + type: 'refresh', + key, + progress: { type: 'progress', progress: 'finding_post_key', internal: false }, + }) + } else { + dispatch({ + type: 'refresh', + key, + progress: { type: 'error', error: message, internal: false }, + }) } } - } - - const postURL = current.url.getCurrentValue()?.toString() - if (deconstructedPayload.ok) + if (deconstructedPayload.ok) { makeProgress( - 'post text', - ServicesWithProgress.decryptFromText(deconstructedPayload.val, postBy, whoAmI.network, whoAmI, postURL), + postURL, + postBy, + whoAmI, + { + type: 'text', + text: + extractTextFromTypedMessage(current.rawMessage.getCurrentValue()).unwrapOr('') + + ' ' + + mentionedLinks.join(' '), + }, + (message, iv) => { + dispatch({ + type: 'refresh', + key: 'text', + progress: { + type: 'success', + content: message, + internal: false, + iv: encodeArrayBuffer(iv), + }, + }) + }, + current.decryptedReport, + report('text'), + signal.signal, ) + } postMetadataImages.forEach((url) => { if (signal.signal.aborted) return - makeProgress(url, ServicesWithProgress.decryptFromImageUrl(url, postBy, whoAmI.network, whoAmI, postURL)) + makeProgress( + postURL, + postBy, + whoAmI, + { type: 'image-url', image: url }, + (message, iv) => { + dispatch({ + type: 'refresh', + key: url, + progress: { + type: 'success', + content: message, + internal: false, + iv: encodeArrayBuffer(iv), + }, + }) + }, + current.decryptedReport, + report(url), + signal.signal, + ) }) return () => signal.abort() - }, [ - current.iv, - deconstructedPayload.ok, - (deconstructedPayload.val as Payload)?.encryptedText, - postBy.toText(), - postMetadataImages.join(), - whoAmI.toText(), - ]) - - // pass 2: - // decrypt rest attachments which depend on post content - // const decryptedPostContent = progress.find((p) => p.key === postContent) - // useEffect(() => { - // if (decryptedPostContent?.progress.type !== 'success') return - // // TODO: - // // decrypt shuffled image here - // }, [decryptedPostContent]) + }, [deconstructedPayload.ok, postBy.toText(), postMetadataImages.join(), whoAmI.toText(), mentionedLinks.join()]) // pass 3: // invoke callback @@ -218,3 +235,41 @@ export function DecryptPost(props: DecryptPostProps) { } } } + +type ReportProgress = (type: 'e2e' | 'error', message: string) => void +async function makeProgress( + postURL: string | undefined, + authorHint: ProfileIdentifier, + currentProfile: ProfileIdentifier, + payload: SocialNetworkEncodedPayload, + done: (message: TypedMessage, iv: Uint8Array) => void, + reporter: PostContext['decryptedReport'], + reportProgress: ReportProgress, + signal: AbortSignal, +) { + const context: DecryptionContext = { + postURL, + authorHint, + currentProfile, + currentSocialNetwork: activatedSocialNetworkUI.encryptionNetwork, + } + let iv: Uint8Array | undefined + for await (const progress of ServicesWithProgress.decryptionWithSocialNetworkDecoding(payload, context)) { + if (signal.aborted) return + if (progress.type === DecryptProgressKind.Success) { + done(progress.content, iv || new Uint8Array()) + } else if (progress.type === DecryptProgressKind.Info) { + iv ??= progress.iv + if (progress.ownersKeyEncrypted) + reporter({ ownersAESKeyEncrypted: encodeArrayBuffer(progress.ownersKeyEncrypted) }) + if (progress.iv) reporter({ iv: encodeArrayBuffer(progress.iv) }) + if (progress.version) reporter({ version: progress.version }) + if (typeof progress.publicShared === 'boolean') reporter({ sharedPublic: Some(progress.publicShared) }) + } else if (progress.type === DecryptProgressKind.Progress) { + if (progress.event === DecryptIntermediateProgressKind.TryDecryptByE2E) reportProgress('e2e', '') + else safeUnreachable(progress.event) + } else if (progress.type === DecryptProgressKind.Error) { + console.log(progress.message) + } else safeUnreachable(progress) + } +} diff --git a/packages/mask/src/components/InjectedComponents/PostComments.tsx b/packages/mask/src/components/InjectedComponents/PostComments.tsx index 6f858f1f1c14..eed638689914 100644 --- a/packages/mask/src/components/InjectedComponents/PostComments.tsx +++ b/packages/mask/src/components/InjectedComponents/PostComments.tsx @@ -47,17 +47,15 @@ export function PostComment(props: PostCommentProps) { const { needZip } = props const comment = useValueRef(props.comment) const postContent = usePostInfoDetails.rawMessagePiped() - const containingPayload = usePostInfoDetails.containingMaskPayload() const iv = usePostInfoDetails.iv() - const postIV = containingPayload.map((x) => x.iv).unwrapOr(iv) const dec = useAsync(async () => { const decryptedText = extractTextFromTypedMessage(postContent).unwrap() - if (!postIV || !decryptedText) throw new Error('Decrypt comment failed') - const result = await Services.Crypto.decryptComment(postIV, decryptedText, comment) + if (!iv || !decryptedText) throw new Error('Decrypt comment failed') + const result = await Services.Crypto.decryptComment(iv, decryptedText, comment) if (result === null) throw new Error('Decrypt result empty') return result - }, [postIV, postContent, comment]) + }, [iv, postContent, comment]) useEffect(() => void (dec.value && needZip()), [dec.value, needZip]) if (dec.value) return {dec.value} diff --git a/packages/mask/src/components/InjectedComponents/PostInspector.tsx b/packages/mask/src/components/InjectedComponents/PostInspector.tsx index faeb5a714672..251d5e4a3309 100644 --- a/packages/mask/src/components/InjectedComponents/PostInspector.tsx +++ b/packages/mask/src/components/InjectedComponents/PostInspector.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useAsync } from 'react-use' import { DecryptPost } from './DecryptedPost/DecryptedPost' import Services from '../../extension/service' -import { ProfileIdentifier, type PayloadAlpha40_Or_Alpha39, type PayloadAlpha38 } from '@masknet/shared-base' +import { ProfileIdentifier } from '@masknet/shared-base' import type { TypedMessageTuple } from '@masknet/typed-message' import type { Profile } from '../../database' import { useCurrentIdentity, useFriendsList } from '../DataSource/useActivatedUI' @@ -28,7 +28,9 @@ export interface PostInspectorProps { export function PostInspector(props: PostInspectorProps) { const postBy = usePostInfoDetails.author() const encryptedPost = usePostInfoDetails.containingMaskPayload() - const decryptedPayloadForImage = usePostInfoDetails.decryptedPayloadForImage() + const ownersKeyEncrypted = usePostInfoDetails.ownersKeyEncrypted() + const version = usePostInfoDetails.version() + const iv = usePostInfoDetails.iv() const postImages = usePostInfoDetails.postMetadataImages() const isDebugging = useValueRef(debugModeSetting) const whoAmI = useCurrentIdentity() @@ -48,21 +50,14 @@ export function PostInspector(props: PostInspectorProps) { { - const val = (postImages ? decryptedPayloadForImage : encryptedPost.val) as - | PayloadAlpha40_Or_Alpha39 - | PayloadAlpha38 - - const { iv, version } = val - const ownersAESKeyEncrypted = - val.version === -38 ? val.AESKeyEncrypted : val.ownersAESKeyEncrypted - setAlreadySelectedPreviously(alreadySelectedPreviously.concat(profile)) return Services.Crypto.appendShareTarget( version, - ownersAESKeyEncrypted, + ownersKeyEncrypted, iv, profile.map((x) => x.identifier), whoAmI!.identifier, diff --git a/packages/mask/src/crypto/crypto-alpha-39.ts b/packages/mask/src/crypto/crypto-alpha-39.ts index 58f8bfcddb62..924e53c6d36e 100644 --- a/packages/mask/src/crypto/crypto-alpha-39.ts +++ b/packages/mask/src/crypto/crypto-alpha-39.ts @@ -79,8 +79,8 @@ export async function encrypt1ToN(info: { } } -export { encrypt1To1, decryptMessage1To1 } from './crypto-alpha-40' -export { decryptMessage1ToNByOther, decryptMessage1ToNByMyself, extractAESKeyInMessage } from './crypto-alpha-40' +export { encrypt1To1 } from './crypto-alpha-40' +export { extractAESKeyInMessage } from './crypto-alpha-40' export { encryptWithAES, decryptWithAES } from './crypto-alpha-40' export { encryptComment, decryptComment } from './crypto-alpha-40' export { typedMessageStringify, typedMessageParse } from './crypto-alpha-40' diff --git a/packages/mask/src/crypto/crypto-alpha-40.ts b/packages/mask/src/crypto/crypto-alpha-40.ts index b9183f0bf5da..316a1f62560f 100644 --- a/packages/mask/src/crypto/crypto-alpha-40.ts +++ b/packages/mask/src/crypto/crypto-alpha-40.ts @@ -11,7 +11,6 @@ import { concatArrayBuffer, memoizePromise, } from '@dimensiondev/kit' -import { i18n } from '../../shared-ui/locales_legacy' import { CryptoWorker } from '../modules/workers' import type { EC_Private_JsonWebKey, EC_Public_JsonWebKey, AESJsonWebKey } from '@masknet/shared-base' import { makeTypedMessageText } from '@masknet/typed-message' @@ -165,58 +164,6 @@ export async function encrypt1ToN(info: { } // #endregion // #region decrypt text -/** - * Decrypt 1 to 1 - */ -export async function decryptMessage1To1(info: { - version: -40 | -39 | -38 - encryptedContent: string | ArrayBuffer - salt: string | ArrayBuffer - /** Your private key */ - privateKeyECDH: EC_Private_JsonWebKey - /** If you are the author, this should be the receiver's public key. - * Otherwise, this should be the author's public key */ - anotherPublicKeyECDH: EC_Public_JsonWebKey -}): Promise { - const { anotherPublicKeyECDH, version, salt, encryptedContent, privateKeyECDH } = info - const encrypted = typeof encryptedContent === 'string' ? decodeArrayBuffer(encryptedContent) : encryptedContent - - const { iv, key } = await deriveAESKey(privateKeyECDH, anotherPublicKeyECDH, salt) - return CryptoWorker.decrypt_aes_gcm(key, iv, encrypted) -} -/** - * Decrypt 1 to N message that send by other - */ -export async function decryptMessage1ToNByOther(info: { - version: -40 | -39 | -38 - encryptedContent: string | ArrayBuffer - privateKeyECDH: EC_Private_JsonWebKey - authorsPublicKeyECDH: EC_Public_JsonWebKey - AESKeyEncrypted: PublishedAESKey | PublishedAESKey[] - iv: ArrayBuffer | string -}): Promise<[ArrayBuffer, AESJsonWebKey]> { - const { encryptedContent, privateKeyECDH, authorsPublicKeyECDH, iv } = info - const AESKeyEncrypted = Array.isArray(info.AESKeyEncrypted) ? info.AESKeyEncrypted : [info.AESKeyEncrypted] - - let resolvedAESKey: string | null = null - await Promise.all( - AESKeyEncrypted.map(async (key) => { - try { - const result = await decryptMessage1To1({ - version: -40, - salt: key.salt, - encryptedContent: key.encryptedKey, - anotherPublicKeyECDH: authorsPublicKeyECDH, - privateKeyECDH: privateKeyECDH, - }) - resolvedAESKey = decodeText(result) - } catch {} - }), - ) - if (resolvedAESKey === null) throw new Error(i18n.t('service_not_share_target')) - const aesKey: AESJsonWebKey = JSON.parse(resolvedAESKey) - return [await decryptWithAES({ aesKey, iv, encrypted: encryptedContent }), aesKey] -} export async function extractAESKeyInMessage( version: -40 | -39 | -38, encodedEncryptedKey: string | ArrayBuffer, @@ -231,22 +178,6 @@ export async function extractAESKeyInMessage( ) return decryptedAESKeyJWK } -/** - * Decrypt 1 to N message that send by myself - */ -export async function decryptMessage1ToNByMyself(info: { - version: -40 | -39 | -38 - encryptedContent: string | ArrayBuffer - /** This should be included in the message */ - encryptedAESKey: string | ArrayBuffer - myLocalKey: AESJsonWebKey - iv: string | ArrayBuffer -}): Promise<[ArrayBuffer, AESJsonWebKey]> { - const { encryptedContent, myLocalKey, iv, encryptedAESKey } = info - const decryptedAESKey = await extractAESKeyInMessage(-40, encryptedAESKey, iv, myLocalKey) - const post = await decryptWithAES({ aesKey: decryptedAESKey, encrypted: encryptedContent, iv }) - return [post, decryptedAESKey] -} /** * Decrypt the content encrypted by AES */ diff --git a/packages/mask/src/extension/background-script/CryptoServices/decryptFrom.ts b/packages/mask/src/extension/background-script/CryptoServices/decryptFrom.ts index 73cbdaac4f9b..a2f1fddef97b 100644 --- a/packages/mask/src/extension/background-script/CryptoServices/decryptFrom.ts +++ b/packages/mask/src/extension/background-script/CryptoServices/decryptFrom.ts @@ -1,426 +1,22 @@ -import * as Alpha40 from '../../../crypto/crypto-alpha-40' -import * as Alpha39 from '../../../crypto/crypto-alpha-39' -import { decodeArrayBuffer, decodeText, encodeArrayBuffer, delay, abortSignalTimeout } from '@dimensiondev/kit' -import { deconstructPayload } from '../../../utils/type-transform/Payload' -import { i18n } from '../../../../shared-ui/locales_legacy' -import { queryPersonaRecord, queryLocalKey } from '../../../database' -import { ProfileIdentifier, PostIVIdentifier, EC_Public_CryptoKey } from '@masknet/shared-base' -import { PostRecord, queryPostDB, updatePostDB } from '../../../../background/database/post' -import { getNetworkWorker, getNetworkWorkerUninitialized } from '../../../social-network/worker' -import { cryptoProviderTable } from './cryptoProviderTable' -import type { PersonaRecord } from '../../../../background/database/persona/db' -import { verifyOthersProve } from './verifyOthersProve' -import { publicSharedAESKey } from '../../../crypto/crypto-alpha-38' -import { DecryptFailedReason } from '../../../utils/constants' -import { asyncIteratorWithResult, memorizeAsyncGenerator } from '../../../utils/type-transform/asyncIteratorHelpers' -import { DecryptStaticECDH_PostKey, steganographyDecodeImage } from '@masknet/encryption' import type { TypedMessage } from '@masknet/typed-message' -import type { AESJsonWebKey, Payload } from '@masknet/shared-base' -import stringify from 'json-stable-stringify' -import { MaskMessages } from '../../../utils/messages' -import { Err, Ok, Result } from 'ts-results' -import { decodeTextPayloadWorker } from '../../../social-network/utils/text-payload-worker' -import { steganographyDownloadImage } from './utils' -import { - GUN_queryPostKey_version39Or38, - GUN_queryPostKey_version40, -} from '../../../../background/network/gun/encryption/queryPostKey' -type Progress = ( - | { progress: 'finding_person_public_key' | 'finding_post_key' | 'init' | 'decode_post' } - | { progress: 'intermediate_success'; data: Success } - | { progress: 'iv_decrypted'; iv: string } - | { progress: 'payload_decrypted'; decryptedPayloadForImage: Payload } -) & { - type: 'progress' - /** if this is true, this progress should not cause UI change. */ - internal: boolean -} -type SuccessThrough = 'author_key_not_found' | 'post_key_cached' | 'normal_decrypted' -type Success = { +export type SuccessDecryption = { type: 'success' iv: string - decryptedPayloadForImage: Payload content: TypedMessage - through: SuccessThrough[] internal: boolean } -type Failure = { +export type FailureDecryption = { error: string type: 'error' internal: boolean } -export type SuccessDecryption = Success -export type FailureDecryption = Failure -export type DecryptionProgress = Progress -type ReturnOfDecryptPostContentWithProgress = AsyncGenerator - -const successDecryptionCache = new Map() -const makeSuccessResultF = - ( - cacheKey: string, - iv: string, - decryptedPayloadForImage: Payload, - cryptoProvider: typeof cryptoProviderTable[keyof typeof cryptoProviderTable], - ) => - (rawEncryptedContent: string, through: Success['through']): Success => { - const success: Success = { - through, - iv, - decryptedPayloadForImage, - content: cryptoProvider.typedMessageParse(rawEncryptedContent), - type: 'success', - internal: false, - } - successDecryptionCache.set(cacheKey, success) - return success - } - -function makeProgress( - progress: Exclude | Success, - internal = false, -): Progress { - if (typeof progress === 'string') return { type: 'progress', progress, internal } - return { type: 'progress', progress: 'intermediate_success', data: progress, internal } -} -function makeError(error: string | Error, internal = false): Failure { - if (typeof error === 'string') return { type: 'error', error, internal } - return makeError(error.message, internal) -} -/** - * Decrypt message from a user - * @param post post - * @param author Post by - * @param authorNetworkHint When the author is unknown, the decryption (to public) won't die - * @param whoAmI My username - * @param publicShared Is this post public shared - * - * @description - * The decrypt process: - * - * ## Prepare - * a. if unknown payload, throw - * b. if unknown payload version, throw - * - * ## Decrypt for version -38, -39 and -40 - * a. read the cache `cachedPostResult` - * b. find author's public key (See: `findAuthorPublicKey` function) - * 0. if it is in the payload, use the key in the payload. - * 1. if there is cache, return the cache - * 2. if try N times but not finding the key, throw - * c. if there is cache, return the cache - * d. try to decrypt by `author` with `decryptAsAuthor` - * e. try to decrypt by `whoAmI` with `decryptAsAuthor` - * f. if `author` === `whoAmI`, throw - * g. find key for `whoAmI` on Gun - * h. try to decrypt by the key on Gun - * i. return a Promise - * 0. if version === -40, throws - * 1. listen to future new keys on Gun - * 2. try to decrypt with that key - */ -async function* decryptFromPayloadWithProgress_raw( - post: Payload, - author: ProfileIdentifier, - authorNetworkHint: string, - whoAmI: ProfileIdentifier, - discoverURL: string | undefined, -): ReturnOfDecryptPostContentWithProgress { - const cacheKey = stringify(post) - if (successDecryptionCache.has(cacheKey)) return successDecryptionCache.get(cacheKey)! - yield makeProgress('init') - - const authorNetworkWorker = Result.wrap(() => - getNetworkWorkerUninitialized(author.isUnknown ? authorNetworkHint : author.network), - ).andThen((x) => (x ? Ok(x) : Err(new Error('Worker not found')))) - if (authorNetworkWorker.err) return makeError(authorNetworkWorker.val as Error) - - const data = post - const { version } = data - const sharePublic = data.version === -38 ? data.sharedPublic ?? false : false - - if (version === -40 || version === -39 || version === -38) { - const { encryptedText, iv, version } = data - const cryptoProvider = cryptoProviderTable[version] - const makeSuccessResult = makeSuccessResultF(cacheKey, iv, data, cryptoProvider) - const ownersAESKeyEncrypted = data.version === -38 ? data.AESKeyEncrypted : data.ownersAESKeyEncrypted - - yield { type: 'progress', progress: 'payload_decrypted', decryptedPayloadForImage: data, internal: true } - yield { type: 'progress', progress: 'iv_decrypted', iv: iv, internal: true } - // ? Early emit the cache. - const [cachedPostResult, setPostCache] = await decryptFromCache(data, author, discoverURL) - if (cachedPostResult) { - yield makeProgress(makeSuccessResult(cachedPostResult, ['post_key_cached'])) - } - - // ? If the author's key is in the payload, store it. - if (data.version === -38 && data.authorPublicKey && !author.isUnknown) { - await verifyOthersProve({ raw: data.authorPublicKey }, author).catch(console.error) - } - // ? Find author's public key. - let authorPersona!: PersonaRecord - for await (const _ of asyncIteratorWithResult(findAuthorPublicKey(author, !!cachedPostResult))) { - if (author.isUnknown) break - if (!_.done) { - yield _.value - continue - } - const result = _.value - if (result === 'out of chance') - return makeError(i18n.t('service_others_key_not_found', { name: author.userId })) - else if (result === 'use cache') - return makeSuccessResult(cachedPostResult!, ['author_key_not_found', 'post_key_cached']) - else authorPersona = result - } - - // ? Get my public & private key. - const queryWhoAmI = () => queryPersonaRecord(whoAmI) - const mine = await queryWhoAmI().then((x) => x || delay(1000).then(queryWhoAmI)) - if (!mine?.privateKey) return makeError(DecryptFailedReason.MyCryptoKeyNotFound) - - const { privateKey: minePrivate } = mine - const minePublic = crypto.subtle.importKey('jwk', mine.publicKey, { name: 'ECDH', namedCurve: 'K-256' }, true, [ - 'deriveKey', - 'deriveBits', - ]) as Promise - if (cachedPostResult) return makeSuccessResult(cachedPostResult, ['post_key_cached']) - - let lastError: unknown - /** - * ? try to decrypt as I am the author - * ? then try to decrypt as whoAmI - * ? then try to go through a normal decrypt process - */ - try { - const a = decryptAsAuthor(author) - const b = decryptAsAuthor(whoAmI) - // ! Don't remove the await - return await a.catch(() => b) - } catch (error) { - lastError = error - } - - if (author.equals(whoAmI)) { - // if the decryption process goes here, - // that means it is failed to decrypt by local identities. - // If remove this if block, Mask will search the key - // for the post even that post by myself. - if (lastError instanceof DOMException) return handleDOMException(lastError) - console.error(lastError) - return makeError(i18n.t('service_self_key_decryption_failed')) - } - - yield makeProgress('finding_post_key') - const aesKeyEncrypted: Array = [] - if (version === -40) { - const result = await GUN_queryPostKey_version40(new Uint8Array(decodeArrayBuffer(iv)), whoAmI.userId) - if (result === null) return makeError(i18n.t('service_not_share_target')) - aesKeyEncrypted.push(result) - } else if (version === -39 || version === -38) { - const collector = GUN_queryPostKey_version39Or38( - version, - new Uint8Array(decodeArrayBuffer(iv)), - await minePublic, - authorNetworkWorker.val.gunNetworkHint, - abortSignalTimeout(2000), - ) - try { - for await (const key of collector) aesKeyEncrypted.push(key) - } catch {} - } - // If we can decrypt with current info, just do it. - try { - // ! Do not remove the await here. - return await decryptWith(aesKeyEncrypted) - } catch (error) { - if (error instanceof Error && error.message === i18n.t('service_not_share_target')) { - console.debug(error) - // TODO: Replace this error with: - // You do not have the necessary private key to decrypt this message. - // What to do next: You can ask your friend to visit your profile page, so that their Mask extension will detect and add you to recipients. - // ? after the auto-share with friends is done. - yield makeError(error) - } else { - return handleDOMException(error) - } - } - - // Failed, we have to wait for the future info from gun. - if (version === -40) return makeError(i18n.t('service_not_share_target')) - const subscription = GUN_queryPostKey_version39Or38( - version, - new Uint8Array(decodeArrayBuffer(iv)), - await minePublic, - authorNetworkWorker.val.gunNetworkHint, - new AbortController().signal, - ) - for await (const aes of subscription) { - console.log('New key received, trying', aes) - try { - return await decryptWith(aes) - } catch (error) { - console.debug(error) - } - } - return makeError(i18n.t('service_not_share_target')) - - function convertKey(key: DecryptStaticECDH_PostKey | DecryptStaticECDH_PostKey[]): Alpha40.PublishedAESKey[] { - if (!Array.isArray(key)) return convertKey([key]) - const result: Alpha40.PublishedAESKey[] = [] - for (const k of key) { - result.push({ - salt: encodeArrayBuffer(k.postKeyIV), - encryptedKey: encodeArrayBuffer(k.encryptedPostKey), - }) - } - return [] - } - async function decryptWith(key: DecryptStaticECDH_PostKey | DecryptStaticECDH_PostKey[]): Promise { - const [contentArrayBuffer, postAESKey] = await cryptoProvider.decryptMessage1ToNByOther({ - version, - AESKeyEncrypted: convertKey(key), - authorsPublicKeyECDH: authorPersona.publicKey, - encryptedContent: encryptedText, - privateKeyECDH: minePrivate!, - iv, - }) - - // Store the key to speed up next time decrypt - setPostCache(postAESKey) - const content = decodeText(contentArrayBuffer) - return makeSuccessResult(content, ['normal_decrypted']) - } - - async function decryptAsAuthor(authorIdentifier: ProfileIdentifier) { - const localKey = sharePublic ? publicSharedAESKey : await queryLocalKey(authorIdentifier) - if (!localKey) throw new Error(`Local key for identity ${authorIdentifier.toText()} not found`) - const [contentArrayBuffer, postAESKey] = await cryptoProvider.decryptMessage1ToNByMyself({ - version, - encryptedAESKey: ownersAESKeyEncrypted, - encryptedContent: encryptedText, - myLocalKey: localKey, - iv, - }) - // Store the key to speed up next time decrypt - setPostCache(postAESKey) - const content = decodeText(contentArrayBuffer) - return makeSuccessResult(content, ['normal_decrypted']) - } - } - return makeError(i18n.t('service_unknown_payload')) -} - -async function* decryptFromImageUrlWithProgress_raw( - url: string, - author: ProfileIdentifier, - authorNetworkHint: string, - whoAmI: ProfileIdentifier, - discoverURL: string | undefined, -): ReturnOfDecryptPostContentWithProgress { - if (successDecryptionCache.has(url)) return successDecryptionCache.get(url)! - yield makeProgress('decode_post', true) - const post = await steganographyDecodeImage(url, { - pass: author.toText(), - downloadImage: steganographyDownloadImage, - }) - if (!post.startsWith('\u{1F3BC}') && !/https:\/\/.+\..+\/(\?PostData_v\d=)?%20(.+)%40/.test(post)) - return makeError(i18n.t('service_decode_image_payload_failed'), true) - const worker = await Result.wrapAsync(() => getNetworkWorker(author)) - if (worker.err) return makeError(worker.val as Error) - const payload = deconstructPayload(post, await decodeTextPayloadWorker(author)) - if (payload.err) return makeError(payload.val) - return yield* decryptFromText(payload.val, author, authorNetworkHint, whoAmI, discoverURL) -} - -export const decryptFromText = memorizeAsyncGenerator( - decryptFromPayloadWithProgress_raw, - (encrypted, author, _, whoAmI) => - JSON.stringify([encrypted.iv, encrypted.encryptedText, author.toText(), whoAmI.toText()]), - 1000 * 30, -) - -export const decryptFromImageUrl = memorizeAsyncGenerator( - decryptFromImageUrlWithProgress_raw, - (url, author, _, whoAmI) => JSON.stringify([url, author.toText(), whoAmI.toText()]), - 1000 * 30, -) - -function handleDOMException(e: unknown) { - if (e instanceof DOMException) { - return makeError(i18n.t('service_decryption_failed')) - } else throw e -} -async function* findAuthorPublicKey( - by: ProfileIdentifier, - hasCache: boolean, - maxIteration = 10, -): AsyncGenerator { - let author = await queryPersonaRecord(by) - let iterations = 0 - while (!author?.publicKey) { - iterations += 1 - if (iterations < maxIteration) yield makeProgress('finding_person_public_key') - else return 'out of chance' as const - - author = await queryPersonaRecord(by).catch(() => null) - - if (!author?.publicKey) { - if (hasCache) return 'use cache' as const - const abort = new AbortController() - const databasePromise = new Promise((resolve, reject) => { - abort.signal.addEventListener('abort', () => { - undo() - reject() - }) - const undo = MaskMessages.events.profilesChanged.on((data) => { - for (const x of data) { - if (x.reason === 'delete') continue - if (x.of.equals(by)) { - undo() - resolve() - break - } - } - }) - }) - await Promise.race([databasePromise]) - .then(() => abort.abort()) - .catch(() => null) - } - } - if (author?.publicKey) return author - return 'out of chance' -} - -async function decryptFromCache(postPayload: Payload, by: ProfileIdentifier, discoverURL: string | undefined) { - const { encryptedText, iv, version } = postPayload - const cryptoProvider = version === -40 ? Alpha40 : Alpha39 - - const postIdentifier = new PostIVIdentifier(by.network, iv) - const cachedKey = await queryPostDB(postIdentifier) - if (cachedKey && !cachedKey.url && discoverURL) { - await updatePostDB({ identifier: postIdentifier, url: discoverURL }, 'append') - } - const setCache = (postAESKey: AESJsonWebKey) => { - const postUpdate: Partial & Pick = { - identifier: postIdentifier, - postCryptoKey: postAESKey, - postBy: by, - } - if (discoverURL) postUpdate.url = discoverURL - updatePostDB(postUpdate, 'append') - } - if (cachedKey?.postCryptoKey) { - try { - const result = decodeText( - await cryptoProvider.decryptWithAES({ - aesKey: cachedKey.postCryptoKey, - encrypted: encryptedText, - iv: iv, - }), - ) - return [result, setCache] as const - } catch {} - } - return [undefined, setCache] as const +export type DecryptionProgress = ( + | { progress: 'finding_person_public_key' | 'finding_post_key' | 'init' | 'decode_post' } + | { progress: 'intermediate_success'; data: SuccessDecryption } + | { progress: 'iv_decrypted'; iv: string } +) & { + type: 'progress' + /** if this is true, this progress should not cause UI change. */ + internal: boolean } diff --git a/packages/mask/src/extension/background-script/CryptoServices/verifyOthersProve.ts b/packages/mask/src/extension/background-script/CryptoServices/verifyOthersProve.ts deleted file mode 100644 index d866d3afdb85..000000000000 --- a/packages/mask/src/extension/background-script/CryptoServices/verifyOthersProve.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type ProfileIdentifier, decompressSecp256k1Key, ECKeyIdentifierFromJsonWebKey } from '@masknet/shared-base' -import { decodePublicKeyWorker } from '../../../social-network/utils/text-payload-worker' -import { queryPersonaRecord } from '../../../database' -import { createProfileWithPersona } from '../../../../background/database/persona/helper' - -export async function verifyOthersProve(bio: string | { raw: string }, others: ProfileIdentifier): Promise { - const compressedX = typeof bio === 'string' ? (await decodePublicKeyWorker(others.network))(bio) : [bio.raw] - if (!compressedX) return false - const publicKey = compressedX - .map((x) => { - try { - return decompressSecp256k1Key(x) - } catch { - return null - } - }) - .filter((x) => x)[0] - if (!publicKey) return false - // TODO: use json schema / other ways to verify the JWK - // or - // throw new Error(i18n.t('service_key_parse_failed')) - // if privateKey, we should possibly not recreate it - const hasPrivate = - (await queryPersonaRecord(ECKeyIdentifierFromJsonWebKey(publicKey)))?.privateKey || - (await queryPersonaRecord(others))?.privateKey - if (!hasPrivate) await createProfileWithPersona(others, { connectionConfirmState: 'pending' }, { publicKey }) - // TODO: unhandled case: if the profile is connected but a different key. - return true -} diff --git a/packages/mask/src/extension/service-generator.ts b/packages/mask/src/extension/service-generator.ts index d49953d0cdd9..6f63d120f84e 100644 --- a/packages/mask/src/extension/service-generator.ts +++ b/packages/mask/src/extension/service-generator.ts @@ -1 +1 @@ -export { decryptFromText, decryptFromImageUrl } from './background-script/CryptoServices/decryptFrom' +export { decryptionWithSocialNetworkDecoding } from '../../background/services/crypto' diff --git a/packages/mask/src/social-network-adaptor/browser-action/index.ts b/packages/mask/src/social-network-adaptor/browser-action/index.ts index e054b838ff65..b02841e2192e 100644 --- a/packages/mask/src/social-network-adaptor/browser-action/index.ts +++ b/packages/mask/src/social-network-adaptor/browser-action/index.ts @@ -1,10 +1,11 @@ import { defineSocialNetworkUI, definedSocialNetworkUIs, SocialNetworkUI, SocialNetwork } from '../../social-network' import { isEnvironment, Environment, ValueRef } from '@dimensiondev/holoflows-kit' +import { SocialNetworkEnum } from '@masknet/encryption' import { IdentifierMap, EnhanceableSite } from '@masknet/shared-base' const base: SocialNetwork.Base = { + encryptionNetwork: SocialNetworkEnum.Unknown, networkIdentifier: EnhanceableSite.Localhost, - name: '', declarativePermissions: { origins: [] }, shouldActivate(location) { return isEnvironment(Environment.ManifestAction) diff --git a/packages/mask/src/social-network-adaptor/facebook.com/base.ts b/packages/mask/src/social-network-adaptor/facebook.com/base.ts index a6d1e834373a..4dc26603c117 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/base.ts +++ b/packages/mask/src/social-network-adaptor/facebook.com/base.ts @@ -1,11 +1,12 @@ +import { SocialNetworkEnum } from '@masknet/encryption' import { EnhanceableSite } from '@masknet/shared-base' import type { SocialNetwork, SocialNetworkWorker } from '../../social-network/types' const origins = ['https://www.facebook.com/*', 'https://m.facebook.com/*', 'https://facebook.com/*'] export const facebookBase: SocialNetwork.Base = { + encryptionNetwork: SocialNetworkEnum.Facebook, networkIdentifier: EnhanceableSite.Facebook, - name: 'facebook', declarativePermissions: { origins }, shouldActivate(location) { return location.hostname.endsWith('facebook.com') diff --git a/packages/mask/src/social-network-adaptor/instagram.com/base.ts b/packages/mask/src/social-network-adaptor/instagram.com/base.ts index 8220422ce186..54bbad8276e2 100644 --- a/packages/mask/src/social-network-adaptor/instagram.com/base.ts +++ b/packages/mask/src/social-network-adaptor/instagram.com/base.ts @@ -1,10 +1,11 @@ +import { SocialNetworkEnum } from '@masknet/encryption' import type { SocialNetwork, SocialNetworkWorker } from '../../social-network/types' const id = 'instagram.com' const origins = ['https://www.instagram.com/*', 'https://m.instagram.com/*', 'https://instagram.com/*'] export const instagramBase: SocialNetwork.Base = { networkIdentifier: id, - name: 'instagram', + encryptionNetwork: SocialNetworkEnum.Instagram, declarativePermissions: { origins }, shouldActivate(location) { return location.host.endsWith(id) diff --git a/packages/mask/src/social-network-adaptor/minds.com/base.ts b/packages/mask/src/social-network-adaptor/minds.com/base.ts index d68eef969776..e827f3b6372b 100644 --- a/packages/mask/src/social-network-adaptor/minds.com/base.ts +++ b/packages/mask/src/social-network-adaptor/minds.com/base.ts @@ -1,10 +1,11 @@ +import { SocialNetworkEnum } from '@masknet/encryption' import type { SocialNetwork, SocialNetworkWorker } from '../../social-network/types' export const MINDS_ID = 'minds.com' const origins = ['https://www.minds.com/*', 'https://minds.com/*', 'https://cdn.minds.com/*'] export const mindsBase: SocialNetwork.Base = { networkIdentifier: MINDS_ID, - name: 'minds', + encryptionNetwork: SocialNetworkEnum.Minds, declarativePermissions: { origins }, shouldActivate(location) { return location.hostname.endsWith('minds.com') diff --git a/packages/mask/src/social-network-adaptor/opensea.io/base.ts b/packages/mask/src/social-network-adaptor/opensea.io/base.ts index 38ea7dd27a48..6757cb5464da 100644 --- a/packages/mask/src/social-network-adaptor/opensea.io/base.ts +++ b/packages/mask/src/social-network-adaptor/opensea.io/base.ts @@ -1,10 +1,11 @@ +import { SocialNetworkEnum } from '@masknet/encryption' import type { SocialNetwork, SocialNetworkWorker } from '../../social-network/types' const OPENSEA_ID = 'opensea.io' const origins = ['https://opensea.io/*'] export const openseaBase: SocialNetwork.Base = { networkIdentifier: OPENSEA_ID, - name: 'opensea', + encryptionNetwork: SocialNetworkEnum.Unknown, declarativePermissions: { origins }, shouldActivate(location) { return location.host.endsWith(OPENSEA_ID) diff --git a/packages/mask/src/social-network-adaptor/twitter.com/base.ts b/packages/mask/src/social-network-adaptor/twitter.com/base.ts index a85a61db85cd..6f6a912ac250 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/base.ts +++ b/packages/mask/src/social-network-adaptor/twitter.com/base.ts @@ -1,10 +1,11 @@ +import { SocialNetworkEnum } from '@masknet/encryption' import type { SocialNetwork, SocialNetworkWorker } from '../../social-network/types' const id = 'twitter.com' const origins = ['https://mobile.twitter.com/*', 'https://twitter.com/*'] export const twitterBase: SocialNetwork.Base = { networkIdentifier: id, - name: 'twitter', + encryptionNetwork: SocialNetworkEnum.Twitter, declarativePermissions: { origins }, shouldActivate(location) { return location.hostname.endsWith('twitter.com') diff --git a/packages/mask/src/social-network-adaptor/twitter.com/shared.ts b/packages/mask/src/social-network-adaptor/twitter.com/shared.ts index 015ae96e54a3..dcb4ef53063f 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/shared.ts +++ b/packages/mask/src/social-network-adaptor/twitter.com/shared.ts @@ -16,14 +16,6 @@ export const twitterShared: SocialNetwork.Shared & SocialNetwork.Base = { getHomePage: () => 'https://twitter.com', getProfilePage: (userId) => `https://twitter.com/${userId}`, isValidUsername: usernameValidator, - publicKeyEncoding: { - encoder(text) { - return twitterEncoding.publicKeyEncoder(text) - }, - decoder(text) { - return twitterEncoding.publicKeyDecoder(text) - }, - }, textPayloadPostProcessor: { encoder(text) { return twitterEncoding.payloadEncoder(text) diff --git a/packages/mask/src/social-network/defaults/inject/CommentBox.tsx b/packages/mask/src/social-network/defaults/inject/CommentBox.tsx index 31e859c05f0e..94a25f51fee7 100644 --- a/packages/mask/src/social-network/defaults/inject/CommentBox.tsx +++ b/packages/mask/src/social-network/defaults/inject/CommentBox.tsx @@ -29,21 +29,20 @@ export const injectCommentBoxDefaultFactory = function ( ) { const CommentBoxUI = memo(function CommentBoxUI({ dom }: { dom: HTMLElement | null }) { const info = usePostInfo() - const payload = usePostInfoDetails.containingMaskPayload() const postContent = usePostInfoDetails.rawMessagePiped() const { classes } = useCustomStyles() const iv = usePostInfoDetails.iv() const props = additionPropsToCommentBox(classes) const onCallback = useCallback( async (content) => { - const postIV = iv || payload.unwrap().iv const decryptedText = extractTextFromTypedMessage(postContent).unwrap() - const encryptedComment = await Services.Crypto.encryptComment(postIV, decryptedText, content) + const encryptedComment = await Services.Crypto.encryptComment(iv!, decryptedText, content) onPasteToCommentBox(encryptedComment, info!, dom).catch(console.error) }, - [payload, postContent, info, dom, iv], + [postContent, info, dom, iv], ) + if (!iv) return null if (!postContent.items.length) return null return }) diff --git a/packages/mask/src/social-network/types.ts b/packages/mask/src/social-network/types.ts index ab4172151655..cd3376887413 100644 --- a/packages/mask/src/social-network/types.ts +++ b/packages/mask/src/social-network/types.ts @@ -1,5 +1,5 @@ import type { ValueRef } from '@dimensiondev/holoflows-kit' -import type { GrayscaleAlgorithm } from '@masknet/encryption' +import type { GrayscaleAlgorithm, SocialNetworkEnum } from '@masknet/encryption' import type { PostInfo } from '@masknet/plugin-infra' import type { Identifier, @@ -37,8 +37,6 @@ export namespace SocialNetwork { getPostURL?(post: PostIdentifier): URL | null /** Is this username valid in this network */ isValidUsername?(username: string): boolean - /** How to encode/decode public keys when it is put in the bio. */ - publicKeyEncoding?: PayloadEncoding /** How to encode/decode text payload (e.g. make it into a link so it will be shortened by SNS). */ textPayloadPostProcessor?: PayloadEncoding /** Given a text, return a URL that will allow user to share this text */ @@ -55,7 +53,7 @@ export namespace SocialNetwork { * !!! THIS SHOULD NOT BE USED TO CONSTRUCT A NEW ProfileIdentifier !!! */ networkIdentifier: string - name: string + encryptionNetwork: SocialNetworkEnum /** * This field _will_ be overwritten by SocialNetworkUI.permissions */ diff --git a/packages/mask/src/social-network/ui.ts b/packages/mask/src/social-network/ui.ts index 3a0bf0fd07de..c03a6135bea9 100644 --- a/packages/mask/src/social-network/ui.ts +++ b/packages/mask/src/social-network/ui.ts @@ -21,6 +21,7 @@ import { createPluginHost } from '../plugin-infra/host' import { definedSocialNetworkUIs } from './define' import { setupShadowRootPortal, MaskMessages } from '../utils' import { delay, waitDocumentReadyState } from '@dimensiondev/kit' +import { SocialNetworkEnum } from '@masknet/encryption' const definedSocialNetworkUIsResolved = new Map() export let activatedSocialNetworkUI: SocialNetworkUI.Definition = { @@ -36,8 +37,8 @@ export let activatedSocialNetworkUI: SocialNetworkUI.Definition = { throw new Error() }, injection: {}, + encryptionNetwork: SocialNetworkEnum.Unknown, networkIdentifier: EnhanceableSite.Localhost, - name: '', shouldActivate: () => false, utils: { createPostContext: null! }, notReadyForProduction: true, diff --git a/packages/mask/src/social-network/utils/create-post-context.ts b/packages/mask/src/social-network/utils/create-post-context.ts index f838e01279c0..8e6561790e5f 100644 --- a/packages/mask/src/social-network/utils/create-post-context.ts +++ b/packages/mask/src/social-network/utils/create-post-context.ts @@ -23,6 +23,7 @@ import { Err, Result } from 'ts-results' import type { Subscription } from 'use-subscription' import { activatedSocialNetworkUI } from '../' import { resolveFacebookLink } from '../../social-network-adaptor/facebook.com/utils/resolveFacebookLink' +import type { SupportedPayloadVersions } from '@masknet/encryption' export function createSNSAdaptorSpecializedPostContext(create: PostContextSNSActions) { return function createPostContext(opt: PostContextCreation): PostContext { @@ -104,8 +105,15 @@ export function createSNSAdaptorSpecializedPostContext(create: PostContextSNSAct return () => void [a(), b()] }, }) + const iv = new ValueRef(null) + const isPublicShared = new ValueRef(undefined) + const ownersAESKeyEncrypted = new ValueRef(undefined) + const version = new ValueRef(undefined) return { - ...author, + author: author.author, + avatarURL: author.avatarURL, + nickname: author.nickname, + snsID: author.snsID, get rootNode() { return opt.rootElement.realCurrent @@ -139,15 +147,16 @@ export function createSNSAdaptorSpecializedPostContext(create: PostContextSNSAct postContent: SubscriptionFromValueRef(postContent), containingMaskPayload: SubscriptionFromValueRef(postPayload), - decryptedPayloadForImage: new ValueRef(null), - iv: new ValueRef(null), - publicShared: debug({ - getCurrentValue: () => - postPayload.value - .map((val) => val.version === -38 && val.sharedPublic) - .unwrapOr(undefined), - subscribe: (sub) => postPayload.addListener(sub), - }), + iv, + publicShared: SubscriptionFromValueRef(isPublicShared), + ownersKeyEncrypted: SubscriptionFromValueRef(ownersAESKeyEncrypted), + version: SubscriptionFromValueRef(version), + decryptedReport(opts) { + if (opts.iv) iv.value = opts.iv + if (opts.sharedPublic?.some) isPublicShared.value = opts.sharedPublic.val + if (opts.ownersAESKeyEncrypted) ownersAESKeyEncrypted.value = opts.ownersAESKeyEncrypted + if (opts.version) version.value = opts.version + }, } } } diff --git a/packages/mask/src/social-network/utils/text-payload-worker.ts b/packages/mask/src/social-network/utils/text-payload-worker.ts index bdf1509e376b..13be0c5a6fd2 100644 --- a/packages/mask/src/social-network/utils/text-payload-worker.ts +++ b/packages/mask/src/social-network/utils/text-payload-worker.ts @@ -1,15 +1,5 @@ import { getNetworkWorker, NetworkWorkerQuery } from '../worker' -export async function decodePublicKeyWorker(query: NetworkWorkerQuery): Promise<(text: string) => string[]> { - const f = (await getNetworkWorker(query)).utils.publicKeyEncoding?.decoder - if (f) return (x) => f(x).concat(x) - return (x) => [x] -} export async function encodeTextPayloadWorker(query: NetworkWorkerQuery): Promise<(text: string) => string> { const f = (await getNetworkWorker(query)).utils.textPayloadPostProcessor?.encoder return (x) => f?.(x) || x } -export async function decodeTextPayloadWorker(query: NetworkWorkerQuery): Promise<(text: string) => string[]> { - const f = (await getNetworkWorker(query)).utils.textPayloadPostProcessor?.decoder - if (f) return (x) => f(x).concat(x) - return (x) => [x] -} diff --git a/packages/mask/src/utils/constants.ts b/packages/mask/src/utils/constants.ts deleted file mode 100644 index 090383f89115..000000000000 --- a/packages/mask/src/utils/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum DecryptFailedReason { - MyCryptoKeyNotFound = 'MyCryptoKeyNotFound', -} diff --git a/packages/mask/src/utils/index.ts b/packages/mask/src/utils/index.ts index 8336323e4d8a..c6a009552f39 100644 --- a/packages/mask/src/utils/index.ts +++ b/packages/mask/src/utils/index.ts @@ -5,7 +5,6 @@ export * from './shadow-root' export * from './suspends' export * from './type-transform' export * from './comparer' -export * from './constants' export * from './dom' export * from './getTextUILength' export * from './i18n-next-ui' diff --git a/packages/mask/src/utils/type-transform/asyncIteratorHelpers.ts b/packages/mask/src/utils/type-transform/asyncIteratorHelpers.ts index 86b302c742f3..4cf584a10402 100644 --- a/packages/mask/src/utils/type-transform/asyncIteratorHelpers.ts +++ b/packages/mask/src/utils/type-transform/asyncIteratorHelpers.ts @@ -1,61 +1,3 @@ -export async function* asyncIteratorWithResult( - iter: AsyncIterator, - callback?: (promise: Promise>) => void, -) { - let yielded: IteratorResult - do { - const p = iter.next() - callback?.(p) - yielded = await p - yield yielded - } while (yielded.done === false) - return -} - -export function memorizeAsyncGenerator( - f: (...args: Args) => AsyncGenerator, - getKey: (...args: Args) => string, - expireAfter: number, -): typeof f { - type Iter = AsyncGenerator - type IterResult = Promise> - const iterCache = new Map() - const progressCache = new WeakMap() - return async function* memorizedAsyncGenerator(...args: Args) { - const key = getKey(...args) - const iter = iterCache.get(key) ?? f(...args) - iterCache.set(key, iter) - const process = progressCache.get(iter) - if (!process) { - setTimeout(() => iterCache.delete(key), expireAfter) - const arr: IterResult[] = [] - progressCache.set(iter, arr) - try { - for await (const _ of asyncIteratorWithResult(iter, (x) => arr.push(x))) { - if (_.done) return _.value - yield _.value - } - } catch { - iterCache.delete(key) - } - } else { - let index = 0 - while (index < process.length) { - const p = await process[index] - if (p.done) return p.value - yield p.value - index += 1 - } - } - // cache not working - for await (const _ of asyncIteratorWithResult(f(...args))) { - if (_.done) return _.value - yield _.value - } - throw new Error('Unreachable') - } -} - export async function asyncIteratorToArray(it: AsyncIterable) { const arr: T[] = [] for await (const x of it) arr.push(x) diff --git a/packages/plugin-infra/package.json b/packages/plugin-infra/package.json index 41ef48903281..f8a6d1040499 100644 --- a/packages/plugin-infra/package.json +++ b/packages/plugin-infra/package.json @@ -7,6 +7,7 @@ "scripts": {}, "dependencies": { "@dimensiondev/holoflows-kit": "^0.9.0-20210902104757-7c3d0d0", + "@masknet/encryption": "workspace:*", "@masknet/shared-base-ui": "workspace:*", "@masknet/theme": "workspace:*", "@masknet/typed-message": "workspace:*", diff --git a/packages/plugin-infra/src/PostContext.ts b/packages/plugin-infra/src/PostContext.ts index 12ed72a3e7de..8e998f29b6d3 100644 --- a/packages/plugin-infra/src/PostContext.ts +++ b/packages/plugin-infra/src/PostContext.ts @@ -8,9 +8,10 @@ import { import { useObservableValues, useValueRef } from '@masknet/shared-base-ui' import type { TypedMessageTuple } from '@masknet/typed-message' import { ValueRef, LiveSelector, DOMProxy } from '@dimensiondev/holoflows-kit' -import type { Result } from 'ts-results' +import type { Result, Some } from 'ts-results' import { createContext, createElement, memo, useContext } from 'react' import { Subscription, useSubscription } from 'use-subscription' +import type { SupportedPayloadVersions } from '@masknet/encryption' export interface PostContextSNSActions { /** Parse payload into Payload */ payloadParser(raw: string): Result @@ -77,14 +78,22 @@ export interface PostContext extends PostContextAuthor { // #endregion // #region Post payload discovered in the rawMessage readonly containingMaskPayload: Subscription> - /** @deprecated Use postPayload instead */ - readonly decryptedPayloadForImage: ValueRef // TODO: should be a Subscription readonly iv: ValueRef /** * undefined => payload not found */ readonly publicShared: Subscription + /** @deprecated */ + readonly ownersKeyEncrypted: Subscription + /** @deprecated */ + readonly version: Subscription + decryptedReport(content: { + sharedPublic?: Some + iv?: string + ownersAESKeyEncrypted?: string + version?: SupportedPayloadVersions + }): void // #endregion } export type PostInfo = PostContext @@ -136,11 +145,3 @@ function isSubscription(x: any): x is Subscription { Boolean((x as Subscription).getCurrentValue && (x as Subscription).subscribe) ) } - -export function usePostInfoSharedPublic(): boolean { - const info = usePostInfoDetails.containingMaskPayload() - if (info.err) return false - const payload = info.val - if (payload.version !== -38) return false - return !!payload.sharedPublic -} diff --git a/packages/plugin-infra/tsconfig.json b/packages/plugin-infra/tsconfig.json index 8f97c17830e7..6f5384220ea4 100644 --- a/packages/plugin-infra/tsconfig.json +++ b/packages/plugin-infra/tsconfig.json @@ -10,7 +10,8 @@ { "path": "../shared-base-ui" }, { "path": "../theme" }, { "path": "../web3-shared/base" }, - { "path": "../typed-message/base" } + { "path": "../typed-message/base" }, + { "path": "../encryption" } ], "ts-node": { "swc": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33edf9626f20..6ccb39f94736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -586,6 +586,7 @@ importers: packages/plugin-infra: specifiers: '@dimensiondev/holoflows-kit': ^0.9.0-20210902104757-7c3d0d0 + '@masknet/encryption': workspace:* '@masknet/shared-base-ui': workspace:* '@masknet/theme': workspace:* '@masknet/typed-message': workspace:* @@ -600,6 +601,7 @@ importers: use-subscription: ^1.5.1 dependencies: '@dimensiondev/holoflows-kit': 0.9.0-20210902104757-7c3d0d0 + '@masknet/encryption': link:../encryption '@masknet/shared-base-ui': link:../shared-base-ui '@masknet/theme': link:../theme '@masknet/typed-message': link:../typed-message