2023.04.10

Ledger Nano SのハードウェアウォレットにEIP155署名をさせる

こんにちは。次世代システム研究室のL.W.です。

ホットウォレットの資金流出不祥事が相次いでいる中、ハードウェアウォレットで資金管理するのはますます重要となります。

研究室のメンバーはLedger Nano Sのハードウェアウォレットでトランザクションにサインすることをよく纏めましたが、実際にこのサイン(署名は)がリスクが潜んでいます。

どういうことでしょうか、解説してみます。

 

1.replay-attack(リプレイ攻撃)

1.1 ソース

Ledger Nano Sのハードウェアウォレットでトランザクションにサインする 記事を基づいて、ソースコードを纏めました。(実行できるレベル)

const TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default;
const Eth = require('@ledgerhq/hw-app-eth').default;
const EthereumTx = require('ethereumjs-tx').Transaction;
const Web3 = require('web3-eth');
const {encode} = require('rlp');
const utils = require("ethereumjs-util");
const ethereumNodeUrl = 'https://goerli.infura.io/v3/<YOUR ID>';
const polygonNodeUrl = 'https://polygon-mumbai.g.alchemy.com/v2/<YOUR ID>';
const arbitrumNodeUrl = 'https://arb-goerli.g.alchemy.com/v2/<YOUR ID>';
const web3 = new Web3(arbitrumNodeUrl);
//const Common = require('ethereumjs-common').default;

TransportNodeHid.create().then(async transport => {
  const ledgerhqEth = new Eth(transport);

  // Set the transaction parameters
  const toAddress = '0x46352bF252F8E150F87a54cC09372B89E538c2Bd';
  const value = "0x01";
  const gasPrice = "0x174876E800"; // 100g
  const gasLimit = 5000000;
  const nonce = '0x00';
  //const chainId = 5;
  const data = '0x';

  // chian id defination
  //const commonEth = Common.forCustomChain('mainnet', { name: 'private', networkId: 5, chainId}, 'petersburg');
  //const commonPly = Common.forCustomChain('mainnet', { name: 'private', networkId: 80001, chainId }, 'petersburg');
  // Create a new transaction object
  const tx = new EthereumTx({
    nonce: nonce,
    gasPrice: gasPrice,
    gasLimit: gasLimit,
    to: toAddress,
    value: value,
    data: data,
  });

  // log
  console.log('--- Transaction raw field before signature: ---');
  tx.raw.map(x => console.log(x.toString('hex')));

  // BIP32 path of Ledger Nano S account
  const path = "m/44'/60'/0'/3/0";
  const curAddress = await ledgerhqEth.getAddress(path);
  console.log(`\n--- The address ${curAddress.address} in Ledger Nano S is selected. ---`);

  // transaction.raw の後ろから3つのオブジェクトを削除します。
  // transaction.raw には、サイン、つまりv, r, s が含まれておますから。
  const serializedTxTemporary = encode(tx.raw.slice(0,-3));

  // Ledger Nano S で署名をしてください。
  console.log('\n--- Ledger Nano Sを持ち上げて、署名をしてください。... ... ---');
  return ledgerhqEth.signTransaction(path, serializedTxTemporary.toString('hex'), null).then(signedTx => {

    // signed transaction
    console.log('\n--- Signature of the signed transaction: ---');
    console.log(signedTx);

    // Combine the signed transaction with the original transaction object
    tx.v = '0x' + signedTx.v;
    tx.r = '0x' + signedTx.r;
    tx.s = '0x' + signedTx.s;
    
    // log
    console.log('\n--- Transaction raw field after signature: --- ');
    tx.raw.map(x => console.log(x.toString('hex')));

    // send the signed transaction to network.
    const serializedTx = tx.serialize();

    console.log('\n--- Signed Transaction: --- ');
    console.log('0x' + serializedTx.toString('hex'));

    web3.sendSignedTransaction('0x' + serializedTx.toString('hex'))
      .once('transactionHash', (txHash) => {
        console.log('\n--- Transaction hash: ---', txHash);
      })
      .on('error', (error) => {
        console.log('\n--- Error: ---', error);
      });
  });
}).catch(error => console.log(error));

