diff --git a/.circleci/config.yml b/.circleci/config.yml index 835d29200f4d..503f06789cfe 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,41 +2,39 @@ # # Check https://circleci.com/docs/2.0/language-javascript/ for more details # -version: 2 +version: 2.1 jobs: build: docker: - # specify the version you desire here - image: circleci/node:11.0 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/mongo:3.4.4 - working_directory: ~/repo - steps: # build holoflows-kit - - run: | - cd .. - git clone -q --depth=1 https://github.com/DimensionFoundation/holoflows-kit + - run: + name: Clone @holoflows/kit + command: | + cd .. + git clone -q --depth=1 https://github.com/project-holoflows/holoflows-kit - restore_cache: keys: - - v1-dependencies-holoflows-{{ checksum "~/holoflows-kit/package.json" }} + - v1-holoflows-{{ checksum "~/holoflows-kit/yarn.lock" }} # fallback to using the latest cache if no exact match is found - - v1-dependencies-holoflows- - - run: | - cd ../holoflows-kit - yarn install + - v1-holoflows- + - run: + name: Install @holoflows/kit + command: | + cd ../holoflows-kit + yarn install --frozen-lockfile - save_cache: paths: - ~/holoflows-kit/node_modules - key: v1-dependencies-holoflows-{{ checksum "~/holoflows-kit/package.json" }} - - run: | - cd ../holoflows-kit - yarn build - yarn link + key: v1-holoflows-{{ checksum "~/holoflows-kit/yarn.lock" }} + - run: + name: Build @holoflows/kit + command: | + cd ../holoflows-kit + yarn build + yarn link # build maskbook - checkout @@ -45,21 +43,61 @@ jobs: - tsbuild-cache - restore_cache: keys: - - v1-dependencies-{{ checksum "package.json" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - run: yarn install + - v1-maskbook-{{ .Branch }}-{{ checksum "yarn.lock" }} + - v1-maskbook-{{ .Branch }}- + - v1-maskbook- + - run: + name: Build Maskbook + command: | + yarn install --frozen-lockfile + yarn link @holoflows/kit + yarn build + sudo apt-get install zip + cd build + zip -r ../Maskbook.zip ./* - save_cache: paths: - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - run: yarn link @holoflows/kit - - run: yarn build - - run: sudo apt-get install zip + key: v1-maskbook-{{ .Branch }}-{{ checksum "yarn.lock" }} - save_cache: paths: - .tscache/ key: tsbuild-cache - - run: zip -r build.zip build/ - store_artifacts: - path: build.zip + path: Maskbook.zip + destination: /Maskbook.zip + - persist_to_workspace: + root: ~/repo/ + paths: + - Maskbook.zip + publish-github-release: + docker: + - image: cibuilds/github:0.10 + steps: + - checkout + - attach_workspace: + at: ~/repo/ + - run: + name: 'Publish Release on GitHub' + command: | + set -o nounset + ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -b "โœ” No breaking changes. / โš  Has breaking changes! + + ๐ŸŽจ UI Improvements + + ๐Ÿ‘ฉโ€๐Ÿ’ป Miscellaneous" -replace -draft -prerelease $(git describe HEAD) ~/repo/Maskbook.zip + # -b BODY \ # Set text describing the contents of the release + # -delete \ # Delete release and its git tag in advance if it exists (same as -recreate) + # -n TITLE \ # Set release title +workflows: + version: 2 + main: + jobs: + - build + - publish-github-release: + requires: + - build + filters: + branches: + only: released +# test diff --git a/README.md b/README.md index e69de29bb2d1..72b97a959e88 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,53 @@ +# Maskbook · ![GitHub license](https://img.shields.io/badge/license-AGPL-blue.svg?style=flat-square) ![Ciecle CI](https://img.shields.io/circleci/project/github/project-maskbook/Maskbook.svg?style=flat-square&logo=circleci) + +[![Join the chat at https://gitter.im/Maskbook/community](https://badges.gitter.im/Maskbook/community.svg)](https://gitter.im/Maskbook/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +Encrypt your posts & chats on You-Know-Where. Allow only your friends to decrypt. + +For general introductions, see https://Maskbook.io/ + +[Install Maskbook](https://maskbook.io/install/) + +## Documentation for developers + +- License: AGPL +- Code Style: Use [prettier](https://github.com/prettier/prettier) +- [Git flow](https://github.com/nvie/gitflow) enabled, `master` as the latest branch, `released` as the stable branch +- UI developing: Use `yarn start` / `npm start` to start a [Storybook](https://storybook.js.org/) +- Extension developing: Use `yarn watch` / `npm run watch` to start watch build for extension +- Crypto: We're using [ECDH SECP256-k1](https://en.wikipedia.org/wiki/ECC) and [AES-GCM](https://en.wikipedia.org/wiki/AES) +- Data transfer between users: We're using [gun.js](https://gun.eco) + +### Prepare + +After `Maskbook` and `@holoflows/kit` gets stable, we will directly add `@holoflows/kit` as a dependency. Currently, you need to install and build the latest version of `@holoflows/kit`. + +#### Install dependencies + +- `yarn install` + +#### Prepare for library @holoflows/kit + +- `cd ..` +- `git clone https://github.com/project-holoflows/Holoflows-kit.git` +- `cd Holoflows-kit` +- `yarn install` +- `yarn build` +- `yarn link` +- `cd ../Maskbook` + +#### Install @holoflows/kit in Maskbook + +- `yarn link @holoflows/kit` + +### Folder Structure + +- ./public - Resource file +- ./src/components - UI Components +- ./src/crypto - Crypto related +- ./src/key-management - How we manage keys and user infos +- ./src/utils - Utils +- ./src/extension +- - ./background-script - Scripts that running in the background page as a service +- - ./content-script - Script that be injected into the web page +- - ./injected-script - Script that will run in the main frame of the injected web page diff --git a/config-overrides.js b/config-overrides.js index b927d467c193..a4de53998de5 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -9,6 +9,7 @@ module.exports = function override(/** @type{import("webpack").Configuration} */ app: path.join(__dirname, './src/index.tsx'), contentscript: path.join(__dirname, './src/content-script.ts'), backgroundservice: path.join(__dirname, './src/background-service.ts'), + injectedscript: path.join(__dirname, './src/extension/injected-script/index.ts'), } config.output.filename = 'static/js/[name].js' config.output.chunkFilename = 'static/js/[name].chunk.js' diff --git a/package.json b/package.json index 74a5cc1fe28f..7cb2afa5dc5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maskbook", - "version": "1.0.0", + "version": "1.1.1", "private": true, "dependencies": { "@material-ui/core": "^3.9.2", diff --git a/public/128x128.png b/public/128x128.png new file mode 100644 index 000000000000..41dea76f4719 Binary files /dev/null and b/public/128x128.png differ diff --git a/public/16x16.png b/public/16x16.png new file mode 100644 index 000000000000..e1b298e1af1d Binary files /dev/null and b/public/16x16.png differ diff --git a/public/256x256.png b/public/256x256.png new file mode 100644 index 000000000000..ae75e5b184e2 Binary files /dev/null and b/public/256x256.png differ diff --git a/public/48x48.png b/public/48x48.png new file mode 100644 index 000000000000..09edf07a5015 Binary files /dev/null and b/public/48x48.png differ diff --git a/public/manifest.json b/public/manifest.json index fee21c37c62b..bd7add21cf05 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/chrome-manifest", "name": "Maskbook", - "version": "1.0", + "version": "1.1.1", "manifest_version": 2, "content_scripts": [ { @@ -9,6 +9,12 @@ "js": ["/static/js/contentscript.js"], "run_at": "document_idle", "all_frames": true + }, + { + "matches": ["https://www.facebook.com/*"], + "js": ["/static/js/injectedscript.js"], + "run_at": "document_start", + "all_frames": true } ], "web_accessible_resources": ["*.css", "*.js", "*.jpg", "*.png"], @@ -22,7 +28,11 @@ "chrome_style": true }, "icons": { - "256": "/maskbook-icon.png", - "16": "/maskbook-icon-padded.png" - } + "16": "/16x16.png", + "48": "/48x48.png", + "128": "/128x128.png", + "256": "/256x256.png" + }, + "homepage_url": "https://maskbook.io", + "description": "Encrypt your posts & chats on You-Know-Where. Allow only your friends to decrypt." } diff --git a/src/components/DataSource/PeopleRef.ts b/src/components/DataSource/PeopleRef.ts index 95dcabd54072..5b442b4bc17d 100644 --- a/src/components/DataSource/PeopleRef.ts +++ b/src/components/DataSource/PeopleRef.ts @@ -5,7 +5,6 @@ import { MessageCenter } from '../../utils/messages' import { PeopleService } from '../../extension/content-script/rpc' const ref = new ValueRef([]) -ref.startWatch() PeopleService.getAllPeople().then(p => (ref.value = p)) MessageCenter.on('newPerson', p => { const old = ref.value.filter(x => x.username !== p.username) @@ -13,10 +12,6 @@ MessageCenter.on('newPerson', p => { }) export function usePeople() { const [people, setPeople] = React.useState([]) - const cb = React.useCallback(() => setPeople(ref.value), [setPeople]) - React.useEffect(() => { - ref.addListener('onChange', cb) - return () => void ref.removeListener('onChange', cb) - }) + React.useEffect(() => ref.addListener(val => setPeople(val))) return people.filter(x => x.username !== '$self') } diff --git a/src/components/InjectedComponents/AdditionalPostBox.tsx b/src/components/InjectedComponents/AdditionalPostBox.tsx index de4189c9ba89..265e3ee0cea1 100644 --- a/src/components/InjectedComponents/AdditionalPostBox.tsx +++ b/src/components/InjectedComponents/AdditionalPostBox.tsx @@ -11,18 +11,23 @@ import Button from '@material-ui/core/Button/Button' import { withStylesTyped, MaskbookLightTheme } from '../../utils/theme' import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider' import { useAsync } from '../../utils/AsyncComponent' -import { CryptoService } from '../../extension/content-script/rpc' +import { CryptoService, PeopleService } from '../../extension/content-script/rpc' import { Person } from '../../extension/background-script/PeopleService' import { usePeople } from '../DataSource/PeopleRef' import { SelectPeopleUI } from './SelectPeople' +import { CustomPasteEventId } from '../../utils/Names' +import { sleep } from '../../utils/utils' +import { myUsername, getUsername } from '../../extension/content-script/injections/LiveSelectors' interface Props { avatar?: string - encrypted: string + nickname?: string + username?: string onCombinationChange(people: Person[], text: string): void + onRequestPost(): void } const _AdditionalPostBox = withStylesTyped({ - root: { maxWidth: 500, marginBottom: 10 }, + root: { margin: '10px 0' }, paper: { borderRadius: 0, display: 'flex' }, avatar: { margin: '12px 0 0 12px' }, input: { @@ -31,15 +36,16 @@ const _AdditionalPostBox = withStylesTyped({ padding: 12, boxSizing: 'border-box', }, + innerInput: { + minHeight: '3em', + }, // todo: theme grayArea: { background: '#f5f6f7', padding: 8, wordBreak: 'break-all' }, - typo: { lineHeight: '28.5px' }, - button: { padding: '2px 30px' }, + button: { padding: '2px 30px', flex: 1 }, })(props => { const { classes } = props const [text, setText] = React.useState('') const [selectedPeople, selectPeople] = React.useState([]) - const encrypted = `Decrypt this post with ${props.encrypted}` const people = usePeople() return ( @@ -57,7 +63,7 @@ const _AdditionalPostBox = withStylesTyped({ undefined )} { setText(e.currentTarget.value) @@ -65,12 +71,14 @@ const _AdditionalPostBox = withStylesTyped({ }} fullWidth multiline - placeholder="What's your mind? Encrypt with Maskbook" + placeholder={`${ + props.nickname ? `Hey ${props.nickname}, w` : 'W' + }hat's your mind? Encrypt with Maskbook`} /> x.username !== props.username)} onSetSelected={p => { selectPeople(p) props.onCombinationChange(p, text) @@ -79,22 +87,15 @@ const _AdditionalPostBox = withStylesTyped({ /> - - Encrypted Text Preview - - - - {encrypted} - ) }) @@ -105,18 +106,46 @@ export function AdditionalPostBoxUI(props: Props) { ) } +function selectElementContents(el: Node) { + const range = document.createRange() + range.selectNodeContents(el) + const sel = window.getSelection()! + sel.removeAllRanges() + sel.addRange(range) +} export function AdditionalPostBox() { const [text, setText] = React.useState('') const [people, setPeople] = React.useState([]) - const [encrypted, setEncrypted] = React.useState('') - useAsync(() => CryptoService.encryptTo(text, people), [text, people]).then(setEncrypted) + const [avatar, setAvatar] = React.useState('') + let nickname + { + const link = myUsername.evaluateOnce()[0] + if (link) nickname = link.innerText + } + const username = getUsername() + useAsync(() => PeopleService.queryAvatar(username || ''), []).then(setAvatar) return ( { + const [encrypted, token] = await CryptoService.encryptTo(text, people) + const fullPost = 'Decrypt this post with ' + encrypted + const element = document.querySelector('.notranslate')! + element.focus() + await sleep(100) + selectElementContents(element) + await sleep(100) + document.dispatchEvent(new CustomEvent(CustomPasteEventId, { detail: fullPost })) + navigator.clipboard.writeText(fullPost) + // Prevent Custom Paste failed, this will cause service not available to user. + CryptoService.publishPostAESKey(token) + }} onCombinationChange={(p, t) => { - if (p !== people) setPeople(p) - if (t !== text) setText(t) + setPeople(p) + setText(t) }} /> ) diff --git a/src/components/InjectedComponents/AdditionalPostContent.tsx b/src/components/InjectedComponents/AdditionalPostContent.tsx index f7d77b889988..be90adb6867f 100644 --- a/src/components/InjectedComponents/AdditionalPostContent.tsx +++ b/src/components/InjectedComponents/AdditionalPostContent.tsx @@ -11,7 +11,7 @@ interface Props { const _AdditionalContent = withStylesTyped({})(props => { return ( <> - + (props => { {props.children} + ) }) diff --git a/src/components/InjectedComponents/DecryptedPost.tsx b/src/components/InjectedComponents/DecryptedPost.tsx index aba6acf9900a..53a93443ccb3 100644 --- a/src/components/InjectedComponents/DecryptedPost.tsx +++ b/src/components/InjectedComponents/DecryptedPost.tsx @@ -14,8 +14,8 @@ export function DecryptPost({ postBy, whoAmI, encryptedText }: Props) { const [b, _2] = a.split(':||') return ( CryptoService.decryptFrom(b, postBy, whoAmI)} - values={[encryptedText]} + promise={async (encryptedString: string) => CryptoService.decryptFrom(encryptedString, postBy, whoAmI)} + values={[b]} awaitingComponent={DecryptPostAwaiting} completeComponent={DecryptPostSuccess} failedComponent={DecryptPostFailed} @@ -27,7 +27,7 @@ function DecryptPostSuccess({ data }: { data: { signatureVerifyResult: boolean; - Decrypted with Maskbook: + Maskbook decrypted content: {data.signatureVerifyResult ? ( Signature verified โœ” ) : ( @@ -46,10 +46,10 @@ function DecryptPostSuccess({ data }: { data: { signatureVerifyResult: boolean; ) } -const DecryptPostAwaiting = +const DecryptPostAwaiting = function DecryptPostFailed({ error }: { error: Error }) { return ( - + {(e => { if (e.match('DOMException')) return 'Maybe this post is not sent to you.' return e diff --git a/src/components/InjectedComponents/SelectPeople.tsx b/src/components/InjectedComponents/SelectPeople.tsx index acb70b1a02db..e8dd55dea003 100644 --- a/src/components/InjectedComponents/SelectPeople.tsx +++ b/src/components/InjectedComponents/SelectPeople.tsx @@ -78,7 +78,7 @@ export const SelectPeopleUI = withStylesTyped({ )} diff --git a/src/components/Welcomes/0.tsx b/src/components/Welcomes/0.tsx index 1ca9a484ccd4..cd105821ad21 100644 --- a/src/components/Welcomes/0.tsx +++ b/src/components/Welcomes/0.tsx @@ -27,7 +27,8 @@ export default withStylesTyped((theme: Theme) => createStyles({ paper: { paddingBottom: '1rem', - maxWidth: '35rem', + width: 600, + boxSizing: 'border-box', '& article': { padding: '0 3rem', textAlign: 'center', diff --git a/src/components/Welcomes/1a1.tsx b/src/components/Welcomes/1a1.tsx index 94d510138c57..a848e25e2cbb 100644 --- a/src/components/Welcomes/1a1.tsx +++ b/src/components/Welcomes/1a1.tsx @@ -14,7 +14,8 @@ export default withStylesTyped((theme: Theme) => paper: { padding: '2rem 4rem 1rem 4rem', textAlign: 'center', - maxWidth: '25rem', + width: 600, + boxSizing: 'border-box', '& > *': { marginBottom: theme.spacing.unit * 3, }, diff --git a/src/components/Welcomes/1a2.tsx b/src/components/Welcomes/1a2.tsx index 21c6f2e3e4f9..9f26788ed7fb 100644 --- a/src/components/Welcomes/1a2.tsx +++ b/src/components/Welcomes/1a2.tsx @@ -15,7 +15,8 @@ export default withStylesTyped((theme: Theme) => paper: { padding: '2rem 1rem 1rem 1rem', textAlign: 'center', - maxWidth: '35rem', + width: 600, + boxSizing: 'border-box', '& > *': { marginBottom: theme.spacing.unit * 3, }, diff --git a/src/components/Welcomes/1a3.tsx b/src/components/Welcomes/1a3.tsx index 30710a97aecf..718254b57f72 100644 --- a/src/components/Welcomes/1a3.tsx +++ b/src/components/Welcomes/1a3.tsx @@ -15,7 +15,8 @@ export default withStylesTyped((theme: Theme) => paper: { padding: '2rem 1rem 1rem 1rem', textAlign: 'center', - maxWidth: '35rem', + width: 600, + boxSizing: 'border-box', '& > *': { marginBottom: theme.spacing.unit * 3, }, diff --git a/src/components/Welcomes/1a4.tsx b/src/components/Welcomes/1a4.tsx index dc6e95741de3..448427c8e6e6 100644 --- a/src/components/Welcomes/1a4.tsx +++ b/src/components/Welcomes/1a4.tsx @@ -6,23 +6,27 @@ import { Theme } from '@material-ui/core/styles/createMuiTheme' import createStyles from '@material-ui/core/styles/createStyles' import Button from '@material-ui/core/Button/Button' import { createBox } from '../../utils/Flex' -import Checkbox from '@material-ui/core/Checkbox' -import FormControlLabel from '@material-ui/core/FormControlLabel/FormControlLabel' -const TextField = createBox(theme => ({ - background: theme.palette.background.default, - color: theme.palette.text.hint, - padding: `${theme.spacing.unit * 2}px`, - border: `1px solid ${theme.palette.divider}`, - textAlign: 'start', - whiteSpace: 'pre-line', - minHeight: '10em', - borderRadius: theme.shape.borderRadius, - fontSize: '1.15rem', - wordBreak: 'break-all', -})) +const TextField = createBox( + theme => ({ + background: theme.palette.background.default, + color: theme.palette.text.hint, + padding: `${theme.spacing.unit * 2}px`, + border: `1px solid ${theme.palette.divider}`, + textAlign: 'start', + whiteSpace: 'pre-line', + borderRadius: theme.shape.borderRadius, + fontSize: '1.15rem', + wordBreak: 'break-all', + display: 'block', + resize: 'none', + width: '100%', + boxSizing: 'border-box', + }), + 'textarea', +) interface Props { - copyToClipboard(text: string): void + copyToClipboard(text: string, gotoBio: boolean): void provePost: string } export default withStylesTyped((theme: Theme) => @@ -30,7 +34,8 @@ export default withStylesTyped((theme: Theme) => paper: { padding: '2rem 2rem 1rem 2rem', textAlign: 'center', - maxWidth: '35rem', + width: 600, + boxSizing: 'border-box', '& > *': { marginBottom: theme.spacing.unit * 3, }, @@ -40,30 +45,50 @@ export default withStylesTyped((theme: Theme) => }, }), )(function Welcome({ classes, copyToClipboard, provePost }) { - const full = `I'm using Maskbook to encrypt my posts to prevent Facebook from peeping into them. -Install Maskbook as well so that you may read my encrypted posts, -and may prevent Facebook from intercepting our communication. -Here is my public key ${provePost}` + const full = `I'm using https://maskbook.io/ to encrypt my posts to prevent Facebook from peeping into them. +Install Maskbook as well, so that you may read my encrypted posts, and prevent Facebook from imposing surveillance on our communication. +Privacy, enforced. +${provePost}` const [showShort, setShort] = React.useState(false) + const ref = React.createRef() + function onFocus() { + setTimeout(() => { + if (!ref.current) return + ref.current.select() + }, 20) + } + const onBlur = React.useCallback(() => { + const selection = getSelection() + if (!selection) return + selection.removeAllRanges() + }, []) return ( Let your friends join Maskbook - {showShort ? provePost : full} + - Mathematically, you have to {!showShort ? 'post this' : 'add it to your bio'}. Or your friends cannot - verify the connection between your keypair and your account. + {showShort + ? 'Paste this into your profile bio, then your friends can verify the connection between your Maskbook and your Facebook account.' + : 'Avoid any confusion before your first encrypted post.'} - setShort(e.currentTarget.checked)} checked={showShort} />} - label="No, I don't want to create a post." - /> -
+
+
) diff --git a/src/components/Welcomes/1b1.tsx b/src/components/Welcomes/1b1.tsx index 90c73664c43e..0457e3d3f3b8 100644 --- a/src/components/Welcomes/1b1.tsx +++ b/src/components/Welcomes/1b1.tsx @@ -8,6 +8,7 @@ import Button from '@material-ui/core/Button/Button' import { createBox } from '../../utils/Flex' import ArrowBack from '@material-ui/icons/ArrowBack' +import { useDragAndDrop } from '../../utils/useDragAndDrop' const RestoreBox = createBox(theme => ({ color: theme.palette.text.hint, @@ -22,6 +23,7 @@ const RestoreBox = createBox(theme => ({ textAlign: 'center', cursor: 'pointer', padding: theme.spacing.unit * 4, + transition: '0.4s', })) interface Props { back(): void @@ -30,7 +32,8 @@ interface Props { export default withStylesTyped((theme: Theme) => createStyles({ paper: { - maxWidth: '35rem', + width: 600, + boxSizing: 'border-box', }, nav: { paddingTop: theme.spacing.unit, @@ -55,9 +58,9 @@ export default withStylesTyped((theme: Theme) => }), )(function Welcome({ classes, back, restore }) { const ref = React.useRef(null) - const [[name, blob], setJSON] = React.useState<[string, File]>(['', null as any]) + const { dragEvents, fileReceiver, fileRef, dragStatus } = useDragAndDrop() return ( - +
- Restore Keypairs + Restore your keypair
setJSON(getBlob(e))} + onChange={fileReceiver} /> - ref.current && ref.current.click()}> - {!name ? 'Select exported keystore file' : `Selected exported keystore file ${name}`} + ref.current && ref.current.click()}> + {dragStatus === 'drag-enter' + ? 'Drag your key backup into this dialog' + : fileRef.current + ? `Selected exported key backup: ${fileRef.current.name}` + : 'Select your exported key backup'} + + + + + + ) +}) + +export function Banner(props: Props) { + return ( + + <_Banner {...props} /> + + ) +} diff --git a/src/crypto/crypto-alpha-41.ts b/src/crypto/crypto-alpha-40.ts similarity index 91% rename from src/crypto/crypto-alpha-41.ts rename to src/crypto/crypto-alpha-40.ts index c6ff29e937e3..c9df70ecc46b 100644 --- a/src/crypto/crypto-alpha-41.ts +++ b/src/crypto/crypto-alpha-40.ts @@ -23,7 +23,7 @@ async function deriveAESKey( const derivedKey = await crypto.subtle.deriveKey( { name: 'ECDH', public: op }, pr, - { name: 'AES-CBC', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], ) @@ -46,7 +46,7 @@ async function deriveAESKey( // tslint:disable-next-line: no-bitwise iv[i] = iv_pre[i] ^ iv_pre[16 + i] } - const key = await crypto.subtle.importKey('raw', password, { name: 'AES-CBC', length: 256 }, true, [ + const key = await crypto.subtle.importKey('raw', password, { name: 'AES-GCM', length: 256 }, true, [ 'encrypt', 'decrypt', ]) @@ -58,7 +58,7 @@ async function deriveAESKey( * Encrypt 1 to 1 */ export async function encrypt1To1(info: { - version: -41 + version: -40 /** Message that you want to encrypt */ content: string | ArrayBuffer /** Your private key */ @@ -66,7 +66,7 @@ export async function encrypt1To1(info: { /** Other's public key */ othersPublicKeyECDH: CryptoKey }): Promise<{ - version: -41 + version: -40 encryptedContent: ArrayBuffer salt: ArrayBuffer }> { @@ -75,14 +75,14 @@ export async function encrypt1To1(info: { if (typeof content === 'string') content = encodeText(content) const { iv, key, salt } = await deriveAESKey(privateKeyECDH, othersPublicKeyECDH) - const encryptedContent = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, content) - return { salt, encryptedContent, version: -41 } + const encryptedContent = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, content) + return { salt, encryptedContent, version: -40 } } /** * Encrypt 1 to N */ export async function encrypt1ToN(info: { - version: -41 + version: -40 /** Message to encrypt */ content: string | ArrayBuffer /** Your private key */ @@ -94,7 +94,7 @@ export async function encrypt1ToN(info: { /** iv */ iv: ArrayBuffer }): Promise<{ - version: -41 + version: -40 encryptedContent: ArrayBuffer iv: ArrayBuffer /** Your encrypted post aes key. Should be attached in the post. */ @@ -106,9 +106,9 @@ export async function encrypt1ToN(info: { }[] }> { const { version, content, othersPublicKeyECDH, privateKeyECDH, ownersLocalKey, iv } = info - const AESKey = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']) + const AESKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) const encryptedContent = await crypto.subtle.encrypt( - { name: 'AES-CBC', iv }, + { name: 'AES-GCM', iv }, AESKey, typeof content === 'string' ? encodeText(content) : content, ) @@ -127,7 +127,7 @@ export async function encrypt1ToN(info: { }> >(async ({ key, name }) => { const encrypted = await encrypt1To1({ - version: -41, + version: -40, content: exportedAESKey, othersPublicKeyECDH: key, privateKeyECDH: privateKeyECDH, @@ -135,7 +135,7 @@ export async function encrypt1ToN(info: { return { name, key: { - version: -41, + version: -40, salt: encodeArrayBuffer(encrypted.salt), encryptedKey: encodeArrayBuffer(encrypted.encryptedContent), }, @@ -143,7 +143,7 @@ export async function encrypt1ToN(info: { }), ) - return { encryptedContent, iv, version: -41, ownersAESKeyEncrypted, othersAESKeyEncrypted } + return { encryptedContent, iv, version: -40, ownersAESKeyEncrypted, othersAESKeyEncrypted } } //#endregion //#region decrypt text @@ -151,7 +151,7 @@ export async function encrypt1ToN(info: { * Decrypt 1 to 1 */ export async function decryptMessage1To1(info: { - version: -41 + version: -40 encryptedContent: string | ArrayBuffer salt: string | ArrayBuffer /** Your private key */ @@ -164,13 +164,13 @@ export async function decryptMessage1To1(info: { const encrypted = typeof encryptedContent === 'string' ? decodeArrayBuffer(encryptedContent) : encryptedContent const { iv, key } = await deriveAESKey(privateKeyECDH, anotherPublicKeyECDH, salt) - return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, encrypted) + return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted) } /** * Decrypt 1 to N message that send by other */ export async function decryptMessage1ToNByOther(info: { - version: -41 + version: -40 encryptedContent: string | ArrayBuffer privateKeyECDH: CryptoKey authorsPublicKeyECDH: CryptoKey @@ -180,7 +180,7 @@ export async function decryptMessage1ToNByOther(info: { const { AESKeyEncrypted, version, encryptedContent, privateKeyECDH, authorsPublicKeyECDH, iv } = info const aesKeyJWK = decodeText( await decryptMessage1To1({ - version: -41, + version: -40, salt: AESKeyEncrypted.salt, encryptedContent: AESKeyEncrypted.encryptedKey, anotherPublicKeyECDH: authorsPublicKeyECDH, @@ -190,7 +190,7 @@ export async function decryptMessage1ToNByOther(info: { const aesKey = await crypto.subtle.importKey( 'jwk', JSON.parse(aesKeyJWK), - { name: 'AES-CBC', length: 256 }, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'], ) @@ -200,7 +200,7 @@ export async function decryptMessage1ToNByOther(info: { * Decrypt 1 to N message that send by myself */ export async function decryptMessage1ToNByMyself(info: { - version: -41 + version: -40 encryptedContent: string | ArrayBuffer /** This should be included in the message */ encryptedAESKey: string | ArrayBuffer @@ -218,7 +218,7 @@ export async function decryptMessage1ToNByMyself(info: { const decryptedAESKey = await crypto.subtle.importKey( 'jwk', decryptedAESKeyJWK, - { name: 'AES-CBC', length: 256 }, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'], ) @@ -236,7 +236,7 @@ export async function decryptWithAES(info: { const { aesKey } = info const iv = typeof info.iv === 'string' ? decodeArrayBuffer(info.iv) : info.iv const encrypted = typeof info.encrypted === 'string' ? decodeArrayBuffer(info.encrypted) : info.encrypted - return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aesKey, encrypted) + return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, encrypted) } export async function encryptWithAES(info: { content: string | ArrayBuffer @@ -246,7 +246,7 @@ export async function encryptWithAES(info: { const iv = info.iv ? info.iv : crypto.getRandomValues(new Uint8Array(16)) const content = typeof info.content === 'string' ? encodeText(info.content) : info.content - const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, info.aesKey, content) + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, info.aesKey, content) return { content: encrypted, iv } } //#endregion diff --git a/src/extension/background-script/BackgroundService.ts b/src/extension/background-script/BackgroundService.ts index 4851367df722..5a8b0ea0fbfe 100644 --- a/src/extension/background-script/BackgroundService.ts +++ b/src/extension/background-script/BackgroundService.ts @@ -1,11 +1,14 @@ -import { AsyncCall, MessageCenter, OnlyRunInContext } from '@holoflows/kit' +import { AsyncCall, OnlyRunInContext } from '@holoflows/kit' import { CryptoKeyRecord, getMyPrivateKey, toStoreCryptoKey } from '../../key-management/keystore-db' import { encodeText } from '../../utils/EncodeDecode' import { BackgroundName } from '../../utils/Names' import { getMyLocalKey } from '../../key-management/local-db' +import { sleep } from '../../utils/utils' OnlyRunInContext('background', 'BackgroundService') async function backupMyKeyPair() { + // Don't make the download pop so fast + await sleep(1000) const key = await getMyPrivateKey() const localKey = await crypto.subtle.exportKey('jwk', (await getMyLocalKey()).key) if (!key) throw new TypeError('You have no private key yet') @@ -15,7 +18,10 @@ async function backupMyKeyPair() { const blob = new Blob([buffer], { type: 'application/json' }) const url = URL.createObjectURL(blob) const date = new Date() - const today = date.getFullYear() + '' + (date.getMonth() + 1) + '' + date.getDate() + const today = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date + .getDate() + .toString() + .padStart(2, '0')}` chrome.downloads.download( { url, filename: `maskbook-keystore-backup-${today}.json`, conflictAction: 'prompt', saveAs: true }, downloadId => {}, @@ -30,4 +36,4 @@ const Impl = { } Object.assign(window, { backgroundService: Impl }) export type Background = typeof Impl -AsyncCall(Impl, { key: BackgroundName }) +AsyncCall(Impl, { key: BackgroundName }) diff --git a/src/extension/background-script/CryptoService.ts b/src/extension/background-script/CryptoService.ts index 855b324b349f..01c1d06a30f9 100644 --- a/src/extension/background-script/CryptoService.ts +++ b/src/extension/background-script/CryptoService.ts @@ -1,13 +1,12 @@ import { queryPersonCryptoKey, getMyPrivateKey, storeKey, generateNewKey } from '../../key-management/keystore-db' -import * as Alpha41 from '../../crypto/crypto-alpha-41' +import * as Alpha40 from '../../crypto/crypto-alpha-40' import { AsyncCall, MessageCenter, OnlyRunInContext } from '@holoflows/kit/es' import { CryptoName } from '../../utils/Names' import { addPersonPublicKey } from '../../key-management/people-gun' import { Person } from './PeopleService' import { getMyLocalKey } from '../../key-management/local-db' -import { publishPostAESKey, queryPostAESKey } from '../../key-management/posts-gun' +import { publishPostAESKey as publishPostAESKey_Service, queryPostAESKey } from '../../key-management/posts-gun' -import { debounce } from 'lodash-es' import { decodeText, encodeArrayBuffer, @@ -17,23 +16,24 @@ import { } from '../../utils/EncodeDecode' OnlyRunInContext('background', 'EncryptService') -const publishPostAESKeyDebounce = debounce(publishPostAESKey, 2000, { trailing: true }) +// v40: ๐ŸŽผ2/4|ownersAESKeyEncrypted|iv|encryptedText|signature:|| +//#region Encrypt & Decrypt +type EncryptedText = string +type OthersAESKeyEncryptedToken = string /** - * ! Remember to call requestRegenerateIV ! + * This map stores . */ -let lastiv = crypto.getRandomValues(new Uint8Array(16)) -async function requestRegenerateIV() { - lastiv = crypto.getRandomValues(new Uint8Array(16)) -} -// v41: ๐ŸŽผ1/4|ownersAESKeyEncrypted|iv|encryptedText|signature:|| -//#region Encrypt & Decrypt +const OthersAESKeyEncryptedMap = new Map>() /** * Encrypt to a user * @param content Original text * @param to Encrypt target + * @returns Will return a tuple of [encrypted: string, token: string] where + * - `encrypted` is the encrypted string + * - `token` is used to call `publishPostAESKey` before post the content */ -async function encryptTo(content: string, to: Person[]) { - if (to.length === 0) return '' +async function encryptTo(content: string, to: Person[]): Promise<[EncryptedText, OthersAESKeyEncryptedToken]> { + if (to.length === 0) return ['', ''] const toKey = (await Promise.all( to.map(async person => ({ name: person.username, key: await queryPersonCryptoKey(person.username) })), )).map(person => ({ name: person.name, key: (person.key === null ? null : person.key.key.publicKey)! })) @@ -49,27 +49,34 @@ async function encryptTo(content: string, to: Person[]) { othersAESKeyEncrypted, ownersAESKeyEncrypted, iv, - } = await Alpha41.encrypt1ToN({ - version: -41, + } = await Alpha40.encrypt1ToN({ + version: -40, content: content, othersPublicKeyECDH: toKey, ownersLocalKey: mineLocal.key, privateKeyECDH: mine!.key.privateKey, - iv: lastiv, + iv: crypto.getRandomValues(new Uint8Array(16)), }) - const str = `1/4|${encodeArrayBuffer(ownersAESKeyEncrypted)}|${encodeArrayBuffer(iv)}|${encodeArrayBuffer( + const str = `2/4|${encodeArrayBuffer(ownersAESKeyEncrypted)}|${encodeArrayBuffer(iv)}|${encodeArrayBuffer( encryptedText, )}` - const signature = encodeArrayBuffer(await Alpha41.sign(str, mine!.key.privateKey)) - { - // Store AES key to gun - const stored: Record = {} - for (const k of othersAESKeyEncrypted) { - stored[k.name] = k.key - } - publishPostAESKeyDebounce(encodeArrayBuffer(iv), stored) + const signature = encodeArrayBuffer(await Alpha40.sign(str, mine!.key.privateKey)) + // Store AES key to gun + const stored: Record = {} + for (const k of othersAESKeyEncrypted) { + stored[k.name] = k.key } - return `Maskbook.io:๐ŸŽผ${str}|${signature}:||` + const key = encodeArrayBuffer(iv) + OthersAESKeyEncryptedMap.set(key, stored) + return [`https://Maskbook.io : ๐ŸŽผ${str}|${signature}:||`, key] +} +/** + * MUST call before send post, or othersAESKeyEncrypted will not be published to the internet! + * @param token Token that returns in the encryptTo + */ +async function publishPostAESKey(token: string) { + if (!OthersAESKeyEncryptedMap.has(token)) throw new Error('Publish AES key failed!') + return publishPostAESKey_Service(token, OthersAESKeyEncryptedMap.get(token)!) } /** @@ -86,8 +93,11 @@ async function decryptFrom( const [version, ownersAESKeyEncrypted, salt, encryptedText, signature] = encrypted.split('|') if (!version || !ownersAESKeyEncrypted || !salt || !encryptedText || !signature) throw new TypeError('This post is not complete, you need to view the full post.') - // 1/4 === version 41 - if (version !== '1/4') throw new TypeError('Unknown post type') + // 1/4 === version 41, has dropped. + // 2/4 === version 40 + if (version === '1/4') + throw new TypeError('We have dropped support for preview version ๐ŸŽผ1/4. Tell your friend to update Maskbook!') + if (version !== '2/4') throw new TypeError('Unknown post version, maybe you should update Maskbook?') if (!ownersAESKeyEncrypted || !salt || !encryptedText || !signature) throw new TypeError('Invalid post') async function getKey(name: string) { let key = await queryPersonCryptoKey(by) @@ -101,8 +111,8 @@ async function decryptFrom( const unverified = [version, ownersAESKeyEncrypted, salt, encryptedText].join('|') if (by === whoAmI) { const content = decodeText( - await Alpha41.decryptMessage1ToNByMyself({ - version: -41, + await Alpha40.decryptMessage1ToNByMyself({ + version: -40, encryptedAESKey: ownersAESKeyEncrypted, encryptedContent: encryptedText, myLocalKey: (await getMyLocalKey()).key, @@ -110,21 +120,25 @@ async function decryptFrom( }), ) try { - const signatureVerifyResult = await Alpha41.verify(unverified, signature, mine.key.publicKey) + const signatureVerifyResult = await Alpha40.verify(unverified, signature, mine.key.publicKey) return { signatureVerifyResult, content } } catch { return { signatureVerifyResult: false, content } } } else { const aesKeyEncrypted = await queryPostAESKey(salt, whoAmI) + // 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 Maskbook extension will detect and add you to recipients. + // ? after the auto-share with friends is done. if (aesKeyEncrypted === undefined) { throw new Error( 'Maskbook does not find the key used to decrypt this post. Maybe this post is not intended to share with you?', ) } const content = decodeText( - await Alpha41.decryptMessage1ToNByOther({ - version: -41, + await Alpha40.decryptMessage1ToNByOther({ + version: -40, AESKeyEncrypted: aesKeyEncrypted, authorsPublicKeyECDH: byKey.key.publicKey, encryptedContent: encryptedText, @@ -133,7 +147,7 @@ async function decryptFrom( }), ) try { - const signatureVerifyResult = await Alpha41.verify(unverified, signature, byKey.key.publicKey) + const signatureVerifyResult = await Alpha40.verify(unverified, signature, byKey.key.publicKey) return { signatureVerifyResult, content } } catch { return { signatureVerifyResult: false, content } @@ -184,8 +198,8 @@ const Impl = { decryptFrom, getMyProveBio, verifyOthersProve, - requestRegenerateIV, + publishPostAESKey, } -Object.assign(window, { encryptService: Impl, crypto41: Alpha41 }) +Object.assign(window, { encryptService: Impl, crypto40: Alpha40 }) export type Encrypt = typeof Impl -AsyncCall(Impl, { key: CryptoName }) +AsyncCall(Impl, { key: CryptoName }) diff --git a/src/extension/background-script/PeopleService.ts b/src/extension/background-script/PeopleService.ts index 12081cf35c37..ea9de8ff3e5b 100644 --- a/src/extension/background-script/PeopleService.ts +++ b/src/extension/background-script/PeopleService.ts @@ -38,7 +38,7 @@ async function storeKeyService(key: { key: CryptoKeyRecord; local: JsonWebKey }) const k = await toReadCryptoKey(key.key) const a = storeKey(k) const b = storeLocalKey( - await crypto.subtle.importKey('jwk', key.local, { name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']), + await crypto.subtle.importKey('jwk', key.local, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']), ) await a await b @@ -49,9 +49,10 @@ const Impl = { getAllPeople, uploadProvePostUrl, storeAvatar, + queryAvatar, getMyPrivateKey, storeKey: storeKeyService, } Object.assign(window, { friendService: Impl }) export type PeopleService = typeof Impl -AsyncCall(Impl, { key: FriendServiceName }) +AsyncCall(Impl, { key: FriendServiceName }) diff --git a/src/extension/content-script/index.tsx b/src/extension/content-script/index.tsx index aea03d0c6eb6..069c662c09d8 100644 --- a/src/extension/content-script/index.tsx +++ b/src/extension/content-script/index.tsx @@ -3,3 +3,6 @@ import './injections/PostBox' // ? Inject postbox import './injections/Posts' // ? Inject all posts import './injections/ProfilePage' // ? Inject to ProfilePage import './tasks' // ? AutomatedTabTask Run tasks when invoked by background page + +import * as hk from '@holoflows/kit' +Object.assign(window, hk) diff --git a/src/extension/content-script/injections/LiveSelectors.ts b/src/extension/content-script/injections/LiveSelectors.ts new file mode 100644 index 000000000000..73b32c3b17ec --- /dev/null +++ b/src/extension/content-script/injections/LiveSelectors.ts @@ -0,0 +1,15 @@ +import { LiveSelector } from '@holoflows/kit/es/DOM/LiveSelector' + +export const myUsername = new LiveSelector().querySelector( + `[aria-label="Facebook"][role="navigation"] [data-click="profile_icon"] a`, +) +export function getUsername(link?: HTMLAnchorElement | null) { + // tslint:disable-next-line: no-parameter-reassignment + if (link === undefined) link = myUsername.evaluateOnce()[0] + if (link === null) return undefined + const url = link.href + const after = url.split('https://www.facebook.com/')[1] + if (!after) return undefined + if (after.match('profile.php')) return after.match(/id=(?\d+)/)!.groups!.id + else return after.split('?')[0] +} diff --git a/src/extension/content-script/injections/PostBox.tsx b/src/extension/content-script/injections/PostBox.tsx index 31a2c8d52aa5..8203f16343c3 100644 --- a/src/extension/content-script/injections/PostBox.tsx +++ b/src/extension/content-script/injections/PostBox.tsx @@ -2,7 +2,6 @@ import React from 'react' import ReactDOM from 'react-dom' import { LiveSelector, MutationObserverWatcher } from '@holoflows/kit' import { AdditionalPostBox } from '../../../components/InjectedComponents/AdditionalPostBox' -import { CryptoService } from '../rpc' const box = new MutationObserverWatcher( new LiveSelector() @@ -12,8 +11,12 @@ const box = new MutationObserverWatcher( ) box.useNodeForeach(node => { return { - onRemove: () => CryptoService.requestRegenerateIV(), - onTargetChanged: () => CryptoService.requestRegenerateIV(), + onTargetChanged: () => { + console.log('target changed') + }, + onNodeMutation: () => { + console.log('node mutation') + }, } }).startWatch() ReactDOM.render(, box.firstVirtualNode.after) diff --git a/src/extension/content-script/injections/Posts.tsx b/src/extension/content-script/injections/Posts.tsx index 49b473d371df..4cfe694f5ed0 100644 --- a/src/extension/content-script/injections/Posts.tsx +++ b/src/extension/content-script/injections/Posts.tsx @@ -4,36 +4,20 @@ import { LiveSelector, MutationObserverWatcher } from '@holoflows/kit' import { DecryptPost } from '../../../components/InjectedComponents/DecryptedPost' import { AddToKeyStore } from '../../../components/InjectedComponents/AddToKeyStore' import { PeopleService } from '../rpc' +import { getUsername } from './LiveSelectors' -function getUsername(url: string) { - const after = url.split('https://www.facebook.com/')[1] - if (after.match('profile.php')) return after.match(/id=(?\d+)/)!.groups!.id - else return after.split('?')[0] -} -const myUsername = new LiveSelector() - .querySelector(`[aria-label="Facebook"][role="navigation"] [data-click="profile_icon"] a`) - .map(x => x.href) - .map(getUsername) - -const posts = new LiveSelector().querySelectorAll('.userContent').filter((x: HTMLElement | null) => { - while (x) { - if (x.classList.contains('hidden_elem')) return false - // tslint:disable-next-line: no-parameter-reassignment - x = x.parentElement - } - return true -}) +const posts = new LiveSelector().querySelectorAll('.userContent, .userContent+*+div>div>div>div>div') const PostInspector = (props: { post: string; postBy: string; postId: string; needZip(): void }) => { const { post, postBy, postId } = props const type = { - encryptedPost: post.match('Maskbook.io:๐ŸŽผ') && post.match(':||'), + encryptedPost: post.match(/๐ŸŽผ([a-zA-Z0-9\+=\/|]+):\|\|/), provePost: post.match(/๐Ÿ”’(.+)๐Ÿ”’/)!, } if (type.encryptedPost) { props.needZip() - return + return } else if (type.provePost) { PeopleService.uploadProvePostUrl(postBy, postId) return @@ -43,10 +27,10 @@ const PostInspector = (props: { post: string; postBy: string; postId: string; ne new MutationObserverWatcher(posts) .useNodeForeach((node, key, realNode) => { // Get author - const postBy = getUsername(node.current.previousElementSibling!.querySelector('a')!.href) + const postBy = getUsername(node.current.parentElement!.querySelectorAll('a')[1])! // Save author's avatar try { - const avatar = node.current.previousElementSibling!.querySelector('img')! + const avatar = node.current.parentElement!.querySelector('img')! PeopleService.storeAvatar(postBy, avatar.getAttribute('aria-label')!, avatar.src) } catch {} // Get post id @@ -59,28 +43,56 @@ new MutationObserverWatcher(posts) // In single url (postIdInHref && postIdInHref.groups!.id) || // In timeline - node.current.previousElementSibling!.querySelector('div[id^=feed]')!.id.split(';')[2] + node.current.parentElement!.querySelector('div[id^=feed]')!.id.split(';')[2] } catch {} // Click "See more" if it may be a encrypted post { const more = node.current.parentElement!.querySelector('.see_more_link_inner') - if (more && node.current.innerText.match('Maskbook.io:๐ŸŽผ')) { + if (more && node.current.innerText.match(/๐ŸŽผ.+|/)) { more.click() } } + { + // Style modification for repost + if (!node.current.className.match('userContent') && node.current.innerText.length > 0) { + node.after.setAttribute( + 'style', + ` + border: 1px solid #ebedf0; + display: block; + border-top: none; + border-bottom: none; + margin-bottom: -23px; + padding: 0px 10px;`, + ) + } + } // Render it const render = () => { - console.log(node) ReactDOM.render( { - const pe = node.current.parentElement - if (!pe) return - const p = pe.querySelector('p') - if (!p) return - p.style.display = 'block' - p.style.maxHeight = '20px' - p.style.overflow = 'hidden' + { + // Post content + const pe = node.current.parentElement + if (pe) { + const p = pe.querySelector('p') + if (p) { + p.style.display = 'block' + p.style.maxHeight = '20px' + p.style.overflow = 'hidden' + p.style.marginBottom = '0' + } + } + } + { + // Link preview + const img = node.current.parentElement!.querySelector('a[href*="maskbook.io"] img') + const parent = img && img.closest('span') + if (img && parent) { + parent.style.display = 'none' + } + } }} postId={postId} post={node.current.innerText} diff --git a/src/extension/content-script/injections/Welcome.tsx b/src/extension/content-script/injections/Welcome.tsx index 6e86e98152fe..0696e46a8a42 100644 --- a/src/extension/content-script/injections/Welcome.tsx +++ b/src/extension/content-script/injections/Welcome.tsx @@ -1,22 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom' -import { DomProxy, LiveSelector, MutationObserverWatcher } from '@holoflows/kit' -//#region Welcome -enum WelcomeState { - // Step 0 - Start, - // Step 1 - WaitLogin, - Intro, - BackupKey, - ProvePost, - Restore1, - // End -} -const body = DomProxy() -body.realCurrent = document.body -ReactDOM.render(, body.after) - +import { DomProxy, LiveSelector, MutationObserverWatcher, ValueRef } from '@holoflows/kit' import Welcome0 from '../../../components/Welcomes/0' import Welcome1a1 from '../../../components/Welcomes/1a1' import Welcome1a2 from '../../../components/Welcomes/1a2' @@ -30,9 +14,40 @@ import { sleep } from '../../../utils/utils' import { useAsync } from '../../../utils/AsyncComponent' import { BackgroundService, CryptoService, PeopleService } from '../rpc' import { useEsc } from '../../../components/Welcomes/useEsc' -const isLogined = () => !document.querySelector('.login_form_label_field') -const loginWatcher = async () => { - while (!isLogined()) await sleep(500) +import { myUsername } from './LiveSelectors' +import { Banner } from '../../../components/Welcomes/Banner' + +//#region Welcome +enum WelcomeState { + // Step 0 + Start, + // Step 1 + WaitLogin, + Intro, + BackupKey, + ProvePost, + Restore1, + // End +} +type setWelcomeDisplay = (newState: boolean) => void +const setWelcomeDisplayRef = React.createRef() +const setWelcomeDisplay: setWelcomeDisplay = newState => { + const current = setWelcomeDisplayRef.current + if (!current) return + current(newState) +} +{ + const body = DomProxy() + body.realCurrent = document.body + const WelcomePortal = React.forwardRef(_WelcomePortal) + ReactDOM.render(, body.after) +} + +async function loginWatcher() { + while (!isLogin()) await sleep(500) +} +function isLogin() { + return !document.querySelector('.login_form_label_field') } function restoreFromFile(file: File) { const fr = new FileReader() @@ -42,86 +57,120 @@ function restoreFromFile(file: File) { PeopleService.storeKey(json) }) } -function Welcome(props: { - current: WelcomeState - setCurrent(x: WelcomeState): void - waitLogin(): void - finish(): void -}) { - const { current, setCurrent, waitLogin } = props +interface Welcome { + // Display + currentStep: WelcomeState + onStepChange(state: WelcomeState): void + // Actions + waitForLogin(): void + onFinish(reason: 'done' | 'quit'): void +} +function Welcome(props: Welcome) { + const { currentStep, onFinish, onStepChange, waitForLogin } = props + const [provePost, setProvePost] = React.useState('') useAsync(() => CryptoService.getMyProveBio(), [provePost.length !== 0]).then(setProvePost) - switch (current) { + + switch (currentStep) { case WelcomeState.Start: return ( setCurrent(isLogined() ? WelcomeState.Intro : WelcomeState.WaitLogin)} - restore={() => setCurrent(WelcomeState.Restore1)} - close={() => props.finish()} + create={() => onStepChange(isLogin() ? WelcomeState.Intro : WelcomeState.WaitLogin)} + restore={() => onStepChange(WelcomeState.Restore1)} + close={() => onFinish('quit')} /> ) case WelcomeState.WaitLogin: - return (waitLogin(), setCurrent(WelcomeState.Intro))} /> - case WelcomeState.Intro: return ( - { - setCurrent(WelcomeState.BackupKey) - BackgroundService.backupMyKeyPair() + waitForLogin() + onStepChange(WelcomeState.Intro) }} /> ) + case WelcomeState.Intro: + return onStepChange(WelcomeState.BackupKey)} /> case WelcomeState.BackupKey: - return setCurrent(WelcomeState.ProvePost)} /> + BackgroundService.backupMyKeyPair() + return onStepChange(WelcomeState.ProvePost)} /> case WelcomeState.ProvePost: return ( { - ;(navigator as any).clipboard.writeText(text) - props.finish() + copyToClipboard={(text, goToBio) => { + navigator.clipboard.writeText(text) + if (goToBio) { + const a = myUsername.evaluateOnce()[0] + if (a) location.href = a.href + } + onFinish('done') }} /> ) case WelcomeState.Restore1: return ( setCurrent(WelcomeState.Start)} + back={() => onStepChange(WelcomeState.Start)} restore={url => { - props.finish() + onFinish('done') restoreFromFile(url) }} /> ) } } -function getStorage() { - return new Promise((resolve, reject) => { - chrome.storage.local.get(resolve) +{ + /** + * Upgrade version from true to number. + * Remove this after 1/1/2020 + */ + chrome.storage.local.get(items => { + if (items.init === true) chrome.storage.local.set({ init: WelcomeVersion.A }) }) } -function WelcomePortal() { - const [open, setOpen] = React.useState(true) - const [current, setCurrent] = React.useState(WelcomeState.Start) - const [init, setInit] = React.useState(true) +interface Storage { + init: WelcomeVersion + userDismissedWelcomeAtVersion: WelcomeVersion +} +function getStorage() { + return new Promise>(resolve => chrome.storage.local.get(resolve)) +} +function setStorage(item: Partial) { + return new Promise(resolve => chrome.storage.local.set(item, resolve)) +} +const enum WelcomeVersion { + A = 1, +} +const LATEST_VERSION = WelcomeVersion.A +function _WelcomePortal(props: {}, ref: React.Ref) { + const [step, setStep] = React.useState(WelcomeState.Start) + const [open, setOpen] = React.useState(false) - function onFinish() { + React.useImperativeHandle( + ref, + () => (newState: boolean) => { + if (newState) setStep(WelcomeState.Start) + setOpen(newState) + }, + [setOpen], + ) + + const onFinish: Welcome['onFinish'] = reason => { setOpen(false) - chrome.storage.local.set({ init: true }) + setStorage({ init: LATEST_VERSION }) } - function waitLogin() { + function waitForLogin() { setOpen(false) loginWatcher().then(() => setOpen(true)) } - useAsync(() => getStorage(), [0]).then(data => setInit(data.init)) - useEsc(onFinish) + useEsc(onFinish.bind(null, 'quit')) // Only render in main page if (location.pathname !== '/') return null - if (init) return null return ( - + ) @@ -135,12 +184,7 @@ function WelcomePortal() { ReactDOM.render( <> {' ยท '} - { - chrome.storage.local.clear() - location.reload() - }}> + setWelcomeDisplay(true)}> Maskbook Setup , @@ -148,3 +192,28 @@ function WelcomePortal() { ) } //#endregion +//#region Banner +{ + getStorage().then(({ init, userDismissedWelcomeAtVersion }) => { + const to = new MutationObserverWatcher( + new LiveSelector().querySelector('#pagelet_composer'), + ).startWatch() + if (userDismissedWelcomeAtVersion && userDismissedWelcomeAtVersion >= LATEST_VERSION) return + if (init && init >= LATEST_VERSION) return + ReactDOM.render( + { + setWelcomeDisplay(false) + setStorage({ userDismissedWelcomeAtVersion: LATEST_VERSION }) + ReactDOM.unmountComponentAtNode(to.firstVirtualNode.before) + }} + getStarted={() => { + setWelcomeDisplay(true) + ReactDOM.unmountComponentAtNode(to.firstVirtualNode.before) + }} + />, + to.firstVirtualNode.before, + ) + }) +} +//#endregion diff --git a/src/extension/content-script/rpc.ts b/src/extension/content-script/rpc.ts index 2ed55e8a45f5..20562af8337c 100644 --- a/src/extension/content-script/rpc.ts +++ b/src/extension/content-script/rpc.ts @@ -4,6 +4,6 @@ import { BackgroundName, CryptoName, FriendServiceName } from '../../utils/Names import { Encrypt } from '../background-script/CryptoService' import { PeopleService as People } from '../background-script/PeopleService' -export const BackgroundService = AsyncCall({}, { key: BackgroundName }) -export const CryptoService = AsyncCall({}, { key: CryptoName }) -export const PeopleService = AsyncCall({}, { key: FriendServiceName }) +export const BackgroundService = AsyncCall({}, { key: BackgroundName }) +export const CryptoService = AsyncCall({}, { key: CryptoName }) +export const PeopleService = AsyncCall({}, { key: FriendServiceName }) diff --git a/src/extension/injected-script/addEventListener.ts b/src/extension/injected-script/addEventListener.ts new file mode 100644 index 000000000000..a9c3fcc1703f --- /dev/null +++ b/src/extension/injected-script/addEventListener.ts @@ -0,0 +1,47 @@ +import { CustomPasteEventId } from '../../utils/Names' +{ + const store: Partial void>>> = {} + function hijack(key: keyof DocumentEventMap) { + store[key] = new Set() + } + function isEnabled(key: any): key is keyof typeof store { + return key in store + } + + document.addEventListener(CustomPasteEventId, e => { + const ev = e as CustomEvent + const transfer = new DataTransfer() + transfer.setData('text/plain', ev.detail) + const event = { + clipboardData: transfer, + defaultPrevented: false, + preventDefault: () => {}, + target: document.activeElement, + // ! Magic. Why? + _inherits_from_prototype: true, + } + for (const f of store.paste || []) { + try { + f(event as any) + } catch (e) { + console.error(e) + } + } + }) + + hijack('paste') + hijack('click') + + document.addEventListener = new Proxy(document.addEventListener, { + apply(target, thisRef, [event, callback, ...args]) { + if (isEnabled(event)) store[event]!.add(callback) + return Reflect.apply(target, thisRef, [event, callback, ...args]) + }, + }) + document.removeEventListener = new Proxy(document.removeEventListener, { + apply(target, thisRef, [event, callback, ...args]) { + if (isEnabled(event)) store[event]!.delete(callback) + return Reflect.apply(target, thisRef, [event, callback, ...args]) + }, + }) +} diff --git a/src/extension/injected-script/index.ts b/src/extension/injected-script/index.ts new file mode 100644 index 000000000000..6ea60918a483 --- /dev/null +++ b/src/extension/injected-script/index.ts @@ -0,0 +1,14 @@ +import { GetContext } from '@holoflows/kit/es' +switch (GetContext()) { + case 'content': + const script = document.createElement('script') + script.src = chrome.runtime.getURL('/static/js/injectedscript.js') + script.dataset.chrome = 'true' + document.querySelector('html')!.appendChild(script) + break + case 'webpage': + require('./addEventListener') + document.querySelector('script[data-chrome=true]')!.remove() + console.log('Injected script loaded') + break +} diff --git a/src/key-management/gun.ts b/src/key-management/gun.ts index 5b4ed03245aa..011d36425efa 100644 --- a/src/key-management/gun.ts +++ b/src/key-management/gun.ts @@ -1,7 +1,7 @@ import Gun from 'gun' import 'gun/lib/then' import { OnlyRunInContext } from '@holoflows/kit/es' -import { PublishedAESKey } from '../crypto/crypto-alpha-41' +import { PublishedAESKey } from '../crypto/crypto-alpha-40' OnlyRunInContext('background', 'Gun') interface Person { diff --git a/src/key-management/local-db.ts b/src/key-management/local-db.ts index 65d7abdbe038..b8a969bb2412 100644 --- a/src/key-management/local-db.ts +++ b/src/key-management/local-db.ts @@ -24,7 +24,7 @@ export async function getMyLocalKey(): Promise { return record } async function generateAESKey() { - return crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']) + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) } export async function storeLocalKey(key: CryptoKey) { return query(t => t.put({ username: '$self', key }), 'readwrite') diff --git a/src/key-management/people-gun.ts b/src/key-management/people-gun.ts index 0809b79fba08..d569015a1ec5 100644 --- a/src/key-management/people-gun.ts +++ b/src/key-management/people-gun.ts @@ -22,13 +22,13 @@ export async function addPersonPublicKey(username: string): Promise { - const bio = await tasks(bioUrl, Infinity).getBioContent() + const bio = await tasks(bioUrl).getBioContent() if ((await verifyOthersProve(bio, username)) === null) throw new Error('Not in bio!') } const fromPost = async () => { const person = await queryPerson(username) if (!person) throw new Error('Not in gun!') - const post = await tasks(postUrl(person.provePostId), Infinity).getPostContent() + const post = await tasks(postUrl(person.provePostId)).getPostContent() if ((await verifyOthersProve(post, username)) === null) throw new Error('Not in prove post!') } let bioRejected = false diff --git a/src/key-management/posts-gun.ts b/src/key-management/posts-gun.ts index 82803c4a745c..da017269477b 100644 --- a/src/key-management/posts-gun.ts +++ b/src/key-management/posts-gun.ts @@ -1,5 +1,5 @@ import { gun } from './gun' -import { PublishedAESKey } from '../crypto/crypto-alpha-41' +import { PublishedAESKey } from '../crypto/crypto-alpha-40' export async function queryPostAESKey(postIdentifier: string, myUsername: string) { return gun diff --git a/src/stories/index.tsx b/src/stories/index.tsx index e33cb29c20a4..00cb92dd3c9a 100644 --- a/src/stories/index.tsx +++ b/src/stories/index.tsx @@ -21,8 +21,10 @@ import { SelectPeopleSingle } from '../components/InjectedComponents/SelectPeopl import { DecryptPostUI } from '../components/InjectedComponents/DecryptedPost' import { AddToKeyStoreUI } from '../components/InjectedComponents/AddToKeyStore' import { Person } from '../extension/background-script/PeopleService' +import { Banner } from '../components/Welcomes/Banner' storiesOf('Welcome', module) + .add('Banner', () => ) .add('Step 0', () => ( )) @@ -32,7 +34,7 @@ storiesOf('Welcome', module) .add('Step 1a-4', () => ( )) .add('Step 1b-1', () => ) @@ -111,7 +113,14 @@ const demoPeople: Person[] = [ storiesOf('Injections', module) // .add('Checkbox (unused)', () => ) - .add('Post box', () => {}} />) + .add('Post box', () => ( + {}} + /> + )) .add('Additional Post Content', () => ) .add('Select people', () => { function SelectPeople() { diff --git a/src/tests/1to1.ts b/src/tests/1to1.ts index fc2d09487415..2e6185e11b4e 100644 --- a/src/tests/1to1.ts +++ b/src/tests/1to1.ts @@ -1,21 +1,21 @@ -import * as crypto41 from '../crypto/crypto-alpha-41' +import * as crypto40 from '../crypto/crypto-alpha-40' import { decodeText } from '../utils/EncodeDecode' export async function test1to1(text: string) { const alice = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) const bob = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) - const encrypted = await crypto41.encrypt1To1({ + const encrypted = await crypto40.encrypt1To1({ content: text, privateKeyECDH: alice.privateKey, othersPublicKeyECDH: bob.publicKey, - version: -41, + version: -40, }) - const decrypted = await crypto41.decryptMessage1To1({ + const decrypted = await crypto40.decryptMessage1To1({ encryptedContent: encrypted.encryptedContent, salt: encrypted.salt, privateKeyECDH: bob.privateKey, anotherPublicKeyECDH: alice.publicKey, - version: -41, + version: -40, }) if (decodeText(decrypted) !== text) throw new Error() } diff --git a/src/tests/1toN.ts b/src/tests/1toN.ts index 5dca71ff9fe2..914e53fd0fbb 100644 --- a/src/tests/1toN.ts +++ b/src/tests/1toN.ts @@ -1,15 +1,15 @@ -import { encrypt1ToN, decryptMessage1ToNByMyself, decryptMessage1ToNByOther } from '../crypto/crypto-alpha-41' +import { encrypt1ToN, decryptMessage1ToNByMyself, decryptMessage1ToNByOther } from '../crypto/crypto-alpha-40' import { decodeText } from '../utils/EncodeDecode' async function test1toN(msg: string) { const alice = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) - const aliceLocal = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']) + const aliceLocal = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) const bob = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) const david = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) const zoe = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) const encrypted = await encrypt1ToN({ - version: -41, + version: -40, content: msg, iv: crypto.getRandomValues(new Uint8Array(16)), privateKeyECDH: alice.privateKey, @@ -18,7 +18,7 @@ async function test1toN(msg: string) { }) const aliceDecrypt = await decryptMessage1ToNByMyself({ - version: -41, + version: -40, encryptedAESKey: encrypted.ownersAESKeyEncrypted, encryptedContent: encrypted.encryptedContent, iv: encrypted.iv, @@ -27,7 +27,7 @@ async function test1toN(msg: string) { if (decodeText(aliceDecrypt) !== msg) throw new Error('Alice decrypted not equal') const bobDecrypt = await decryptMessage1ToNByOther({ - version: -41, + version: -40, AESKeyEncrypted: encrypted.othersAESKeyEncrypted.find(x => x.name === 'bob')!.key, authorsPublicKeyECDH: alice.publicKey, encryptedContent: encrypted.encryptedContent, @@ -38,7 +38,7 @@ async function test1toN(msg: string) { try { await decryptMessage1ToNByOther({ - version: -41, + version: -40, AESKeyEncrypted: encrypted.othersAESKeyEncrypted.find(x => x.name === 'bob')!.key, authorsPublicKeyECDH: alice.publicKey, encryptedContent: encrypted.encryptedContent, diff --git a/src/tests/sign&verify.ts b/src/tests/sign&verify.ts index 8b9e30e679fb..99ef8f4bafcb 100644 --- a/src/tests/sign&verify.ts +++ b/src/tests/sign&verify.ts @@ -1,4 +1,4 @@ -import { sign, verify } from '../crypto/crypto-alpha-41' +import { sign, verify } from '../crypto/crypto-alpha-40' async function testSignVerify(msg: string) { const alice = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'K-256' }, true, ['deriveKey']) diff --git a/src/utils/Flex.tsx b/src/utils/Flex.tsx index 610f866a49f4..b25b6dabfd1a 100644 --- a/src/utils/Flex.tsx +++ b/src/utils/Flex.tsx @@ -1,22 +1,24 @@ -import React, { ReactHTML, ClassAttributes, HTMLAttributes } from 'react' +import React from 'react' import { Theme } from '@material-ui/core/styles/createMuiTheme' import { withStylesTyped } from './theme' import { CSSProperties } from '@material-ui/core/styles/withStyles' import classNames from 'classnames' import createStyles from '@material-ui/core/styles/createStyles' -export function createBox( +export function createBox( fn: ((theme: Theme) => CSSProperties) | CSSProperties, element?: T, ) { - return withStylesTyped(typeof fn === 'function' ? (theme: Theme) => createStyles({ box: fn(theme) }) : { box: fn })< - ClassAttributes & HTMLAttributes - >(({ classes, ...props }) => - React.createElement(element || 'div', { + const style = typeof fn === 'function' ? (theme: Theme) => createStyles({ box: fn(theme) }) : { box: fn } + const Real = React.forwardRef(({ classes, className, ...props }: any, ref: any) => { + return React.createElement(element || 'div', { ...props, - className: classNames(classes.box, (props as any).className), - }), - ) + ref, + className: classNames(classes.box, className), + }) + }) + const Styled = withStylesTyped(style)(Real) + return Styled as React.ComponentType } export const FlexBox = createBox({ display: 'flex' }) diff --git a/src/utils/Names.ts b/src/utils/Names.ts index 282f9f9be857..8d96a96ae95e 100644 --- a/src/utils/Names.ts +++ b/src/utils/Names.ts @@ -1,3 +1,5 @@ export const BackgroundName = 'background' export const CryptoName = 'crypto' export const FriendServiceName = 'friends' +/** Just a random one. Never mind. */ +export const CustomPasteEventId = '6fea93e2-1ce4-442f-b2f9-abaf4ff0ce64' diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 9f7cb27943da..5415e150325e 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -8,6 +8,10 @@ import React from 'react' import createMuiTheme, { ThemeOptions } from '@material-ui/core/styles/createMuiTheme' import { TypographyOptions } from '@material-ui/core/styles/createTypography' +// See: https://material-ui.com/style/typography/#migration-to-typography-v2 +Object.assign(window, { + __MUI_USE_NEXT_TYPOGRAPHY_VARIANTS__: true, +}) const _refTheme = createMuiTheme() const _refThemeDark = createMuiTheme({ palette: { type: 'dark' } }) const baseTheme = (theme: 'dark' | 'light') => @@ -72,7 +76,14 @@ export function withStylesTyped> : React.ForwardRefExoticComponent & React.RefAttributes>, ) { - return withStyles(style, options as any)(component as any) as Ref extends null + const Styled = withStyles(style, options as any)(component as any) + const Wrap = React.forwardRef((props: any, ref: any) => { + return React.createElement(Styled, { + innerRef: ref, + ...props, + }) + }) + return Wrap as Ref extends null ? React.ComponentType : React.ForwardRefExoticComponent & Props> } diff --git a/src/utils/useDragAndDrop.ts b/src/utils/useDragAndDrop.ts new file mode 100644 index 000000000000..4e93a0c6496a --- /dev/null +++ b/src/utils/useDragAndDrop.ts @@ -0,0 +1,46 @@ +import React from 'react' +/** + * Usage: + * const { dragEvents, fileReceiver, dragStatus, fileRef } = useDragAndDrop() + * return
// Now you can drag into this div + * // Also provide a way to select manually! + * { dragStatus === 'drag-enter' && 'Dragging!' } // Status of dragging + * // Get the file! + *
+ */ +export function useDragAndDrop() { + const [status, setStatus] = React.useState(undefined) + const fileRef = React.useRef() + const onChange = React.useCallback((event: React.ChangeEvent | React.DragEvent) => { + const files = ( + (event as React.DragEvent).dataTransfer || (event as React.ChangeEvent).currentTarget + ).files + if (!files) return + const file = files.item(0) + fileRef.current = file + setStatus('selected') + }, []) + const onEnter = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + setStatus('drag-enter') + }, []) + const onCapture = React.useCallback((e: React.DragEvent) => { + e.preventDefault() + onChange(e) + setTimeout(onLeave, 200) + }, []) + const onLeave = React.useCallback((e: React.DragEvent) => { + setStatus(undefined) + }, []) + return { + dragEvents: { + onDragEnterCapture: onEnter, + onDragLeaveCapture: onLeave, + onDropCapture: onCapture, + onDragOverCapture: onEnter, + }, + fileReceiver: onChange, + fileRef: fileRef, + dragStatus: status, + } +}