Skip to content

Commit

Permalink
feat: add arbitrary message signing
Browse files Browse the repository at this point in the history
closes #1051
  • Loading branch information
beguene committed Apr 25, 2022
1 parent 39baa4d commit 873d1ab
Show file tree
Hide file tree
Showing 34 changed files with 2,223 additions and 1,359 deletions.
7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
"@sentry/tracing": "6.16.1",
"@stacks/blockchain-api-client": "0.65.0",
"@stacks/common": "4.0.0",
"@stacks/connect": "6.5.0",
"@stacks/connect": "https://github.com/beguene/connect-tgz/raw/main/stacks-connect-v6.4.1.tgz",
"@stacks/connect-ui": "5.5.0",
"@stacks/encryption": "4.0.0",
"@stacks/network": "4.0.0",
Expand Down Expand Up @@ -149,6 +149,7 @@
"react-virtuoso": "2.6.0",
"redux-persist": "6.0.0",
"svg-url-loader": "7.1.1",
"sha.js": "2.4.11",
"ts-debounce": "4",
"use-events": "1.4.2",
"use-latest": "1.2.0",
Expand All @@ -168,8 +169,7 @@
"@emotion/cache": "11.7.1",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
"@schemastore/web-manifest": "0.0.5",
"@stacks/auth": "4.0.0",
"@stacks/connect-react": "14.0.0",
"@stacks/connect-react": "https://github.com/beguene/connect-tgz/raw/main/stacks-connect-react-v13.0.2.tgz",
"@stacks/eslint-config": "1.0.10",
"@stacks/prettier-config": "0.0.10",
"@stacks/stacks-blockchain-api-types": "0.65.0",
Expand Down Expand Up @@ -273,7 +273,6 @@
"async": "2.6.4",
"bn.js": "5.2.0",
"buffer": "6.0.3",
"css-what": "5.0.1",
"dot-prop": "6.0.1",
"hosted-git-info": "4.0.2",
"immer": "9.0.6",
Expand Down
31 changes: 31 additions & 0 deletions src/app/common/actions/finalize-message-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ExternalMethods, MESSAGE_SOURCE, SignatureResponseMessage } from '@shared/message-types';
import { deleteTabForRequest, StorageKey } from '@shared/utils/storage';
import { logger } from '@shared/logger';
import { SignatureData } from '@shared/crypto/sign-message';

export const finalizeMessageSignature = (
requestPayload: string,
tabId: number,
data: SignatureData | string
) => {
try {
const responseMessage: SignatureResponseMessage = {
source: MESSAGE_SOURCE,
method: ExternalMethods.signatureResponse,
payload: {
signatureRequest: requestPayload,
signatureResponse: data,
},
};
chrome.tabs.sendMessage(tabId, responseMessage);
deleteTabForRequest(StorageKey.signatureRequests, requestPayload);
// If this is a string, then the transaction has been canceled
// and the user has closed the window
window.close();
} catch (error) {
logger.debug('Failed to get Tab ID for message signature request:', requestPayload);
throw new Error(
'Your message was signed, but we lost communication with the app you started with.'
);
}
};
1 change: 1 addition & 0 deletions src/app/common/hooks/use-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export enum LoadingKeys {
EDIT_NONCE_DRAWER = 'loading/EDIT_NONCE_DRAWER',
INCREASE_FEE_DRAWER = 'loading/INCREASE_FEE_DRAWER',
SUBMIT_TRANSACTION = 'loading/SUBMIT_TRANSACTION',
SUBMIT_SIGNATURE = 'loading/SUBMIT_SIGNATURE',
}

export function useLoading(key: string) {
Expand Down
100 changes: 100 additions & 0 deletions src/app/common/signature/requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Account, getAppPrivateKey } from '@stacks/wallet-sdk';
import { SignaturePayload } from '@stacks/connect';
import { decodeToken, TokenVerifier } from 'jsontokens';
import { getPublicKeyFromPrivate } from '@stacks/encryption';
import { getAddressFromPrivateKey, TransactionVersion } from '@stacks/transactions';
import { logger } from '@shared/logger';
import { getRequestOrigin, StorageKey } from '@shared/utils/storage';
import { getPayloadFromToken } from '@app/store/signatures/utils';

function getTransactionVersionFromRequest(signature: SignaturePayload) {
const { network } = signature;
if (!network) return TransactionVersion.Mainnet;
if (![TransactionVersion.Mainnet, TransactionVersion.Testnet].includes(network.version)) {
throw new Error('Invalid network version provided');
}
return network.version;
}