1.2 イーサリアムのL2のArbitrumで実行させる

nodeで叩くと、結果が出る。トランザクションはここで参照できます。
--- Transaction raw field before signature: ---

174876e800
4c4b40
46352bf252f8e150f87a54cc09372b89e538c2bd
01





--- The address 0xa788C4d20Fa28443dB6c359900A9E18A3071587d in Ledger Nano S is selected. ---

--- Ledger Nano Sを持ち上げて、署名をしてください。... ... ---

--- Signature of the signed transaction: ---
{
  v: '1c',
  r: 'e60c2b6825cb52b13cab4abbf054634e969b91cde4da1fcaeaa9c07006edbf54',
  s: '180349dbb259cad497dcecbeb2b5d3bbce69d26d76e027cdbcccb8b79017efbc'
}

--- Transaction raw field after signature: --- 

174876e800
4c4b40
46352bf252f8e150f87a54cc09372b89e538c2bd
01

1c
e60c2b6825cb52b13cab4abbf054634e969b91cde4da1fcaeaa9c07006edbf54
180349dbb259cad497dcecbeb2b5d3bbce69d26d76e027cdbcccb8b79017efbc

--- Signed Transaction: --- 
0xf8658085174876e800834c4b409446352bf252f8e150f87a54cc09372b89e538c2bd01801ca0e60c2b6825cb52b13cab4abbf054634e969b91cde4da1fcaeaa9c07006edbf54a0180349dbb259cad497dcecbeb2b5d3bbce69d26d76e027cdbcccb8b79017efbc

--- Transaction hash: --- 0x1e9a30550f8deeec2576caf806cdfd8a162c70acb9644697309a20b8886fb8ff

1.3 Transactionのrawの説明

rawはBufferの配列ですが、nonce、gasPrice、gasLimit、to、value、data、v、r、sが格納されている。トランザクションは署名される前、v、r、sが空となります。

前の6つの要素(nonce、gasPrice、gasLimit、to、value、data)をこの順番でRLP(Recursive Length Prefix)構造で結合したbyte列に対してkeccak256で算出したhash値がsignの対象となります。

