はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、パスワードを使って秘密鍵のように取引データへ安全な署名を行い、より直感的で管理しやすい認証体験を実現しようとする仕組みを提案しているERC6327についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

Elastic Signature(以下、ES)は、人が覚えやすい秘密情報を使ってデータへ署名できる仕組みです。 ここでいう「秘密情報」は、一般的な暗号資約で使われる固定長の秘密鍵ではなく、ユーザーが自由に決められるパスワードのようなものです。 ESの大きな特徴は、秘密情報そのものを保存せず、ゼロ知識証明(秘密を明かさずに知っていることだけを証明する仕組み)を使って完全にオンチェーンで検証できる点です。

ユーザーは必要に応じて好きなタイミングで秘密情報を変更でき、秘密情報の長さも制限されません。 つまり、従来の秘密鍵のように「一度流出したら終わり」という状況を避けることができます。 特に、ブロックチェーンに慣れていないユーザーにとって、秘密鍵よりもパスワードの方が理解しやすいという点が大きなメリットになります。

ESは、この仕組みをスマートコントラクトで扱えるようにするための標準インターフェースを定義するものです。 これにより、ESを利用した署名検証や操作の許可が、あらゆるDappやウォレットから安全に利用できるようになります。

動機

ESが実現しようとしているのは、「変更可能な秘密鍵」という新しい概念です。 ブロックチェーンを利用する時、多くの人がつまずくポイントは秘密鍵の扱いです。 秘密鍵は非常に強力ですが、紛失・流出すると取り返しがつかず、初心者にとっては心理的なハードルが高いものです。

これまでの対策としては、秘密鍵を預かるサービス(カストディアル型ウォレット)がありましたが、これらはサービス提供者に依存してしまいます。 つまり、 decentralization(完全な分散性)とは相性がよくありません。

ESは、ゼロ知識証明を使うことで「ユーザー自身が管理でき、しかも秘密鍵のように扱う必要のない署名方式」を実現します。 ユーザーは秘密情報(パスワード)をただ覚えておくだけでよく、スマートコントラクト側はユーザーがその秘密情報を「知っている」という事実だけを安全に検証できます。

この仕組みによって、オンボーディング(初めてのDapp利用時の手順)が簡単になり、非技術ユーザーでも分散的な環境を直接扱えるようになります。 さらに、秘密情報をいつでも変更できるため、安全性に対する不安も軽減できます。

利用例

ESは、従来の秘密鍵の代わりになるものではなく、秘密鍵に追加して使える署名方式です。 つまり「どちらか一方」という選択ではなく、両方を組み合わせて安全性を高めることができます。

DeFiアプリでの利用

DeFiアプリにESを組み込むと、送金など重要な操作を行う時に、ユーザーへ追加のパスワード入力を求めることができます。 もし秘密鍵が漏洩したとしても、ESで設定したパスワードを知らない限り不正操作はできません。 二段階認証のような役割を果たし、セキュリティを大幅に強化できます。

スマートコントラクトウォレットでの利用

Account Abstraction(ERC4337)のようなスマートコントラクトウォレットでは、柔軟に署名方式を追加できます。

ERC4337については以下の記事を参考にしてください。

そこにESを組み込むことで、以下のような使い方が可能になり、ブロックチェーン初心者でもストレスなくDappを使えるようになります。

  • 初回利用時にパスワードを設定するだけでよい
  • 秘密鍵を保管する必要がない
  • 秘密情報はいつでも変更可能

特に、「パスワードで使える分散ウォレット」という形は、既存のWebサービスに近い感覚で利用できるため、ユーザーの心理的な抵抗を大きく下げる効果があります。

仕様

ES(Elastic Signature)は、パスワードのような任意長の秘密情報を使い、その秘密を公開せずにゼロ知識証明で「知っていること」だけを示す仕組みです。 この署名方式では、パスワードは保存されず、オンチェーンで完全に検証できます。 パスワードは任意のタイミングで変更でき、従来の秘密鍵と併せて利用することでセキュリティを向上できます。

ESの検証には Verifier(検証者)、Requester(検証要求者)、Prover(証明生成者)の三者が関与します。

全体の流れは次のハッシュ計算を土台としています。

