この記事の続きです
前回,AggregateTransactionの連署の注意点を挙げましたが調べてみたところハッシュの検証が不十分でしたのでこちらの記事にて追記します.
前回,連署はAggregateTransactionのハッシュに対して行なっていることについて記載しました.これより,連署を行うハッシュについて精査が必要なことがわかりました.
そこで,トランザクションペイロードの検証の必要性について調べるために次の操作を実施してみました
1 空のアグリゲートトランザクションと不正なアグリゲートトランザクションの生成,送り主の署名作成
//listenAccountが送信する空のTX
const tx = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[],
xym.PlainMessage.create(""),
networkType
).toAggregate(listenAccount.publicAccount);
//swapAccountが送信する空のTX
const tx2 = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[],
xym.PlainMessage.create(""),
networkType
).toAggregate(swapAccount.publicAccount);
//swapAccountが3A8416DB2D53B6C8を9999999送信するTX
const txb = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[new Mosaic(new MosaicId("3A8416DB2D53B6C8"),UInt64.fromUint(9999999))],
xym.PlainMessage.create(""),
networkType
).toAggregate(swapAccount.publicAccount);
//空TXのみを送り合うアグリゲート
const agg = xym.AggregateTransaction.createComplete(
xym.Deadline.create(epochAdjustment),
[tx,tx2],
networkType,
[],
).setMaxFeeForAggregate(1);
//swapAccountが3A8416DB2D53B6C8を9999999送信するアグリゲート
const aggb = xym.AggregateTransaction.createComplete(
xym.Deadline.create(epochAdjustment),
[tx,txb],
networkType,
[],
).setMaxFeeForAggregate(1);
//stは空TX
const st = listenAccount.sign(agg, networkGenerationHash);
//stbはswapAccountのみが9999999送信するTX
const stb = listenAccount.sign(aggb, networkGenerationHash);
2 ペイロードの差し替え
stのヘッダをstbの物に差し替えます.これをbadpayloadとします
const badPayload = stb.payload.slice(0,(1+1+2+8+8+4+64+8+32 + 32)*2)+st.payload.slice((1+1+2+8+8+4+64+8+32 + 32)*2);
3 送り先による署名
st.payload, stb.payload ,badpayloadに連署してみます
console.log("st");
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, st.payload, networkGenerationHash));
console.log("stb")
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, stb.payload, networkGenerationHash));
console.log("badpayload");
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, badPayload, networkGenerationHash));
また,stbとbadpaylaodからトランザクションをパースしてみます
console.log(TransactionMapping.createFromPayload(stb.payload));
console.log(TransactionMapping.createFromPayload(badPayload))
すると,連署結果は次のようになります
stbとbadpayloadによる署名結果が一致していることがわかります
パース結果は次のようになります
stb
AggregateTransaction {
type: 16705,
networkType: 152,
version: 1,
deadline: Deadline { adjustedValue: 9436558629 },
maxFee: UInt64 { lower: 344, higher: 0 },
signature: '58532ABD5ABE0666DF1363FF961D07575FD60005D8CC24B66EFA7CF1E00031C2F3C8CEA3AFC01EAC1666F96811FE7C590B1DBDA0A9AFE646320B4B129FEB3B0D',
signer: PublicAccount {
publicKey: '3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29',
address: Address {
address: 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I',
networkType: 152
}
},
transactionInfo: undefined,
payloadSize: undefined,
innerTransactions: [
TransferTransaction {
type: 16724,
networkType: 152,
version: 1,
deadline: [Deadline],
maxFee: [UInt64],
signature: undefined,
signer: [PublicAccount],
transactionInfo: undefined,
payloadSize: undefined,
recipientAddress: [Address],
mosaics: [],
message: [RawMessage]
},
TransferTransaction {
type: 16724,
networkType: 152,
version: 1,
deadline: [Deadline],
maxFee: [UInt64],
signature: undefined,
signer: [PublicAccount],
transactionInfo: undefined,
payloadSize: undefined,
recipientAddress: [Address],
mosaics: [Array],
message: [RawMessage]
}
],
cosignatures: []
}
badpayload
AggregateTransaction {
type: 16705,
networkType: 152,
version: 1,
deadline: Deadline { adjustedValue: 9436558629 },
maxFee: UInt64 { lower: 344, higher: 0 },
signature: '58532ABD5ABE0666DF1363FF961D07575FD60005D8CC24B66EFA7CF1E00031C2F3C8CEA3AFC01EAC1666F96811FE7C590B1DBDA0A9AFE646320B4B129FEB3B0D',
signer: PublicAccount {
publicKey: '3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29',
address: Address {
address: 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I',
networkType: 152
}
},
transactionInfo: undefined,
payloadSize: undefined,
innerTransactions: [
TransferTransaction {
type: 16724,
networkType: 152,
version: 1,
deadline: [Deadline],
maxFee: [UInt64],
signature: undefined,
signer: [PublicAccount],
transactionInfo: undefined,
payloadSize: undefined,
recipientAddress: [Address],
mosaics: [],
message: [RawMessage]
},
TransferTransaction {
type: 16724,
networkType: 152,
version: 1,
deadline: [Deadline],
maxFee: [UInt64],
signature: undefined,
signer: [PublicAccount],
transactionInfo: undefined,
payloadSize: undefined,
recipientAddress: [Address],
mosaics: [],
message: [RawMessage]
}
],
cosignatures: []
}
stbではmosaicsに表示があるのに対してbadpayloadではmosaicsに表示がないことがわかります.
送られた側がpayloadの不正を見抜けなかった場合,送られた側は空のトランザクションに署名したはずなのに,実際にはモザイクを送金するトランザクションに署名しているといった恐ろしいことが発生します.
当然,この連署内容を送信元に返した場合,トランザクションを成立させてしまうことができます.
検証して回避しよう
今回のような事態はノードから取得するアグリゲートボンデッドに対しての連署であれば,ノードがペイロードの検証まで行なっているのでこのような事態が起きるリスクは低くなります.基本的にはオフラインでのペイロードのやり取りの際に起きる問題となります.ただし,ノードが不正なアグリゲートを配信した場合は効果がありません.
ノードが侵害された場合であっても,オフラインであってもpayloadをしっかり検証することで防ぐことが可能ですので実際にやってみましょう.
ルートハッシュの計算
大まかな計算
アグリゲートに含まれるトランザクションはembededに変換された後,それぞれハッシュが計算されます.その後隣接するハッシュの合成を繰り返し,最終的に1つのハッシュを生成します.これがルートハッシュ(transactionsHash)となります
例4つTxがある場合,ハッシュh1~4が存在し
h1,h2,h3,h4=>h12,h34=>h1234
ハッシュが奇数個の場合は最後のハッシュが自分自身同士で合成されます
例5つTxがある場合,ハッシュh1~5が存在し
h1,h2,h3,h4,h5=>h12,h34,h55=>h1234,h5555=>h12345555
詳細な計算はcalcTransacationsHashのようになります
function calcTransacationsHash(innerTx){
let hashes = [];
//各TXのhash生成
innerTx.forEach(tx => {
hashes.push(embeddedTxHashMaker(tx));
});
//ハッシュを2個ずつの合成を繰り返し,ルートハッシュを生成する(奇数の場合は最後を自分自身でハッシュ)
//例[h1,h2,h3,h4]=>[h12,h34]=>[h1234]
//例[h1,h2,h3,h4,h5]=>[h12,h34,h55]=>[h1234,h5555]=>[h12345555]
let hashLength = hashes.length;
while(hashLength > 1){
for(let i = 0;i < hashLength ; i+=2){
if(i+1 < hashLength){
hashes.splice(i/2, 0, hashMixer(hashes[i], hashes[i+1]));
continue;
}
hashes.splice(i / 2, 0, hashMixer(hashes[i], hashes[i]));
++hashLength;
}
hashLength /= 2;
}
return hashes[0];
}
function hashMixer(hash1, hash2){
const hasher = sha3_256.create(32);
hasher.update(hash1);
hasher.update(hash2);
hasher.finalize();
return new Uint8Array(hasher.arrayBuffer());
}
function embeddedTxHashMaker(tx){
const etx = stringToUint8Array(tx.toEmbeddedTransaction().serialize());
const padding = new Uint8Array(calcPadding(etx.length, 8));
const data = new Uint8Array([...etx, ...padding]);
const hasher = sha3_256.create(32);
hasher.update(data);
return new Uint8Array(hasher.arrayBuffer());
}
function calcPadding(numberOfByte, padding){
if(padding ===0 || numberOfByte%padding === 0)return 0;
return padding - numberOfByte%padding;
}
この結果をpayloadと比較します
ルートハッシュはpayloadの(1+1+2+8+8+4+64+8+32)バイトから(1+1+2+8+8+4+64+8+32 + 32)にあるのでこれと比較します
console.log("stbInnerRootHash",calcTransacationsHash(stbt.innerTransactions));
console.log("stbRootHash",stringToUint8Array(stb.payload.slice((1+1+2+8+8+4+64+8+32)*2,(1+1+2+8+8+4+64+8+32 + 32)*2)));
console.log("badpayload",calcTransacationsHash(stbb.innerTransactions));
console.log("badPayloadRootHash",stringToUint8Array(badPayload.slice((1+1+2+8+8+4+64+8+32)*2,(1+1+2+8+8+4+64+8+32 + 32)*2)))
すると結果は次のようになりました
stbInnerRootHash Uint8Array(32) [
195, 226, 86, 181, 125, 165, 135, 51,
190, 44, 106, 75, 150, 15, 0, 250,
224, 198, 177, 14, 154, 146, 243, 37,
1, 38, 58, 23, 150, 34, 11, 144
]
stbRootHash Uint8Array(32) [
195, 226, 86, 181, 125, 165, 135, 51,
190, 44, 106, 75, 150, 15, 0, 250,
224, 198, 177, 14, 154, 146, 243, 37,
1, 38, 58, 23, 150, 34, 11, 144
]
badpayload Uint8Array(32) [
200, 223, 129, 217, 115, 156, 228,
254, 218, 113, 80, 255, 246, 141,
192, 174, 41, 207, 107, 122, 66,
23, 71, 23, 79, 98, 44, 204,
239, 187, 26, 154
]
badPayloadRootHash Uint8Array(32) [
195, 226, 86, 181, 125, 165, 135, 51,
190, 44, 106, 75, 150, 15, 0, 250,
224, 198, 177, 14, 154, 146, 243, 37,
1, 38, 58, 23, 150, 34, 11, 144
]
偽装していないstbはハッシュが一致しているのに対して偽装したbadpaylaodはハッシュが一致せず,不正TXであると見抜くことができます
あとはこの記事に従って署名を検証すればaggregateTransactionの正当性を検証できます
全文
const { TransactionMapping, Mosaic, MosaicId, UInt64 } = require('symbol-sdk');
const xym = require('symbol-sdk');
const sha3_256 = require('js-sha3').sha3_256;
var networkGenerationHash = '';
var epochAdjustment = '';
var repo;
var node;
var networkType = xym.NetworkType.TEST_NET;
const privateKey = ''.padStart(64,"0");
//送信元アカウント
const listenAccount = xym.Account.createFromPrivateKey(
privateKey,
networkType,
);
const swapAccount = xym.Account.createFromPrivateKey(
"".padStart(64,"1"),
networkType
);
(async () => {
node = 'https://sym-test-09.opening-line.jp:3001';
repo = new xym.RepositoryFactoryHttp(node);
await getInfo()
swapTx()
})();
async function getInfo() {
epochAdjustment = await repo.getEpochAdjustment().toPromise();
networkGenerationHash = await repo.getGenerationHash().toPromise();
}
function swapTx(){
const tx = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[],
xym.PlainMessage.create(""),
networkType
).toAggregate(listenAccount.publicAccount);
const tx2 = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[],
xym.PlainMessage.create(""),
networkType
).toAggregate(swapAccount.publicAccount);
const txb = xym.TransferTransaction.create(
xym.Deadline.create(epochAdjustment),
listenAccount.address,
[new Mosaic(new MosaicId("3A8416DB2D53B6C8"),UInt64.fromUint(9999999))],
xym.PlainMessage.create(""),
networkType
).toAggregate(swapAccount.publicAccount);
const agg = xym.AggregateTransaction.createComplete(
xym.Deadline.create(epochAdjustment),
[tx,tx2],
networkType,
[],
).setMaxFeeForAggregate(1);
const aggb = xym.AggregateTransaction.createComplete(
xym.Deadline.create(epochAdjustment),
[tx,txb],
networkType,
[],
).setMaxFeeForAggregate(1);
const st = listenAccount.sign(agg, networkGenerationHash);
const stb = listenAccount.sign(aggb, networkGenerationHash);
//不正Tx
const badPayload = stb.payload.slice(0,(1+1+2+8+8+4+64+8+32 + 32)*2)+st.payload.slice((1+1+2+8+8+4+64+8+32 + 32)*2);
console.log(TransactionMapping.createFromPayload(badPayload));
console.log("st");
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, st.payload, networkGenerationHash));
console.log("stb")
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, stb.payload, networkGenerationHash));
console.log("badpayload");
console.log(xym.CosignatureTransaction.signTransactionPayload(swapAccount, badPayload, networkGenerationHash));
console.log(TransactionMapping.createFromPayload(st.payload));
const stbt = TransactionMapping.createFromPayload(stb.payload);
const stbb = TransactionMapping.createFromPayload(badPayload);
console.log("stbInnerRootHash",calcTransacationsHash(stbt.innerTransactions));
console.log("stbRootHash",stringToUint8Array(stb.payload.slice((4+64+8+32 + 20)*2,(1+1+2+8+8+4+64+8+32 + 32)*2)));
console.log("badpayload",calcTransacationsHash(stbb.innerTransactions));
console.log("badPayloadRootHash",stringToUint8Array(badPayload.slice((4+64+8+32+ 20)*2,(1+1+2+8+8+4+64+8+32 + 32)*2)))
}
function calcTransacationsHash(innerTx){
let hashes = [];
//各TXのhash生成
innerTx.forEach(tx => {
hashes.push(embeddedTxHashMaker(tx));
});
//ハッシュを2個ずつの合成を繰り返し,ルートハッシュを生成する(奇数の場合は最後を自分自身でハッシュ)
//例[h1,h2,h3,h4]=>[h12,h34]=>[h1234]
//例[h1,h2,h3,h4,h5]=>[h12,h34,h55]=>[h1234,h5555]=>[h12345555]
let hashLength = hashes.length;
while(hashLength > 1){
for(let i = 0;i < hashLength ; i+=2){
if(i+1 < hashLength){
hashes.splice(i/2, 0, hashMixer(hashes[i], hashes[i+1]));
continue;
}
hashes.splice(i / 2, 0, hashMixer(hashes[i], hashes[i]));
++hashLength;
}
hashLength /= 2;
}
return hashes[0];
}
function hashMixer(hash1, hash2){
const hasher = sha3_256.create(32);
hasher.update(hash1);
hasher.update(hash2);
hasher.finalize();
return new Uint8Array(hasher.arrayBuffer());
}
function embeddedTxHashMaker(tx){
const etx = stringToUint8Array(tx.toEmbeddedTransaction().serialize());
const padding = new Uint8Array(calcPadding(etx.length, 8));
const data = new Uint8Array([...etx, ...padding]);
const hasher = sha3_256.create(32);
hasher.update(data);
return new Uint8Array(hasher.arrayBuffer());
}
function calcPadding(numberOfByte, padding){
if(padding ===0 || numberOfByte%padding === 0)return 0;
return padding - numberOfByte%padding;
}
function stringToUint8Array(str){
const buf = Buffer.from(str,"hex");
return bufferToUint8Array(buf);
}
function bufferToUint8Array(buf) {
const view = new Uint8Array(buf.length);
for (let i = 0; i < buf.length; ++i) {
view[i] = buf[i];
}
return view;
}
最後に
この検証は特にオフライン環境で署名を求められる際には必要となる検証だと思います.通常,オンラインで取得するトランザクションはノードを通して取得するため,バリデーションは全てノードが行ってくれます.しかしながら,オフライン環境下ではノードのバリデーションの行われないトランザクションを受け取ることになります.このような理由からオフラインでのトランザクションについては検証を自分自身で実施する必要があります.
また,今回の記事でも示したようにアグリゲートトランザクションは内部トランザクションのルートハッシュに対しての署名(厳密にはネットワークタイプなどが含まれたトランザクションヘッダ52バイト)に対して行っているので署名の際にトランザクションのデータそのものに署名しているわけではないことも頭に入れる必要があります.
オフラインで使用しない場合においても,ノードから得た情報自体を検証できるようになるため,よりプログラムの安全性が高くなると思います.
他人から渡される情報については信頼せず,検証するようにしましょう