const UNAUTHORIZED_SIGNATURE_REQUEST =
'The signature request provided is not signed by this wallet.';
/**
* Verify a transaction request.
* A transaction request is a signed JWT that is created on an app,
* via `@stacks/connect`. The private key used to sign this JWT is an
* `appPrivateKey`, which an app can get from authentication.
*
* The payload in this JWT can include an `stxAddress`. This indicates the
* 'default' STX address that should be used to sign this transaction. This allows
* the wallet to use the same account to sign a transaction as it used to sign
* in to the app.
*
* This JWT is invalidated if:
* - The JWT is not signed properly
* - The public key used to sign this tx request does not match an `appPrivateKey`
* for any of the accounts in this wallet.
* - The `stxAddress` provided in the payload does not match an STX address
* for any of the accounts in this wallet.
*
* @returns The decoded and validated `SignaturePayload`
* @throws if the transaction request is invalid
*/
interface VerifySignatureRequestArgs {
requestToken: string;
accounts: Account[];
appDomain: string;
}
export async function verifySignatureRequest({
requestToken,
accounts,
appDomain,
}: VerifySignatureRequestArgs): Promise<SignaturePayload> {
const token = decodeToken(requestToken);
const signature = token.payload as unknown as SignaturePayload;
const { publicKey, stxAddress } = signature;
const txVersion = getTransactionVersionFromRequest(signature);
const verifier = new TokenVerifier('ES256k', publicKey);
const isSigned = await verifier.verifyAsync(requestToken);
if (!isSigned) {
throw new Error('Signature request is not signed');
}
const foundAccount = accounts.find(account => {
const appPrivateKey = getAppPrivateKey({
account,
appDomain,
});
const appPublicKey = getPublicKeyFromPrivate(appPrivateKey);
if (appPublicKey !== publicKey) return false;
if (!stxAddress) return true;
const accountStxAddress = getAddressFromPrivateKey(account.stxPrivateKey, txVersion);
if (stxAddress !== accountStxAddress) return false;
return true;
});
if (!foundAccount) {
throw new Error(UNAUTHORIZED_SIGNATURE_REQUEST);
}
return signature;
}

export const requestTokenState = () => {
const searchParams = new URLSearchParams(window.location.hash.slice(2));
const result = searchParams.get('signature?request');
return result;
};

export const requestTokenPayloadState = () => {
const token = requestTokenState();
if (!token) return null;
return getPayloadFromToken(token);
};

export const requestTokenOriginState = () => {
const token = requestTokenState();
if (!token) return;
try {
return getRequestOrigin(StorageKey.signatureRequests, token);
} catch (e) {
logger.error(e);
return false;
}
};
7 changes: 6 additions & 1 deletion src/app/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,15 @@ export function getUrlHostname(url: string) {
return new URL(url).hostname;
}