名称 説明
pwdhash パスワードのハッシュ。
datahash トランザクション対象データのハッシュ。
fullhash datahash と既知の変数(nonce や chainid など)から計算されるハッシュ。
expiration 証明が有効な期限(タイムスタンプ)。
allhash fullhash と pwdhash から計算されるハッシュ。

各者の役割は以下のとおりです。

Verifier(検証者)

  • Requesterが提供した datahash をもとに fullhash を計算する。
  • 対象ユーザーのアドレス(EOA またはスマートコントラクトウォレット)から pwdhash を取得する。
  • Proverが生成し、Requesterが提出した allhash が正しいか検証する。

Requester(要求者)

  • datahash を生成し、証明の期限 expiration を決める。
  • Prover から受け取った proof および allhash をVerifierに提出する。

Prover(証明生成者)

  • datahash、expiration、nonce、chainid などの既知の変数を使って proof と allhash を生成する。

必要条件

  • 既知の変数は全参加者に共有されている必要がある。
  • nonce を含める必要がある。
  • chainid を含める必要がある。
  • Verifier 固有の変数を含めてもよい。
  • 公開ステートメントには pwdhash・fullhash・allhash をそれぞれ反映する必要がある。
  • fullhash の計算方法はVerifierとProverが合意しておく必要がある。
  • datahash の計算方法も明確に定義される必要がある。

IElasticSignature インターフェース

javascript
pragma solidity ^0.8.0;

interface IElasticSignature {
    event SetPassword(address indexed user, uint indexed pwdhash);

    event Verified(address indexed user, uint indexed nonce);

    function pwdhashOf(address user) external view returns (uint);

    function resetPassword(
        uint[8] memory proof1,
        uint expiration1,
        uint allhash1,
        uint[8] memory proof2,
        uint pwdhash2,
        uint expiration2,
        uint allhash2
    ) external;

    function verify(
        address user,
        uint[8] memory proof,
        uint datahash,
        uint expiration,
        uint allhash
    ) external;
}

イベント

SetPassword
javascript
event SetPassword(address indexed user, uint indexed pwdhash);

ユーザーが自身のパスワードハッシュを設定または再設定した時に発行されるイベント。 ユーザーのアドレスに紐づけて、パスワードを変更したことを通知します。 EOAだけでなく、スマートコントラクトウォレットでも利用できます。

パラメータ

  • user

    • パスワードハッシュを設定したユーザーアドレス。
  • pwdhash

    • 設定されたパスワードのハッシュ。
Verified
javascript
event Verified(address indexed user, uint indexed nonce);

ユーザーが提出した proof の検証に成功した時に発行されるイベント。 パスワードによる認証が成功した際に、新しい nonce を発行し、再利用されないように管理します。 nonce はゼロ知識証明における再利用防止のための重要な要素です。

パラメータ

  • user

    • 検証の対象となったユーザーアドレス。
  • nonce

    • 新しく発行された nonce。

関数

pwdhashOf
javascript
function pwdhashOf(address user) external view returns (uint);

指定したユーザーアドレスに登録されている pwdhash を取得する関数で Verifier が proofの検証を行う時に、対象ユーザーに対応するパスワードハッシュを取り出すために使われます。

引数

  • user
    • パスワードハッシュを取得したいユーザーのアドレス。

戻り値

  • pwdhash
    • 指定アドレスのパスワードハッシュ。
resetPassword
javascript
function resetPassword(
    uint[8] memory proof1,
    uint expiration1,
    uint allhash1,
    uint[8] memory proof2,
    uint pwdhash2,
    uint expiration2,
    uint allhash2
) external;

古いパスワードから新しいパスワードへ安全に更新するための関数。 1つ目の proof(proof1)で旧パスワードを知っていることを証明し、2つ目の proof(proof2)で新しいパスワードに対応する正しい証明を提示します。 この2段階の検証により、パスワード変更が正当なユーザーによって行われていることを保証します。

引数

  • proof1

    • 旧パスワードで生成した proof。
  • expiration1

    • 旧パスワードの証明が有効である期限。
  • allhash1

    • 旧パスワードから生成された allhash。
  • proof2

    • 新パスワードで生成した proof。
  • pwdhash2

    • 新しいパスワードのハッシュ。
  • expiration2

    • 新パスワードの証明が有効である期限。
  • allhash2

    • 新パスワードで生成した allhash。
verify
javascript
function verify(
    address user,
    uint[8] memory proof,
    uint datahash,
    uint expiration,
    uint allhash
) external;

