Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BRO-13] Payload decoding in the browser wallet of the CIS3 standard #460

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/browser-wallet-api-helpers/src/wallet-api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
DeployModulePayload,
ConfigureBakerPayload,
ConfigureDelegationPayload,
ContractName,
EntrypointName,
} from '@concordium/web-sdk';
import type { RpcTransport } from '@protobuf-ts/runtime-rpc';
import { LaxNumberEnumValue, LaxStringEnumValue } from './util';
Expand Down Expand Up @@ -250,6 +252,29 @@ interface MainWalletApi {
message: string | SignMessageObject
): Promise<AccountTransactionSignature>;

/**
* Sends a message of the CIS3 contract standard, to the Concordium Wallet and awaits the users action. If the user signs the message, this will resolve to the signature.
*
* @param contractAddress the {@link ContractAddress} of the contract
* @param contractName the {@link ContractName} of the contract
* @param entrypointName the {@link EntrypointName} of the contract
* @param nonce the nonce (CIS3 standard) that was part of the message that was signed
* @param expiryTimeSignature RFC 3339 format (e.g. 2030-08-08T05:15:00Z)
* @param accountAddress the address of the account that should sign the message
* @param payloadMessage payload message to be signed, complete CIS3 message will be created from provided parameters. Note that the wallet will prepend some bytes to ensure the message cannot be a transaction. The message should be { @link SignMessageObject }.
*
* @throws if the user rejects signing the message.
*/
signCIS3Message(
contractAddress: ContractAddress.Type,
contractName: ContractName.Type,
entrypointName: EntrypointName.Type,
nonce: bigint | number,
expiryTimeSignature: string,
Ivan-Mahda marked this conversation as resolved.
Show resolved Hide resolved
accountAddress: AccountAddressSource,
payloadMessage: SignMessageObject
): Promise<AccountTransactionSignature>;

