diff --git a/src/common/types/objects/transactions.ts b/src/common/types/objects/transactions.ts index c37b1090..c12e9932 100644 --- a/src/common/types/objects/transactions.ts +++ b/src/common/types/objects/transactions.ts @@ -1,3 +1,5 @@ +import { Signer } from '../../../models/common' + import { RippledAmount } from './amounts' import { Memo } from './memos' @@ -10,7 +12,7 @@ export interface OfferCreateTransaction { Flags: number LastLedgerSequence?: number Sequence: number - Signers: any[] + Signers: Signer[] SigningPubKey: string SourceTag?: number TakerGets: RippledAmount diff --git a/src/models/common/index.ts b/src/models/common/index.ts index 23825c7e..408a8769 100644 --- a/src/models/common/index.ts +++ b/src/models/common/index.ts @@ -26,9 +26,11 @@ export interface IssuedCurrencyAmount extends IssuedCurrency { export type Amount = IssuedCurrencyAmount | string export interface Signer { - Account: string - TxnSignature: string - SigningPubKey: string + Signer: { + Account: string + TxnSignature: string + SigningPubKey: string + } } export interface Memo { diff --git a/src/models/transactions/common.ts b/src/models/transactions/common.ts index 784fe82c..476b839a 100644 --- a/src/models/transactions/common.ts +++ b/src/models/transactions/common.ts @@ -55,7 +55,13 @@ const SIGNER_SIZE = 3 function isSigner(obj: unknown): boolean { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Only used by JS - const signer = obj as Record + const signerWrapper = obj as Record + + if (signerWrapper.Signer == null) { + return false + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Only used by JS and Signer is previously unknown + const signer = signerWrapper.Signer as Record return ( Object.keys(signer).length === SIGNER_SIZE && typeof signer.Account === 'string' && @@ -105,6 +111,7 @@ export interface BaseTransaction { AccountTxnID?: string Flags?: number | GlobalFlags LastLedgerSequence?: number + // TODO: Make Memo match the format of Signer (By including the Memo: wrapper inside the Interface) Memos?: Array<{ Memo: Memo }> Signers?: Signer[] SourceTag?: number diff --git a/src/transaction/sign.ts b/src/transaction/sign.ts index 94dee513..45e7b952 100644 --- a/src/transaction/sign.ts +++ b/src/transaction/sign.ts @@ -5,7 +5,6 @@ import keypairs from 'ripple-keypairs' import type { Client, Wallet } from '..' import { ValidationError } from '../common/errors' -import { SignedTransaction } from '../common/types/objects' import { xrpToDrops } from '../utils' import { computeBinaryTransactionHash } from '../utils/hashes' @@ -25,7 +24,7 @@ function signWithKeypair( options: SignOptions = { signAs: '', }, -): SignedTransaction { +): { signedTransaction: string; id: string } { const tx = JSON.parse(txJSON) if (tx.TxnSignature || tx.Signers) { throw new ValidationError( @@ -222,7 +221,7 @@ function sign( secret?: any, options?: SignOptions, keypair?: KeyPair, -): SignedTransaction { +): { signedTransaction: string; id: string } { if (typeof secret === 'string') { // we can't validate that the secret matches the account because // the secret could correspond to the regular key @@ -245,7 +244,7 @@ function signOffline( wallet: Wallet, txJSON: string, options?: SignOptions, -): SignedTransaction { +): { signedTransaction: string; id: string } { const { publicKey, privateKey } = wallet return signWithKeypair(null, txJSON, { publicKey, privateKey }, options) } diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 6a22d287..fbc7dcea 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -11,7 +11,6 @@ import { import ECDSA from '../common/ecdsa' import { ValidationError } from '../common/errors' -import { SignedTransaction } from '../common/types/objects' import { signOffline } from '../transaction/sign' import { SignOptions } from '../transaction/types' @@ -126,8 +125,9 @@ class Wallet { signTransaction( transaction: any, // TODO: transaction should be typed with Transaction type. options: SignOptions = { signAs: '' }, - ): SignedTransaction { + ): string { return signOffline(this, JSON.stringify(transaction), options) + .signedTransaction } /** @@ -153,6 +153,10 @@ class Wallet { getXAddress(tag: number, test = false): string { return classicAddressToXAddress(this.classicAddress, tag, test) } + + getClassicAddress(): string { + return deriveAddress(this.publicKey) + } } export default Wallet diff --git a/src/wallet/signer.ts b/src/wallet/signer.ts new file mode 100644 index 00000000..a23248cc --- /dev/null +++ b/src/wallet/signer.ts @@ -0,0 +1,185 @@ +import { BigNumber } from 'bignumber.js' +import { flatMap } from 'lodash' +import { decodeAccountID } from 'ripple-address-codec' +import { + decode, + encodeForSigning, + encodeForSigningClaim, +} from 'ripple-binary-codec' +import { sign as signWithKeypair, verify } from 'ripple-keypairs' + +import { ValidationError } from '../common/errors' +import { Signer } from '../models/common' +import { Transaction } from '../models/transactions' +import { verifyBaseTransaction } from '../models/transactions/common' + +import Wallet from '.' + +/** + * Uses a wallet to cryptographically sign a transaction which proves the owner of the wallet + * is issuing this transaction. + * + * @param wallet - A Wallet that holds your cryptographic keys. + * @param tx - The Transaction that is being signed. + * @param forMultisign - If true, changes the signature format to encode for multisigning. + * @returns A signed Transaction. + */ +function sign(wallet: Wallet, tx: Transaction, forMultisign = false): string { + return wallet.signTransaction( + tx, + forMultisign ? { signAs: wallet.getClassicAddress() } : { signAs: '' }, + ) +} + +/** + * Takes several transactions (in object or blob form) and creates a single transaction with all Signers + * that then gets signed and returned. + * + * @param transactions - An array of Transactions (in object or blob form) to combine and sign. + * @returns A single signed Transaction which has all Signers from transactions within it. + * @throws ValidationError if: + * - There were no transactions given to sign + * - The SigningPubKey field is not the empty string in any given transaction + * - Any transaction is missing a Signers field. + */ +function multisign(transactions: Array): Transaction { + if (transactions.length === 0) { + throw new ValidationError('There were 0 transactions to multisign') + } + + transactions.forEach((txOrBlob) => { + const tx: Transaction = getDecodedTransaction(txOrBlob) + + // This will throw a more clear error for JS users if any of the supplied transactions has incorrect formatting + // TODO: Replace this with verify() (The general validation function for all Transactions) + // , also make verify accept '| Transaction' to avoid type casting here. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- verify does not accept Transaction type + verifyBaseTransaction(tx as unknown as Record) + if (tx.Signers == null || tx.Signers.length === 0) { + throw new ValidationError( + "For multisigning all transactions must include a Signers field containing an array of signatures. You may have forgotten to pass the 'forMultisign' parameter when signing.", + ) + } + + if (tx.SigningPubKey !== '') { + throw new ValidationError( + 'SigningPubKey must be an empty string for all transactions when multisigning.', + ) + } + }) + + const decodedTransactions: Transaction[] = transactions.map( + (txOrBlob: string | Transaction) => { + return getDecodedTransaction(txOrBlob) + }, + ) + + validateTransactionEquivalence(decodedTransactions) + + return getTransactionWithAllSigners(decodedTransactions) +} + +/** + * Creates a signature that can be used to redeem a specific amount of XRP from a payment channel. + * + * @param wallet - The account that will sign for this payment channel. + * @param channelId - An id for the payment channel to redeem XRP from. + * @param amount - The amount in drops to redeem. + * @returns A signature that can be used to redeem a specific amount of XRP from a payment channel. + */ +function authorizeChannel( + wallet: Wallet, + channelId: string, + amount: string, +): string { + const signingData = encodeForSigningClaim({ + channel: channelId, + amount, + }) + + return signWithKeypair(signingData, wallet.privateKey) +} + +/** + * Verifies that the given transaction has a valid signature based on public-key encryption. + * + * @param tx - A transaction to verify the signature of. (Can be in object or encoded string format). + * @returns Returns true if tx has a valid signature, and returns false otherwise. + */ +function verifySignature(tx: Transaction | string): boolean { + const decodedTx: Transaction = getDecodedTransaction(tx) + return verify( + encodeForSigning(decodedTx), + decodedTx.TxnSignature, + decodedTx.SigningPubKey, + ) +} + +/** + * The transactions should all be equal except for the 'Signers' field. + * + * @param transactions - An array of Transactions which are expected to be equal other than 'Signers'. + * @throws ValidationError if the transactions are not equal in any field other than 'Signers'. + */ +function validateTransactionEquivalence(transactions: Transaction[]): void { + const exampleTransaction = JSON.stringify({ + ...transactions[0], + Signers: null, + }) + if ( + transactions + .slice(1) + .some( + (tx) => JSON.stringify({ ...tx, Signers: null }) !== exampleTransaction, + ) + ) { + throw new ValidationError( + 'txJSON is not the same for all signedTransactions', + ) + } +} + +function getTransactionWithAllSigners( + transactions: Transaction[], +): Transaction { + // Signers must be sorted in the combined transaction - See compareSigners' documentation for more details + const sortedSigners: Signer[] = flatMap( + transactions, + (tx) => tx.Signers ?? [], + ).sort(compareSigners) + + return { ...transactions[0], Signers: sortedSigners } +} + +/** + * If presented in binary form, the Signers array must be sorted based on + * the numeric value of the signer addresses, with the lowest value first. + * (If submitted as JSON, the submit_multisigned method handles this automatically.) + * https://xrpl.org/multi-signing.html. + * + * @param left - A Signer to compare with. + * @param right - A second Signer to compare with. + * @returns 1 if left \> right, 0 if left = right, -1 if left \< right, and null if left or right are NaN. + */ +function compareSigners(left: Signer, right: Signer): number { + return addressToBigNumber(left.Signer.Account).comparedTo( + addressToBigNumber(right.Signer.Account), + ) +} + +function addressToBigNumber(address: string): BigNumber { + const hex = Buffer.from(decodeAccountID(address)).toString('hex') + const numberOfBitsInHex = 16 + return new BigNumber(hex, numberOfBitsInHex) +} + +function getDecodedTransaction(txOrBlob: Transaction | string): Transaction { + if (typeof txOrBlob === 'object') { + return txOrBlob + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We are casting here to get strong typing + return decode(txOrBlob) as unknown as Transaction +} + +export { sign, authorizeChannel, verifySignature, multisign } diff --git a/test/models/baseTransaction.ts b/test/models/baseTransaction.ts index 386c52bf..d054db76 100644 --- a/test/models/baseTransaction.ts +++ b/test/models/baseTransaction.ts @@ -42,9 +42,11 @@ describe('BaseTransaction', function () { ], Signers: [ { - Account: 'r....', - TxnSignature: 'DEADBEEF', - SigningPubKey: 'hex-string', + Signer: { + Account: 'r....', + TxnSignature: 'DEADBEEF', + SigningPubKey: 'hex-string', + }, }, ], SourceTag: 31, @@ -197,7 +199,9 @@ describe('BaseTransaction', function () { TransactionType: 'Payment', Signers: [ { - Account: 'r....', + Signer: { + Account: 'r....', + }, }, ], } as any diff --git a/test/wallet/index.ts b/test/wallet/index.ts index 6a92a56c..610af7d1 100644 --- a/test/wallet/index.ts +++ b/test/wallet/index.ts @@ -165,12 +165,10 @@ describe('Wallet', function () { SigningPubKey: publicKey, } const wallet = new Wallet(publicKey, privateKey) - const signedTx: { signedTransaction: string; id: string } = - wallet.signTransaction(txJSON) + const signedTx: string = wallet.signTransaction(txJSON) - assert.hasAllKeys(signedTx, ['id', 'signedTransaction']) - assert.isString(signedTx.id) - assert.isString(signedTx.signedTransaction) + // TODO: Check the output of the signature against a known result + assert.isString(signedTx) }) }) diff --git a/test/wallet/signer.ts b/test/wallet/signer.ts new file mode 100644 index 00000000..1f76f7b7 --- /dev/null +++ b/test/wallet/signer.ts @@ -0,0 +1,303 @@ +import { assert } from 'chai' +import { decode, encode } from 'ripple-binary-codec/dist' +import { JsonObject } from 'ripple-binary-codec/dist/types/serialized-type' + +import { ValidationError } from '../../src/common/errors' +import { Transaction } from '../../src/models/transactions' +import Wallet from '../../src/wallet' +import { + sign, + authorizeChannel, + multisign, + verifySignature, +} from '../../src/wallet/signer' + +const publicKey = + '030E58CDD076E798C84755590AAF6237CA8FAE821070A59F648B517A30DC6F589D' +const privateKey = + '00141BA006D3363D2FB2785E8DF4E44D3A49908780CB4FB51F6D217C08C021429F' +const address = 'rhvh5SrgBL5V8oeV9EpDuVszeJSSCEkbPc' +const seed = 'ss1x3KLrSvfg7irFc1D929WXZ7z9H' + +const tx: Transaction = { + TransactionType: 'Payment', + Account: address, + Destination: 'rQ3PTWGLCbPz8ZCicV5tCX3xuymojTng5r', + Amount: '20000000', + Sequence: 1, + Fee: '12', + SigningPubKey: publicKey, +} + +const unsignedTx1: Transaction = { + TransactionType: 'TrustSet', + Account: 'rEuLyBCvcw4CFmzv8RepSiAoNgF8tTGJQC', + Fee: '30000', + Flags: 262144, + LimitAmount: { + currency: 'USD', + issuer: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + value: '100', + }, + Sequence: 2, +} + +const unsignedSecret1 = 'spzGHmohX9bAM6gzF4m9FvJmJb1CR' + +const multisignTx1: Transaction = { + TransactionType: 'TrustSet', + Account: 'rEuLyBCvcw4CFmzv8RepSiAoNgF8tTGJQC', + Fee: '30000', + Flags: 262144, + LimitAmount: { + currency: 'USD', + issuer: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + value: '100', + }, + Sequence: 2, + Signers: [ + { + Signer: { + Account: 'rJvuSQhQR37czfxRou4vNWaM97uEhT4ShE', + SigningPubKey: + '02B78EEA571B2633180834CC6E7B4ED84FBF6811D12ECB59410E0C92D13B7726F5', + TxnSignature: + '304502210098009CEFA61EE9843BB7FC29B78CFFAACF28352A4A7CF3AAE79EF12D79BA50910220684F116266E5E4519A7A33F7421631EB8494082BE51A8B03FECCB3E59F77154A', + }, + }, + ], + SigningPubKey: '', +} + +const multisignTxToCombine1: Transaction = { + Account: 'rEuLyBCvcw4CFmzv8RepSiAoNgF8tTGJQC', + Fee: '30000', + Flags: 262144, + LimitAmount: { + currency: 'USD', + issuer: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + value: '100', + }, + Sequence: 2, + Signers: [ + { + Signer: { + Account: 'rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW', + SigningPubKey: + '02B3EC4E5DD96029A647CFA20DA07FE1F85296505552CCAC114087E66B46BD77DF', + TxnSignature: + '30450221009C195DBBF7967E223D8626CA19CF02073667F2B22E206727BFE848FF42BEAC8A022048C323B0BED19A988BDBEFA974B6DE8AA9DCAE250AA82BBD1221787032A864E5', + }, + }, + ], + SigningPubKey: '', + TransactionType: 'TrustSet', +} + +const multisignTxToCombine2: Transaction = { + Account: 'rEuLyBCvcw4CFmzv8RepSiAoNgF8tTGJQC', + Fee: '30000', + Flags: 262144, + LimitAmount: { + currency: 'USD', + issuer: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + value: '100', + }, + Sequence: 2, + Signers: [ + { + Signer: { + Account: 'rJvuSQhQR37czfxRou4vNWaM97uEhT4ShE', + SigningPubKey: + '02B78EEA571B2633180834CC6E7B4ED84FBF6811D12ECB59410E0C92D13B7726F5', + TxnSignature: + '304502210098009CEFA61EE9843BB7FC29B78CFFAACF28352A4A7CF3AAE79EF12D79BA50910220684F116266E5E4519A7A33F7421631EB8494082BE51A8B03FECCB3E59F77154A', + }, + }, + ], + SigningPubKey: '', + TransactionType: 'TrustSet', +} + +const expectedMultisign: Transaction = { + Account: 'rEuLyBCvcw4CFmzv8RepSiAoNgF8tTGJQC', + Fee: '30000', + Flags: 262144, + LimitAmount: { + currency: 'USD', + issuer: 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh', + value: '100', + }, + Sequence: 2, + Signers: [ + { + Signer: { + Account: 'rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW', + SigningPubKey: + '02B3EC4E5DD96029A647CFA20DA07FE1F85296505552CCAC114087E66B46BD77DF', + TxnSignature: + '30450221009C195DBBF7967E223D8626CA19CF02073667F2B22E206727BFE848FF42BEAC8A022048C323B0BED19A988BDBEFA974B6DE8AA9DCAE250AA82BBD1221787032A864E5', + }, + }, + { + Signer: { + Account: 'rJvuSQhQR37czfxRou4vNWaM97uEhT4ShE', + SigningPubKey: + '02B78EEA571B2633180834CC6E7B4ED84FBF6811D12ECB59410E0C92D13B7726F5', + TxnSignature: + '304502210098009CEFA61EE9843BB7FC29B78CFFAACF28352A4A7CF3AAE79EF12D79BA50910220684F116266E5E4519A7A33F7421631EB8494082BE51A8B03FECCB3E59F77154A', + }, + }, + ], + SigningPubKey: '', + TransactionType: 'TrustSet', +} + +describe('Signer', function () { + it('sign', function () { + // Test case data generated using this tutorial - https://xrpl.org/send-xrp.html#send-xrp + const tx3: Transaction = { + TransactionType: 'Payment', + Account: 'rHLEki8gPUMnF72JnuALvnAMRhRemzhRke', + Amount: '22000000', + Destination: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', + Flags: 2147483648, + LastLedgerSequence: 20582339, + Fee: '12', + Sequence: 20582260, + } + const signedTxBlob = + '120000228000000024013A0F74201B013A0FC36140000000014FB18068400000000000000C732102A8A44DB3D4C73EEEE11DFE54D2029103B776AA8A8D293A91D645977C9DF5F544744730450221009ECB5324717E14DD6970126271F05BC2626D2A8FA9F3797555D417F8257C1E6002206BDD74A0F30425F2BA9DB69C90F21B3E27735C190FB4F3A640F066ACBBF06AD98114B3263BD0A9BF9DFDBBBBD07F536355FF477BF0E98314F667B0CA50CC7709A220B0561B85E53A48461FA8' + + const wallet = Wallet.fromSeed(seed) + + const signedTx: string = sign(wallet, tx3) + + assert.equal(signedTx, signedTxBlob) + }) + + it('sign in multisign format', function () { + const wallet = Wallet.fromSeed(unsignedSecret1) + + assert.deepEqual( + decode(sign(wallet, unsignedTx1, true)), + multisignTx1 as unknown as JsonObject, + ) + }) + + it('multisign runs successfully with Transaction objects', function () { + const transactions: Transaction[] = [ + multisignTxToCombine1, + multisignTxToCombine2, + ] + + assert.deepEqual(multisign(transactions), expectedMultisign) + }) + + it('multisign runs successfully with tx_blobs', function () { + const transactions: Transaction[] = [ + multisignTxToCombine1, + multisignTxToCombine2, + ] + + const encodedTransactions: string[] = transactions.map(encode) + + assert.deepEqual(multisign(encodedTransactions), expectedMultisign) + }) + + it('multisign throws a validation error when there are no transactions', function () { + const transactions: Transaction[] = [] + assert.throws(() => multisign(transactions), ValidationError) + }) + + it('multisign throws when trying to combine two different transactions', function () { + const differentMultisignedTx: Transaction = { + TransactionType: 'Payment', + Sequence: 1, + Amount: '20000000', + Fee: '12', + SigningPubKey: '', + Account: 'rhvh5SrgBL5V8oeV9EpDuVszeJSSCEkbPc', + Destination: 'rQ3PTWGLCbPz8ZCicV5tCX3xuymojTng5r', + Signers: [ + { + Signer: { + SigningPubKey: + '02A8A44DB3D4C73EEEE11DFE54D2029103B776AA8A8D293A91D645977C9DF5F544', + TxnSignature: + '3044022077BCE143B9A0B51A7716BB93CBC0C99FB41BA339D91A87CB9E47DA80A7EF660802205C81AA49D408771F65A131200CCBFC536ACFE212C1414E05E43B56BE1F9380F2', + Account: 'rHLEki8gPUMnF72JnuALvnAMRhRemzhRke', + }, + }, + ], + } + + const transactions: Transaction[] = [ + multisignTxToCombine1, + differentMultisignedTx, + ] + + assert.throws(() => multisign(transactions)) + }) + + it('multisign throws when trying to combine transaction with normal signature', function () { + const signedTxBlob = + '120000228000000024013A0F74201B013A0FC36140000000014FB18068400000000000000C732102A8A44DB3D4C73EEEE11DFE54D2029103B776AA8A8D293A91D645977C9DF5F544744730450221009ECB5324717E14DD6970126271F05BC2626D2A8FA9F3797555D417F8257C1E6002206BDD74A0F30425F2BA9DB69C90F21B3E27735C190FB4F3A640F066ACBBF06AD98114B3263BD0A9BF9DFDBBBBD07F536355FF477BF0E98314F667B0CA50CC7709A220B0561B85E53A48461FA8' + + const transactions = [signedTxBlob] + + assert.throws(() => multisign(transactions), /forMultisign/u) + }) + + it('authorizeChannel succeeds with secp256k1 seed', function () { + const wallet = Wallet.fromSeed('snGHNrPbHrdUcszeuDEigMdC1Lyyd') + const channelId = + '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' + const amount = '1000000' + + assert.equal( + authorizeChannel(wallet, channelId, amount), + '304402204E7052F33DDAFAAA55C9F5B132A5E50EE95B2CF68C0902F61DFE77299BC893740220353640B951DCD24371C16868B3F91B78D38B6F3FD1E826413CDF891FA8250AAC', + ) + }) + + it('authorizeChannel succeeds with ed25519 seed', function () { + const wallet = Wallet.fromSeed('sEdSuqBPSQaood2DmNYVkwWTn1oQTj2') + const channelId = + '5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3' + const amount = '1000000' + assert.equal( + authorizeChannel(wallet, channelId, amount), + '7E1C217A3E4B3C107B7A356E665088B4FBA6464C48C58267BEF64975E3375EA338AE22E6714E3F5E734AE33E6B97AAD59058E1E196C1F92346FC1498D0674404', + ) + }) + + it('verifySignature succeeds for valid signed transaction blob', function () { + const wallet = new Wallet(publicKey, privateKey) + + const signedTx: string = sign(wallet, tx) + + assert.isTrue(verifySignature(signedTx)) + }) + + it('verify succeeds for valid signed transaction object', function () { + const wallet = new Wallet(publicKey, privateKey) + + const signedTx: string = sign(wallet, tx) + + assert.isTrue(verifySignature(decode(signedTx) as unknown as Transaction)) + }) + + it('verify throws for invalid signing key', function () { + const wallet = new Wallet(publicKey, privateKey) + const signedTx: string = sign(wallet, tx) + + const decodedTx: Transaction = decode(signedTx) as unknown as Transaction + + // Use a different key for validation + decodedTx.SigningPubKey = + '0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020' + + assert.isFalse(verifySignature(decodedTx)) + }) +})