diff --git a/examples/buildAccountSigner.ts b/examples/buildAccountSigner.ts new file mode 100644 index 000000000..6b81d78d8 --- /dev/null +++ b/examples/buildAccountSigner.ts @@ -0,0 +1,52 @@ +import meow from 'meow'; +import fs from 'fs'; +import path from 'path'; +import { + AccountAddress, + signMessage, + buildAccountSigner, +} from '@concordium/node-sdk'; + +const cli = meow( + ` + Usage + $ yarn ts-node [options] + + Required + --keyFile, -f A file containing the private key(s) of an account, which must be a supported format (e.g. a private key export from a Concordium wallet) + + Options + --help, -h Displays this message +`, + { + importMeta: import.meta, + flags: { + keyFile: { + type: 'string', + alias: 'f', + isRequired: true, + }, + }, + } +); + +if (cli.flags.h) { + cli.showHelp(); +} + +const file = fs.readFileSync(path.resolve(process.cwd(), cli.flags.keyFile)); +const contents = JSON.parse(file.toString('utf8')); + +try { + const signer = buildAccountSigner(contents); + + signMessage( + new AccountAddress( + '3eP94feEdmhYiPC1333F9VoV31KGMswonuHk5tqmZrzf761zK5' + ), + 'test', + signer + ).then(console.log); +} catch { + console.error('File passed does not conform to a supported JSON format'); +} diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 9f47ee94b..33cd4c363 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -7,6 +7,7 @@ - `CIS2Contract` class for interacting with smart contracts adhering to the CIS-2 standard. - `cis0Supports` function for checking standard support in smart contracts. - Made the `streamToList()` function public. +- Build function `buildAccountSigner` for creating `AccountSigner` objects from genesis format, wallet export format, and a simple representation of credentials with keys. ## 6.4.2 2023-04-21 diff --git a/packages/common/README.md b/packages/common/README.md index ce9cbc72a..90571c59d 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -24,6 +24,7 @@ This package is the shared library for the nodejs and web SDK's. - [Deserialize a receive function's return value](#deserialize-a-receive-functions-return-value) - [Deserialize a function's error](#deserialize-a-functions-error) - [Deserialize a transaction](#deserialize-a-transaction) + - [Creating an AccountSigner](#creating-an-accountsigner) - [Sign an account transaction](#sign-an-account-transaction) - [Sign a message](#sign-a-message) - [Check smart contract for support for standards](#check-smart-contract-for-support-for-standards) @@ -609,6 +610,28 @@ if (deserialized.kind === BlockItemKind.AccountTransactionKind) { Note that currently the only supported account transaction kinds are `Transfer`, `TransferWithMemo` and `RegisterData`. If attempting to deserialize other transaction kinds, the function will throw an error; +## Creating an `AccountSigner` +It is possible to build an `AccountSigner` in a variety of ways by utilizing the function `buildAccountSigner`. For a simple account, with a single credential and one keypair in the credential, one can supply a single private key, like so: + +```js +const privateKey = '...'; // Private key of an account as hex string +const signer: AccountSigner = buildAccountSigner(privateKey); +``` + +For a more complex account with one or more credentials, each with one or more keypairs, `buildAccountSigner` is also compatible with the format created by the chain genesis tool, Concordium wallet exports, along with a map of type `SimpleAccountKeys`. + +```js +const keys: SimpleAccountKeys = { + 0: { + 0: '...', // Private key of an account as hex string + 1: '...', + ... + }, + ... +}; +const signer: AccountSigner = buildAccountSigner(keys); +``` + ## Sign an account transaction The following example demonstrates how to use the `signTransaction` helper function to sign a account transaction: @@ -623,9 +646,6 @@ const transactionSignature: AccountTransactionSignature = signTransaction(accoun sendTransaction(accountTransaction, transactionSignature); ``` -For a simple account, with a single credential and one keypair in the credential, one can use the `buildBasicAccountSigner` to get the signer. Otherwise one needs to implement the AccountSigner interface themselves, for now. -The `buildBasicAccountSigner` function take the account's private key as a hex string. - The following is an example of how to sign an account transaction without using the `signTransaction` helper function: ```js import * as ed from "@noble/ed25519"; diff --git a/packages/common/src/signHelpers.ts b/packages/common/src/signHelpers.ts index 5c5324358..5b64a69b7 100644 --- a/packages/common/src/signHelpers.ts +++ b/packages/common/src/signHelpers.ts @@ -3,40 +3,169 @@ import { AccountInfo, AccountTransaction, AccountTransactionSignature, + CredentialSignature, + HexString, + SimpleAccountKeys, + WalletExportFormat, + WithAccountKeys, } from './types'; import * as ed from '@noble/ed25519'; import { Buffer } from 'buffer/'; import { AccountAddress } from './types/accountAddress'; import { sha256 } from './hash'; +import { mapRecord } from './util'; +/** + * A structure to use for creating signatures on a given digest. + */ export interface AccountSigner { + /** + * Creates a signature of the provided digest + * + * @param {Buffer} digest - The digest to create signatures on. + * + * @returns {Promise} A promise resolving with a set of signatures for a set of credentials corresponding to some account + */ sign(digest: Buffer): Promise; + /** + * Returns the amount of signatures that the signer produces + */ getSignatureCount(): bigint; } +const getSignature = async ( + digest: Buffer, + privateKey: HexString +): Promise => + Buffer.from(await ed.sign(digest, privateKey)).toString('hex'); + /** - * Creates a signer for an account which uses the first credential's first keypair. + * Creates an `AccountSigner` for an account which uses the first credential's first keypair. * Note that if the account has a threshold > 1 or the first credentials has a threshold > 1, the transaction signed using this will fail. - * @param privateKey the ed25519 private key in HEX format. (First credential's first keypair's private key) + * + * @param {HexString} privateKey - the ed25519 private key in HEX format. (First credential's first keypair's private key) + * + * @returns {AccountSigner} an `AccountSigner` which creates a signature using the first credentials first keypair */ -export function buildBasicAccountSigner(privateKey: string): AccountSigner { +export function buildBasicAccountSigner(privateKey: HexString): AccountSigner { return { getSignatureCount() { return 1n; }, async sign(digest: Buffer) { - const signature = Buffer.from( - await ed.sign(digest, privateKey) - ).toString('hex'); return { 0: { - 0: signature, + 0: await getSignature(digest, privateKey), }, }; }, }; } +const isWalletExport = ( + value: T | WalletExportFormat +): value is WalletExportFormat => + (value as WalletExportFormat).value?.accountKeys !== undefined; + +const isSimpleAccountKeys = ( + value: T | WalletExportFormat | SimpleAccountKeys +): value is SimpleAccountKeys => + (value as WalletExportFormat).value?.accountKeys === undefined && + (value as T).accountKeys === undefined; + +const getKeys = ( + value: T | WalletExportFormat | SimpleAccountKeys +): SimpleAccountKeys => { + if (isSimpleAccountKeys(value)) { + return value; + } + const { keys } = isWalletExport(value) + ? value.value.accountKeys + : value.accountKeys; + + return mapRecord(keys, (credKeys) => + mapRecord(credKeys.keys, (keyPair) => keyPair.signKey) + ); +}; + +const getCredentialSignature = async ( + digest: Buffer, + keys: Record +): Promise => { + const sig: CredentialSignature = {}; + for (const key in keys) { + sig[key] = await getSignature(digest, keys[key]); + } + return sig; +}; + +/** + * Creates an `AccountSigner` for an account exported from a Concordium wallet. + * Creating signatures using the `AccountSigner` will hold signatures for all credentials and all their respective keys included in the export. + * + * @param {WalletExportFormat} walletExport - The wallet export object. + * + * @returns {AccountSigner} An `AccountSigner` which creates signatures using all keys for all credentials + */ +export function buildAccountSigner( + walletExport: WalletExportFormat +): AccountSigner; +/** + * Creates an `AccountSigner` for an arbitrary format extending the {@link WithAccountKeys} type. + * Creating signatures using the `AccountSigner` will hold signatures for all credentials and all their respective keys included. + * + * @param {AccountKeys} value.accountKeys - Account keys of type {@link AccountKeys} to use for creating signatures + * + * @returns {AccountSigner} An `AccountSigner` which creates signatures using all keys for all credentials + */ +export function buildAccountSigner( + value: T +): AccountSigner; +/** + * Creates an `AccountSigner` for the {@link SimpleAccountKeys} type. + * Creating signatures using the `AccountSigner` will hold signatures for all credentials and all their respective keys included. + * + * @param {SimpleAccountKeys} keys - Account keys to use for creating signatures + * + * @returns {AccountSigner} An `AccountSigner` which creates signatures using all keys for all credentials + */ +export function buildAccountSigner(keys: SimpleAccountKeys): AccountSigner; +/** + * Creates an `AccountSigner` for an account which uses the first credential's first keypair. + * Note that if the account has a threshold > 1 or the first credentials has a threshold > 1, the transaction signed using this will fail. + * + * @param {HexString} key - The ed25519 private key in HEX format. (First credential's first keypair's private key) + * + * @returns {AccountSigner} An `AccountSigner` which creates a signature using the first credentials first keypair + */ +export function buildAccountSigner(key: HexString): AccountSigner; +export function buildAccountSigner( + value: T | WalletExportFormat | SimpleAccountKeys | string +): AccountSigner { + if (typeof value === 'string') { + return buildBasicAccountSigner(value); + } + + const keys = getKeys(value); + const numKeys = Object.values(keys).reduce( + (acc, credKeys) => acc + BigInt(Object.keys(credKeys).length), + 0n + ); + + return { + getSignatureCount() { + return numKeys; + }, + async sign(digest: Buffer) { + const sig: AccountTransactionSignature = {}; + for (const key in keys) { + sig[key] = await getCredentialSignature(digest, keys[key]); + } + return sig; + }, + }; +} + /** * Helper function to sign an AccountTransaction. * @param transaction the account transaction to sign diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index b84fd3fc2..c0727ad43 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -704,7 +704,12 @@ export interface AccountEncryptedAmount { export interface VerifyKey { schemeId: string; - verifyKey: string; + verifyKey: HexString; +} + +export interface KeyPair { + signKey: HexString; + verifyKey: HexString; } export interface CredentialPublicKeys { @@ -712,6 +717,33 @@ export interface CredentialPublicKeys { threshold: number; } +export interface CredentialKeys { + keys: Record; + threshold: number; +} + +export interface AccountKeys { + keys: Record; + threshold: number; +} + +export type SimpleAccountKeys = Record>; + +export interface WithAccountKeys { + accountKeys: AccountKeys; +} + +export interface WalletExportFormat { + type: string; + v: number; + environment: string; + value: { + accountKeys: AccountKeys; + address: Base58String; + credentials: Record; + }; +} + export interface ChainArData { encIdCredPubShare: string; } diff --git a/packages/common/test/signHelpers.test.ts b/packages/common/test/signHelpers.test.ts index 465ad90dc..7f7d96d99 100644 --- a/packages/common/test/signHelpers.test.ts +++ b/packages/common/test/signHelpers.test.ts @@ -3,9 +3,28 @@ import { buildBasicAccountSigner, verifyMessageSignature, } from '../src/signHelpers'; -import { AccountAddress, AccountInfo } from '../src'; +import { + AccountAddress, + AccountInfo, + buildAccountSigner, + SimpleAccountKeys, +} from '../src'; + +const TEST_ACCOUNT_SINGLE = + '3eP94feEdmhYiPC1333F9VoV31KGMswonuHk5tqmZrzf761zK5'; +const TEST_ACCOUNT_MULTI = '4hTGW1Uz6u2hUgEtwWjJUdZQncVpHGWZPgGdRpgL1VNn5NzyHd'; + +const TEST_KEY_SINGLE = + 'e1cf504954663e49f4fe884c7c35415b09632cccd82d3d2a62ab2825e67d785d'; +const TEST_KEYS_MULTI: SimpleAccountKeys = { + 0: { + 0: '671eb13486ea747a1c27984aca67778508dcf54bdac00a32fd138ef69ad2e5b5', + 1: '76cc8d4202810aa60109435d83357751f3108d00d27d0d6cae07ab536cf6731d', + 2: '131a05cab3b2b18a867ae3e245881cb0f2cf2924ae33a9fa948d1451c2bd8707', + }, +}; -const TEST_CREDENTIALS = { +const TEST_CREDENTIALS_SINGLE = { 0: { value: { contents: { @@ -23,29 +42,64 @@ const TEST_CREDENTIALS = { }, }, }; +const TEST_CREDENTIALS_MULTI = { + 0: { + value: { + contents: { + credentialPublicKeys: { + keys: { + 0: { + schemeId: 'Ed25519', + verifyKey: + '008739a5c6708b25c359d45179fefda7ef1345099c0ad8e9b66ed253d968098d', + }, + 1: { + schemeId: 'Ed25519', + verifyKey: + '45b55ad7438cb72c06489be231443cb3b7708f9b3f770729e2092f78ea9e2d9d', + }, + 2: { + schemeId: 'Ed25519', + verifyKey: + 'ed2f710f6edbf65806eaee6d643a12124332a6dc687be099b63fd0150294168d', + }, + }, + threshold: 3, + }, + }, + }, + }, +}; const testEachMessageType = test.each(['test', Buffer.from('test', 'utf8')]); testEachMessageType('[%o] test signMessage', async (message) => { - const account = new AccountAddress( - '3eP94feEdmhYiPC1333F9VoV31KGMswonuHk5tqmZrzf761zK5' - ); - const signature = await signMessage( - account, - message, - buildBasicAccountSigner( - 'e1cf504954663e49f4fe884c7c35415b09632cccd82d3d2a62ab2825e67d785d' - ) - ); + const sign = () => signMessage(account, message, signer); + + let account = new AccountAddress(TEST_ACCOUNT_SINGLE); + let signer = buildBasicAccountSigner(TEST_KEY_SINGLE); + let signature = await sign(); expect(signature[0][0]).toBe( '445197d79ca90d8cc8440328dac9f307932ade0c03cc7aa575b59b746e26e5f1bca13ade5ff7a56e918ba5a32450fdf52b034cd2580929b21213263e81f7f809' ); + + account = new AccountAddress(TEST_ACCOUNT_MULTI); + signer = buildAccountSigner(TEST_KEYS_MULTI); + signature = await sign(); + + expect(signature).toEqual({ + 0: { + 0: '37798d551f26f48496a3d14aee0d29f5bb6a1dc99a75c06b5a8be4f901ba8e6e7c32a7461bd419f481115e647a43d43075f0ccb000627eaa2329eed81582fc02', + 1: '36fc3a13869535a934adb61809b010dd015126920c24032dfcde1c3883151bc61219f2582564f1e13d743a34ce762925d6171685a1fec62e1cbf731e551a430f', + 2: '024c91adf278e9018f27546da73acf865823989b2385dd9575743c1390dda1afa47e85894ac2324bd9cd5459393b69a18787c18262ac90d65b404245491c6b0c', + }, + }); }); testEachMessageType( '[%o] verifyMessageSignature returns true on the correct address/signature', async (message) => { - const signature = await verifyMessageSignature( + const signatureSingle = await verifyMessageSignature( message, { 0: { @@ -53,13 +107,29 @@ testEachMessageType( }, }, { - accountAddress: - '3eP94feEdmhYiPC1333F9VoV31KGMswonuHk5tqmZrzf761zK5', + accountAddress: TEST_ACCOUNT_SINGLE, accountThreshold: 1, - accountCredentials: TEST_CREDENTIALS, + accountCredentials: TEST_CREDENTIALS_SINGLE, } as unknown as AccountInfo ); - expect(signature).toBeTruthy(); + expect(signatureSingle).toBeTruthy(); + + const signatureMutli = await verifyMessageSignature( + message, + { + 0: { + 0: '37798d551f26f48496a3d14aee0d29f5bb6a1dc99a75c06b5a8be4f901ba8e6e7c32a7461bd419f481115e647a43d43075f0ccb000627eaa2329eed81582fc02', + 1: '36fc3a13869535a934adb61809b010dd015126920c24032dfcde1c3883151bc61219f2582564f1e13d743a34ce762925d6171685a1fec62e1cbf731e551a430f', + 2: '024c91adf278e9018f27546da73acf865823989b2385dd9575743c1390dda1afa47e85894ac2324bd9cd5459393b69a18787c18262ac90d65b404245491c6b0c', + }, + }, + { + accountAddress: TEST_ACCOUNT_MULTI, + accountThreshold: 1, + accountCredentials: TEST_CREDENTIALS_MULTI, + } as unknown as AccountInfo + ); + expect(signatureMutli).toBeTruthy(); } ); @@ -76,7 +146,7 @@ test('verifyMessageSignature returns false on the incorrect address', async () = accountAddress: '3dbRxtzhb8MotFBgH5DcdFJy7t4we4N8Ep6Mxdha8XvLhq7YmZ', accountThreshold: 1, - accountCredentials: TEST_CREDENTIALS, + accountCredentials: TEST_CREDENTIALS_SINGLE, } as unknown as AccountInfo ); expect(signature).toBeFalsy(); @@ -92,11 +162,31 @@ test('verifyMessageSignature returns false on the incorrect signature', async () }, }, { - accountAddress: - '3eP94feEdmhYiPC1333F9VoV31KGMswonuHk5tqmZrzf761zK5', + accountAddress: TEST_ACCOUNT_SINGLE, accountThreshold: 1, - accountCredentials: TEST_CREDENTIALS, + accountCredentials: TEST_CREDENTIALS_SINGLE, } as unknown as AccountInfo ); expect(signature).toBeFalsy(); }); + +testEachMessageType( + '[%o] verifyMessageSignature returns false on not enough signatures on specific credential', + async (message) => { + const signature = await verifyMessageSignature( + message, + { + 0: { + 0: '37798d551f26f48496a3d14aee0d29f5bb6a1dc99a75c06b5a8be4f901ba8e6e7c32a7461bd419f481115e647a43d43075f0ccb000627eaa2329eed81582fc02', + 1: '36fc3a13869535a934adb61809b010dd015126920c24032dfcde1c3883151bc61219f2582564f1e13d743a34ce762925d6171685a1fec62e1cbf731e551a430f', + }, + }, + { + accountAddress: TEST_ACCOUNT_MULTI, + accountThreshold: 1, + accountCredentials: TEST_CREDENTIALS_MULTI, + } as unknown as AccountInfo + ); + expect(signature).toBeFalsy(); + } +);