/**
* Requests a connection to the Concordium wallet, prompting the user to either accept or reject the request.
* If a connection has already been accepted for the url once the returned promise will resolve without prompting the user.
Expand Down
34 changes: 34 additions & 0 deletions packages/browser-wallet-api/src/wallet-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
SchemaVersion,
ContractAddress,
VerifiablePresentation,
ContractName,
EntrypointName,
} from '@concordium/web-sdk/types';
import { CredentialStatements } from '@concordium/web-sdk/web3-id';
import {
Expand Down Expand Up @@ -79,6 +81,38 @@ class WalletApi extends EventEmitter implements IWalletApi {
return response.result;
}

public async signCIS3Message(
contractAddress: ContractAddress.Type,
contractName: ContractName.Type,
entrypointName: EntrypointName.Type,
nonce: bigint | number,
expiryTimeSignature: string,
accountAddress: AccountAddressSource,
payloadMessage: SignMessageObject
): Promise<AccountTransactionSignature> {
const input = sanitizeSignMessageInput(accountAddress, payloadMessage);
const response = await this.messageHandler.sendMessage<MessageStatusWrapper<AccountTransactionSignature>>(
MessageType.SignCIS3Message,
{
message: input.message,
accountAddress: AccountAddress.toBase58(input.accountAddress),
cis3ContractDetails: {
contractAddress,
contractName,
entrypointName,
nonce,
expiryTimeSignature,
},
}
);

if (!response.success) {
throw new Error(response.message);
}

return response.result;
}

/**
* Requests connection to wallet. Resolves with account address or rejects if rejected in wallet.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-wallet-message-hub/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum MessageType {
ConnectAccounts = 'M_ConnectAccounts',
AddWeb3IdCredential = 'M_AddWeb3IdCredential',
AddWeb3IdCredentialFinish = 'M_AddWeb3IdCredentialFinish',
SignCIS3Message = 'M_SignCIS3Message',
}

/**
Expand Down Expand Up @@ -49,6 +50,7 @@ export enum InternalMessageType {
ImportWeb3IdBackup = 'I_ImportWeb3IdBackup',
AbortRecovery = 'I_AbortRecovery',
OpenFullscreen = 'I_OpenFullscreen',
SignCIS3Message = 'I_SignCIS3Message',
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
8 changes: 8 additions & 0 deletions packages/browser-wallet/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,14 @@ forwardToPopup(
undefined,
withPromptEnd
);
forwardToPopup(
MessageType.SignCIS3Message,
InternalMessageType.SignCIS3Message,
runConditionComposer(runIfAccountIsAllowlisted, ensureMessageWithSchemaParse, withPromptStart()),
appendUrlToPayload,
undefined,
withPromptEnd
);
forwardToPopup(
MessageType.AddTokens,
InternalMessageType.AddTokens,
Expand Down
3 changes: 3 additions & 0 deletions packages/browser-wallet/src/popup/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export const relativeRoutes = {
signMessage: {
path: 'sign-message',
},
signCIS3Message: {
path: 'sign-cis3-message',
},
sendTransaction: {
path: 'send-transaction',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
$message-details-horizontal-padding: rem(20px);

.sign-cis3-message {
&__details {
display: flex;
flex-direction: column;
align-items: center;
background-color: $color-bg;
height: 100%;
border: rem(1px) solid $color-grey;
border-radius: rem(10px);
padding: rem(10px) $message-details-horizontal-padding;

:where(&) h5 {
margin-top: rem(10px);
margin-bottom: 0;
}
}

&__details-text-area textarea {
min-height: 10rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
font-family: $font-family-mono;
padding: rem(10px);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import clsx from 'clsx';
import { stringify } from 'json-bigint';
import { useTranslation } from 'react-i18next';
import { useSetAtom } from 'jotai';
import { Buffer } from 'buffer/';
import {
AccountAddress,
AccountTransactionSignature,
buildBasicAccountSigner,
ContractAddress,
ContractName,
deserializeTypeValue,
EntrypointName,
serializeTypeValue,
signMessage,
} from '@concordium/web-sdk';
import { fullscreenPromptContext } from '@popup/page-layouts/FullscreenPromptLayout';
import { usePrivateKey } from '@popup/shared/utils/account-helpers';
import { displayUrl } from '@popup/shared/utils/string-helpers';
import { TextArea } from '@popup/shared/Form/TextArea';
import ConnectedBox from '@popup/pages/Account/ConnectedBox';
import Button from '@popup/shared/Button';
import { addToastAtom } from '@popup/state';
import ExternalRequestLayout from '@popup/page-layouts/ExternalRequestLayout';
import { SignMessageObject } from '@concordium/browser-wallet-api-helpers';

const SERIALIZATION_HELPER_SCHEMA =
'FAAFAAAAEAAAAGNvbnRyYWN0X2FkZHJlc3MMBQAAAG5vbmNlBQkAAAB0aW1lc3RhbXANCwAAAGVudHJ5X3BvaW50FgEHAAAAcGF5bG9hZBABAg==';

type Props = {
onSubmit(signature: AccountTransactionSignature): void;
onReject(): void;
};

interface Location {
state: {
payload: {
accountAddress: string;
message: SignMessageObject;
url: string;
cis3ContractDetails: Cis3ContractDetailsObject;
};
};
}

type Cis3ContractDetailsObject = {
contractAddress: ContractAddress.Type;
contractName: ContractName.Type;
entrypointName: EntrypointName.Type;
nonce: bigint | number;
expiryTimeSignature: string;
};
limemloh marked this conversation as resolved.
Show resolved Hide resolved

async function parseMessage(message: SignMessageObject) {
return stringify(
deserializeTypeValue(Buffer.from(message.data, 'hex'), Buffer.from(message.schema, 'base64')),
undefined,
2
);
}

function serializeMessage(payloadMessage: SignMessageObject, cis3ContractDetails: Cis3ContractDetailsObject) {
const { contractAddress, entrypointName, nonce, expiryTimeSignature } = cis3ContractDetails;
const message = {
contract_address: {
index: Number(contractAddress.index),
subindex: Number(contractAddress.subindex),
},
nonce: Number(nonce),
timestamp: expiryTimeSignature,
entry_point: EntrypointName.toString(entrypointName),
payload: Array.from(Buffer.from(payloadMessage.data, 'hex')),
};

return serializeTypeValue(message, Buffer.from(SERIALIZATION_HELPER_SCHEMA, 'base64'));
}

function MessageDetailsDisplay({
payloadMessage,
cis3ContractDetails,
}: {
payloadMessage: SignMessageObject;
cis3ContractDetails: Cis3ContractDetailsObject;
}) {
const { t } = useTranslation('signCIS3Message');
const { contractAddress, contractName, entrypointName, nonce, expiryTimeSignature } = cis3ContractDetails;
const [parsedMessage, setParsedMessage] = useState<string>('');
const expiry = new Date(expiryTimeSignature).toString();

useEffect(() => {
parseMessage(payloadMessage)
.then((m) => setParsedMessage(m))
.catch(() => setParsedMessage(t('unableToDeserialize')));
}, []);

return (
<div className="m-10 sign-cis3-message__details">
<h5>{t('contractIndex')}:</h5>
<div>
{contractAddress.index.toString()} ({contractAddress.subindex.toString()})
</div>
<h5>{t('receiveName')}:</h5>
<div>
{contractName.value.toString()}.{entrypointName.value.toString()}
</div>
<h5>{t('nonce')}:</h5>
<div>{nonce.toString()}</div>
<h5>{t('expiry')}:</h5>
<div>{expiry}</div>
<h5>{t('parameter')}:</h5>
<TextArea
readOnly
className={clsx('m-b-10 w-full flex-child-fill sign-cis3-message__details-text-area')}
value={parsedMessage}
/>
</div>
);
}

export default function SignCIS3Message({ onSubmit, onReject }: Props) {
const { state } = useLocation() as Location;
const { t } = useTranslation('signCIS3Message');
const { withClose } = useContext(fullscreenPromptContext);
const { accountAddress, url, message, cis3ContractDetails } = state.payload;
const key = usePrivateKey(accountAddress);
const addToast = useSetAtom(addToastAtom);
const onClick = useCallback(async () => {
if (!key) {
throw new Error('Missing key for the chosen address');
}

return signMessage(
AccountAddress.fromBase58(accountAddress),
serializeMessage(message, cis3ContractDetails).buffer,
buildBasicAccountSigner(key)
);
}, [state.payload.message, state.payload.accountAddress, key]);

return (
<ExternalRequestLayout className="p-10">
<ConnectedBox accountAddress={accountAddress} url={new URL(url).origin} />
<div className="h-full flex-column align-center">
<h3 className="m-t-0 text-center">{t('description', { dApp: displayUrl(url) })}</h3>
<p className="m-t-0 text-center">{t('descriptionWithSchema', { dApp: displayUrl(url) })}</p>
<MessageDetailsDisplay payloadMessage={message} cis3ContractDetails={cis3ContractDetails} />
<br />
<div className="flex p-b-10 p-t-10 m-t-auto">
<Button width="narrow" className="m-r-10" onClick={withClose(onReject)}>
{t('reject')}
</Button>
<Button
width="narrow"
onClick={() =>
onClick()
.then(withClose(onSubmit))
.catch((e) => addToast(e.message))
}
>
{t('sign')}
</Button>
</div>
</div>
</ExternalRequestLayout>
);
}
17 changes: 17 additions & 0 deletions packages/browser-wallet/src/popup/pages/SignCIS3Message/i18n/da.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type en from './en';

const t: typeof en = {
description: '{{ dApp }} anmoder om en signatur på følgende besked',
descriptionWithSchema:
'{{ dApp }} har sendt en rå besked og et schema til at oversætte den. Vi har oversat beskeden, men du burde kun underskrive hvis du stoler på {{ dApp }}',
unableToDeserialize: 'Det var ikke muligt at oversætte beskeden',
contractIndex: 'Kontrakt indeks (under indeks)',
receiveName: 'Kontrakt og funktions navn',
parameter: 'Parameter',
nonce: 'Nonce',
expiry: 'Udløber',
sign: 'Signér',
reject: 'Afvis',
};

export default t;
15 changes: 15 additions & 0 deletions packages/browser-wallet/src/popup/pages/SignCIS3Message/i18n/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const t = {
description: '{{ dApp }} requests a signature on a message',
descriptionWithSchema:
"{{ dApp }} has provided the raw message and a schema to render it. We've rendered the message but you should only sign it if you trust {{ dApp }}.",
unableToDeserialize: 'Unable to render message',
contractIndex: 'Contract index (subindex)',
receiveName: 'Contract and function name',
parameter: 'Parameter',
nonce: 'Nonce',
expiry: 'Expiry time',
sign: 'Sign',
reject: 'Reject',
};

export default t;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './SignCIS3Message';
18 changes: 18 additions & 0 deletions packages/browser-wallet/src/popup/shell/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import FullscreenPromptLayout from '@popup/page-layouts/FullscreenPromptLayout';
import Account from '@popup/pages/Account';
import Identity from '@popup/pages/Identity';
import SignMessage from '@popup/pages/SignMessage';
import SignCIS3Message from '@popup/pages/SignCIS3Message';
import SendTransaction from '@popup/pages/SendTransaction';
import Setup from '@popup/pages/Setup';
import ConnectionRequest from '@popup/pages/ConnectionRequest';
Expand Down Expand Up @@ -106,6 +107,10 @@ export default function Routes() {
InternalMessageType.SignMessage,
'signMessage'
);
const handleSignCIS3MessageResponse = useMessagePrompt<MessageStatusWrapper<AccountTransactionSignature>>(
InternalMessageType.SignCIS3Message,
'signCIS3Message'
);
const handleAddTokensResponse = useMessagePrompt<MessageStatusWrapper<string[]>>(
InternalMessageType.AddTokens,
'addTokens'
Expand Down Expand Up @@ -155,6 +160,19 @@ export default function Routes() {
/>
}
/>
<Route
path={relativeRoutes.prompt.signCIS3Message.path}
element={
<SignCIS3Message
onSubmit={(signature) =>
handleSignCIS3MessageResponse({ success: true, result: signature })
}
onReject={() =>
handleSignCIS3MessageResponse({ success: false, message: 'Signing was rejected' })
}
/>
}
/>
<Route
path={relativeRoutes.prompt.sendTransaction.path}
element={
Expand Down
Loading
Loading