diff --git a/src/app/App.tsx b/src/app/App.tsx index 1d03ed1..4a33237 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -50,10 +50,12 @@ import { transferType, } from '../types'; import { useFullscreenMac } from './hooks/useFullscreenMac'; +import RequestAccessPage from './components/RequestAccessPage'; function App() { const { state, dispatch } = useContext(Store); const [confirmationModalOpen, setConfirmationModalOpen] = useState(false); + const [accessOpened, setAccessOpened] = useState(false); const [incorrectPassword, setIncorrectPassword] = useState(false); const [loggedIn, setLoggedIn] = useState(false); @@ -352,6 +354,20 @@ function App() { } } + async function getAccessRequests() { + const res = await fetchBackground({ + method: 'GET_ACCESS_REQUESTS', + }); + + const requests = res.data; + + if (requests && requests.length > 0 && !accessOpened) { + setAccessOpened(true); + + goTo(RequestAccessPage, { accessRequests: requests }); + } + } + async function getAliasCreationRequests() { const response = await fetchBackground({ method: 'GET_ALIAS_CREATE_REQUESTS', @@ -563,6 +579,7 @@ function App() { } } + await getAccessRequests(); await getBurnAssetRequests(); await getAliasCreationRequests(); await getIonicSwapRequests(); diff --git a/src/app/assets/svg/asset.svg b/src/app/assets/svg/asset.svg new file mode 100644 index 0000000..f9a0f3b --- /dev/null +++ b/src/app/assets/svg/asset.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/app/assets/svg/history.svg b/src/app/assets/svg/history.svg new file mode 100644 index 0000000..ff4283c --- /dev/null +++ b/src/app/assets/svg/history.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/app/assets/svg/wallet.svg b/src/app/assets/svg/wallet.svg new file mode 100644 index 0000000..ec64842 --- /dev/null +++ b/src/app/assets/svg/wallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/components/OuterConfirmation/OuterConfirmation.module.scss b/src/app/components/OuterConfirmation/OuterConfirmation.module.scss index b6820f7..99baccb 100644 --- a/src/app/components/OuterConfirmation/OuterConfirmation.module.scss +++ b/src/app/components/OuterConfirmation/OuterConfirmation.module.scss @@ -133,7 +133,7 @@ &_row { display: flex; justify-content: space-between; - width: calc(var(--app-width) - 20px); + width: calc(var(--app-width) - 40px); margin-inline: auto; &.total { @@ -165,7 +165,7 @@ &_error { margin-inline: auto; - width: calc(var(--app-width) - 20px); + width: calc(var(--app-width) - 40px); color: #ff6767; font-size: 14px; font-weight: 500; @@ -187,7 +187,7 @@ place-content: center; .content { - width: calc(var(--app-width) - 20px); + width: calc(var(--app-width) - 40px); display: flex; gap: 8px; } diff --git a/src/app/components/RequestAccessPage/index.tsx b/src/app/components/RequestAccessPage/index.tsx new file mode 100644 index 0000000..acfb321 --- /dev/null +++ b/src/app/components/RequestAccessPage/index.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { getCurrent, goBack } from 'react-chrome-extension-router'; +import styles from './styles.module.scss'; +import Button, { ButtonThemes } from '../UI/Button/Button'; +import infoIcon from '../../assets/svg/info-blue.svg'; +import walletIcon from '../../assets/svg/wallet.svg'; +import assetIcon from '../../assets/svg/asset.svg'; +import historyIcon from '../../assets/svg/history.svg'; +import { fetchBackground } from '../../utils/utils'; +import { PermissionType } from '../../../types'; + +const permissionMap = { + general: { + title: 'Wallet address', + desc: 'View your address, alias and suggest transactions', + icon: walletIcon, + }, + balance: { + title: 'Balance', + desc: 'View your wallet balances', + icon: assetIcon, + }, + history: { + title: 'Transaction history', + desc: 'View your transactions', + icon: historyIcon, + }, +}; + +const RequestAccessPage = () => { + const { props } = getCurrent(); + const { accessRequests } = props; + const [reqIndex, setReqIndex] = useState(0); + const [accepting, setAccepting] = useState(false); + const [denying, setDenying] = useState(false); + + if (!accessRequests?.length) return null; + + const req = accessRequests[reqIndex]; + + if (!req) return null; + + async function nextRequest() { + if (reqIndex < accessRequests.length - 1) { + setReqIndex(reqIndex + 1); + } else { + goBack(); + } + } + + async function acceptClick() { + setAccepting(true); + + await fetchBackground({ + method: 'FINALIZE_REQUEST_ACCESS', + id: req.id, + success: true, + }); + + setAccepting(false); + await nextRequest(); + } + + async function denyClick() { + if (denying) return; + + setDenying(true); + + await fetchBackground({ + method: 'FINALIZE_REQUEST_ACCESS', + id: req.id, + success: false, + }); + + setDenying(false); + await nextRequest(); + } + + return ( +
+

Access Request

+

Review access before continuing

+ +
+
+ site favicon +

{req.hostname}

+
+ +
+
Permissions requested:
+ +
+ {req?.permissions?.map((p: { type: PermissionType }) => { + const item = permissionMap[p.type]; + + if (!item) return null; + + return ( +
+ icon + +
+

{item.title}

+

{item.desc}

+
+
+ ); + })} +
+
+
+ +
+

+ info + This site cannot move funds without your approval +

+ +
+ + +
+
+
+ ); +}; + +export default RequestAccessPage; diff --git a/src/app/components/RequestAccessPage/styles.module.scss b/src/app/components/RequestAccessPage/styles.module.scss new file mode 100644 index 0000000..aa84b45 --- /dev/null +++ b/src/app/components/RequestAccessPage/styles.module.scss @@ -0,0 +1,141 @@ +@import 'src/app/styles/variables'; + +.main { + height: calc($appHeight - 72px); + display: flex; + flex-direction: column; + + &__title { + text-align: center; + font-size: 22px; + font-weight: 500; + + :global(.app-fullscreen) & { + font-size: 25px; + } + } + + &__subtitle { + margin-top: 4px; + text-align: center; + color: #b6b6c4; + font-size: 16px; + font-weight: 400; + + :global(.app-fullscreen) & { + font-size: 18px; + } + } + + &__content { + margin-top: 12px; + flex: 1; + display: flex; + flex-direction: column; + } + + &__siteInfo { + display: flex; + align-items: center; + gap: 16px; + padding-inline: 16px; + padding-block: 12px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom: 1px solid #273666; + background-color: rgba(31, 143, 235, 0.15); + + &_icon { + width: 38px; + height: 38px; + } + + &_origin { + font-size: 18px; + font-weight: 600; + } + } + + &__permissions { + padding-inline: 16px; + padding-block: 12px; + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + background-color: #0f2055; + display: flex; + flex-direction: column; + gap: 10px; + + &_title { + font-size: 16px; + font-weight: 500; + color: #b6b6c4; + } + + &_list { + display: flex; + flex-direction: column; + gap: 6px; + + .item { + background-color: rgba(31, 143, 235, 0.15); + padding-block: 12px; + padding-inline: 16px; + border-radius: 16px; + display: flex; + align-items: center; + gap: 16px; + + &__text { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title { + font-size: 16px; + font-weight: 600; + } + + &__desc { + font-size: 14px; + font-weight: 400; + opacity: 0.7; + } + } + } + } + + &__bottom { + position: fixed; + bottom: 0; + left: 0; + z-index: 3; + background-color: #0f2055; + border-top: 1px solid #273666; + width: 100%; + padding: 16px; + padding-top: 12px; + + &_info { + max-width: calc(var(--app-width) - 20px); + width: 100%; + margin-inline: auto; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 400; + color: #b6b6c4; + } + + &_actions { + max-width: calc(var(--app-width) - 20px); + width: 100%; + margin-inline: auto; + margin-top: 16px; + display: flex; + gap: 8px; + } + } +} diff --git a/src/app/components/Wallet/Wallet.tsx b/src/app/components/Wallet/Wallet.tsx index 5d7f04d..3f823ba 100644 --- a/src/app/components/Wallet/Wallet.tsx +++ b/src/app/components/Wallet/Wallet.tsx @@ -1,6 +1,5 @@ import React, { Dispatch, SetStateAction, useContext, useRef, useState } from 'react'; import Decimal from 'decimal.js'; -import { styleText } from 'util'; import copyIcon from '../../assets/svg/copy.svg'; import dotsIcon from '../../assets/svg/dots.svg'; import sendIcon from '../../assets/svg/send.svg'; diff --git a/src/app/styles/_variables.scss b/src/app/styles/_variables.scss index ef6f206..5a6921d 100644 --- a/src/app/styles/_variables.scss +++ b/src/app/styles/_variables.scss @@ -2,12 +2,12 @@ $fontFamily: 'SFUIDisplay'; $fontSize: 18px; $mainColor: #ffffff; $blueColor: #1f8feb; -$appWidth: 360px; +$appWidth: 370px; $appHeight: 600px; :root { // Sizes - --app-width: 360px; + --app-width: 370px; --app-height: 600px; // Z-index's diff --git a/src/app/utils/permission.ts b/src/app/utils/permission.ts new file mode 100644 index 0000000..c40dd5a --- /dev/null +++ b/src/app/utils/permission.ts @@ -0,0 +1,55 @@ +import { getWalletData } from '../../background/wallet'; +import { METHOD_EXTRA_PERMISSIONS, PUBLIC_METHODS } from '../../constants'; +import { PermissionType, RequestType, Sender, SendResponse } from '../../types'; +import { normalizeOrigin } from './utils'; + +export async function getPermissions(origin: string, address: string): Promise { + const stored = await chrome.storage.local.get('permissions'); + const map = stored.permissions || {}; + + return (map?.[origin]?.[address] || []).map((p: { type: PermissionType }) => p.type); +} + +export function hasPermission(userPerms: PermissionType[], required: PermissionType) { + return userPerms.includes(required); +} + +export async function permissionMiddleware( + request: RequestType, + sender: Sender, + sendResponse: SendResponse, +): Promise { + const isFromExtensionFrontend = sender.url && sender.url.includes(chrome.runtime.getURL('/')); + + if (PUBLIC_METHODS.includes(request.method) || isFromExtensionFrontend) return true; + + if (!sender.origin && !sender.url) { + sendResponse({ error: 'Unknown origin' }); + return false; + } + + const origin = normalizeOrigin(sender.origin || new URL(sender.url!).origin); + + const wallet = await getWalletData(); + const { address } = wallet; + + const perms = await getPermissions(origin, address); + + if (!hasPermission(perms, 'general')) { + sendResponse({ error: 'General permission required' }); + return false; + } + + const extra = METHOD_EXTRA_PERMISSIONS[request.method]; + + if (extra) { + const ok = extra.every((p) => hasPermission(perms, p as PermissionType)); + + if (!ok) { + sendResponse({ error: 'Permission denied' }); + return false; + } + } + + return true; +} diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 2b1eebc..b9692ae 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -83,3 +83,11 @@ export function isPositiveFloatStr( const regExp = allowCommaSeparator ? /^\d+([.,]\d*)?$/ : /^\d+(\.\d*)?$/; return regExp.test(input); } + +export function normalizeOrigin(origin: string) { + try { + return new URL(origin).origin; + } catch { + return origin; + } +} diff --git a/src/background/background.ts b/src/background/background.ts index 9694b43..2a80419 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,6 +1,15 @@ import JSONbig from 'json-bigint'; -import { ZANO_ASSET_ID } from '../constants'; -import { BurnAssetDataType, ionicSwapType, RequestType, TransferDataType } from '../types/index'; +import { SELF_ONLY_REQUESTS, ZANO_ASSET_ID } from '../constants'; +import { + AccessRequestType, + BurnAssetDataType, + ionicSwapType, + RequestType, + TransferDataType, + GetWalletDataRes, + Sender, + SendResponse, +} from '../types/index'; import { fetchData, getWalletData, @@ -21,7 +30,8 @@ import { updateAlias, getAliasByAddress, } from './wallet'; -import { truncateToDecimals } from '../app/utils/utils'; +import { normalizeOrigin, truncateToDecimals } from '../app/utils/utils'; +import { getPermissions, hasPermission, permissionMiddleware } from '../app/utils/permission'; const POPUP_HEIGHT = 630; const POPUP_WIDTH = 370; @@ -252,54 +262,22 @@ function openWindow(): Promise { }); } -// requests that can only be made by the extension frontend -const SELF_ONLY_REQUESTS = [ - 'SET_API_CREDENTIALS', - 'VALIDATE_CONNECT_KEY', - 'GET_PASSWORD', - 'GET_SIGN_REQUESTS', - 'FINALIZE_MESSAGE_SIGN', - 'GET_IONIC_SWAP_REQUESTS', - 'GET_ALIAS_CREATE_REQUESTS', - 'FINALIZE_IONIC_SWAP_REQUEST', - 'GET_ACCEPT_IONIC_SWAP_REQUESTS', - 'FINALIZE_ACCEPT_IONIC_SWAP_REQUEST', - 'FINALIZE_ALIAS_CREATE', - 'SET_PASSWORD', - 'SEND_TRANSFER', - 'EXECUTE_BRIDGING_TRANSFER', - 'PING_WALLET', - 'SET_ACTIVE_WALLET', - 'GET_WALLETS', - 'FINALIZE_TRANSFER_REQUEST', - 'GET_ASSETS_WHITELIST_ADD_REQUESTS', - 'FINALIZE_ASSETS_WHITELIST_ADD_REQUESTS', - 'GET_BURN_ASSET_REQUESTS', - 'FINALIZE_BURN_ASSET_REQUEST', - 'GET_TRANSFER_REQUEST', - 'REGISTER_ALIAS', - 'UPDATE_ALIAS', - 'GET_ALIAS_BY_ADDRESS', -]; -interface Sender { - id: string; - name?: string; - email?: string; - phoneNumber?: string; - address?: string; - [key: string]: string | undefined; -} - chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - processRequest(request, sender as Sender, sendResponse); + (async () => { + await processRequest(request, sender as Sender, sendResponse); + })(); return true; }); -interface SendResponse { - (_response: unknown): void; -} +const accessReqs: AccessRequestType[] = []; + +const accessReqFinalizers: Record void> = {}; async function processRequest(request: RequestType, sender: Sender, sendResponse: SendResponse) { + const allowed = await permissionMiddleware(request, sender, sendResponse); // check permission access + + if (!allowed) return; + const isFromExtensionFrontend = sender.url && sender.url.includes(chrome.runtime.getURL('/')); if (SELF_ONLY_REQUESTS.includes(request.method) && !isFromExtensionFrontend) { @@ -330,6 +308,117 @@ async function processRequest(request: RequestType, sender: Sender, sendResponse .catch((_err) => sendResponse({ data: false })); break; + case 'REQUEST_ACCESS': { + if (!sender.origin && !sender.url) { + return sendResponse({ error: 'Unknown origin' }); + } + + const allowed = ['general', 'balance', 'history']; + const { permissions } = request; + + if ( + !Array.isArray(permissions) || + permissions.length === 0 || + permissions.some((p) => !allowed.includes(p.type)) + ) { + return sendResponse({ + error: 'Invalid or empty permissions', + }); + } + + const uniqueTypes = [...new Set(permissions.map((p) => p.type))]; + const cleanPermissions = uniqueTypes.map((type) => ({ type })); + + const origin = normalizeOrigin(sender.origin || new URL(sender.url!).origin); + const wallet = await getWalletData(); + const { address } = wallet; + + const existing = await getPermissions(origin, address); + const alreadyApproved = cleanPermissions.every((p) => existing.includes(p.type)); + + if (alreadyApproved) { + return sendResponse({ success: true }); + } + + openWindow().then((requestWindow) => { + const id = crypto.randomUUID(); + + const { hostname } = new URL(origin); + const favicon = `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`; + + accessReqFinalizers[id] = (result) => sendResponse(result); + + accessReqs.push({ + id, + windowId: Number(requestWindow.id), + origin, + hostname, + favicon, + permissions: cleanPermissions, + }); + }); + + break; + } + + case 'GET_ACCESS_REQUESTS': { + sendResponse({ data: accessReqs }); + break; + } + + case 'FINALIZE_REQUEST_ACCESS': { + const { id, success } = request; + + const req = accessReqs.find((r) => r.id === id); + + if (!req) { + return sendResponse({ error: 'Request not found' }); + } + + const finalize = (data: unknown) => { + accessReqFinalizers[id]?.(data); + delete accessReqFinalizers[id]; + accessReqs.splice(accessReqs.indexOf(req), 1); + + chrome.windows.remove(req.windowId); + }; + + if (!success) { + finalize({ error: 'User rejected the access request' }); + return sendResponse({ data: true }); + } + + const wallet = await getWalletData(); + const { address } = wallet; + + const stored = await chrome.storage.local.get('permissions'); + const map = stored.permissions || {}; + const origin = normalizeOrigin(req.origin); + const newPermissions = req.permissions || []; + + if (!map[origin]) { + map[origin] = {}; + } + + const existing = map[origin][address] || []; + + const merged = [ + ...existing, + ...newPermissions.filter( + (p) => !existing.some((e: { type: string }) => e.type === p.type), + ), + ]; + + map[origin][address] = merged; + + await chrome.storage.local.set({ permissions: map }); + + finalize({ success: true }); + sendResponse({ success: true }); + + break; + } + case 'SET_ACTIVE_WALLET': fetchData('mw_select_wallet', { wallet_id: request.id }) .then((response) => response.json()) @@ -366,18 +455,38 @@ async function processRequest(request: RequestType, sender: Sender, sendResponse }); break; - case 'GET_WALLET_DATA': - getWalletData() // removed request.id - .then((data) => { - sendResponse({ data }); - }) - .catch((error) => { - console.error('Error fetching wallet data:', error); - sendResponse({ - error: 'An error occurred while fetching wallet data', - }); + case 'GET_WALLET_DATA': { + try { + const origin = normalizeOrigin(sender.origin || new URL(sender.url!).origin); + + const data = await getWalletData(); + const perms = await getPermissions(origin, data.address); + + if (isFromExtensionFrontend) { + return sendResponse({ data }); + } + + const filtered: GetWalletDataRes = { + address: data.address, + alias: data.alias, + }; + + if (hasPermission(perms, 'balance')) { + filtered.balance = data.balance; + filtered.assets = data.assets; + } + + if (hasPermission(perms, 'history')) { + filtered.transactions = data.transactions; + } + + return sendResponse({ data: filtered }); + } catch (e) { + return sendResponse({ + error: 'Failed to get wallet data', }); - break; + } + } case 'SEND_TRANSFER': transfer( @@ -479,6 +588,7 @@ async function processRequest(request: RequestType, sender: Sender, sendResponse } const wrongDecimalPoint = destinations.some((dest) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, dec] = dest.amount.toString().split('.'); return dec && dec.length > decimal_point; }); diff --git a/src/constants/index.ts b/src/constants/index.ts index 12de7fc..b80944f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,3 +2,41 @@ export const ZANO_ASSET_ID = 'd6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645 export const BANDIT_ASSET_ID = '55a8e0a730b133fb83915ba0e4335a680ae9d07a99642b17774460560f3b003d'; export const WETH_ASSET_ID = '93da681503353509367e241cda3234299dedbbad9ec851de31e900490807bf0c'; export const WBTC_ASSET_ID = '040a180aca4194a158c17945dd115db42086f6f074c1f77838621a4927fffa91'; + +// requests that can only be made by the extension frontend +export const SELF_ONLY_REQUESTS = [ + 'SET_API_CREDENTIALS', + 'VALIDATE_CONNECT_KEY', + 'GET_PASSWORD', + 'GET_SIGN_REQUESTS', + 'FINALIZE_MESSAGE_SIGN', + 'GET_IONIC_SWAP_REQUESTS', + 'GET_ALIAS_CREATE_REQUESTS', + 'FINALIZE_IONIC_SWAP_REQUEST', + 'GET_ACCEPT_IONIC_SWAP_REQUESTS', + 'FINALIZE_ACCEPT_IONIC_SWAP_REQUEST', + 'FINALIZE_ALIAS_CREATE', + 'SET_PASSWORD', + 'SEND_TRANSFER', + 'EXECUTE_BRIDGING_TRANSFER', + 'PING_WALLET', + 'SET_ACTIVE_WALLET', + 'GET_WALLETS', + 'FINALIZE_TRANSFER_REQUEST', + 'GET_ASSETS_WHITELIST_ADD_REQUESTS', + 'FINALIZE_ASSETS_WHITELIST_ADD_REQUESTS', + 'GET_BURN_ASSET_REQUESTS', + 'FINALIZE_BURN_ASSET_REQUEST', + 'GET_TRANSFER_REQUEST', + 'REGISTER_ALIAS', + 'UPDATE_ALIAS', + 'GET_ALIAS_BY_ADDRESS', + 'GET_ACCESS_REQUESTS', + 'FINALIZE_REQUEST_ACCESS', +]; + +export const METHOD_EXTRA_PERMISSIONS: Record = { + GET_WALLET_BALANCE: ['balance'], +}; + +export const PUBLIC_METHODS = ['REQUEST_ACCESS']; diff --git a/src/types/index.ts b/src/types/index.ts index 266c77a..882f475 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -224,6 +224,8 @@ interface AssetDataType { decimal_point: number; } +export type PermissionType = 'general' | 'balance' | 'history'; + export interface RequestType { method: string; credentials: object; @@ -265,6 +267,16 @@ export interface RequestType { destinations?: destinationsType; destinationAssetAmount?: string; currentAssetAmount?: string; + permissions?: { type: PermissionType }[]; +} + +export interface AccessRequestType { + id: string; + windowId: number; + origin: string; + hostname: string; + favicon: string; + permissions?: { type: PermissionType }[]; } export interface TransferDataType { @@ -293,3 +305,24 @@ export type ValidationsType = { customValidation?: boolean; customError?: boolean; }; + +export interface GetWalletDataRes { + address: string; + alias: string; + balance?: string; + transactions?: Transaction[]; + assets?: WalletAsset[]; +} + +export interface Sender { + id: string; + name?: string; + email?: string; + phoneNumber?: string; + address?: string; + [key: string]: string | undefined; +} + +export interface SendResponse { + (_response: unknown): void; +}