Skip to content

Commit

Permalink
fix(ledger): stacks message signing, closes #4945
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranjamie committed Feb 28, 2024
1 parent 105fedb commit cc19e40
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 57 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
"ledger-bitcoin": "0.2.3",
"limiter": "2.1.0",
"lodash.get": "4.4.2",
"lodash.isequal": "4.5.0",
"lodash.uniqby": "4.7.0",
"micro-packed": "0.3.2",
"object-hash": "3.0.0",
Expand Down Expand Up @@ -269,6 +270,7 @@
"@types/html-webpack-plugin": "3.2.9",
"@types/jsdom": "21.1.3",
"@types/lodash.get": "4.4.7",
"@types/lodash.isequal": "4.5.8",
"@types/node": "20.11.19",
"@types/prismjs": "1.26.3",
"@types/promise-memoize": "1.2.4",
Expand Down
10 changes: 10 additions & 0 deletions src/app/common/publish-subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Transaction } from '@scure/btc-signer';
import type { SignatureData } from '@stacks/connect';
import type { StacksTransaction } from '@stacks/transactions';

import type { UnsignedMessage } from '@shared/signature/signature-types';

type PubTypeFn<E> = <Key extends string & keyof E>(
event: Key,
// Ensures if we have an event with no payload, the second arg can be empty,
Expand Down Expand Up @@ -59,6 +62,13 @@ export interface GlobalAppEvents {
ledgerBitcoinTxSigningCancelled: {
unsignedPsbt: string;
};
ledgerStacksMessageSigned: {
unsignedMessage: UnsignedMessage;
messageSignatures: SignatureData;
};
ledgerStacksMessageSigningCancelled: {
unsignedMessage: UnsignedMessage;
};
}

export const appEvents = createPublishSubscribe<GlobalAppEvents>();
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import { serializeCV } from '@stacks/transactions';
import { LedgerError } from '@zondax/ledger-stacks';
import get from 'lodash.get';

import { finalizeMessageSignature } from '@shared/actions/finalize-message-signature';
import { logger } from '@shared/logger';
import { UnsignedMessage, whenSignableMessageOfType } from '@shared/signature/signature-types';
import { isError } from '@shared/utils';

import { useScrollLock } from '@app/common/hooks/use-scroll-lock';
import { appEvents } from '@app/common/publish-subscribe';
import { delay } from '@app/common/utils';
import { BaseDrawer } from '@app/components/drawer/base-drawer';
import {
Expand All @@ -23,7 +22,6 @@ import {
} from '@app/features/ledger/utils/stacks-ledger-utils';
import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
import { StacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.models';
import { useSignatureRequestSearchParams } from '@app/store/signatures/requests.hooks';

import { useLedgerAnalytics } from '../../hooks/use-ledger-analytics.hook';
import { useLedgerNavigate } from '../../hooks/use-ledger-navigate';
Expand Down Expand Up @@ -57,7 +55,6 @@ function LedgerSignStacksMsg({ account, unsignedMessage }: LedgerSignMsgProps) {
const ledgerNavigate = useLedgerNavigate();
const ledgerAnalytics = useLedgerAnalytics();
const verifyLedgerPublicKey = useVerifyMatchingLedgerStacksPublicKey();
const { tabId, requestToken } = useSignatureRequestSearchParams();

const [latestDeviceResponse, setLatestDeviceResponse] = useLedgerResponseState();
const canUserCancelAction = useActionCancellableByUser();
Expand Down Expand Up @@ -86,11 +83,6 @@ function LedgerSignStacksMsg({ account, unsignedMessage }: LedgerSignMsgProps) {
return;
}

if (!tabId || !requestToken) {
logger.warn('Cannot sign message without corresponding `tabId` or `requestToken');
return;
}

ledgerNavigate.toDeviceBusyStep(`Verifying public key on Ledger…`);
await verifyLedgerPublicKey(stacksApp);

Expand Down Expand Up @@ -122,7 +114,7 @@ function LedgerSignStacksMsg({ account, unsignedMessage }: LedgerSignMsgProps) {
if (resp.returnCode === LedgerError.TransactionRejected) {
ledgerNavigate.toOperationRejectedStep(`Message signing operation rejected`);
ledgerAnalytics.messageSignedOnLedgerRejected();
finalizeMessageSignature({ requestPayload: requestToken, tabId, data: 'cancel' });
appEvents.publish('ledgerStacksMessageSigningCancelled', { unsignedMessage });
return;
}
if (resp.returnCode !== LedgerError.NoErrors) {
Expand All @@ -133,13 +125,12 @@ function LedgerSignStacksMsg({ account, unsignedMessage }: LedgerSignMsgProps) {

ledgerAnalytics.messageSignedOnLedgerSuccessfully();

finalizeMessageSignature({
requestPayload: requestToken,
tabId,
data: {
appEvents.publish('ledgerStacksMessageSigned', {
messageSignatures: {
signature: signatureVrsToRsv(resp.signatureVRS.toString('hex')),
publicKey: account.stxPublicKey,
},
unsignedMessage,
});

await stacksApp.transport.close();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import isEqual from 'lodash.isequal';

import type { UnsignedMessage } from '@shared/signature/signature-types';

import { GlobalAppEvents, appEvents } from '@app/common/publish-subscribe';

export async function listenForStacksMessageSigning(
unsignedMessage: UnsignedMessage
): Promise<any> {
return new Promise((resolve, reject) => {
function stacksMessageSignedHandler(msg: GlobalAppEvents['ledgerStacksMessageSigned']) {
if (isEqual(msg.unsignedMessage, unsignedMessage)) {
appEvents.unsubscribe('ledgerStacksMessageSigned', stacksMessageSignedHandler);
appEvents.unsubscribe('ledgerStacksMessageSigningCancelled', signingAbortedHandler);
resolve(msg.messageSignatures);
}
}
appEvents.subscribe('ledgerStacksMessageSigned', stacksMessageSignedHandler);

function signingAbortedHandler(msg: GlobalAppEvents['ledgerStacksMessageSigningCancelled']) {
if (isEqual(msg.unsignedMessage, unsignedMessage)) {
appEvents.unsubscribe('ledgerStacksMessageSigningCancelled', signingAbortedHandler);
appEvents.unsubscribe('ledgerStacksMessageSigned', stacksMessageSignedHandler);
reject(new Error('User cancelled the signing operation'));
}
}
appEvents.subscribe('ledgerStacksMessageSigningCancelled', signingAbortedHandler);
});
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { ClarityValue } from '@stacks/transactions';

import { StructuredMessageDataDomain, UnsignedMessage } from '@shared/signature/signature-types';
import {
type SignedMessageType,
UnsignedMessage,
deserializeUnsignedMessage,
} from '@shared/signature/signature-types';
import { isString } from '@shared/utils';

import { useLocationStateWithCache } from '@app/common/hooks/use-location-state';

export function useUnsignedMessageType(): UnsignedMessage | null {
const message = useLocationStateWithCache<string | ClarityValue>('message');
const domain = useLocationStateWithCache<StructuredMessageDataDomain>('domain');
const messageType = useLocationStateWithCache<SignedMessageType>('messageType');
const message = useLocationStateWithCache<string | Uint8Array>('message');
const domain = useLocationStateWithCache<Uint8Array>('domain');

if (isString(message))
return {
messageType: 'utf8',
message,
};
if (messageType === 'utf8' && isString(message)) return { messageType, message };

if (message && domain) return { messageType: 'structured', message, domain };
if (messageType === 'structured' && message && !isString(message) && domain)
return deserializeUnsignedMessage({ messageType, message, domain });

return null;
}
18 changes: 8 additions & 10 deletions src/app/features/ledger/hooks/use-ledger-navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { bytesToHex } from '@stacks/common';
import { ClarityValue, StacksTransaction } from '@stacks/transactions';
import { StacksTransaction } from '@stacks/transactions';

import { SupportedBlockchains } from '@shared/constants';
import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config';
import { RouteUrls } from '@shared/route-urls';
import {
type UnsignedMessage,
toSerializableUnsignedMessage,
} from '@shared/signature/signature-types';

import { immediatelyAttemptLedgerConnection } from './use-when-reattempt-ledger-connection';

Expand Down Expand Up @@ -42,17 +46,11 @@ export function useLedgerNavigate() {
});
},

toConnectAndSignUtf8MessageStep(message: string) {
toConnectAndSignMessageStep(message: UnsignedMessage) {
return navigate(RouteUrls.ConnectLedger, {
replace: true,
state: { type: 'utf8', message },
});
},

toConnectAndSignStructuredMessageStep(domain: ClarityValue, message: ClarityValue) {
return navigate(RouteUrls.ConnectLedger, {
replace: true,
state: { type: 'structured', domain, message },
// Unsigned messages may contain unserializable data, such as bigint
state: { ...toSerializableUnsignedMessage(message) },
});
},

Expand Down
26 changes: 15 additions & 11 deletions src/app/features/stacks-message-signer/use-sign-stacks-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from 'react';
import { SignatureData } from '@stacks/connect';

import { logger } from '@shared/logger';
import { UnsignedMessage, whenSignableMessageOfType } from '@shared/signature/signature-types';
import { UnsignedMessage } from '@shared/signature/signature-types';

import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { useWalletType } from '@app/common/use-wallet-type';
Expand All @@ -13,11 +13,16 @@ import {
useMessageSignerStacksSoftwareWallet,
} from '@app/features/stacks-message-signer/stacks-message-signing.utils';

import { listenForStacksMessageSigning } from '../ledger/flows/stacks-message-signing/stacks-message-signing-event-listeners';

interface SignStacksMessageProps {
onSignMessageCompleted(messageSignature: SignatureData): void;
onSignMessageCancelled(): void;
}

export function useSignStacksMessage({ onSignMessageCompleted }: SignStacksMessageProps) {
export function useSignStacksMessage({
onSignMessageCompleted,
onSignMessageCancelled,
}: SignStacksMessageProps) {
const analytics = useAnalytics();
const signSoftwareWalletMessage = useMessageSignerStacksSoftwareWallet();

Expand Down Expand Up @@ -46,14 +51,13 @@ export function useSignStacksMessage({ onSignMessageCompleted }: SignStacksMessa

async ledger(unsignedMessage: UnsignedMessage) {
void analytics.track('request_signature_sign', { type: 'ledger' });
whenSignableMessageOfType(unsignedMessage)({
utf8(msg) {
ledgerNavigate.toConnectAndSignUtf8MessageStep(msg);
},
structured(domain, msg) {
ledgerNavigate.toConnectAndSignStructuredMessageStep(domain, msg);
},
});
ledgerNavigate.toConnectAndSignMessageStep(unsignedMessage);
try {
const messageSignature = await listenForStacksMessageSigning(unsignedMessage);
onSignMessageCompleted(messageSignature);
} catch (e) {
onSignMessageCancelled();
}
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';

import { RpcErrorCode } from '@btckit/types';
import { StacksNetwork } from '@stacks/network';
Expand All @@ -15,6 +14,7 @@ import { closeWindow } from '@shared/utils';

import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params';
import { initialSearchParams } from '@app/common/initial-search-params';
import {
StructuredPayload,
Utf8Payload,
Expand Down Expand Up @@ -59,17 +59,16 @@ export function useRpcStacksMessagePayload() {
}

export function useRpcSignStacksMessageParams() {
const [searchParams] = useSearchParams();
const { origin, tabId } = useDefaultRequestParams();
const requestId = searchParams.get('requestId');
const network = searchParams.get('network');
const appName = searchParams.get('appName');
const message = searchParams.get('message');
const messageType = searchParams.get('messageType');
const domain = searchParams.get('domain');
const requestId = initialSearchParams.get('requestId');
const network = initialSearchParams.get('network');
const appName = initialSearchParams.get('appName');
const message = initialSearchParams.get('message');
const messageType = initialSearchParams.get('messageType');
const domain = initialSearchParams.get('domain');

if (!requestId || !message || !origin || !isSignableMessageType(messageType))
throw new Error('Invalid params');
throw new Error('Missing required parameters for Stacks signing message request');

return useMemo(
() => ({
Expand All @@ -93,7 +92,7 @@ export function useRpcSignStacksMessage() {
if (!tabId) throw new Error('Requests can only be made with corresponding tab');

const { isLoading, signMessage } = useSignStacksMessage({
onSignMessageCompleted: messageSignature => {
onSignMessageCompleted(messageSignature) {
chrome.tabs.sendMessage(
tabId,
makeRpcSuccessResponse('stx_signMessage', {
Expand All @@ -103,6 +102,7 @@ export function useRpcSignStacksMessage() {
);
closeWindow();
},
onSignMessageCancelled: onCancelMessageSigning,
});

function onCancelMessageSigning() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function useSignStacksMessageRequest() {
onSignMessageCompleted: messageSignature => {
finalizeMessageSignature({ requestPayload: requestToken, tabId, data: messageSignature });
},
onSignMessageCancelled: onCancelMessageSigning,
});

function onCancelMessageSigning() {
Expand Down
37 changes: 36 additions & 1 deletion src/shared/signature/signature-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ClarityValue, StringAsciiCV, TupleCV, UIntCV } from '@stacks/transactions';
import {
ClarityValue,
StringAsciiCV,
TupleCV,
UIntCV,
deserializeCV,
serializeCV,
} from '@stacks/transactions';

export type SignedMessageType = 'utf8' | 'structured';

Expand All @@ -23,6 +30,12 @@ export interface UnsignedMessageStructured extends AbstractUnsignedMessage {
domain: StructuredMessageDataDomain;
}

interface SerializedUnsignedMessageStructured extends AbstractUnsignedMessage {
messageType: 'structured';
message: Uint8Array;
domain: Uint8Array;
}

export type UnsignedMessage = UnsignedMessageUtf8 | UnsignedMessageStructured;

export function isStructuredMessageType(
Expand All @@ -49,3 +62,25 @@ export function whenSignableMessageOfType(msg: UnsignedMessage) {
return structured(msg.domain, msg.message);
};
}

export function toSerializableUnsignedMessage(
unsignedMessage: UnsignedMessage
): UnsignedMessageUtf8 | SerializedUnsignedMessageStructured {
if (unsignedMessage.messageType === 'utf8') return unsignedMessage;
return {
messageType: 'structured',
message: serializeCV(unsignedMessage.message),
domain: serializeCV(unsignedMessage.domain),
};
}

export function deserializeUnsignedMessage(
serialisedUnsignedMessage: UnsignedMessageUtf8 | SerializedUnsignedMessageStructured
): UnsignedMessage {
if (serialisedUnsignedMessage.messageType === 'utf8') return serialisedUnsignedMessage;
return {
messageType: 'structured',
message: deserializeCV(serialisedUnsignedMessage.message),
domain: deserializeCV(serialisedUnsignedMessage.domain),
};
}
Loading

0 comments on commit cc19e40

Please sign in to comment.