https://github.com/ethereumjs/ethereumjs-tx/blob/master/src/transaction.ts#L89
[
            {
                name: 'nonce',
                length: 32,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'gasPrice',
                length: 32,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'gasLimit',
                alias: 'gas',
                length: 32,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'to',
                allowZero: true,
                length: 20,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'value',
                length: 32,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'data',
                alias: 'input',
                allowZero: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'v',
                allowZero: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 'r',
                length: 32,
                allowZero: true,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
            {
                name: 's',
                length: 32,
                allowZero: true,
                allowLess: true,
                default: new buffer_1.Buffer([]),
            },
 ]

1.4リプレイ攻撃

ここで署名されたトランザクションは別のEVMチェーンでも実行させます。

では、EVMのあるKlaytnブロックチェーンに上のSigned Transactionの0xf8658085174876e800834c4b409446352bf252f8e150f87a54cc09372b89e538c2bd01801ca0e60c2b6825cb52b13cab4abbf054634e969b91cde4da1fcaeaa9c07006edbf54a0180349dbb259cad497dcecbeb2b5d3bbce69d26d76e027cdbcccb8b79017efbcを投げます。

なんと、Klaytnで実行されましたね。

署名者が知らないうちに、別チェーンでの署名情報はこのチェーンでリプレイされたね。危ない!知らないうちに、資金が流出される、予期せぬ損となるリスクがありますね。

実際には、イーサリアム、AstarなどのEVM系ブロックチェーンにもリプレイできますよ。試してみてね。

 

1.5サポートされないリスク

polygonとOptimismのブロックチェーン に上の署名済みのトランザクションを投げてみましたが、”only replay-protected (EIP-155) transactions allowed over RPC”というエラーが出ました。

つまり、これらのチェーンでは既にこのような署名を認めなくなりましたね。

 

2.EIP155(replay-protection)

上の課題をどのように解決できるか。

2016年、Vitalik Buterin氏は「EIP-155: Simple replay attack protection」を提案しました。

端的にいうと、(nonce、gasPrice、gasLimit、to、value、data, chainid, 0, 0)をこの順番でRLP(Recursive Length Prefix)構造で結合したbyte列に対してkeccak256で算出したhash値がsignの対象とします。

chainidを加えたことで、どのチェーンでの署名かきちんと区別することとなれますね。

2016年11月、Spurious Dragonというハードフォークで、EIP155が導入されました。

Spurious Dragonハードフォーク前の署名はEIP155非対応署名、または、EIP155適用前署名を呼ばれてます。

Spurious Dragonハードフォーク後の署名はEIP155対応署名、または、EIP155適用後署名を呼ばれてます。

 

2.1Ledger Nano SのハードウェアウォレットにEIP155署名をさせるソース

const TransportNodeHid = require('@ledgerhq/hw-transport-node-hid').default;
const Eth = require('@ledgerhq/hw-app-eth').default;
const EthereumTx = require('ethereumjs-tx').Transaction;
const Web3 = require('web3-eth');
const ec = require('rlp');
const utils = require("ethereumjs-util");
const ethereumNodeUrl = 'https://goerli.infura.io/v3/<YOUR ID>';
const polygonNodeUrl = 'https://polygon-mumbai.g.alchemy.com/v2/<YOUR ID>';
const arbitrumNodeUrl = 'https://arb-goerli.g.alchemy.com/v2/<YOUR ID>';
const web3 = new Web3(arbitrumNodeUrl);
const Common = require('ethereumjs-common').default;

TransportNodeHid.create().then(async transport => {
  const ledgerhqEth = new Eth(transport);

  // Set the transaction parameters
  const toAddress = '0x46352bF252F8E150F87a54cC09372B89E538c2Bd';
  const value = "0x01";
  const gasPrice = "0x174876E800"; // 100g
  const gasLimit = 8000000;
  const nonce = '0x00';
  const chainId = 421613;
  const data = '0x';

  // chian id defination
  const commonEth = Common.forCustomChain('mainnet', { name: 'private', networkId: 421613, chainId}, 'petersburg');
  //const commonPly = Common.forCustomChain('mainnet', { name: 'private', networkId: 80001, chainId }, 'petersburg');
  // Create a new transaction object
  const tx = new EthereumTx({
    nonce: nonce,
    gasPrice: gasPrice,
    gasLimit: gasLimit,
    to: toAddress,
    value: value,
    data: data,
  }, { common: commonEth });

  // log
  console.log('--- Transaction raw field before signature: ---');
  tx.raw.map(x => console.log(x.toString('hex')));

  // BIP32 path of Ledger Nano S account
  const path = "m/44'/60'/0'/4/0";
  const curAddress = await ledgerhqEth.getAddress(path);
  console.log(`\n--- The address ${curAddress.address} in Ledger Nano S is selected. ---`);

  const items = tx.raw.slice(0, 6).concat([
    utils.toBuffer(chainId),
    (utils.toBuffer('0x')),
    (utils.toBuffer('0x')),
  ]);

    //input chain id for temporary
  tx.raw = items;

  const serializedTxTemporary = tx.serialize();
  // Ledger Nano S で署名をしてください。
  console.log('\n--- Ledger Nano Sを持ち上げて、署名をしてください。... ... ---');
  return ledgerhqEth.signTransaction(path, serializedTxTemporary.toString('hex'), null).then(signedTx => {

    // signed transaction
    console.log('\n--- Signature of the signed transaction: ---');
    console.log(signedTx);

    // Combine the signed transaction with the original transaction object
    tx.v = '0x' + signedTx.v;
    tx.r = '0x' + signedTx.r;
    tx.s = '0x' + signedTx.s;
    
    // log
    console.log('\n--- Transaction raw field after signature: --- ');
    tx.raw.map(x => console.log(x.toString('hex')));

    // send the signed transaction to network.
    const serializedTx = tx.serialize();
    console.log('\n--- Signed Transaction: --- ');
    console.log('0x' + serializedTx.toString('hex'));

    web3.sendSignedTransaction('0x' + serializedTx.toString('hex'))
      .once('transactionHash', (txHash) => {
        console.log('\n--- Transaction hash: ---', txHash);
      })
      .on('error', (error) => {
        console.log('\n--- Error: ---', error);
      });
  });
}).catch(error => console.log(error));

2.2 実行結果

EIP155適用後のトランザクションが実行されました。
--- Transaction raw field before signature: ---

174876e800
7a1200
46352bf252f8e150f87a54cc09372b89e538c2bd
01





--- The address 0xC443739307a877E732A65e02Dfa6B9f70F61c18b in Ledger Nano S is selected. ---

--- Ledger Nano Sを持ち上げて、署名をしてください。... ... ---

--- Signature of the signed transaction: ---
{
  v: '0cddfe',
  r: 'e963dfb40209369bbda2df928056dc8cfdcb55b7ff3d88086473174ea27f8bd1',
  s: '73700f066f8b5dd54d95fb4e05ea11729baf03a4bc27fb87a36e552d4efee18d'
}

--- Transaction raw field after signature: --- 

174876e800
7a1200
46352bf252f8e150f87a54cc09372b89e538c2bd
01

0cddfe
e963dfb40209369bbda2df928056dc8cfdcb55b7ff3d88086473174ea27f8bd1
73700f066f8b5dd54d95fb4e05ea11729baf03a4bc27fb87a36e552d4efee18d

--- Signed Transaction: --- 
0xf8688085174876e800837a12009446352bf252f8e150f87a54cc09372b89e538c2bd0180830cddfea0e963dfb40209369bbda2df928056dc8cfdcb55b7ff3d88086473174ea27f8bd1a073700f066f8b5dd54d95fb4e05ea11729baf03a4bc27fb87a36e552d4efee18d

--- Transaction hash: --- 0xf703f143558f63443889c58f0a09ac9a06fe19d12af42ee2b0c338d6d9eba30f

2.2 リプレイしてみる

では、上のSigned Transactionの0xf8688085174876e800837a12009446352bf252f8e150f87a54cc09372b89e538c2bd0180830cddfea0e963dfb40209369bbda2df928056dc8cfdcb55b7ff3d88086473174ea27f8bd1a073700f066f8b5dd54d95fb4e05ea11729baf03a4bc27fb87a36e552d4efee18dをKlaytnに投げてみました。

「”Returned error: invalid chain id”」というエラーが直ちに返却されました。

リプレイが防げましたね。

他のEVM系ブロックチェーンでも実行されないと思います。試してみてね。

 

3.まとめ

署名部分のv、r、sですが、EIP155の導入により、vが変更されました。EIP155適用前は、v = 27 または v = 28となります。EIP155適用後は、v = CHAIN_ID * 2 + 35 または v = CHAIN_ID * 2 + 36となります。

実際には、イーサリアム、Klaytn、Arbitrum、AstarなどのEVM系ブロックチェーンはEIP155対応署名、EIP155非対応署名の両方をサポートしています。polygon、OptimismはEIP155しかサポートされていません。

今はMetamaskを始めのほとんどのウォレットは既にEIP155を導入済み、EIP155署名しかできません。ですから、Metamaskまたは市販のモバイルウォレットを利用する時は、別に、EIP155非対応かという心配が要りません。

Ledger Nano Sなどのハードウェアウォレットを利用する場合、使い方により、EIP155適用かしないか両方の署名が作れますので、要注意です。

 

4.最後に

次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。

皆さんのご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事