diff --git a/src/core/crypto/MerkleHashBuilder.ts b/src/core/crypto/MerkleHashBuilder.ts index ac2ee6ebe2..5e1061c2a3 100644 --- a/src/core/crypto/MerkleHashBuilder.ts +++ b/src/core/crypto/MerkleHashBuilder.ts @@ -13,34 +13,46 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { SHA3Hasher } from './SHA3Hasher'; import { SignSchema } from './SignSchema'; export class MerkleHashBuilder { - hashes: Uint8Array[] = new Array(); - hasherFactory: any; - signSchema: SignSchema; - length: number; + /** + * The list of hashes used to calculate root hash. + * + * @var {Uint8Array} + */ + protected hashes: Uint8Array[] = new Array(); /** * Constructor - * @param hasherFactory Hasher (SHA3_256) * @param signSchema Sign schema * @param length Hash size */ - constructor(hasherFactory: any, signSchema: SignSchema = SignSchema.SHA3, length: number = 32) { - this.hasherFactory = hasherFactory; - this.signSchema = signSchema; - this.length = length; + constructor(/** + * Length of produced merkle hash in bytes. + * + * @var {number} + */ + public readonly length: 32 | 64, + /** + * Signature schema used (hash algorithm diff) + * + * @var {SignSchema} + */ + public readonly signSchema: SignSchema) { } - /** @internal + /** * Hash inner transactions + * + * @internal * @param hashes Inner transaction hashes + * @return {Uint8Array} */ protected hash(hashes: Uint8Array[]): Uint8Array { - const hasher = this.hasherFactory(this.length, this.signSchema); + const hasher = SHA3Hasher.createHasher(this.length, this.signSchema); hasher.reset(); hashes.forEach((hashVal: Uint8Array) => { @@ -52,9 +64,12 @@ export class MerkleHashBuilder { return hash; } - /** @internal - * Get root hash of Merkle Trees - * @param hashes Inner transaction hashes + /** + * Get root hash of Merkle Tree + * + * @internal + * @param {Uint8Array[]} hashes Inner transaction hashes + * @return {Uint8Array} */ protected calculateRootHash(hashes: Uint8Array[]): Uint8Array { @@ -80,18 +95,22 @@ export class MerkleHashBuilder { } /** - * Return root hash from Merkle tree + * Get root hash of Merkle tree + * + * @return {Uint8Array} */ public getRootHash(): Uint8Array { return this.calculateRootHash(this.hashes); } /** - * Update hashes array + * Update hashes array (add hash) + * * @param hash Inner transaction hash buffer + * @return {MerkleHashBuilder} */ - public update(hash: Uint8Array): void { + public update(hash: Uint8Array): MerkleHashBuilder { this.hashes.push(hash); + return this; } - } diff --git a/src/model/transaction/AggregateTransaction.ts b/src/model/transaction/AggregateTransaction.ts index d68ac5b14f..04dddd9341 100644 --- a/src/model/transaction/AggregateTransaction.ts +++ b/src/model/transaction/AggregateTransaction.ts @@ -340,7 +340,7 @@ export class AggregateTransaction extends Transaction { this.type.valueOf(), new AmountDto(this.maxFee.toDTO()), new TimestampDto(this.deadline.toDTO()), - new Hash256Dto(this.calculateInnerTransactionHash()), + new Hash256Dto(this.calculateInnerTransactionHash(this.networkType)), transactions, cosignatures, ) : @@ -352,7 +352,7 @@ export class AggregateTransaction extends Transaction { this.type.valueOf(), new AmountDto(this.maxFee.toDTO()), new TimestampDto(this.deadline.toDTO()), - new Hash256Dto(this.calculateInnerTransactionHash()), + new Hash256Dto(this.calculateInnerTransactionHash(this.networkType)), transactions, cosignatures, ); @@ -372,11 +372,23 @@ export class AggregateTransaction extends Transaction { * Generate inner transaction root hash (merkle tree) * @returns {Uint8Array} */ - private calculateInnerTransactionHash(): Uint8Array { - const builder = new MerkleHashBuilder(SHA3Hasher.createHasher); + private calculateInnerTransactionHash(networkType: NetworkType): Uint8Array { + const signSchema = SHA3Hasher.resolveSignSchema(networkType); + const hasher = SHA3Hasher.createHasher(32, signSchema); + const builder = new MerkleHashBuilder(32, signSchema); this.innerTransactions.forEach((transaction) => { - builder.update(RawArray.uint8View(sha3_256.arrayBuffer(transaction.toAggregateTransactionBytes()))); + const entityHash: Uint8Array = new Uint8Array(32); + + // for each embedded transaction hash their body + hasher.reset(); + hasher.update(transaction.toAggregateTransactionBytes()); + hasher.finalize(entityHash); + + // update merkle tree (add transaction hash) + builder.update(entityHash); }); + + // calculate root hash with all transactions return builder.getRootHash(); } diff --git a/src/model/transaction/Transaction.ts b/src/model/transaction/Transaction.ts index ae59254d7e..9d109befa7 100644 --- a/src/model/transaction/Transaction.ts +++ b/src/model/transaction/Transaction.ts @@ -33,6 +33,36 @@ import { TransactionType } from './TransactionType'; */ export abstract class Transaction { + /** + * Transaction header size + * + * Included fields are `size`, `verifiableEntityHeader_Reserved1`, + * `signature`, `signerPublicKey` and `entityBody_Reserved1`. + * + * @var {number} + */ + public static readonly Header_Size: number = 8 + 64 + 32 + 4; + + /** + * Index of the transaction *type* + * + * Included fields are the transaction header, `version` + * and `network` + * + * @var {number} + */ + public static readonly Type_Index: number = Transaction.Header_Size + 2; + + /** + * Index of the transaction *body* + * + * Included fields are the transaction header, `version`, + * `network`, `type`, `maxFee` and `deadline` + * + * @var {number} + */ + public static readonly Body_Index: number = Transaction.Header_Size + 1 + 1 + 2 + 8 + 8; + /** * @constructor * @param type @@ -81,29 +111,68 @@ export abstract class Transaction { /** * Generate transaction hash hex + * + * @see https://github.com/nemtech/catapult-server/blob/master/src/catapult/model/EntityHasher.cpp#L32 + * @see https://github.com/nemtech/catapult-server/blob/master/src/catapult/model/EntityHasher.cpp#L35 + * @see https://github.com/nemtech/catapult-server/blob/master/sdk/src/extensions/TransactionExtensions.cpp#L46 * @param {string} transactionPayload HexString Payload * @param {Array} generationHashBuffer Network generation hash byte * @param {NetworkType} networkType Catapult network identifier * @returns {string} Returns Transaction Payload hash */ public static createTransactionHash(transactionPayload: string, generationHashBuffer: number[], networkType: NetworkType): string { - const type = parseInt(Convert.uint8ToHex(Convert.hexToUint8(transactionPayload.substring(220, 224)).reverse()), 16); - const byteBuffer = Array.from(Convert.hexToUint8(transactionPayload)); - const byteBufferWithoutHeader = byteBuffer.slice(4 + 64 + 32 + 8); - const dataBytes = type === TransactionType.AGGREGATE_BONDED || type === TransactionType.AGGREGATE_COMPLETE ? - generationHashBuffer.concat(byteBufferWithoutHeader.slice(0, 52)) : - generationHashBuffer.concat(byteBufferWithoutHeader); - const signingBytes = byteBuffer - .slice(8, 40) // first half of signature - .concat(byteBuffer - .slice(4 + 4 + 64, 8 + 64 + 32)) // signer - .concat(dataBytes); - const hash = new Uint8Array(32); - const signSchema = SHA3Hasher.resolveSignSchema(networkType); - SHA3Hasher.func(hash, signingBytes, 32, signSchema); + // prepare + const entityHash: Uint8Array = new Uint8Array(32); + const transactionBytes: Uint8Array = Convert.hexToUint8(transactionPayload); + + // read transaction type + const typeIdx: number = Transaction.Type_Index; + const typeBytes: Uint8Array = transactionBytes.slice(typeIdx, typeIdx + 2).reverse(); // REVERSED + const entityType: TransactionType = parseInt(Convert.uint8ToHex(typeBytes), 16); + const isAggregateTransaction = [ + TransactionType.AGGREGATE_BONDED, + TransactionType.AGGREGATE_COMPLETE, + ].find((type: TransactionType) => entityType === type) !== undefined; + + // 1) take "R" part of a signature (first 32 bytes) + const signatureR: Uint8Array = transactionBytes.slice(8, 8 + 32); + + // 2) add public key to match sign/verify behavior (32 bytes) + const pubKeyIdx: number = signatureR.length; + const publicKey: Uint8Array = transactionBytes.slice(8 + 64, 8 + 64 + 32); + + // 3) add generationHash (32 bytes) + const generationHashIdx: number = pubKeyIdx + publicKey.length; + const generationHash: Uint8Array = Uint8Array.from(generationHashBuffer); + + // 4) add transaction data without header (EntityDataBuffer) + // @link https://github.com/nemtech/catapult-server/blob/master/src/catapult/model/EntityHasher.cpp#L30 + const transactionBodyIdx: number = generationHashIdx + generationHash.length; + let transactionBody: Uint8Array = transactionBytes.slice(Transaction.Header_Size); + + // in case of aggregate transactions, we hash only the merkle transaction hash. + if (isAggregateTransaction) { + transactionBody = transactionBytes.slice(Transaction.Header_Size, Transaction.Body_Index + 32); + } + + // 5) concatenate binary hash parts + // layout: `signature_R || signerPublicKey || generationHash || EntityDataBuffer` + const entityHashBytes: Uint8Array = new Uint8Array( + signatureR.length + + publicKey.length + + generationHash.length + + transactionBody.length, + ); + entityHashBytes.set(signatureR, 0); + entityHashBytes.set(publicKey, pubKeyIdx); + entityHashBytes.set(generationHash, generationHashIdx); + entityHashBytes.set(transactionBody, transactionBodyIdx); - return Convert.uint8ToHex(hash); + // 6) create SHA3 or Keccak hash depending on `signSchema` + const signSchema: SignSchema = SHA3Hasher.resolveSignSchema(networkType); + SHA3Hasher.func(entityHash, entityHashBytes, 32, signSchema); + return Convert.uint8ToHex(entityHash); } /** diff --git a/test/core/crypto/MerkleHashBuilder.spec.ts b/test/core/crypto/MerkleHashBuilder.spec.ts index 74819a98c5..3f839f991b 100644 --- a/test/core/crypto/MerkleHashBuilder.spec.ts +++ b/test/core/crypto/MerkleHashBuilder.spec.ts @@ -15,13 +15,13 @@ */ import { expect } from 'chai'; -import { MerkleHashBuilder, SHA3Hasher } from '../../../src/core/crypto'; +import { MerkleHashBuilder, SHA3Hasher, SignSchema } from '../../../src/core/crypto'; import { Convert } from '../../../src/core/format'; -describe('MerkleHashBuilder tests', () => { - it('Zero Value', () => { +describe('MerkleHashBuilder should', () => { + it('fill 0s for empty merkle tree', () => { // Arrange: - const builder = new MerkleHashBuilder(SHA3Hasher.createHasher); + const builder = new MerkleHashBuilder(32, SignSchema.SHA3); const rootHash = builder.getRootHash(); @@ -29,9 +29,9 @@ describe('MerkleHashBuilder tests', () => { }); - it('One Value', () => { + it('return first hash given single child', () => { // Arrange: - const builder = new MerkleHashBuilder(SHA3Hasher.createHasher); + const builder = new MerkleHashBuilder(32, SignSchema.SHA3); builder.update(Convert.hexToUint8('215B158F0BD416B596271BCE527CD9DC8E4A639CC271D896F9156AF6F441EEB9')); @@ -41,9 +41,9 @@ describe('MerkleHashBuilder tests', () => { }); - it('Two Values', () => { + it('create correct merkle hash given two children', () => { // Arrange: - const builder = new MerkleHashBuilder(SHA3Hasher.createHasher); + const builder = new MerkleHashBuilder(32, SignSchema.SHA3); builder.update(Convert.hexToUint8('215b158f0bd416b596271bce527cd9dc8e4a639cc271d896f9156af6f441eeb9')); builder.update(Convert.hexToUint8('976c5ce6bf3f797113e5a3a094c7801c885daf783c50563ffd3ca6a5ef580e25')); @@ -54,9 +54,9 @@ describe('MerkleHashBuilder tests', () => { }); - it('Three Values', () => { + it('create correct merkle hash given three children', () => { // Arrange: - const builder = new MerkleHashBuilder(SHA3Hasher.createHasher); + const builder = new MerkleHashBuilder(32, SignSchema.SHA3); builder.update(Convert.hexToUint8('215b158f0bd416b596271bce527cd9dc8e4a639cc271d896f9156af6f441eeb9')); builder.update(Convert.hexToUint8('976c5ce6bf3f797113e5a3a094c7801c885daf783c50563ffd3ca6a5ef580e25')); diff --git a/test/model/transaction/Transaction.spec.ts b/test/model/transaction/Transaction.spec.ts index 4546271bbe..3ea8631c9c 100644 --- a/test/model/transaction/Transaction.spec.ts +++ b/test/model/transaction/Transaction.spec.ts @@ -28,6 +28,7 @@ import { TransactionType } from '../../../src/model/transaction/TransactionType' import { TransferTransaction } from '../../../src/model/transaction/TransferTransaction'; import { UInt64 } from '../../../src/model/UInt64'; import { TestingAccount } from '../../conf/conf.spec'; +import { Convert } from '../../../src/core/format/Convert'; describe('Transaction', () => { let account: Account; @@ -227,6 +228,199 @@ describe('Transaction', () => { expect(transaction.versionToHex()).to.be.equal('0x9001'); }); }); + + describe('createTransactionHash() should', () => { + + // shortcut + const knownPayload = ( + '970000000000000075DAC796D500CEFDFBD582BC6E0580401FE6DB02FBEA9367' + + '3DF47844246CDEA93715EB700F295A459E59D96A2BC6B7E36C79016A96B9FA38' + + '7E8B8937342FE30C6BE37B726EEE24C4B0E3C943E09A44691553759A89E92C4A' + + '84BBC4AD9AF5D49C0000000001984E4140420F0000000000E4B580B11A000000' + + 'A0860100000000002AD8FC018D9A49E100056576696173' + ); + + const knownAggregatePayload = ( + '0801000000000000AC1F3E0EE2C16F465CDC2E091DC44D6EB55F7FE3988A5F21' + + '309DF479BE6D3F0033E155695FB1133EA0EA64A67C1EDC2B430CFAF9722AF36B' + + 'AE84DBDB1C8F1509C2F93346E27CE6AD1A9F8F5E3066F8326593A406BDF357AC' + + 'B041E2F9AB402EFE000000000190414200000000000000006BA50FB91A000000' + + 'EA8F8301E7EDFD701F62E1DC1601ABDE22E5FCD11C9C7E7A01B87F8DFB6B62B0' + + '60000000000000005D00000000000000C2F93346E27CE6AD1A9F8F5E3066F832' + + '6593A406BDF357ACB041E2F9AB402EFE00000000019054419050B9837EFAB4BB' + + 'E8A4B9BB32D812F9885C00D8FC1650E142000D000000000000746573742D6D65' + + '7373616765000000' + ); + + // expected values + const knownHash_sha3 = '709373248659274C5933BEA2920942D6C7B48B9C2DA4BAEE233510E71495931F'; + const knownHash_keccak = '787423372BEC0CB2BE3EEA58E773074E121989AF29E5E5BD9EE660C1E3A0AF93'; + const generationHashBytes = Array.from(Convert.hexToUint8('988C4CDCE4D188013C13DE7914C7FD4D626169EF256722F61C52EFBE06BD5A2C')); + const generationHashBytes_mt = Array.from(Convert.hexToUint8('17FA4747F5014B50413CCF968749604D728D7065DC504291EEE556899A534CBB')); + + it ('create different hash given different signatures', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + // modify signature part of the payload ; this must affect produced hash + const tamperedSig = knownPayload.substr(0, 16) + '12' + knownPayload.substr(18); + const hash2 = Transaction.createTransactionHash( + tamperedSig, // replaced two first bytes of signature + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.not.equal(hash2); + }); + + it ('create different hash given different signer public key', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + // modify signer public key part of the payload ; this must affect produced hash + const tamperedSigner = knownPayload.substr(0, 16 + 128) + '12' + knownPayload.substr(16 + 128 + 2); + const hash2 = Transaction.createTransactionHash( + tamperedSigner, // replaced two first bytes of signer public key + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.not.equal(hash2); + }); + + it ('create different hash given different generation hash', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + const hash2 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes_mt, // uses different generation hash + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.not.equal(hash2); + }); + + it ('create different hash given different transaction body', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + // modify "transaction body" part of payload ; this must affect produced transaction hash + const tamperedBody = knownAggregatePayload.substr(0, Transaction.Body_Index * 2) + + '12' + knownAggregatePayload.substr(Transaction.Body_Index * 2 + 2); + const hash2 = Transaction.createTransactionHash( + tamperedBody, + generationHashBytes, // uses different generation hash + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.not.equal(hash2); + }); + + it ('create same hash given same payloads', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + const hash2 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.equal(hash2); + }); + + it ('create different hash given different signature schemas', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, // SHA3 + ); + + const hash2 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.TEST_NET, // KECCAK + ); + + expect(hash1).to.not.equal(hash2); + }); + + it('create correct SHA3 transaction hash given network type MIJIN or MIJIN_TEST', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + const hash2 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MIJIN, + ); + + expect(hash1).to.equal(knownHash_sha3); + expect(hash2).to.equal(knownHash_sha3); + }); + + it('create correct KECCAK transaction hash given network type MAIN_NET or TEST_NET', () => { + const hash1 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.TEST_NET, + ); + const hash2 = Transaction.createTransactionHash( + knownPayload, + generationHashBytes, + NetworkType.MAIN_NET, + ); + + expect(hash1).to.equal(knownHash_keccak); + expect(hash2).to.equal(knownHash_keccak); + }); + + it('hash only merkle transaction hash for aggregate transactions', () => { + const hash1 = Transaction.createTransactionHash( + knownAggregatePayload, + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + // modify end of payload ; this must not affect produced transaction hash + // this test is valid only for Aggregate Transactions + const tamperedSize = '12' + knownAggregatePayload.substr(2); + const hashTamperedBody = Transaction.createTransactionHash( + tamperedSize, // replace in size (header change should not affect hash) + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + // modify "merkle hash" part of payload ; this must affect produced transaction hash + const tamperedPayload = knownAggregatePayload.substr(0, Transaction.Body_Index * 2) + + '12' + knownAggregatePayload.substr(Transaction.Body_Index * 2 + 2); + const hashTamperedMerkle = Transaction.createTransactionHash( + tamperedPayload, // replace in merkle hash (will affect hash) + generationHashBytes, + NetworkType.MIJIN_TEST, + ); + + expect(hash1).to.equal(hashTamperedBody); + expect(hash1).to.not.equal(hashTamperedMerkle); + }); + }); }); class FakeTransaction extends Transaction {