feat: add arbitrary message signing

closes #1051
This commit is contained in:
beguene
2022-04-01 13:12:29 +02:00
parent b5ee3995f2
commit cfb28e8d8e
32 changed files with 2653 additions and 1761 deletions

View File

@@ -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": "6.6.0",
"@stacks/connect-ui": "5.5.0",
"@stacks/encryption": "4.0.0",
"@stacks/network": "4.0.0",
@@ -148,6 +148,7 @@
"react-router-dom": "6.2.1",
"react-virtuoso": "2.6.0",
"redux-persist": "6.0.0",
"sha.js": "2.4.11",
"svg-url-loader": "7.1.1",
"ts-debounce": "4",
"use-events": "1.4.2",
@@ -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": "15.0.0",
"@stacks/eslint-config": "1.0.10",
"@stacks/prettier-config": "0.0.10",
"@stacks/stacks-blockchain-api-types": "0.65.0",
@@ -270,10 +270,11 @@
"**/**/xmldom": "github:xmldom/xmldom#0.7.0",
"**/**/@stacks/network": "4.0.0",
"@redux-devtools/cli/**/tar": "4.4.18",
"@types/react": "17.0.37",
"@types/react-dom": "17.0.11",
"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",

View File

@@ -0,0 +1,27 @@
import { ExternalMethods, MESSAGE_SOURCE, SignatureResponseMessage } from '@shared/message-types';
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);
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.'
);
}
};

View File

@@ -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) {

View File

@@ -0,0 +1,79 @@
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';
export function getPayloadFromToken(requestToken: string) {
const token = decodeToken(requestToken);
return token.payload as unknown as SignaturePayload;
}
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;
}

View File

@@ -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));
}

View File

@@ -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();

View File