他のコントラクトから呼び出され、ユーザーが生成した proof を検証する関数。 トランザクションを発行する側(例:DeFi アプリ)が datahash を計算し、ユーザーが提示した proof と allhash をこの関数へ渡します。 Verifierは内部で fullhash を再計算し、allhash が正しいかをチェックします。 期限切れ(expiration 超過)や不正な allhash であれば検証は失敗します。 成功時には Verified イベントが発行され、新しい nonce を更新します。

引数

  • user

    • 検証対象のユーザーアドレス。
  • proof

    • ユーザーがパスワードをもとに生成した証明。
  • datahash

    • 操作対象データのハッシュ。
  • expiration

    • 証明が有効である期限。
  • allhash

    • proof とともに生成される公開ステートメント。

補足

pwdhash をコントラクトに保存する理由

ESでは、各ユーザーのパスワードそのものはどこにも保存しませんが、パスワードをハッシュ化した値である pwdhash はオンチェーンに保存します。 コントラクト側は「このアドレスのユーザーは、こういう pwdhash を持っている」という情報だけを持ち、それをもとにゼロ知識証明の検証を行います。

こうしておくことで、VerifierとなるESコントラクトは、Proverが提出した proof(証明)や allhash(まとめたハッシュ)が、登録済みの pwdhash と整合しているかどうかを判断できます。 パスワードそのものは一切開示されず、ハッシュと証明だけを頼りに「本物のユーザーが操作した」と確認できるようになっています。

zkサーキットとハッシュの全体像

この図は、ユーザーの操作から zkサーキット、そしてVerifierによる検証までのデータの流れを表しています。

  1. 左側にユーザーがいて、「input」と書かれたブロックに操作内容が入ります。 例えば、トークンの種類(token)、送金・出金量(amount)などです。
  2. これらの入力データから datahash が計算されます。 図では token・amount などがまとまって datahash になっています。
  3. さらに expiration(有効期限)、chainId(チェーン ID)、nonce(使い捨てカウンタ)などの値を組み合わせて fullhash が作られます。 これは、「どのチェーンで」、「いつまで有効な」、「どの操作か」を一意に表すハッシュです。
  4. 一方で、ユーザーのウォレットアドレスとパスワード(pwd)がzkサーキットに入力されます。 図の上部には「wallet address (…) → pwdhash」と書かれており、このアドレスに対応する pwdhash がコントラクトに保存されていることを表しています。
  5. zk サーキットは、
    • address
    • pwd
    • fullhash を入力として、
    • proof(ゼロ知識証明)
    • pwdhash(パスワードハッシュ)
    • allhash(fullhash と pwdhash をまとめたハッシュ) を出力します。
  6. 右側では、Verifierコントラクトがすでに保存している pwdhash と、Prover が渡してきた proof、allhash、fullhash を使って verify を行います。 ここで、「このユーザーは登録されている pwdhash に対応するパスワードを知っていて、そのパスワードでこの fullhash を署名した」という事実を検証します。

この図が示しているポイントは、「ユーザーの入力はトランザクション内容とパスワードに分かれ、それらが zkサーキットの中で結びつき、最終的に fullhash・pwdhash・allhash と proof が生成される」という流れです。

Poseidonを使ったzkサーキット内部の計算

この図は、zkサーキットの内部ロジックを、ハッシュ関数 Poseidon を使ってどのように構成しているかを示しています。

Poseidon は、ゼロ知識証明に向いたハッシュ関数で、回路内で計算しやすいように設計されています。 この図では「Poseidon 1」、「Poseidon 2」として2段階のハッシュ処理になっています。