export function getUrlPort(url: string) {
function getUrlPort(url: string) {
return new URL(url).port;
}

export function addPortSuffix(url: string) {
const port = getUrlPort(url);
return port ? `:${port}` : '';
}

export async function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { CurrentAccountAvatar } from '@app/features/current-account/current-acco
import { CurrentAccountName } from '@app/features/current-account/current-account-name';
import { CurrentStxAddress } from '@app/features/current-account/current-stx-address';

import { Balance } from './balance';
import { useCurrentAccount } from '@app/store/accounts/account.hooks';
import { Balance } from '@app/components/balance';

function PopupHeaderSuspense(): JSX.Element {
const account = useCurrentAccount();
Expand Down
60 changes: 60 additions & 0 deletions src/app/pages/signature-request/components/hash-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Stack, Flex, Box, Text } from '@stacks/ui';
import { FiChevronDown, FiChevronUp } from 'react-icons/fi';
import { useState } from 'react';

interface ShowHashButtonProps {
expanded: boolean;
}
const ShowHashButton = (props: ShowHashButtonProps) => {
const { expanded } = props;
return <Box as={expanded ? FiChevronDown : FiChevronUp} mr="tight" size="20px" />;
};

interface HashDrawerProps {
hash: string;
}

export function HashDrawer(props: HashDrawerProps): JSX.Element | null {
const { hash } = props;
const [showHash, setShowHash] = useState(false);
const [displayHash, setDisplayHash] = useState(hash);
return (
<Stack py="tight" px="tight" spacing="loose">
<Flex marginBottom="0px !important">
<Text display="block" fontSize={0}>
{showHash ? 'Hide hash' : 'Show hash'}
</Text>
<Box
_hover={{ cursor: 'pointer' }}
style={{ marginLeft: 'auto' }}
onClick={() => {
setDisplayHash(showHash ? '' : hash);
setShowHash(!showHash);
}}
>
<ShowHashButton expanded={showHash} />
</Box>
</Flex>
<Box
transition="all 0.65s cubic-bezier(0.23, 1, 0.32, 1)"
py={showHash ? 'tight' : 'none'}
height={showHash ? '100%' : '0'}
visibility={showHash ? 'visible' : 'hidden'}
>
<Stack spacing="base-tight">
<Text
display="block"
color="#74777D"
fontSize={2}
fontWeight={500}
lineHeight="1.6"
wordBreak="break-all"
fontFamily={'Fira Code'}
>
{displayHash}
</Text>
</Stack>
</Box>
</Stack>
);
}
51 changes: 51 additions & 0 deletions src/app/pages/signature-request/components/message-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { color, Stack, Text } from '@stacks/ui';
import { sha256 } from 'sha.js';
import { HashDrawer } from './hash-drawer';
import { useEffect, useState } from 'react';

interface MessageBoxProps {
message: string;
}
export function MessageBox(props: MessageBoxProps): JSX.Element | null {
const { message } = props;
const [hash, setHash] = useState<string | undefined>();
useEffect(() => {
setHash(new sha256().update(message).digest('hex'));
}, [message]);

if (!message) return null;

return (
<>
<Stack minHeight={'260px'}>
<Stack
border="4px solid"
borderColor={color('border')}
borderRadius="12px"
backgroundColor={color('border')}
>
<Stack
py="base-tight"
px="base-loose"
spacing="loose"
borderRadius="12px"
backgroundColor={'white'}
>
<Stack spacing="base-tight">
<Text
display="block"
fontSize={2}
fontWeight={500}
lineHeight="1.6"
wordBreak="break-all"
>
{message}
</Text>
</Stack>
</Stack>
{hash ? <HashDrawer hash={hash} /> : null}
</Stack>
</Stack>
</>
);
}
31 changes: 31 additions & 0 deletions src/app/pages/signature-request/components/network-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { whenChainId } from '@app/common/transactions/transaction-utils';
import { SpaceBetween } from '@app/components/space-between';
import { Caption } from '@app/components/typography';
import { StacksNetwork } from '@stacks/network';
import { ChainID } from '@stacks/transactions';
import { Box } from '@stacks/ui';

interface NetworkRowProps {
network: StacksNetwork;
}
export function NetworkRow(props: NetworkRowProps): JSX.Element | null {
const { network } = props;

return (
<Box spacing="base">
<SpaceBetween position="relative">
<Box alignItems="center" isInline>
<Caption>No fees will be incured</Caption>
</Box>
<Caption>
<span>
{whenChainId(network.chainId)({
[ChainID.Testnet]: 'Testnet',
[ChainID.Mainnet]: 'Mainnet',
})}
</span>
</Caption>
</SpaceBetween>
</Box>
);
}
32 changes: 32 additions & 0 deletions src/app/pages/signature-request/components/page-top.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { memo } from 'react';
import { Stack } from '@stacks/ui';

import { useCurrentNetwork } from '@app/common/hooks/use-current-network';
import { addPortSuffix, getUrlHostname } from '@app/common/utils';
import { Caption, Title } from '@app/components/typography';
import { requestTokenOriginState, requestTokenPayloadState } from '@app/common/signature/requests';

function PageTopBase(): JSX.Element | null {
const signatureRequest = requestTokenPayloadState();
const origin = requestTokenOriginState();
const network = useCurrentNetwork();
if (!signatureRequest) return null;

const appName = signatureRequest?.appDetails?.name;
const originAddition = origin ? ` (${getUrlHostname(origin)})` : '';
const testnetAddition = network.isTestnet
? ` using ${getUrlHostname(network.url)}${addPortSuffix(network.url)}`
: '';
const caption = appName ? `Requested by "${appName}"${originAddition}${testnetAddition}` : null;

return (
<Stack pt="extra-loose" spacing="base">
<Title fontWeight="bold" as="h1">
Sign Message
</Title>
{caption && <Caption wordBreak="break-word">{caption}</Caption>}
</Stack>
);
}

export const PageTop = memo(PageTopBase);
Loading

0 comments on commit 873d1ab

Please sign in to comment.