@@ -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 ? FiChevronUp : FiChevronDown} size="16px" />;
};
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 px="loose" spacing="loose">
<Flex marginBottom="0px !important">
<Text display="block" fontSize={1} py="tight">
{showHash ? 'Hide hash' : 'Show hash'}
</Text>
<Box
_hover={{ cursor: 'pointer' }}
marginLeft={'auto'}
marginTop={'auto'}
marginBottom={'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)"
height={showHash ? '100%' : '0'}
visibility={showHash ? 'visible' : 'hidden'}
>
<Stack spacing="base-tight">
<Text
display="block"
color="#74777D"
fontSize={1}
lineHeight="1.6"
wordBreak="break-all"
fontFamily={'Fira Code'}
>
{displayHash}
</Text>
</Stack>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,46 @@
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"
paddingBottom={'8px'}
borderColor={color('border')}
borderRadius="20px"
backgroundColor={color('border')}
>
<Stack
py="loose"
px="loose"
spacing="loose"
borderRadius="16px"
backgroundColor={'white'}
>
<Stack spacing="base-tight">
<Text display="block" fontSize={2} lineHeight="1.6" wordBreak="break-all">
{message}
</Text>
</Stack>
</Stack>
{hash ? <HashDrawer hash={hash} /> : null}
</Stack>
</Stack>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,34 @@
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 { getPayloadFromToken } from '@app/common/signature/requests';
import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks';
function PageTopBase(): JSX.Element | null {
const network = useCurrentNetwork();
const { origin, requestToken } = useSignatureRequestSearchParams();
if (!requestToken) return null;
const signatureRequest = getPayloadFromToken(requestToken);
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);

View File

@@ -0,0 +1,67 @@
import { finalizeMessageSignature } from '@app/common/actions/finalize-message-signature';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { delay } from '@app/common/utils';
import { useCurrentAccount } from '@app/store/accounts/account.hooks';
import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks';
import { signMessage } from '@shared/crypto/sign-message';
import { logger } from '@shared/logger';
import { createStacksPrivateKey } from '@stacks/transactions';
import { Button, Stack } from '@stacks/ui';
import { useCallback, useState } from 'react';
function useSignMessageSoftwareWallet() {
const account = useCurrentAccount();
return useCallback(
(message: string) => {
if (!account) return null;
const privateKey = createStacksPrivateKey(account.stxPrivateKey);
return signMessage(message, privateKey);
},
[account]
);
}
interface SignActionProps {
message: string;
}
export function SignAction(props: SignActionProps): JSX.Element | null {
const { message } = props;
const signSoftwareWalletMessage = useSignMessageSoftwareWallet();
const { tabId, requestToken } = useSignatureRequestSearchParams();
const [isLoading, setIsLoading] = useState(false);
const analytics = useAnalytics();
if (!requestToken || !tabId) return null;
const tabIdInt = parseInt(tabId);
const sign = async () => {
setIsLoading(true);
void analytics.track('request_signature_sign');
const messageSignature = signSoftwareWalletMessage(message);
if (!messageSignature) {
logger.error('Cannot sign message, no account in state');
void analytics.track('request_signature_cannot_sign_message_no_account');
return;
}
// Since the signature is really fast, we add a delay to improve the UX
await delay(1000);
setIsLoading(false);
finalizeMessageSignature(requestToken, tabIdInt, messageSignature);
};
const cancel = () => {
void analytics.track('request_signature_cancel');
finalizeMessageSignature(requestToken, tabIdInt, 'cancel');
};
return (
<Stack isInline>
<Button onClick={cancel} flexGrow={1} borderRadius="10px" mode="tertiary">
Cancel
</Button>
<Button type="submit" flexGrow={1} borderRadius="10px" onClick={sign} isLoading={isLoading}>
Sign
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,111 @@
import { memo } from 'react';
import { Box, color, Stack } from '@stacks/ui';
import { useRouteHeader } from '@app/common/hooks/use-route-header';
import {
useIsSignatureRequestValid,
useSignatureRequestSearchParams,
} from '@app/store/signatures/requests.hooks';
import { PageTop } from './components/page-top';
import { MessageBox } from './components/message-box';
import { NetworkRow } from './components/network-row';
import { SignAction } from './components/sign-action';
import { StacksNetwork, StacksTestnet } from '@stacks/network';
import { FiAlertTriangle } from 'react-icons/fi';
import { Caption } from '@app/components/typography';
import { PopupHeader } from '@app/features/current-account/popup-header';
import { getPayloadFromToken } from '@app/common/signature/requests';
import { isUndefined } from '@app/common/utils';
import { Link } from '@app/components/link';
import { openInNewTab } from '@app/common/utils/open-in-new-tab';
function SignatureRequestBase(): JSX.Element | null {
const validSignatureRequest = useIsSignatureRequestValid();
const { requestToken } = useSignatureRequestSearchParams();
useRouteHeader(<PopupHeader />);
if (!requestToken) return null;
const signatureRequest = getPayloadFromToken(requestToken);
if (!signatureRequest) return null;
if (isUndefined(validSignatureRequest)) return null;
const appName = signatureRequest?.appDetails?.name;
const { message, network } = signatureRequest;
return (
<Stack px={['loose', 'unset']} spacing="loose" width="100%">
<PageTop />
{!validSignatureRequest ? (
<ErrorMessage errorMessage={'Invalid signature request'} />
) : (
<SignatureRequestContent
message={message}
network={network || new StacksTestnet()}
appName={appName}
/>
)}
</Stack>
);
}
interface DisclaimerProps {
appName: string | undefined;
}
function Disclaimer(props: DisclaimerProps) {
const { appName } = props;
return (
<Box>
<Caption>
By signing this message, you are authorizing {appName || 'the app'} to do something specific
on your behalf. Only sign messages that you understand from apps that you trust.
<Link
display={'inline'}
fontSize="14px"
onClick={() => openInNewTab('https://docs.hiro.so/build-apps/message-signing')}
>
{' '}
Learn more
</Link>
.
</Caption>
</Box>
);
}
interface SignatureRequestContentProps {
network: StacksNetwork;
message: string;
appName: string | undefined;
}
function SignatureRequestContent(props: SignatureRequestContentProps) {
const { message, network, appName } = props;
return (
<>
<MessageBox message={message} />
<NetworkRow network={network} />
<SignAction message={message} />
<hr />
<Disclaimer appName={appName} />
</>
);
}
export const SignatureRequest = memo(SignatureRequestBase);
interface ErrorMessageProps {
errorMessage: string;
}
function ErrorMessage(props: ErrorMessageProps): JSX.Element | null {
const { errorMessage } = props;
if (!errorMessage) return null;
return (
<Stack alignItems="center" bg="#FCEEED" p="base" borderRadius="12px" isInline>
<Box color={color('feedback-error')} strokeWidth={2} as={FiAlertTriangle} />
<Caption color={color('feedback-error')}>{errorMessage}</Caption>
</Stack>
);
}

View File

@@ -2,18 +2,13 @@ import { memo } from 'react';
import { Stack } from '@stacks/ui';
import { useCurrentNetwork } from '@app/common/hooks/use-current-network';
import { getUrlHostname, getUrlPort } from '@app/common/utils';
import { addPortSuffix, getUrlHostname } from '@app/common/utils';
import { Caption, Title } from '@app/components/typography';
import { usePageTitle } from '@app/pages/transaction-request/hooks/use-page-title';
import { useTransactionRequestState } from '@app/store/transactions/requests.hooks';
import { useOrigin } from '@app/store/transactions/requests.hooks';
import { TransactionSigningSelectors } from '@tests/page-objects/transaction-signing.selectors';
function addPortSuffix(url: string) {
const port = getUrlPort(url);
return port ? `:${port}` : '';
}
function PageTopBase(): JSX.Element | null {
const transactionRequest = useTransactionRequestState();
const origin = useOrigin();

View File

@@ -8,7 +8,6 @@ import { useFeeSchema } from '@app/common/validation/use-fee-schema';
import { LoadingKeys, useLoading } from '@app/common/hooks/use-loading';
import { useNextTxNonce } from '@app/common/hooks/account/use-next-tx-nonce';
import { HighFeeDrawer } from '@app/features/high-fee-drawer/high-fee-drawer';
import { PopupHeader } from '@app/pages/transaction-request/components/popup-header';
import { PageTop } from '@app/pages/transaction-request/components/page-top';
import { ContractCallDetails } from '@app/pages/transaction-request/components/contract-call-details/contract-call-details';
import { ContractDeployDetails } from '@app/pages/transaction-request/components/contract-deploy-details/contract-deploy-details';
@@ -31,6 +30,7 @@ import { SubmitAction } from './components/submit-action';
import { useUnsignedTransactionFee } from './hooks/use-signed-transaction-fee';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { Estimations } from '@shared/models/fees-types';
import { PopupHeader } from '@app/features/current-account/popup-header';
function TransactionRequestBase(): JSX.Element | null {
useNextTxNonce();

View File

@@ -7,6 +7,7 @@ import { LoadingSpinner } from '@app/components/loading-spinner';
import { MagicRecoveryCode } from '@app/pages/onboarding/magic-recovery-code/magic-recovery-code';
import { ChooseAccount } from '@app/pages/choose-account/choose-account';
import { TransactionRequest } from '@app/pages/transaction-request/transaction-request';
import { SignatureRequest } from '@app/pages/signature-request/signature-request';
import { SignIn } from '@app/pages/onboarding/sign-in/sign-in';
import { ReceiveTokens } from '@app/pages/receive-tokens/receive-tokens';
import { AddNetwork } from '@app/pages/add-network/add-network';
@@ -147,6 +148,16 @@ export function AppRoutes(): JSX.Element | null {
}
/>
<Route path={RouteUrls.UnauthorizedRequest} element={<UnauthorizedRequest />} />
<Route
path={RouteUrls.SignatureRequest}
element={
<AccountGate>
<Suspense fallback={<LoadingSpinner height="600px" />}>
<SignatureRequest />
</Suspense>
</AccountGate>
}
/>
<Route
path={RouteUrls.ViewSecretKey}
element={

View File

@@ -0,0 +1,38 @@
import { verifySignatureRequest } from '@app/common/signature/requests';
import { useMemo } from 'react';
import { useAsync } from 'react-async-hook';
import { useSearchParams } from 'react-router-dom';
import { useAccounts } from '../accounts/account.hooks';
export function useIsSignatureRequestValid() {
const accounts = useAccounts();
const { origin, requestToken } = useSignatureRequestSearchParams();
return useAsync(async () => {
if (typeof accounts === 'undefined') return;
if (!origin || !accounts || !requestToken) return;
try {
const valid = await verifySignatureRequest({
requestToken,
accounts,
appDomain: origin,
});
return !!valid;
} catch (e) {
return false;
}
}, [accounts, requestToken, origin]).result;
}
export function useSignatureRequestSearchParams() {
const [searchParams] = useSearchParams();
return useMemo(
() => ({
requestToken: searchParams.get('request'),
tabId: searchParams.get('tabId'),
origin: searchParams.get('origin'),
}),
[searchParams]
);
}

View File

@@ -71,6 +71,23 @@ chrome.runtime.onConnect.addListener(port =>
}
break;
}
case ExternalMethods.signatureRequest: {
const path = RouteUrls.SignatureRequest;
const urlParams = new URLSearchParams();
if (!port.sender) return;
const { tab, url } = port.sender;
if (!tab?.id || !url) return;
const origin = new URL(url).origin;
urlParams.set('request', payload);
urlParams.set('tabId', tab.id.toString());
urlParams.set('origin', origin);
if (IS_TEST_ENV) {
await openRequestInFullPage(path, urlParams);
} else {
popupCenter({ url: `/popup-center.html#${path}?${urlParams.toString()}` });
}
break;
}
case ExternalMethods.transactionRequest: {
void storePayload({
payload,

View File

@@ -14,6 +14,7 @@ import {
import {
AuthenticationRequestEvent,
DomEventName,
SignatureRequestEvent,
TransactionRequestEvent,
} from '@shared/inpage-types';
import { RouteUrls } from '@shared/route-urls';
@@ -94,6 +95,16 @@ document.addEventListener(DomEventName.transactionRequest, ((event: TransactionR
});
}) as EventListener);
// Listen for a CustomEvent (signature request) coming from the web app
document.addEventListener(DomEventName.signatureRequest, ((event: SignatureRequestEvent) => {
forwardDomEventToBackground({
path: RouteUrls.SignatureRequest,
payload: event.detail.signatureRequest,
urlParam: 'request',
method: ExternalMethods.signatureRequest,
});
}) as EventListener);
// Inject inpage script (Stacks Provider)
const inpage = document.createElement('script');
inpage.src = chrome.runtime.getURL('inpage.js');

View File

@@ -2,6 +2,7 @@ import { StacksProvider } from '@stacks/connect';
import {
AuthenticationRequestEventDetails,
DomEventName,
SignatureRequestEventDetails,
TransactionRequestEventDetails,
} from '@shared/inpage-types';
import {
@@ -9,6 +10,7 @@ import {
ExternalMethods,
MessageToContentScript,
MESSAGE_SOURCE,
SignatureResponseMessage,
TransactionResponseMessage,
} from '@shared/message-types';
import { logger } from '@shared/logger';
@@ -65,6 +67,27 @@ const provider: StacksProvider = {
const { url } = await callAndReceive('getURL');
return url;
},
signatureRequest: async signatureRequest => {
const event = new CustomEvent<SignatureRequestEventDetails>(DomEventName.signatureRequest, {
detail: { signatureRequest },
});
document.dispatchEvent(event);
return new Promise((resolve, reject) => {
const handleMessage = (event: MessageEvent<SignatureResponseMessage>) => {
if (!isValidEvent(event, ExternalMethods.signatureResponse)) return;
if (event.data.payload?.signatureRequest !== signatureRequest) return;
window.removeEventListener('message', handleMessage);
if (event.data.payload.signatureResponse === 'cancel') {
reject(event.data.payload.signatureResponse);
return;
}
if (typeof event.data.payload.signatureResponse !== 'string') {
resolve(event.data.payload.signatureResponse);
}
};
window.addEventListener('message', handleMessage);
});
},
authenticationRequest: async authenticationRequest => {
const event = new CustomEvent<AuthenticationRequestEventDetails>(
DomEventName.authenticationRequest,

View File

@@ -0,0 +1,13 @@
import { signECDSA, hashMessage } from '@stacks/encryption';
import { StacksPrivateKey } from '@stacks/transactions';
export interface SignatureData {
signature: string; // - Hex encoded DER signature
publicKey: string; // - Hex encoded private string taken from privateKey
}
export function signMessage(message: string, privateKey: StacksPrivateKey): SignatureData {
const privateKeyUncompressed = privateKey.data.slice(0, 32);
const hash = hashMessage(message);
return signECDSA(privateKeyUncompressed.toString('hex'), hash);
}

View File

@@ -3,6 +3,7 @@
*/
export enum DomEventName {
authenticationRequest = 'stacksAuthenticationRequest',
signatureRequest = 'signatureRequest',
transactionRequest = 'stacksTransactionRequest',
}
@@ -12,6 +13,12 @@ export interface AuthenticationRequestEventDetails {
export type AuthenticationRequestEvent = CustomEvent<AuthenticationRequestEventDetails>;
export interface SignatureRequestEventDetails {
signatureRequest: string;
}
export type SignatureRequestEvent = CustomEvent<SignatureRequestEventDetails>;
export interface TransactionRequestEventDetails {
transactionRequest: string;
}

View File

@@ -1,4 +1,5 @@
import { FinishedTxPayload, SponsoredFinishedTxPayload } from '@stacks/connect';
import { SignatureData } from './crypto/sign-message';
export const MESSAGE_SOURCE = 'stacks-wallet' as const;
@@ -9,6 +10,8 @@ export enum ExternalMethods {
transactionResponse = 'transactionResponse',
authenticationRequest = 'authenticationRequest',
authenticationResponse = 'authenticationResponse',
signatureRequest = 'signatureRequest',
signatureResponse = 'signatureResponse',
}
export enum InternalMethods {
@@ -44,6 +47,16 @@ export type AuthenticationResponseMessage = Message<
}
>;
type SignatureRequestMessage = Message<ExternalMethods.signatureRequest, string>;
export type SignatureResponseMessage = Message<
ExternalMethods.signatureResponse,
{
signatureRequest: string;
signatureResponse: SignatureData | string;
}
>;
type TransactionRequestMessage = Message<ExternalMethods.transactionRequest, string>;
export type TxResult = SponsoredFinishedTxPayload | FinishedTxPayload;
@@ -56,5 +69,11 @@ export type TransactionResponseMessage = Message<
}
>;
export type MessageFromContentScript = AuthenticationRequestMessage | TransactionRequestMessage;
export type MessageToContentScript = AuthenticationResponseMessage | TransactionResponseMessage;
export type MessageFromContentScript =
| AuthenticationRequestMessage
| TransactionRequestMessage
| SignatureRequestMessage;
export type MessageToContentScript =
| AuthenticationResponseMessage
| TransactionResponseMessage
| SignatureResponseMessage;

View File

@@ -20,4 +20,5 @@ export enum RouteUrls {
ViewSecretKey = '/view-secret-key',
// Locked wallet route
Unlock = '/unlock',
SignatureRequest = '/signature',
}

View File

@@ -3,6 +3,7 @@ export const userHasAllowedDiagnosticsKey = 'stacks-wallet-has-allowed-diagnosti
export enum StorageKey {
'authenticationRequests',
'transactionRequests',
'signatureRequests',
}
interface RequestInfo {

View File

@@ -1,7 +1,7 @@
import { RPCClient } from '@stacks/rpc-client';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { StacksTestnet } from '@stacks/network';
import { StacksMainnet, StacksTestnet } from '@stacks/network';
dayjs.extend(relativeTime);
@@ -16,4 +16,6 @@ export const toRelativeTime = (ts: number): string => dayjs().to(ts);
export const stacksTestnetNetwork = new StacksTestnet({ url: testnetUrl });
export const stacksMainnetNetwork = new StacksMainnet();
export const stacksLocalhostNetwork = new StacksTestnet({ url: localhostUrl });

View File

@@ -56,6 +56,7 @@ export const Debugger = () => {
setTxType(type);
};
// If need to add more test tokens: STW7PFH79HW1C9Z0SXBP5PTPHKZZ58KK9WP1MZZA
const handleSponsoredTransactionBroadcast = async (tx: StacksTransaction) => {
const sponsorOptions = {

View File

@@ -7,8 +7,9 @@ import { Status } from './status';
import { Counter } from './counter';
import { Debugger } from './debugger';
import { Bns } from './bns';
import { Signature } from './signature';
type Tabs = 'status' | 'counter' | 'debug' | 'bns';
type Tabs = 'status' | 'counter' | 'debug' | 'bns' | 'signature';
const Container: React.FC<BoxProps> = ({ children, ...props }) => {
return (
@@ -37,6 +38,9 @@ const Page: React.FC<{ tab: Tabs; setTab: (value: Tabs) => void }> = ({ tab, set
<Tab active={tab === 'bns'}>
<Text onClick={() => setTab('bns')}>BNS</Text>
</Tab>
<Tab active={tab === 'signature'}>
<Text onClick={() => setTab('signature')}>Signature</Text>
</Tab>
</Flex>
</Container>
<Container>
@@ -44,6 +48,7 @@ const Page: React.FC<{ tab: Tabs; setTab: (value: Tabs) => void }> = ({ tab, set
{tab === 'counter' && <Counter />}
{tab === 'debug' && <Debugger />}
{tab === 'bns' && <Bns />}
{tab === 'signature' && <Signature />}
</Container>
</>
);

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import { Box, Button, Text } from '@stacks/ui';
import { SignatureData, useConnect } from '@stacks/connect-react';
import { verifyECDSA, hashMessage } from '@stacks/encryption';
import { stacksTestnetNetwork as network } from '@common/utils';
export const Signature = () => {
const [signature, setSignature] = useState<SignatureData | undefined>();
const [signatureIsVerified, setSignatureIsVerified] = useState<boolean | undefined>();
const [currentMessage, setCurrentMessage] = useState<string | undefined>();
const signatureMessage = 'Hello world!';
const longSignatureMessage = 'Nullam eu ante vel est convallis dignissim. Fusce suscipit, wisi nec facilisis facilisis, est dui fermentum leo, quis tempor ligula erat quis odio. Nunc porta vulputate tellus. Nunc rutrum turpis sed pede. Sed bibendum. Aliquam posuere. Nunc aliquet, augue nec adipiscing interdum, lacus tellus malesuada massa, quis varius mi purus non odio. Pellentesque condimentum, magna ut suscipit hendrerit, ipsum augue ornare nulla, non luctus diam neque sit amet urna. Curabitur vulputate vestibulum lorem. Fusce sagittis, libero non molestie mollis, magna orci ultrices dolor, at vulputate neque nulla lacinia eros. Sed id ligula quis est convallis tempor. Curabitur lacinia pulvinar nibh. Nam a sapien.'
const { sign } = useConnect();
useEffect(() => {
if (!signature || !currentMessage) return;
const verified = verifyECDSA(hashMessage(currentMessage), signature.publicKey, signature.signature);
setSignatureIsVerified(verified);
}, [signature, currentMessage])
const clearState = () => {
setSignatureIsVerified(undefined);
setSignature(undefined);
};
const signMessage = async (message: string) => {
clearState();
setCurrentMessage(message);
await sign ({
/* network: stacksMainnetNetwork, */
network,
message,
onFinish: (sigObj: SignatureData) => {
console.log('signature from debugger', sigObj);
setSignature(sigObj);
},
onCancel: () => {
console.log('popup closed!');
},
});
};
return (
<Box py={6}>
{ signature && (
<Text textStyle="body.large" display="block" my={'base'}>
<Text color="green" fontSize={1}> Signature {signatureIsVerified ? 'successfully ' : 'not'} verified</Text>
</Text>
)}
<Button mt={3} onClick={() => signMessage(signatureMessage)}>
Signature
</Button>
<br />
<Button mt={3} onClick={() => signMessage(longSignatureMessage)}>
Signature (long message)
</Button>
</Box>
);
};

View File

@@ -76,7 +76,12 @@ const HTML_PROD_OPTIONS = IS_DEV
},
};
const aliases = {};
const aliases = {
'@stacks/network/dist/polyfill': '@stacks/network/dist/esm',
'@stacks/network': '@stacks/network/dist/esm',
'@stacks/profile': '@stacks/profile/dist/esm',
'@stacks/auth': '@stacks/auth/dist/esm',
};
const config = {
entry: { index: path.join(SRC_ROOT_PATH, 'index.tsx') },

View File

@@ -79,6 +79,7 @@ const aliases = {
'@stacks/storage': '@stacks/storage/dist/esm',
'@stacks/transactions': '@stacks/transactions/dist/esm',
'@stacks/wallet-sdk': '@stacks/wallet-sdk/dist/esm',
'@stacks/network/dist/polyfill': '@stacks/network/dist/esm',
};
const config = {

3706
yarn.lock

File diff suppressed because it is too large Load Diff