図の流れを整理すると以下のようになります。

  1. 入力(input)として、以下のデータがzkサーキットに入ります。

    • password
    • address
    • fullhash
  2. Poseidon 1では、password と address から pwdhash を計算します。 図の破線は、password と addressが**Poseidon 1**に流れ込み、その出力がpwdhash` として右側に出ていることを示しています。

  3. Poseidon 2では、Poseidon 1の出力 pwdhash と fullhash を組み合わせて allhash を計算します。 図中の緑色の矢印が、fullhash と pwdhash が Poseidon 2 に入力され、右側に all hash が出力される流れを表しています。

  4. zkサーキット全体としては、以下を出力します。

    • proof(ゼロ知識証明)
    • all hash
    • pwdhash
    • fullhash

    fullhash は入力の一部でもありますが、出力側にも含めることで、Verifier が「どのトランザクションに対する証明なのか」を明確に扱えるようにしています。

この図が強調しているのは、pwdhash と allhash がサーキット内部のハッシュ計算から一貫して導かれており、Verifier は「Poseidon でこう計算されているはずだ」という前提で検証する、という点です。 つまり、Prover が勝手な allhash を作ることはできず、パスワードとアドレス、fullhash の組み合わせに従った値しか出せない構造になっています。

ワークフロー(User → Requester → ES コントラクト)

image.png

この図は、実際のフローを「誰が何をどこへ渡すか」という視点で描いたものです。 User(prover)、Third-party contract(requester)、ES contract(verifier)の三者が登場します。

  1. 左側のUser(prover)は、例えば「withdrawal(出金)」操作をしようとします。 ここでは token の種類と amount(数量)が入力として扱われています。

  2. Userは、自分のパスワードと出金内容を使ってzkサーキットを実行していかを生成します。

    • proof
    • allhash

    図ではZKブロックから proof と allhash が出力されています。

  3. Userは、withdrawal の内容とともに、proof と allhash をThird-party contract(requester)に送ります。 Third-party contractは、例えばDeFiアプリのコントラクトのようなイメージです。

  4. Requesterコントラクトは、受け取った token・amount などから datahash を計算し、さらにユーザーアドレスを組み合わせて「どのユーザーがどの操作をしたのか」を表すハッシュを作ります。 図の中央部分では、以下を右側のES contractに渡しています。

    • datahash
    • user address と、User から来た
    • proof
    • allhash
  5. ES contract(verifier)は、あらかじめ保存しているユーザーの pwdhash と、Requester から渡された datahash から fullhash を計算します。 そして、以下を使って検証を行います。

    • fullhash
    • pwdhash
    • proof
    • allhash

    図の右端では、ES contract に fullhash と pwdhash、そこに proof と allhash が入力されている形で表現されています。

  6. 検証に成功すれば、「この出金操作は、登録済みパスワードを知っているユーザーによって署名された」とみなすことができます。 これにより、秘密鍵が知られているだけでは不十分で、パスワードによるES署名も満たしている場合にのみ資金移動が行われるようにできます。

互換性

ESによる署名検証は、すでに存在している従来の署名検証の仕組みと衝突しないように設計されています。

ここで強調されているのは、ESが扱うのは「パスワードベースの署名」だけであり、Externally Owned Account(EOA)の秘密鍵を使った従来の署名方式とは別レイヤーの仕組みだという点です。 そのため、既存のEOA署名検証ロジックを変更したり置き換えたりする必要はなく、追加の署名方式として並行して使うことができます。

言い換えると、既存のコントラクトやウォレットがEOA署名による検証を続けながら、必要に応じてESを組み込んでいくことができる設計になっています。

参考実装

js
pragma solidity ^0.8.0;

import "../interfaces/IElasticSignature.sol";
import "./verifier.sol";

contract ZKPass is IElasticSignature {
    Verifier verifier = new Verifier();

    mapping(address => uint) public pwdhashOf;

    mapping(address => uint) public nonceOf;

    constructor() {
    }

    function resetPassword(
        uint[8] memory proof1,
        uint expiration1,
        uint allhash1,
        uint[8] memory proof2,
        uint pwdhash2,
        uint expiration2,
        uint allhash2
    ) public override {
        uint nonce = nonceOf[msg.sender];

        if (nonce == 0) {
            //init password

            pwdhashOf[msg.sender] = pwdhash2;
            nonceOf[msg.sender] = 1;
            verify(msg.sender, proof2, 0, expiration2, allhash2);
        } else {
            //reset password

            // check old pwdhash
            verify(msg.sender, proof1, 0, expiration1, allhash1);

            // check new pwdhash
            pwdhashOf[msg.sender] = pwdhash2;
            verify(msg.sender, proof2, 0, expiration2, allhash2);
        }

        emit SetPassword(msg.sender, pwdhash2);
    }

    function verify(
        address user,
        uint[8] memory proof,
        uint datahash,
        uint expiration,
        uint allhash
    ) public override {
        require(
            block.timestamp < expiration,
            "ZKPass::verify: expired"
        );

        uint pwdhash = pwdhashOf[user];
        require(
            pwdhash != 0,
            "ZKPass::verify: user not exist"
        );

        uint nonce = nonceOf[user];
        uint fullhash = uint(keccak256(abi.encodePacked(expiration, block.chainid, nonce, datahash))) / 8; // 256b->254b
        require(
            verifyProof(proof, pwdhash, fullhash, allhash),
            "ZKPass::verify: verify proof fail"
        );

        nonceOf[user] = nonce + 1;

        emit Verified(user, nonce);
    }

    /////////// util ////////////

    function verifyProof(
        uint[8] memory proof,
        uint pwdhash,
        uint fullhash, //254b
        uint allhash
    ) internal view returns (bool) {
        return
            verifier.verifyProof(
                [proof[0], proof[1]],
                [[proof[2], proof[3]], [proof[4], proof[5]]],
                [proof[6], proof[7]],
                [pwdhash, fullhash, allhash]
            );
    }
}

verifier.sol はsnarkjsによって自動生成されます。 ソースコード circuit.circom は以下です。

c++
pragma circom 2.0.0;

include "../../node_modules/circomlib/circuits/poseidon.circom";

template Main() {
    signal input in[3];
    signal output out[3];

    component poseidon1 = Poseidon(2);
    component poseidon2 = Poseidon(2);

    poseidon1.inputs[0] <== in[0];  //pwd
    poseidon1.inputs[1] <== in[1];  //address
    out[0] <== poseidon1.out; //pwdhash

    poseidon2.inputs[0] <== poseidon1.out;
    poseidon2.inputs[1] <== in[2]; //fullhash
    out[1] <== in[2]; //fullhash
    out[2] <== poseidon2.out; //allhash
}

component main = Main();

セキュリティ

セキュリティの説明では、「pwdhash が公開される」という前提を置いたうえで、総当たり攻撃(パスワードのハッシュから元のパスワードを推測する攻撃)にどれくらい耐えられるかを具体的な数字で示しています。

pwdhash が公開されることによるリスク

ESではパスワードそのものは保存されませんが、そのハッシュ値である pwdhash はオンチェーンに公開されます。 ハッシュ関数として Poseidon を使うことを前提に、例えばGPU(RTX 3090)で1 秒あたり1億回(100Mhash/s)のハッシュ計算ができると仮定し、「どの程度の長さ・文字種のパスワードなら現実的に安全か」を見積もっています。

想定されるクラック時間の目安

提示されている条件と推定クラック時間を表にすると次のようになります。

パスワード条件 推定クラック時間
8文字(数字のみ) 1 秒
8文字(数字 + 英字) 25 日
8文字(数字 + 英字 + 記号) 594 日
12文字(数字のみ) 10,000 秒
12文字(数字 + 英字) 1,023,042 年
12文字(数字 + 英字 + 記号) 116,586,246 年

ここで示されているのは、あくまでハッシュレート 100Mhash/s 程度の環境で総当たり攻撃を行った場合の「目安」です。 文字種が増えるほど、同じ文字数でも総当たりに必要な組み合わせ数が急激に増えることがわかります。

秘密鍵との比較

次に、パスワードの強度と秘密鍵の強度の比較が示されています。

  • 秘密鍵のクラック難易度は 2^256。
  • 40文字のパスワード(数字 + 英字 + 記号)の総当たり難易度は 92^40。

ここで「92」というのは、「数字 + 英字 + 記号」を合わせた時に想定している文字集合のサイズです。 そして、

  • 92^40 > 2^256

となるため、40文字のパスワード(数字 + 英字 + 記号)であれば、理論上は一般的な 256bit 秘密鍵よりも総当たり攻撃に強い、という結論を示しています。

つまり、pwdhash が公開されている前提でも、十分に長く、かつ多様な文字種を使ったパスワードを選べば、秘密鍵と同等以上の総当たり耐性を持たせることができる、という考え方になっています。

引用

George (@JXRow), "ERC-6327: Elastic Signature [DRAFT]," Ethereum Improvement Proposals, no. 6327, January 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6327.

最後に

今回は「パスワードを使って秘密鍵のように取引データへ安全な署名を行い、より直感的で管理しやすい認証体験を実現しようとする仕組みを提案しているERC6327」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!