diff --git a/package-lock.json b/package-lock.json index d6ecae6b..befb92f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sardinefish/x509": "^1.2.1", "@semaphore-protocol/identity": "^4.3.0", "@shadcn/ui": "^0.0.4", + "@simplewebauthn/browser": "^11.0.0", "async-mutex": "^0.4.0", "axios": "^1.7.7", "buffer": "^6.0.3", @@ -3585,6 +3586,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@simplewebauthn/browser": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-11.0.0.tgz", + "integrity": "sha512-KEGCStrl08QC2I561BzxqGiwoknblP6O1YW7jApdXLPtIqZ+vgJYAv8ssLCdm1wD8HGAHd49CJLkUF8X70x/pg==", + "dependencies": { + "@simplewebauthn/types": "^11.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "license": "MIT", diff --git a/package.json b/package.json index f30a8e30..4e29a0d3 100755 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@sardinefish/x509": "^1.2.1", "@semaphore-protocol/identity": "^4.3.0", "@shadcn/ui": "^0.0.4", + "@simplewebauthn/browser": "^11.0.0", "async-mutex": "^0.4.0", "axios": "^1.7.7", "buffer": "^6.0.3", diff --git a/passkey.zip b/passkey.zip new file mode 100644 index 00000000..8eb47bc0 Binary files /dev/null and b/passkey.zip differ diff --git a/src/components/NavHeader/index.tsx b/src/components/NavHeader/index.tsx index 4196165d..4829076e 100644 --- a/src/components/NavHeader/index.tsx +++ b/src/components/NavHeader/index.tsx @@ -142,6 +142,10 @@ export default function NavHeader({ return 'Disabled'; }; + if (pathname === '/lock') { + return null; + } + return (
{pathname !== '/home' ? ( diff --git a/src/entries/Background/rpc.ts b/src/entries/Background/rpc.ts index 201a46a7..8b019366 100644 --- a/src/entries/Background/rpc.ts +++ b/src/entries/Background/rpc.ts @@ -48,6 +48,7 @@ const charwise = require('charwise'); import { BookmarkManager } from '../../reducers/bookmarks'; import { AttestationObject } from '@eternis/tlsn-js'; + export enum BackgroundActiontype { get_requests = 'get_requests', clear_requests = 'clear_requests', @@ -88,6 +89,16 @@ export enum BackgroundActiontype { get_logging_level = 'get_logging_level', prepare_notarization = 'prepare_notarization', get_notarization_status = 'get_notarization_status', + request_create_identity = 'request_create_identity', + request_unlock_extension = 'request_unlock_extension', + unlock_extension = 'unlock_extension', + close_auth_popup = 'close_auth_popup', + identity_updated = 'identity_updated', +} + +export enum AuthActiontype { + web_authn_authenticate = 'web_authn_authenticate', + web_authn_register = 'web_authn_register', } export type BackgroundAction = { @@ -136,6 +147,8 @@ export type RequestHistory = { type?: string; }; +let identitySecret: string | undefined = undefined; + export const initRPC = () => { browser.runtime.onMessage.addListener( (request, sender, sendResponse): any => { @@ -194,6 +207,15 @@ export const initRPC = () => { return true; case BackgroundActiontype.get_notarization_status: return handleGetNotarizationStatus(request); + case BackgroundActiontype.request_unlock_extension: + return handleRequestUnlockExtension(request); + case BackgroundActiontype.request_create_identity: + return handleRequestCreateIdentity(request); + case BackgroundActiontype.close_auth_popup: + return handleCloseAuthPopup(request); + case BackgroundActiontype.identity_updated: + return handleIdentityUpdated(request); + default: break; } }, @@ -305,6 +327,7 @@ async function handleRetryProveReqest( ...req, notaryUrl, websocketProxyUrl, + identitySecret, }, }); @@ -358,6 +381,7 @@ export async function handleProveRequestStart( body, notaryUrl, websocketProxyUrl, + identitySecret, }, }); @@ -410,6 +434,7 @@ async function runPluginProver(request: BackgroundAction, now = Date.now()) { body, notaryUrl, websocketProxyUrl, + identitySecret, }, }); } @@ -846,6 +871,7 @@ async function handleNotarizeRequest(request: BackgroundAction) { body, notaryUrl, websocketProxyUrl, + identitySecret, }, }); } catch (e) { @@ -1066,3 +1092,99 @@ async function handleRunPluginCSRequest(request: BackgroundAction) { return defer.promise; } + +let authWindow: number | undefined = undefined; +const createAuthPopup = async (left: number, top: number, width: number) => { + const popup = await chrome.windows.create({ + url: 'auth.html', + type: 'panel', + width, + height: 1, + left, + top, + focused: true, + state: 'normal', + }); + authWindow = popup.id; +}; + +async function handleRequestUnlockExtension(request: BackgroundAction) { + try { + const { left, top, width } = request.data; + await createAuthPopup(left, top, width); + setTimeout(() => { + chrome.runtime.sendMessage({ + type: AuthActiontype.web_authn_authenticate, + }); + }, 300); + } catch (e) { + console.error('error', e); + } +} + +async function handleRequestCreateIdentity(request: BackgroundAction) { + try { + const { left, top, width, username, userId } = request.data; + if (!username || !userId) { + throw new Error('username and userId are required'); + } + + await createAuthPopup(left, top, width); + setTimeout(() => { + chrome.runtime.sendMessage({ + type: AuthActiontype.web_authn_register, + data: { + username, + userId, + }, + }); + }, 300); + } catch (e) { + console.error('error', e); + } +} + +async function handleCloseAuthPopup(request: BackgroundAction) { + try { + if (authWindow) { + // Remove all popup windows otherwise popup.html will keep stacking up + // await chrome.windows.remove(authWindow); + const windows = await chrome.windows.getAll(); + await Promise.all( + windows.map(async (window) => { + if (window.type === 'popup') { + await chrome.windows.remove(window.id!); + } + }), + ); + + // Calling open popup forces the popup to remain open as focus is lost from previous remove calls + await chrome.windows.create({ + url: 'popup.html', + type: 'popup', + width: 1, + height: 1, + }); + + // send message unlock extension + if (request && request.data && request.data.userId) { + chrome.runtime.sendMessage({ + type: BackgroundActiontype.unlock_extension, + data: { + userId: request.data.userId, + }, + }); + } + + authWindow = undefined; + } + } catch (e) { + console.error('error', e); + } +} + +async function handleIdentityUpdated(request: BackgroundAction) { + if (request.data.identitySecret) { + identitySecret = request.data.identitySecret; + } +} diff --git a/src/entries/Offscreen/Offscreen.tsx b/src/entries/Offscreen/Offscreen.tsx index e96a63cf..a21e7652 100644 --- a/src/entries/Offscreen/Offscreen.tsx +++ b/src/entries/Offscreen/Offscreen.tsx @@ -13,7 +13,7 @@ import { BackgroundActiontype } from '../Background/rpc'; import browser from 'webextension-polyfill'; import { Proof } from '../../utils/types'; import { Method } from '@eternis/tlsn-js/wasm/pkg'; -import { IdentityManager } from '../../reducers/identity'; +import { Identity } from '@semaphore-protocol/identity'; const { init, verify_attestation, Prover, NotarizedSession, TlsProof }: any = Comlink.wrap(new Worker(new URL('./worker.ts', import.meta.url))); @@ -212,6 +212,7 @@ async function createProof(options: { }; body?: any; id: string; + identitySecret: string; }): Promise { const { url, @@ -221,10 +222,14 @@ async function createProof(options: { notaryUrl, websocketProxyUrl, id, + identitySecret, } = options; - const identityManager = new IdentityManager(); - const identity = await identityManager.getIdentity(); + if (!identitySecret) { + throw new Error('IdentitySecret is required, extension is not unlocked'); + } + + const identity = new Identity(identitySecret); const hostname = urlify(url)?.hostname || ''; const notary = NotaryServer.from(notaryUrl); diff --git a/src/entries/Popup/Popup.tsx b/src/entries/Popup/Popup.tsx index c67cff1c..86af5415 100644 --- a/src/entries/Popup/Popup.tsx +++ b/src/entries/Popup/Popup.tsx @@ -44,11 +44,18 @@ import { getConnection } from '../Background/db'; import NavHeader from '../../components/NavHeader'; import Websites from '../../pages/Websites'; import AttestationDetails from '../../pages/AttestationDetails'; +import Locked from '../../pages/Locked'; +import { + initIdentity, + setIdentity, + useIdentity, +} from '../../reducers/identity'; const Popup = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const location = useLocation(); + const { loading, identity } = useIdentity(); useEffect(() => { (async () => { @@ -70,12 +77,18 @@ const Popup = () => { type: BackgroundActiontype.get_prove_requests, data: tab?.id, }); + + dispatch(await initIdentity()); })(); }, []); useEffect(() => { - chrome.runtime.onMessage.addListener((request) => { + chrome.runtime.onMessage.addListener(async (request) => { switch (request.type) { + case BackgroundActiontype.unlock_extension: { + dispatch(await setIdentity(request.data.userId)); + break; + } case BackgroundActiontype.push_action: { if ( request.data.tabId === store.getState().requests.activeTab?.id || @@ -95,6 +108,14 @@ const Popup = () => { }); }, []); + if (loading) { + return <>; + } + + if (!identity) { + return ; + } + return (
diff --git a/src/entries/Popup/auth.html b/src/entries/Popup/auth.html new file mode 100644 index 00000000..36421da5 --- /dev/null +++ b/src/entries/Popup/auth.html @@ -0,0 +1,10 @@ + + + + + Eternis Passkey Authentication + + + + + \ No newline at end of file diff --git a/src/entries/Popup/auth.ts b/src/entries/Popup/auth.ts new file mode 100644 index 00000000..a6bcbc17 --- /dev/null +++ b/src/entries/Popup/auth.ts @@ -0,0 +1,108 @@ +import { BackgroundActiontype, AuthActiontype } from '../Background/rpc'; +import { arrayBufferToBase64, base64ToArrayBuffer } from '../../utils/misc'; + +const webAuthnAuthenticate = async () => { + try { + const randomChallenge = new Uint8Array(16); + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: randomChallenge, + rpId: 'eternis.ai', + userVerification: 'required', + timeout: 60000, + }, + }); + + if (!assertion) { + return; + } + + // @ts-expect-error assertion type needs to be defined globally + const userId = new Uint8Array(assertion.response.userHandle); + const userIdBase64 = arrayBufferToBase64(userId); + chrome.runtime.sendMessage({ + type: BackgroundActiontype.close_auth_popup, + data: { + userId: userIdBase64, + }, + }); + } catch (error) { + console.error('error', error); + chrome.runtime.sendMessage({ + type: BackgroundActiontype.close_auth_popup, + data: { + userId: null, + }, + }); + } +}; + +const webAuthnRegister = async (username: string, userIdBase64: string) => { + try { + const userId = base64ToArrayBuffer(userIdBase64); + + const publicKeyCredentialCreationOptions = { + challenge: new Uint8Array(16), // not required as we are not going to verify the challenge + rp: { + name: 'Pangea Attestor', + id: 'eternis.ai', + transports: ['internal'], + }, + user: { + id: userId, + name: username, + displayName: username, + }, + pubKeyCredParams: [ + { alg: -7, type: 'public-key' }, + { alg: -257, type: 'public-key' }, + ], + authenticatorSelection: { + residentKey: 'preferred', + requireResidentKey: false, + authenticatorAttachment: 'platform', + userVerification: 'required', + }, + timeout: 60000, + attestation: 'none', + hints: [], + extensions: { + credProps: true, + }, + }; + + const credential = await navigator.credentials.create({ + // @ts-expect-error type not supported + publicKey: publicKeyCredentialCreationOptions, + }); + + if (!credential) { + return; + } + + chrome.runtime.sendMessage({ + type: BackgroundActiontype.close_auth_popup, + data: { + userId: userIdBase64, + }, + }); + } catch (error) { + console.error('error', error); + chrome.runtime.sendMessage({ + type: BackgroundActiontype.close_auth_popup, + data: { + userId: null, + }, + }); + } +}; + +// start listening for messages from background +chrome.runtime.onMessage.addListener((message) => { + if (message.type === AuthActiontype.web_authn_authenticate) { + webAuthnAuthenticate(); + } else if (message.type === AuthActiontype.web_authn_register) { + const { username, userId } = message.data; + webAuthnRegister(username, userId); + } +}); diff --git a/src/manifest.json b/src/manifest.json index 3bc1998f..1f31a065 100755 --- a/src/manifest.json +++ b/src/manifest.json @@ -25,7 +25,7 @@ ], "web_accessible_resources": [ { - "resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js"], + "resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "auth.html", "auth.bundle.js"], "matches": ["http://*/*", "https://*/*", ""] } ], diff --git a/src/pages/AttestationDetails/index.tsx b/src/pages/AttestationDetails/index.tsx index 903b891d..45ecf32b 100644 --- a/src/pages/AttestationDetails/index.tsx +++ b/src/pages/AttestationDetails/index.tsx @@ -7,7 +7,7 @@ import { useIdentity } from '../../reducers/identity'; import { VERIFIER_APP_URL } from '../../utils/constants'; import { AttestationObject, decodeAppData, Attribute } from '@eternis/tlsn-js'; export default function AttestationDetails() { - const [identity] = useIdentity(); + const { identity } = useIdentity(); const params = useParams<{ host: string; requestId: string }>(); const request = useRequestHistory(params.requestId); diff --git a/src/pages/Locked/Lock.tsx b/src/pages/Locked/Lock.tsx new file mode 100644 index 00000000..38c15990 --- /dev/null +++ b/src/pages/Locked/Lock.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useState } from 'react'; +import logo from '../../assets/img/icon-128.png'; +import { BackgroundActiontype } from '../../entries/Background/rpc'; +import { ArrowRightCircleIcon, XIcon } from 'lucide-react'; +import { Identity } from '@semaphore-protocol/identity'; +import { useDispatch } from 'react-redux'; +import { setIdentity } from '../../reducers/identity'; + +export default function Lock() { + const dispatch = useDispatch(); + const [identitySecret, setIdentitySecret] = useState(''); + + const handleUseExistingAccount = useCallback(async () => { + try { + await chrome.runtime.sendMessage({ + type: BackgroundActiontype.request_unlock_extension, + data: { + left: window.screenX + 24, + top: window.screenY, + width: window.innerWidth - 48, + }, + }); + } catch (error) { + console.error(error); + } + }, []); + + const handleUnlockAccount = useCallback(async () => { + if (identitySecret.length !== 44) { + return; + } + + try { + const storage = await chrome.storage.sync.get('identityPublicKey'); + const identityPublicKey = storage?.identityPublicKey; + if (!identityPublicKey) { + return; + } + + const identity = new Identity(identitySecret); + const testPublicKey = [ + identity.publicKey[0].toString(), + identity.publicKey[1].toString(), + ]; + if (JSON.stringify(testPublicKey) !== identityPublicKey) { + throw new Error('Invalid identity secret'); + return; + } + + dispatch(await setIdentity(identitySecret)); + } catch (error) { + console.error(error); + setIdentitySecret(''); + } + }, [identitySecret, dispatch, setIdentity]); + + return ( +
+ logo +
+ Welcome back to Pangea Attestor +
+ +
Unlock your account
+ +
+
+ Login with Passkey +
+
+ +
+
+
or
+
+
+ +
+ setIdentitySecret(e.target.value)} + placeholder="Enter your identity secret" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleUnlockAccount(); + } + }} + /> + {identitySecret.length === 44 ? ( + + ) : identitySecret.length > 0 ? ( + { + setIdentitySecret(''); + }} + className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 cursor-pointer" + /> + ) : null} +
+
+ ); +} diff --git a/src/pages/Locked/NewIdentity.tsx b/src/pages/Locked/NewIdentity.tsx new file mode 100644 index 00000000..93fbc8cc --- /dev/null +++ b/src/pages/Locked/NewIdentity.tsx @@ -0,0 +1,214 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { BackgroundActiontype } from '../../entries/Background/rpc'; +import { useDispatch } from 'react-redux'; +import { + ArrowLeft, + CheckIcon, + CopyIcon, + EyeIcon, + EyeOffIcon, +} from 'lucide-react'; +import { + arrayBufferToBase64, + generateRandomArrayBuffer, +} from '../../utils/misc'; +import { setIdentity } from '../../reducers/identity'; + +export default function NewIdentity({ + setCreateNewIdentity, +}: { + setCreateNewIdentity: (value: boolean) => void; +}) { + const dispatch = useDispatch(); + const [username, setUsername] = useState(''); + const [identitySecret, setIdentitySecret] = useState(''); + const [isPasskeySupported, setIsPasskeySupported] = useState(false); + const [userConsent, setUserConsent] = useState(false); + + const [showIdentitySecret, setShowIdentitySecret] = useState(false); + const [isCopied, setIsCopied] = useState(false); + + const allowPasskey = username.length > 0 && isPasskeySupported === true; + const allowAccountCreate = username.length > 0 && userConsent === true; + + useEffect(() => { + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.indexOf('mac') !== -1) { + setIsPasskeySupported(true); + } + if (userAgent.indexOf('win') !== -1) { + setIsPasskeySupported(true); + } + }, []); + + useEffect(() => { + const identitySecret = generateRandomArrayBuffer(32); + setIdentitySecret(arrayBufferToBase64(identitySecret)); + }, []); + + const handleCreateIdentity = useCallback(async () => { + if (!allowPasskey) { + return; + } + + try { + if (!username) { + return; + } + + const userId = identitySecret; + + await chrome.runtime.sendMessage({ + type: BackgroundActiontype.request_create_identity, + data: { + left: window.screenX + 24, + top: window.screenY, + width: window.innerWidth - 48, + username, + userId, + }, + }); + } catch (error) { + console.log('error', error); + await chrome.windows.create({ + url: 'popup.html', + type: 'popup', + width: 1, + height: 1, + }); + } + }, [username, dispatch, identitySecret, allowPasskey]); + + const handleCreateAccount = useCallback(async () => { + if (!allowAccountCreate) { + return; + } + + const userId = identitySecret; + + try { + dispatch(await setIdentity(userId)); + } catch (error) { + console.log('error', error); + } + }, [allowAccountCreate, identitySecret, dispatch, setIdentity]); + + const handleCopyIdentitySecret = useCallback(() => { + navigator.clipboard.writeText(identitySecret); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 3000); + }, [identitySecret]); + + return ( + <> +
+ setCreateNewIdentity(false)} + className="cursor-pointer opacity-50 hover:opacity-100 h-4 w-4 ml-5" + /> +
+
+ {/* logo +
Welcome to Pangea Attestor
*/} + +
+
Username (Required)
+ setUsername(e.target.value)} + /> +
+ +
+
handleCreateIdentity()} + className={` + bg-[#092EEA] text-white text-sm py-5 w-full rounded-lg text-center font-medium cursor-pointer hover:bg-[#092EEA]/80 + ${!allowPasskey ? 'opacity-50 cursor-not-allowed hover:bg-[#092EEA]' : ''} + `} + > + Secure with Passkey +
+ {isPasskeySupported + ? '(Recommended)' + : isPasskeySupported === false + ? 'Not Supported' + : ''} +
+
+ +
+
+
or
+
+
+ +
+
+
+ Save Your Identity Secret +
+ {isCopied ? ( + + ) : ( + handleCopyIdentitySecret()} + className="cursor-pointer opacity-50 hover:opacity-100 w-4 h-4" + /> + )} + {showIdentitySecret ? ( + setShowIdentitySecret(!showIdentitySecret)} + className="cursor-pointer opacity-50 hover:opacity-100 w-4 h-4" + /> + ) : ( + setShowIdentitySecret(!showIdentitySecret)} + className="cursor-pointer opacity-50 hover:opacity-100 w-4 h-4" + /> + )} +
+ +
+ +
+ setUserConsent(e.target.checked)} + /> +
+
Identity Secret is the only way to generate your account.
+
It needs to be kept securely and not shared with anyone.
+
+ Your account cannot be recovered if you lose your Identity Secret. +
+
+
+ +
+
handleCreateAccount()} + className={` + bg-[#FFFFFF] text-[#092EEA] border border-[#E4E6EA] text-sm py-5 w-full rounded-lg text-center font-medium cursor-pointer hover:bg-gray-100 + ${!allowAccountCreate ? 'opacity-50 cursor-not-allowed hover:bg-[#FFFFFF]' : ''} + `} + > + Create Account +
+
+
+ + ); +} diff --git a/src/pages/Locked/Setup.tsx b/src/pages/Locked/Setup.tsx new file mode 100644 index 00000000..227c36c3 --- /dev/null +++ b/src/pages/Locked/Setup.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useState } from 'react'; +import logo from '../../assets/img/icon-128.png'; +import { BackgroundActiontype } from '../../entries/Background/rpc'; +import { ArrowRightCircleIcon, XIcon } from 'lucide-react'; +import { useDispatch } from 'react-redux'; +import { setIdentity } from '../../reducers/identity'; + +export default function Setup({ + setCreateNewIdentity, +}: { + setCreateNewIdentity: (value: boolean) => void; +}) { + const dispatch = useDispatch(); + const [showIdentityInput, setShowIdentityInput] = useState(false); + const [identitySecret, setIdentitySecret] = useState(''); + const handleUseExistingAccount = useCallback(async () => { + try { + await chrome.runtime.sendMessage({ + type: BackgroundActiontype.request_unlock_extension, + data: { + left: window.screenX + 24, + top: window.screenY, + width: window.innerWidth - 48, + }, + }); + } catch (error) { + console.log('error', error); + } + }, []); + + const handleCreateAccount = useCallback(async () => { + if (identitySecret.length !== 44) { + return; + } + + const userId = identitySecret; + + try { + dispatch(await setIdentity(userId)); + } catch (error) { + console.log('error', error); + } + }, [identitySecret, dispatch, setIdentity]); + + return ( +
+ logo +
Welcome to Pangea Attestor
+ +
+
setCreateNewIdentity(true)} + className="bg-[#092EEA] text-white text-sm py-5 w-full rounded-lg text-center font-medium cursor-pointer hover:bg-[#092EEA]/80" + > + Get Started +
+
+ +
+
+
or
+
+
+ +
+
+ I already have an account +
+
+ With Passkey +
+ + {showIdentityInput ? ( +
+ setIdentitySecret(e.target.value)} + placeholder="Enter your identity secret" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateAccount(); + } + }} + /> + {identitySecret.length === 44 ? ( + + ) : ( + { + setIdentitySecret(''); + setShowIdentityInput(false); + }} + className="w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 cursor-pointer" + /> + )} +
+ ) : ( +
setShowIdentityInput(true)} + className="bg-[#FFFFFF] text-[#092EEA] text-sm py-2 w-full text-center font-medium cursor-pointer hover:bg-gray-100 rounded-md" + > + With Identity Secret +
+ )} +
+
+ ); +} diff --git a/src/pages/Locked/index.tsx b/src/pages/Locked/index.tsx new file mode 100644 index 00000000..2e55c28d --- /dev/null +++ b/src/pages/Locked/index.tsx @@ -0,0 +1,23 @@ +import { useIdentity } from '../../reducers/identity'; +import React, { useEffect, useState } from 'react'; +import Setup from './Setup'; +import Lock from './Lock'; +import NewIdentity from './NewIdentity'; + +export default function Locked() { + const { isSetupCompleted } = useIdentity(); + const [createNewIdentity, setCreateNewIdentity] = useState(false); + + useEffect(() => { + console.log('isSetupCompleted', isSetupCompleted); + }, [isSetupCompleted]); + + if (!isSetupCompleted) { + if (createNewIdentity) { + return ; + } + return ; + } + + return ; +} diff --git a/src/pages/Options/index.tsx b/src/pages/Options/index.tsx index 780a0825..1ad41fdc 100644 --- a/src/pages/Options/index.tsx +++ b/src/pages/Options/index.tsx @@ -64,7 +64,7 @@ export default function Options(): ReactElement { const requests = useUniqueRequests(); const [devMode, setDevMode] = useDevMode(); - const [identity, setIdentity] = useIdentity(); + const { identity } = useIdentity(); useEffect(() => { (async () => { diff --git a/src/reducers/identity.ts b/src/reducers/identity.ts index 109a3ccd..d5a4cc14 100644 --- a/src/reducers/identity.ts +++ b/src/reducers/identity.ts @@ -1,58 +1,123 @@ import { Identity } from '@semaphore-protocol/identity'; -import { sha256 } from '../utils/misc'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; - -export class IdentityManager { - async getIdentity(): Promise { - const identityStorageId = await sha256('identity'); - try { - const storage = await chrome.storage.sync.get(identityStorageId); - const identity = storage[identityStorageId]; - if (!identity) { - return this._createIdentity(); - } - return new Identity(identity); - } catch (e) { - return this._createIdentity(); - } - } +import { useSelector } from 'react-redux'; +import { AppRootState } from './index'; +import deepEqual from 'fast-deep-equal'; +import { BackgroundActiontype } from '../entries/Background/rpc'; + +enum ActionType { + '/identity/setIdentity' = '/identity/setIdentity', + '/identity/setLoading' = '/identity/setLoading', +} + +type Action = { + type: ActionType; + payload?: payload; + error?: boolean; + meta?: any; +}; + +type State = { + loading: boolean; + identity: Identity | null; + isSetupCompleted: boolean; +}; - async _saveIdentity(identity: Identity): Promise { - const identityStorageId = await sha256('identity'); - try { - await chrome.storage.sync.set({ - [identityStorageId]: identity.privateKey.toString(), // Only PRIVATE KEY is enough to reconstruct the identity - }); - } catch (e) { - console.error('Error saving identity', e); - } +const initState: State = { + loading: true, + identity: null, + isSetupCompleted: false, +}; + +export const setIdentity = async ( + userId: string | null, +): Promise< + Action<{ identity: Identity | null; isSetupCompleted?: boolean }> +> => { + if (!userId) { + return { + type: ActionType['/identity/setIdentity'], + payload: { + identity: null, + }, + }; } - async _createIdentity(): Promise { - console.log('creating identity'); - const identity = new Identity(); - await this._saveIdentity(identity); - return identity; + const identity = new Identity(userId); + const publicKey = [ + identity.publicKey[0].toString(), + identity.publicKey[1].toString(), + ]; + + const storage = await chrome.storage.sync.get('isSetupCompleted'); + const isSetupCompleted = storage?.isSetupCompleted ?? false; + if (!isSetupCompleted) { + await chrome.storage.sync.set({ + isSetupCompleted: true, + identityPublicKey: JSON.stringify(publicKey), + }); } - async loadIdentity(privateKey: string): Promise { - const identity = new Identity(privateKey); - await this._saveIdentity(identity); - return identity; + // send message to background to update identity + await chrome.runtime.sendMessage({ + type: BackgroundActiontype.identity_updated, + data: { + identitySecret: userId, + }, + }); + return { + type: ActionType['/identity/setIdentity'], + payload: { + identity, + isSetupCompleted: true, + }, + }; +}; + +export const initIdentity = async () => { + // await chrome.storage.sync.remove('isSetupCompleted'); + const storage = await chrome.storage.sync.get('isSetupCompleted'); + console.log('storage', storage); + const isSetupCompleted = storage?.isSetupCompleted ?? false; + return { + type: ActionType['/identity/setLoading'], + payload: { + loading: false, + isSetupCompleted, + }, + }; +}; + +export default function identity( + state = initState, + action: Action, +): State { + switch (action.type) { + case ActionType['/identity/setIdentity']: + return { + ...state, + ...action.payload, + }; + + case ActionType['/identity/setLoading']: + return { + ...state, + ...action.payload, + }; + default: + return state; } } -export const useIdentity = (): [ - Identity | null, - Dispatch>, -] => { - const [identity, setIdentity] = useState(null); - useEffect(() => { - (async () => { - const identityManager = new IdentityManager(); - const identity = await identityManager.getIdentity(); - setIdentity(identity); - })(); - }, []); - return [identity, setIdentity]; +export const useIdentity = (): { + loading: boolean; + identity: Identity | null; + isSetupCompleted: boolean; +} => { + return useSelector((state: AppRootState) => { + return { + loading: state.identity.loading, + identity: state.identity.identity ?? null, + isSetupCompleted: state.identity.isSetupCompleted ?? false, + }; + }, deepEqual); }; diff --git a/src/reducers/index.tsx b/src/reducers/index.tsx index 42b15667..ddd8d656 100644 --- a/src/reducers/index.tsx +++ b/src/reducers/index.tsx @@ -2,11 +2,13 @@ import { combineReducers } from 'redux'; import requests from './requests'; import history from './history'; import plugins from './plugins'; +import identity from './identity'; const rootReducer = combineReducers({ requests, history, plugins, + identity, }); export type AppRootState = ReturnType; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index cb4e4e8c..149e4759 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -468,3 +468,24 @@ export function urlToRegex(url: string): string { return regexPattern; } + +export const arrayBufferToBase64 = (buffer: ArrayBuffer) => { + const binary = String.fromCharCode(...new Uint8Array(buffer)); + return window.btoa(binary); +}; + +export const base64ToArrayBuffer = (base64: string) => { + const binary = window.atob(base64); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return buffer; +}; + +export const generateRandomArrayBuffer = (size: number) => { + const array = new Uint8Array(size); + window.crypto.getRandomValues(array); + return array; +}; diff --git a/webpack.config.js b/webpack.config.js index 128072a9..2795c22c 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -50,6 +50,7 @@ var options = { contentScript: path.join(__dirname, "src", "entries", "Content", "index.ts"), content: path.join(__dirname, "src", "entries", "Content", "content.ts"), offscreen: path.join(__dirname, "src", "entries", "Offscreen", "index.tsx"), + auth: path.join(__dirname, "src", "entries", "Popup", "auth.ts"), }, // chromeExtensionBoilerplate: { // notHotReload: ["background", "contentScript", "devtools"], @@ -238,6 +239,12 @@ var options = { chunks: ["popup"], cache: false, }), + new HtmlWebpackPlugin({ + template: path.join(__dirname, "src", "entries", "Popup", "auth.html"), + filename: "auth.html", + chunks: ["auth"], + cache: false, + }), new HtmlWebpackPlugin({ template: path.join(__dirname, "src", "entries", "Offscreen", "index.html"), filename: "offscreen.html",