Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

はじめに

最終更新: 2026-04-02

このドキュメントは、STARK Ballot Simulator の公開向けガイドです。

目的

  • システムの全体像を短時間で把握できるようにする
  • 暗号プロトコルと検証パイプラインの設計根拠を説明する
  • 検証手順を再現できる情報を提供する

想定読者

  • 暗号検証・監査に関心のある技術者
  • 本アプリケーションに興味のある技術者

本書の読み方

  1. まず 全体像 でシステムの概要を掴む
  2. 暗号プロトコル でコミットメント・Merkle ツリー等の基盤を理解する
  3. zkVM 設計 でゲストプログラムと証明生成の仕組みを学ぶ
  4. 検証パイプライン で 4 段階検証モデルの全体を把握する
  5. 改ざんシナリオ で教育的シミュレーションの動作を確認する
  6. AWS アーキテクチャ で非同期証明インフラを理解する
  7. API リファレンス でエンドポイント仕様を参照する
  8. 実際に検証する場合は 第三者検証ガイドbundle.zip を使ったローカル検証手順を実行する
  9. 設計上の判断については 設計判断 を参照する
  10. 設計根拠の一次資料は 参考文献 を参照する

全体像

STARK Ballot Simulator は、投票の完全性を段階的に検証するための PoC です。

flowchart LR
  A[Cast-as-Intended] --> B[Recorded-as-Cast]
  B --> C[Counted-as-Recorded]
  C --> D[STARK Verification]

データフロー概観

sequenceDiagram
  participant V as 投票者
  participant S as サーバー
  participant B as 掲示板
  participant Z as zkVM
  participant VS as 検証サービス

  V->>S: 投票意図(選択肢・乱数)+ コミットメント
  S->>B: 掲示板に追記
  S-->>V: 投票レシート
  Note over S: ボット投票を自動追加
  S->>Z: 全投票 + Merkle パス
  Z-->>S: STARK レシート + ジャーナル
  S->>VS: STARK レシート検証
  VS-->>S: 検証レポート
  S-->>V: 検証結果

コアコンセプト

  • 投票コミットメントと投票レシートにより Cast-as-Intended を検証
  • RFC 6962 / CT スタイルの掲示板で Recorded-as-Cast を検証
  • zkVM ジャーナル、入力整合、ビットマップ証明により Counted-as-Recorded を検証
  • RISC Zero レシート検証で STARK 実行の正当性を検証
  • AWS クラウド費用の目標月額を 1 USD(デプロイなし、アプリのアクセスなし時)

プロジェクト規模

概算(tracked files ベース、生成物を除く)。

区分行数
TypeScript / React(アプリ本体)約 53,000 行
TypeScript(テストコード)約 55,000 行
Rust(zkVM ゲスト + ホスト + 検証サービス)約 5,000 行
Terraform / Shell / 補助スクリプト約 8,000 行
合計約 121,000 行

各章への案内

内容
暗号プロトコルコミットメント、CT Merkle、入力コミットメント、STH ダイジェスト、ビットマップ Merkle
zkVM 設計ゲストプログラム、ホスト・証明生成、検証サービス、Image ID
検証パイプライン4 段階モデル、チェック一覧、バンドル構造、ゲーティングロジック
改ざんシナリオS0〜S5 シナリオ、検出メカニズム
AWS アーキテクチャトポロジー、非同期プローバー、イメージ署名、Terraform
API リファレンスエンドポイント一覧、セッションライフサイクル
第三者検証ガイド検証ページで取得した bundle.zip を使う Ubuntu 向けローカル検証手順
設計判断PoC の意図的な制約、設計ふりかえり

暗号プロトコル

STARK Ballot Simulator で使用する暗号プリミティブとプロトコルの設計を解説します。

公開データに対する投票の秘匿性(hiding)と束縛性(binding)を支えるコミットメントスキームから、RFC 6962 に基づく CT スタイルの Merkle ツリー、zkVM 入力の正準エンコーディングまで、検証可能性の基盤となる暗号構成要素を網羅します。

この部に含まれる章

コミットメントスキーム

投票者の選択を公開データから秘匿しつつ束縛するコミットメントスキームの設計を解説します。

SHA-256 ベースのコミットメントにより、投票内容の hiding(秘匿性)と binding(束縛性)を実現します。ドメイン分離タグにより、他プロトコルとのコミットメント衝突を防止します。

概要

投票コミットメントは、投票者が選んだ選択肢を公開データからは隠したまま、その選択に束縛されることを可能にする暗号プリミティブです。投票時にはコミットメント値のみが掲示板に記録され、選択肢と乱数は掲示板や public-input.json を含む配布対象バンドルには現れません。投票者は Cast-as-Intended 検証のためにこれらをローカルに保持します。

ただし、本 PoC は operator に対する完全な秘匿性を目的としていません。現行実装ではクライアントから投票 API に opening(選択肢と乱数)も送信され、サーバー側ストアに保持されうるため、ここでいう hiding は主に公開観測者や公開配布物に対する性質を指します。

flowchart LR
  subgraph 入力
    E[選挙 ID<br/>16 バイト UUID]
    C[選択肢<br/>1 バイト]
    R[乱数<br/>32 バイト]
  end
  E --> H[SHA-256]
  C --> H
  R --> H
  T["ドメインタグ<br/>&quot;stark-ballot:commit|v1.0&quot;"] --> H
  H --> CM[コミットメント<br/>32 バイト]

コミットメントの正準フォーマット

コミットメント値は、以下の入力を連結し SHA-256 で圧縮して生成されます。

commitment = SHA-256(
    domain_tag  ||   ← "stark-ballot:commit|v1.0" (24 バイト, UTF-8)
    election_id ||   ← UUID v4 のバイナリ表現 (16 バイト)
    choice      ||   ← 選択肢の値 (1 バイト, 0〜4)
    random           ← 一様乱数 (32 バイト)
)

各フィールドの仕様

フィールドサイズエンコーディング説明
ドメインタグ24 バイトUTF-8 固定文字列"stark-ballot:commit|v1.0"
選挙 ID16 バイトUUID v4 からハイフンを除去し、16 進数をバイト列に変換選挙スコープの識別子
選択肢1 バイト符号なし整数 (0 = A, 1 = B, 2 = C, 3 = D, 4 = E)投票者の選択
乱数32 バイト暗号学的に安全な一様乱数hiding 性を保証

SHA-256 への入力は合計 73 バイト、出力は 32 バイト(16 進数表記で 64 文字、0x プレフィックス付きでは 66 文字)です。

ドメイン分離

ドメインタグ "stark-ballot:commit|v1.0" は、このコミットメントが他のプロトコルで使用されるハッシュ値と偶発的に衝突することを防ぐための仕組みです。

ドメイン分離は本システムの全暗号プリミティブに一貫して適用されています。

プリミティブドメインタグ
コミットメント"stark-ballot:commit|v1.0"
入力コミットメント"stark-ballot:input|v1.0"
Merkle リーフ0x00 || "stark-ballot:leaf|v1"
Merkle ノード0x01
ログ ID"stark-ballot:bulletin-log|v1.0"

安全性

Hiding(秘匿性)

乱数フィールドが 32 バイト(256 ビット)のエントロピーを持つため、公開されたコミットメント値から選択肢を推測することは計算量的に不可能です。

前提条件:

  • 乱数は暗号学的に安全な乱数生成器(CSPRNG)から生成される
  • 同じ乱数は決して再利用しない

乱数の再利用は hiding 性を破壊します。同一選挙で同一乱数を使用した場合、同じ選択肢であればコミットメント値が一致してしまい、情報が漏洩します。

Binding(束縛性)

SHA-256 の原像耐性(preimage resistance)と第二原像耐性(second-preimage resistance)により、一度コミットした値と異なる選択肢に対して同じコミットメント値を生成することは計算量的に不可能です。

つまり、投票者はコミットメント公開後に「別の選択肢に投票した」と主張を変えることができません。

TypeScript と Rust の実装同期

コミットメントは TypeScript(クライアント・サーバー)と Rust(zkVM ゲスト)の双方で計算されます。これら 2 つの実装は、バイトレベルで完全に同一の出力を生成する必要があります。

同期が必要な要素:

  • ドメインタグの文字列とエンコーディング(UTF-8)
  • UUID からバイト列への変換規則(ハイフン除去 → 16 進数デコード)
  • 選択肢の整数エンコーディング(1 バイト、符号なし)
  • 乱数の 16 進数デコード規則

ドメインタグやエンコーディング規則を変更する場合は、TypeScript と Rust の両実装を同時に更新する必要があります。どちらか一方のみの変更は、コミットメント照合の失敗を引き起こします。

検証パイプラインにおける役割

コミットメントは、4 段階検証モデルの最初の 3 段階で中心的な役割を果たします。

検証段階コミットメントの役割
Cast-as-Intended投票者がローカルに保持する(選択肢, 乱数, 選挙 ID)からコミットメントを再計算し、投票レシートと照合する
Recorded-as-Cast掲示板上でコミットメントの包含証明を検証し、投票時点のツリー状態に対して正しく記録されたことを確認する
Counted-as-RecordedzkVM ゲストが prover から渡された各 vote opening でコミットメントを再計算し、掲示板上の値と整合する票だけを tally に含める

注意: Counted-as-Recorded は投票者ローカルの opening ではなく、prover に渡された opening を使います。Cast-as-Intended とは照合対象の出所が異なる点に注意してください。

現行実装の Recorded-as-Cast では、投票時点(cast-time)のツリー状態に対する包含証明を使います。投票時に投票時ルート(内部名 rootAtCast)を保存し、後続の証明取得や最終化では投票時点のツリーサイズ(bulletinIndex + 1)に対する包含証明を再導出します。保存済みの投票時ルートと再導出した証明のルートが一致しない場合、その票の公開証拠は fail-closed で拒否されます。一致した場合にのみ、レシートの bulletinRootAtCast として公開されます。

各チェックの判定ロジックは チェック一覧 > Cast-as-Intended を参照してください。

sequenceDiagram
    participant V as 投票者
    participant S as サーバー
    participant B as 掲示板
    participant Z as zkVM

    V->>V: (選択肢, 乱数) を選び<br/>コミットメントを計算
    V->>S: コミットメント, 選択肢, 乱数を送信
    S->>S: opening から<br/>コミットメントを再計算して照合
    S->>B: コミットメントを掲示板に追記
    S-->>V: 投票レシート(インデックス,<br/>bulletinRootAtCast)
    Note over V: ローカルに (選択肢, 乱数) を保存
    Note over Z: ゲストプログラムが<br/>コミットメントを再計算し<br/>掲示板の値と照合

CT Merkle ツリー

RFC 6962(Certificate Transparency)を参照した CT スタイルの追記専用 Merkle ツリーの設計を解説します。

リーフハッシュ(0x00 プレフィックス)とノードハッシュ(0x01 プレフィックス)の区別により、second-preimage 攻撃を防止します。包含証明と整合性証明により、投票の記録と掲示板の追記専用性を検証可能にします。

なぜ RFC 6962 CT スタイルか

  • 整合性証明で追記専用性を示せるため、Recorded-as-Cast に必要な「後から削除・改変されていない」保証を与えられる。
  • 包含証明と整合性証明の両方を同一モデルで扱える。
  • STH ダイジェストと組み合わせてスプリットビュー攻撃の検出力を上げられる。

概要

本システムの掲示板(Bulletin Board)は、Certificate Transparency (CT) で実績のある追記専用ログの設計を投票に応用しています。各投票コミットメントは Merkle ツリーのリーフとして追記され、一度記録されたエントリは削除も改変もできません。

graph TD
  subgraph "8 リーフのツリー(N=8)"
    R["ルート<br/>SHA-256(0x01 || L || R)"]
    N1["ノード"]
    N2["ノード"]
    N3["ノード"]
    N4["ノード"]
    N5["ノード"]
    N6["ノード"]
    L0["リーフ 0"]
    L1["リーフ 1"]
    L2["リーフ 2"]
    L3["リーフ 3"]
    L4["リーフ 4"]
    L5["リーフ 5"]
    L6["リーフ 6"]
    L7["リーフ 7"]

    R --> N1
    R --> N2
    N1 --> N3
    N1 --> N4
    N2 --> N5
    N2 --> N6
    N3 --> L0
    N3 --> L1
    N4 --> L2
    N4 --> L3
    N5 --> L4
    N5 --> L5
    N6 --> L6
    N6 --> L7
  end

RFC 6962 参照ハッシュ規則

RFC 6962 Section 2 に従い、リーフノードと内部ノードに異なるドメイン分離プレフィックスを適用します。

リーフハッシュ

LeafHash = SHA-256(0x00 || "stark-ballot:leaf|v1" || leaf_data)
要素サイズ説明
プレフィックス1 バイト0x00(リーフ識別子)
使用タグ20 バイト"stark-ballot:leaf|v1"(UTF-8)
リーフデータ可変コミットメント hex をデコードした生バイト列(32 バイト)

内部ノードハッシュ

NodeHash = SHA-256(0x01 || left_hash || right_hash)
要素サイズ説明
プレフィックス1 バイト0x01(内部ノード識別子)
左子ハッシュ32 バイト左部分木のハッシュ
右子ハッシュ32 バイト右部分木のハッシュ

ドメイン分離の安全性

0x00(リーフ)と 0x01(内部ノード)のプレフィックス区別は、second-preimage 攻撃を防止するために不可欠です。この区別がなければ、攻撃者はリーフノードを内部ノードとして解釈させる(またはその逆の)偽造データを構築できる可能性があります。

使用タグ "stark-ballot:leaf|v1" は、他システムのリーフハッシュとの偶発的な衝突を防止する追加の防御層です。

Merkle Tree Hash(MTH)アルゴリズム

RFC 6962 で定義される MTH アルゴリズムは、任意のサイズのデータセットからルートハッシュを計算します。

アルゴリズムの定義

  1. 空のツリー: MTH({}) = SHA-256() (空入力のハッシュ)
  2. 単一リーフ: MTH({d₀}) = LeafHash(d₀)
  3. 複数リーフ: サイズ n のツリーに対し、k を n 未満の最大の 2 のべき乗とする
MTH({d₀, ..., dₙ₋₁}) = SHA-256(0x01 || MTH({d₀, ..., dₖ₋₁}) || MTH({dₖ, ..., dₙ₋₁}))

非 2 のべき乗サイズへの対応

ツリーサイズが 2 のべき乗でない場合(例: 5, 6, 7 リーフ)、MTH アルゴリズムは「n 未満の最大の 2 のべき乗」で分割を行います。これにより、左部分木は常に完全二分木(2 のべき乗サイズ)となり、右部分木にのみ不完全さが集中します。

graph TD
  subgraph "5 リーフのツリー(k=4 で分割)"
    Root["ルート"]
    Left["左部分木<br/>MTH(d₀..d₃)<br/>完全二分木"]
    Right["右部分木<br/>MTH(d₄)<br/>単一リーフ"]
    Root --> Left
    Root --> Right
  end

デフォルトのデモ構成では 64 票(2⁶)を扱うため、最終的なツリーは完全二分木になります。ただし、実装は任意の treeSize を扱う前提であり、投票の追加途中や小規模な検証ケースでは非 2 のべき乗サイズのツリーが出現するため、MTH アルゴリズムの一般的な対応が必要です。

掲示板のリーフデータ形式

掲示板に追記される各投票のリーフデータは、コミットメントの正規化された 16 進数表現(0x なし、小文字、64 文字)を 32 バイトにデコードした生バイト列です。

leaf_data = hex_decode(normalized_commitment_hex) (32 バイト)

掲示板は以下の不変条件を維持します:

  • 単調増加インデックス: 各投票に 0 から始まる連番が割り当てられる
  • 重複排除: 同一の投票 ID やコミットメントの二重追記を拒否する
  • ルート履歴: 各追記時点のルートハッシュをタイムスタンプとともに保存する

包含証明(Inclusion Proof)

包含証明は、特定のコミットメントがツリーの特定位置に含まれていることを、ルートハッシュに対して暗号学的に証明するものです。

構造

包含証明は以下の要素で構成されます:

フィールド説明
leafIndexリーフの 0 始まりインデックス
proofNodes兄弟ハッシュの配列(監査パス)
treeSize証明時点のツリーサイズ
rootHash検証対象のルートハッシュ

実装での名称: 本章では RFC 6962 の抽象名を使いますが、API では異なるフィールド名で返します。

抽象名API フィールド名
proofNodesmerklePath
rootHashbulletinRootAtCast

/api/verifyuserVote.proof/api/bulletin/:voteId/proof がこの構造に対応します。

PATH アルゴリズム

RFC 6962 の PATH 関数に従い、監査パスを再帰的に生成します。

  1. ツリーをサイズ k(n 未満の最大の 2 のべき乗)で左右に分割
  2. 対象リーフが左部分木にある場合(index < k):
    • 左部分木の PATH を再帰計算
    • 右部分木のハッシュを監査パスに追加
  3. 対象リーフが右部分木にある場合(index >= k):
    • 右部分木の PATH を再帰計算(インデックスを index - k に調整)
    • 左部分木のハッシュを監査パスに追加

検証手順

検証者は以下の手順で包含を確認します:

  1. コミットメントのリーフハッシュを計算: LeafHash(commitment)
  2. PATH アルゴリズムと同じ木構造に従い、監査パスのノードを順に結合
  3. 計算されたルートが期待するルートハッシュと一致するか確認

Recorded-as-Cast では、包含証明に加えて以下の cast-time 一貫性も確認します:

  • leafIndex がレシートの bulletinIndex と一致すること
  • treeSizebulletinIndex + 1 と一致すること(投票時点のツリーサイズ)

監査パスのサイズは O(log n) であり、64 票のツリーでは最大 6 ノードです。

graph TD
  subgraph "インデックス 5 の包含証明"
    Root["ルート ✓"]
    N1["H(N3, N4)"]
    N2["H(N5, N6) ← 計算対象"]
    N3["H(L0,L1)<br/>監査パス"]
    N4["H(L2,L3)<br/>監査パス"]
    N5["H(L4,L5) ← 計算対象"]
    N6["H(L6,L7)<br/>監査パス"]
    L4["L4<br/>監査パス"]
    L5["L5 ← 対象リーフ"]

    Root --> N1
    Root --> N2
    N1 --> N3
    N1 --> N4
    N2 --> N5
    N2 --> N6
    N5 --> L4
    N5 --> L5
  end

  style L5 fill:#4CAF50,color:#fff
  style N3 fill:#2196F3,color:#fff
  style N4 fill:#2196F3,color:#fff
  style N6 fill:#2196F3,color:#fff
  style L4 fill:#2196F3,color:#fff

整合性証明(Consistency Proof)

整合性証明は、古いツリー状態(サイズ m)が新しいツリー状態(サイズ n)の前方互換的なプレフィックスであること、つまり追記専用性を暗号学的に証明するものです。

構造

フィールド説明
oldSize古いツリーのサイズ(m)
newSize新しいツリーのサイズ(n)
proofNodes整合性を証明するハッシュの配列

補足: /api/bulletin/consistency-proofproofNodes に加えて rootAtOldSizerootAtNewSize も返します。Recorded-as-Cast の評価では、これらがレシートの bulletinRootAtCast と最終 bulletinRoot に一致することも確認します。

SUBPROOF アルゴリズム

RFC 6962 の SUBPROOF 関数に基づき、整合性証明を再帰的に生成します。

  1. m = n かつ古いツリーが完全部分木: 空の証明を返す
  2. m = n かつ完全部分木でない: 部分木のルートハッシュを返す
  3. k を n 未満の最大の 2 のべき乗とし:
    • m <= k の場合: 左部分木の SUBPROOF + 右部分木のハッシュ
    • m > k の場合: 右部分木の SUBPROOF + 左部分木のハッシュ

検証の意味

整合性証明の検証が成功することは、以下を意味します:

  • 古いツリーのすべてのリーフが、新しいツリーにも同じ位置・同じ値で存在する
  • 新しいツリーは古いツリーの末尾にリーフを追加しただけで構成されている
  • 古いツリーのルートハッシュと新しいツリーのルートハッシュの両方が、提供された証明ノードから独立に再構成できる

これにより、サーバーが過去に記録した投票を密かに削除したり順序を変更したりする攻撃を検出できます。

検証パイプラインにおける役割

CT Merkle ツリーは、4 段階検証モデルの主に 2 段階で使用されます。

検証段階CT Merkle の役割
Recorded-as-Cast包含証明でコミットメントの記録を確認し、整合性証明で追記専用性を確認する
Counted-as-RecordedzkVM ゲストが同じハッシュ規則で各 vote の包含を内部検証する

ユーザー向けの Recorded-as-Cast では、recorded_inclusion_proof が包含証明を、recorded_consistency_proof が追記専用性を担当し、他の派生チェックもこれらの結果に基づきます。zkVM ゲストも同じハッシュ規則で各 vote の包含を内部検証するため、規則の不一致はゲスト内での検証失敗として即座に検出されます。

各チェックの判定ロジックは チェック一覧 > Recorded-as-Cast を参照してください。

RFC 6962 参照範囲

要件対応状況備考
リーフ・ノードのドメイン分離実装済0x00 / 0x01 プレフィックス
MTH アルゴリズム実装済再帰的分割 + キャッシュ最適化
PATH 関数(包含証明)実装済O(log n) サイズの監査パス
SUBPROOF 関数(整合性証明)実装済再帰的生成 + 1→2 特殊ケース対応
非 2 のべき乗サイズ実装済最大 2 のべき乗分割
証明の独立検証実装済ツリーの完全な再構築なしに検証可能

本システムはリーフハッシュに使用タグ "stark-ballot:leaf|v1" を追加しています。これは RFC 6962 の拡張であり、他システムとのリーフハッシュ衝突を防止するための措置です。標準の CT 実装との直接的な相互運用は意図していません。

入力コミットメント

zkVM 入力の公開可能フィールドに対する正準エンコーディングとコミットメントの設計を解説します。

入力コミットメントが束縛するのは zkVM 入力全体ではなく、現行実装で公開検証に使うフィールド群です。

入力コミットメントにより、「証明されたデータセット」と「主張されたデータセット」の一致を検証可能にします。バイトレベルの正準化により、TypeScript と Rust の間で決定的な一致を保証します。

概要

入力コミットメントは、zkVM 入力のうち現行実装で束縛する公開可能な検証フィールドを単一のハッシュ値に集約するプロトコルです。可変部分として electionIdbulletinRoottreeSizetotalExpectedvotesCount と、各投票の index・コミットメント値・Merkle パスを含みます。厳密な計算では、これに固定のドメインタグ stark-ballot:input|v1.0 と version 10(v1.0)も加わります。

このハッシュ値は zkVM のジャーナル(公開出力)にコミットされるため、第三者はジャーナルに記録された入力コミットメントと、public-input.json などの公開可能な検証データから再計算した値を照合することで、zkVM が実際にどのデータセットを処理したかを独立に検証できます。

flowchart TB
  IN["入力データ<br/>electionId / bulletinRoot / treeSize<br/>/ totalExpected / votes (index 昇順)"]
  SPEC["固定値<br/>domainTag: stark-ballot:input|v1.0<br/>version: 10"]

  IN --> ENC[正準エンコーディング]
  SPEC --> ENC
  ENC --> H[SHA-256]
  H --> IC[入力コミットメント<br/>32 バイト]
  IC --> CMP["第三者照合<br/>再計算値 = journal.inputCommitment"]

入力コミットメントが解決する問題

zkVM の STARK 証明は「ゲストプログラムが正しく実行された」ことを証明しますが、「どの入力に対して実行されたか」は証明のスコープ外です。入力コミットメントがなければ、悪意あるサーバーは以下の攻撃が可能になります:

  1. 投票を除外した入力で zkVM を実行し、有効な STARK 証明を取得する
  2. 公開用の入力データには除外されていない投票を含めて提示する
  3. 第三者は STARK 証明が有効であることを確認できるが、実際に処理された入力は異なる

入力コミットメントをジャーナルに含めることで、第三者は「公開データから再計算した入力コミットメント」と「ジャーナルに記録された入力コミットメント」を照合し、不一致を検出できます。

正準エンコーディング

入力コミットメントの計算には、すべてのフィールドを決定的な順序・エンコーディングで連結する正準化が不可欠です。

バイトレイアウト

input_commitment = SHA-256(
    domain_tag       ← "stark-ballot:input|v1.0" (23 バイト, UTF-8)
    || version       ← u32 リトルエンディアン (4 バイト) = 10
    || election_id   ← UUID v4 バイナリ (16 バイト)
    || bulletin_root ← 32 バイト
    || tree_size     ← u32 リトルエンディアン (4 バイト)
    || total_expected← u32 リトルエンディアン (4 バイト)
    || votes_count   ← u32 リトルエンディアン (4 バイト)
    || [投票データ]  ← インデックス昇順でソートされた各投票
)

各投票のエンコーディング

投票配列の各要素は以下の形式でエンコードされます:

vote_entry =
    index            ← u32 リトルエンディアン (4 バイト)
    || commitment_len← u16 リトルエンディアン (2 バイト) = 32 (固定)
    || commitment    ← 32 バイト
    || path_len      ← u16 リトルエンディアン (2 バイト)
    || path_nodes    ← path_len × 32 バイト

フィールド一覧

フィールドサイズエンコーディング説明
ドメインタグ23 バイトUTF-8 固定文字列"stark-ballot:input|v1.0"
バージョン4 バイトu32 LEv1.0 = 10
選挙 ID16 バイトUUID バイナリ選挙スコープの識別子
掲示板ルート32 バイトハッシュ値最終的な Merkle ルート
ツリーサイズ4 バイトu32 LE掲示板のリーフ数
期待投票数4 バイトu32 LE想定される総投票数
投票数4 バイトu32 LE実際に含まれる投票数
各投票インデックス4 バイトu32 LE掲示板上の位置
コミットメント長2 バイトu16 LE固定値 32
コミットメント32 バイトハッシュ値投票コミットメント
パス長2 バイトu16 LEMerkle パスのノード数
パスノード各 32 バイトハッシュ値包含証明の兄弟ハッシュ

public-input.json と公開監査アーティファクトとの関係

public-input.json は、zkVM 検証に使う秘密データを含まない検証用レコードです。現行実装では schemaversionelectionIdelectionConfigHashbulletinRoottreeSizetotalExpectedlogIdtimestampmethodVersion と、各投票の index・コミットメント値・Merkle パスを含みます。

ただし、public-input.json に含まれる全フィールドが入力コミットメントの計算対象に入るわけではありません。現行実装で直接束縛されるのは、概要で示した対象フィールドに固定のドメインタグと version を加えた部分です。electionConfigHashlogIdtimestampmethodVersion は入力コミットメントには直接含まれません。

現行実装では、公開パラメータの照合は public-input.json 単体では完結せず、proof bundle に含まれる election-manifest.jsonclose-statement.json も組み合わせて検証されます。入力コミットメント対象外のフィールドは、次のように別経路で照合されます。

  • electionConfigHashcounted_election_manifest_consistent(manifest と journal 等を照合)
  • logIdtimestampcounted_close_statement_consistent(close statement と journal 等を照合)
  • methodVersion → 入力コミットメントには含まれない。journal 互換性チェックや Image ID 解決に使用

したがって、counted_input_commitment_match は公開パラメータ照合の中核ですが、唯一のクロスチェックではありません。残りのパラメータは上記チェックで補完的に検証されます。

正準化規則

エンコーディングの決定性を保証するために、以下の規則が厳守されます。

ソート規則

投票はインデックスの昇順にソートしてからエンコードする必要があります。

入力データの投票順序は任意であり得ますが、エンコーディング前にインデックスでソートすることで、同じ投票集合から常に同一のバイト列が生成されます。この規則に違反すると、TypeScript と Rust で異なるハッシュ値が計算され、検証が失敗します。

前提となる不変条件:

  • 各投票の index は一意である(重複インデックスは不正入力)

重複インデックスが存在する入力はプロトコル違反であり、正常系の正準化対象ではありません。したがって、正準順序の主キーは index の昇順として定義されます。

実装上は、重複インデックスという異常入力に対して Rust 側で追加の tie-break(commitmentmerklePath)を行いますが、これはあくまで異常系の決定性補助であり、正常系仕様を変更するものではありません。

エンディアン規則

すべての整数フィールドはリトルエンディアンでエンコードされます。

バイト数エンコーディング
u162リトルエンディアン
u324リトルエンディアン

16 進数正規化

コミットメント値やパスノードなどの 16 進数表現は、0x プレフィックスを除去した上でバイト列にデコードされます。16 進数文字列のまま連結するのではなく、常にバイナリ表現を使用します。

TypeScript と Rust の同期

入力コミットメントは TypeScript(サーバー側)と Rust(zkVM ゲスト内)の双方で独立に計算され、結果が一致する必要があります。

flowchart LR
  subgraph TypeScript
    TS_IN[public-input.json から抽出した<br/>入力コミットメント対象フィールド] --> TS_CALC[正準エンコーディング<br/>+ SHA-256]
    TS_CALC --> TS_IC[入力コミットメント A]
  end

  subgraph "Rust (zkVM ゲスト)"
    RS_IN[ゲスト入力] --> RS_CALC[正準エンコーディング<br/>+ SHA-256]
    RS_CALC --> RS_IC[入力コミットメント B]
  end

  TS_IC --> CMP{A = B ?}
  RS_IC --> CMP
  CMP -->|一致| OK[検証成功]
  CMP -->|不一致| NG[検証失敗:<br/>入力データが異なる]

同期が破壊される典型的な原因:

  • ソート順序の不一致
  • エンディアンの不一致
  • ドメインタグの文字列差異
  • バージョン番号の不一致
  • 16 進数正規化規則の差異(大文字/小文字、0x プレフィックスの有無)

検証パイプラインにおける役割

入力コミットメントは、Counted-as-Recorded 段階での検証チェック counted_input_commitment_match として使用されます。

現行の Counted-as-Recorded 段階では、これに加えて counted_election_manifest_consistentcounted_close_statement_consistent も必須チェックとして動作します(対象フィールドと対応関係は上記を参照)。

チェック ID検証内容
counted_input_commitment_match公開可能な検証データから再計算した入力コミットメントがジャーナルの値と一致するか

このチェックが失敗する場合、zkVM が処理した入力データと公開可能な検証データから再構成される入力コミットメント対象フィールドが異なることを意味し、結果の信頼性が根本的に損なわれます。

各チェックの判定ロジックは チェック一覧 > Counted-as-Recorded を参照してください。

注意事項

入力コミットメントには投票者の秘密データ(選択肢や乱数)は含まれません。束縛対象は概要で示した公開可能なフィールドのみであるため、入力コミットメントの公開は投票の秘密性を損ないません。

STH ダイジェスト

Signed Tree Head (STH) ダイジェストによる分割ビュー攻撃の緩和メカニズムを解説します。

ログ ID、ツリーサイズ、タイムスタンプ、掲示板ルートを束縛するダイジェストにより、サーバーが異なるクライアントに異なるツリー状態を提示する攻撃を検出可能にします。

概要

分割ビュー攻撃(split-view attack)とは、悪意あるサーバーが異なる検証者に対して異なる掲示板の状態を提示する攻撃です。例えば、投票者 A には「全 64 票が含まれたツリー」を見せながら、投票者 B には「特定の票が除外されたツリー」を見せることが考えられます。

STH ダイジェストは、掲示板の状態を(ログ ID、ツリーサイズ、タイムスタンプ、ルートハッシュ)の組として束縛し、独立した第三者ソースとの合意確認を通じてこの攻撃を検出します。

flowchart TD
  subgraph "STH ダイジェストの構成"
    LID[ログ ID<br/>32 バイト]
    TSZ[ツリーサイズ<br/>4 バイト]
    TS[タイムスタンプ<br/>8 バイト]
    BR[掲示板ルート<br/>32 バイト]
  end

  LID --> H[SHA-256]
  TSZ --> H
  TS --> H
  BR --> H
  H --> STH[STH ダイジェスト<br/>32 バイト]

  STH --> J[zkVM ジャーナルに記録]
  STH --> CS[close-statement.json に記録]
  STH --> TP[第三者ソースの STH と照合]

ダイジェストフォーマット

sth_digest = SHA-256(
    log_id        ← 32 バイト
    || tree_size  ← u32 リトルエンディアン (4 バイト)
    || timestamp  ← u64 リトルエンディアン (8 バイト, Unix 時刻ミリ秒)
    || bulletin_root ← 32 バイト
)

SHA-256 への入力は合計 76 バイトです。

各フィールドの仕様

フィールドサイズエンコーディング説明
ログ ID32 バイトハッシュ値掲示板インスタンスの識別子
ツリーサイズ4 バイトu32 LE掲示板のリーフ数
タイムスタンプ8 バイトu64 LEUnix 時刻(ミリ秒)
掲示板ルート32 バイトハッシュ値Merkle ツリーのルート

ログ ID

ログ ID は掲示板インスタンスを一意に識別するための値であり、以下のように生成されます:

log_id = SHA-256("stark-ballot:bulletin-log|v1.0" || seed)

ドメインタグ "stark-ballot:bulletin-log|v1.0" と任意のシード値を連結し、SHA-256 でハッシュします。ログ ID は掲示板のライフタイム中に変化しない固定値です。

ログ ID を STH ダイジェストに含めることで、異なる掲示板インスタンスの STH が偶然に衝突することを防止します。

分割ビュー攻撃と検出メカニズム

攻撃シナリオ

sequenceDiagram
    participant A as 投票者 A
    participant S as 悪意あるサーバー
    participant B as 投票者 B

    S->>A: ツリー状態 X<br/>(64 票、ルート R₁)
    S->>B: ツリー状態 Y<br/>(63 票、ルート R₂)
    Note over A,B: A と B は互いに異なる<br/>ツリー状態を見ている

この攻撃では、サーバーは投票者 B に対して特定の票を除外したツリーを見せています。投票者 B は自身に提示されたツリーに対する包含証明や整合性証明を検証できますが、投票者 A とは異なるツリーを見ていることに気づけません。

第三者合意による検出

検証者は、NEXT_PUBLIC_STH_SOURCES に設定された独立ソースへ問い合わせ、ジャーナル内の STH ダイジェストと照合することで分割ビューを検出できます。

sequenceDiagram
    participant V as 検証者
    participant S as サーバー
    participant T1 as 第三者ソース 1
    participant T2 as 第三者ソース 2

    V->>S: ジャーナルから STH ダイジェスト D' を取得
    V->>T1: STH を問い合わせ → D₁
    V->>T2: STH を問い合わせ → D₂
    V->>V: D' = D₁ = D₂ ?
    alt 全て一致
        V->>V: 合意成立 → 検証成功
    else 不一致あり
        V->>V: 分割ビューの疑い → 検証失敗
    end

現行実装は第三者ソースへの照会(fetch)を行うものであり、アプリケーションが第三者ソースへ STH を自動公開する機能は含みません。

開発用に利用できる /api/sth は same-origin の session-scoped API です。アクセスにはセッション capability が必要であり、検証ロジックもそれらの認証ヘッダーを same-origin ソースにのみ転送します。cross-origin のソースへセッション認証情報を送ることはありません。

また、/api/sth が返す timestamp は現状ではジャーナル内の canonical な時刻ではなく session.lastActivity です。第三者合意の一致判定で実際に照合するのは、必須の sthDigest と、ソースが返した場合の bulletinRoot / treeSize です。

合意ロジック

第三者 STH 検証は以下の条件をすべて満たした場合に成功します:

  1. 十分な一致数: 一致するソースの数が最小要求数(コードフォールバック値: 2)以上
  2. 全会一致: 応答可能なすべてのソースが一致すること(matchingSources = comparableSources

各ソースに対して以下のフィールドが照合されます:

照合フィールド条件
STH ダイジェスト必須一致
掲示板ルート提供されている場合は一致
ツリーサイズ提供されている場合は一致

zkVM との連携

zkVM ゲストプログラムは、入力として受け取ったログ ID、ツリーサイズ、タイムスタンプ、掲示板ルートから STH ダイジェストを再計算し、ジャーナルにコミットします。

finalize 時には、同じツリー状態から close-statement.json も構築され、sthDigest が公開 bundle に含まれます。

この仕組みにより、STARK 証明と公開 bundle 内の close-statement.json がともに特定のツリー状態へ束縛されます。第三者はジャーナルの STH ダイジェストを独立ソースの値と照合することで、サーバーが証明と異なるツリー状態を提示していないかを確認できます。

検証パイプラインにおける役割

STH ダイジェストは 2 つの段階で利用されます。

段階チェック ID検証内容
Recorded-as-Castrecorded_sth_third_party独立ソースから取得した STH ダイジェストがジャーナルの値と一致するか
Counted-as-Recordedcounted_close_statement_consistentclose-statement.jsonsthDigest が公開入力およびジャーナルの値と整合するか

recorded_sth_third_party は既定では任意チェック(optional)ですが、STH ソースが設定されている場合は必須扱いへ昇格します。昇格後は、このチェックが成功以外の状態にある限り「Verified」にはなりません(failednot_runpendingrunning のほか、required チェックの欠落も同様にブロックします)。

counted_close_statement_consistent は常に必須チェック(required)です。close-statement.json がジャーナルと整合しない場合、検証は失敗します。

各チェックの判定ロジックは チェック一覧 > Recorded-as-Castチェック一覧 > Counted-as-Recorded を参照してください。

設定

第三者 STH 検証は環境変数で制御されます。

環境変数説明コードフォールバック値
NEXT_PUBLIC_STH_SOURCESカンマ区切りの STH ソース URL未設定(第三者照合を実行しない)
NEXT_PUBLIC_STH_MIN_MATCHES必要な最小一致ソース数2

STH ソースが未設定の場合、recorded_sth_third_partynot_run(未実行)となり、第三者照合は行いません。セキュリティ上は少なくとも 2 つ以上の独立ソースを設定することが推奨されます。

相対パス(例: /api/sth)はリクエスト元のオリジンに対して解決されます。

/api/sth のような same-origin ソースを使う場合、アプリケーションはセッション capability をそのオリジンにのみ転送します。独立第三者ソースを absolute URL で構成する場合、それらはセッション認証に依存しない公開 STH エンドポイントであることが前提です。

開発用の .env.local.example では次の値が設定されています:

  • NEXT_PUBLIC_STH_SOURCES=/api/sth
  • NEXT_PUBLIC_STH_MIN_MATCHES=1

この設定は PoC の動作確認を優先したものです。独立した複数ソースと NEXT_PUBLIC_STH_MIN_MATCHES >= 2 を使用すれば、より強い保証が得られます。

PoC における制約

本 PoC の開発用テンプレート(.env.local.example)では、STH ソースとして同一サーバー上の API エンドポイント(/api/sth)を使用します。同一サーバー上のソースのみでは防御力が限定的であるため、独立した組織が運営する複数ソースの使用を推奨します。

ビットマップ Merkle

投票カウント証明のためのビットマップ Merkle ツリーの設計を解説します。

zkVM ゲスト内で計算されるビットマップにより、各投票インデックスが集計に含まれたかどうかを個別に検証可能にします。Merkle 証明により、自分の投票が含まれていることをサーバーを信頼せずに確認できます。

概要

Counted-as-Recorded 段階の検証では、「全体として正しい集計が行われた」ことは STARK 証明で保証されますが、個々の投票者にとって「自分の票が集計に含まれたか」を直接確認する手段が別途必要です。

ビットマップ Merkle ツリーは、この「個別のカウント証明」を提供します。zkVM ゲストは投票ごとの状態をビットマップとしてエンコードし、その Merkle ルートをジャーナルにコミットします。投票者は自分のインデックスに対応するビットの Merkle 証明を取得し、「自分の票がカウントされたか」「そもそも prover に提示されたか」を独立に検証できます。

flowchart TD
  subgraph "zkVM ゲスト内"
    BM["ビットマップ<br/>[true, true, false, true, ...]"] --> PK[ビットパッキング<br/>LSB-first]
    PK --> CH[32 バイトチャンク分割]
    CH --> LH["リーフハッシュ<br/>SHA-256(0x00 || tag || chunk)"]
    LH --> MT[Merkle ツリー構築]
    MT --> ROOT["includedBitmapRoot / seenBitmapRoot"]
  end

  ROOT --> JNL[ジャーナルにコミット]

ビットマップの構造

ビットマップの定義

ビットマップは、ツリーサイズ(投票数)と同じ長さのブール配列です。現行実装では同じエンコーディング規則を持つ 2 種類のビットマップを扱います。

  • includedBitmap[i] = true: インデックス i の投票が正常に検証され、集計に含まれた
  • includedBitmap[i] = false: インデックス i の投票が除外された
  • seenBitmap[i] = true: インデックス i の投票が prover に提示された
  • seenBitmap[i] = false: インデックス i の投票が prover に提示されなかった

本 PoC では 64 票を扱うため、ビットマップは 64 ビット(= 8 バイト)です。

LSB-first ビットパッキング

ブール配列は LSB-first(Least Significant Bit first)方式でバイト列にパッキングされます。

ビット配列: [b₀, b₁, b₂, b₃, b₄, b₅, b₆, b₇, b₈, ...]

バイト 0 = b₀ | (b₁ << 1) | (b₂ << 2) | ... | (b₇ << 7)
バイト 1 = b₈ | (b₉ << 1) | ...
ビット位置バイトインデックスバイト内ビット位置
000 (LSB)
101
707 (MSB)
810 (LSB)
6377 (MSB)

64 ビットのビットマップは 8 バイトにパッキングされます。

32 バイトチャンク分割

パッキングされたバイト列は 32 バイト(256 ビット)単位のチャンクに分割されます。各チャンクが Merkle ツリーの 1 つのリーフとなります。

  • 1 チャンク = 32 バイト = 256 ビット分の投票カウント状態
  • 最後のチャンクが 32 バイトに満たない場合はゼロパディング

本 PoC の 64 票は 8 バイトであるため、1 つのチャンク(24 バイトのゼロパディング付き)に収まります。

Merkle ツリーの構築

ハッシュ規則

ビットマップ Merkle ツリーは、CT Merkle ツリーと同一のハッシュ規則を使用します。

リーフハッシュ:

LeafHash = SHA-256(0x00 || "stark-ballot:leaf|v1" || chunk)

内部ノードハッシュ:

NodeHash = SHA-256(0x01 || left_hash || right_hash)

ドメイン分離プレフィックス(0x00 / 0x01)と使用タグ("stark-ballot:leaf|v1")は、CT Merkle ツリーの章で解説したものと同一です。

ツリー構築アルゴリズム

  1. 各 32 バイトチャンクにリーフハッシュを適用
  2. ボトムアップでペアを結合し、内部ノードハッシュを計算
  3. 奇数ノードがある場合は、そのまま次のレベルに昇格(ハッシュなし)
  4. ルートに到達するまで繰り返す
graph TD
  subgraph "3 チャンクの場合"
    R["ルート<br/>SHA-256(0x01 || N1 || C2)"]
    N1["ノード<br/>SHA-256(0x01 || C0 || C1)"]
    C2["リーフ 2<br/>SHA-256(0x00 || tag || chunk₂)"]
    C0["リーフ 0<br/>SHA-256(0x00 || tag || chunk₀)"]
    C1["リーフ 1<br/>SHA-256(0x00 || tag || chunk₁)"]

    R --> N1
    R --> C2
    N1 --> C0
    N1 --> C1
  end

Merkle 証明の生成と検証

証明の構造

GET /api/bitmap-proof?i=<bitIndex>&kind=included|seen のレスポンスは、以下の要素で構成されます:

フィールド説明
leafChunk対象ビットを含む 32 バイトチャンク(16 進数)
auditPathルートまでの兄弟ハッシュ配列(各要素にハッシュ値と位置)

leafIndexfloor(bitIndex / 256))と bitOffsetbitIndex mod 256)は、クライアント側で bitIndex クエリから導出します。サーバーは返しません。

kind は省略可能で、既定値は included です。

  • kind=included: includedBitmapRoot に対する証明を返す
  • kind=seen: seenBitmapRoot に対する証明を返す

この endpoint はセッション ID と capability token によるセッションスコープ認証を前提とした証明材料 API であり、公開 API ではありません。

ビット抽出

投票者は受け取ったチャンクから、自分が指定した bitIndex のビットを以下の手順で抽出します:

bit_offset  = bit_index mod 256
byte_index  = bit_offset / 8    (整数除算)
bit_in_byte = bit_offset mod 8

included = (chunk[byte_index] AND (1 << bit_in_byte)) != 0

kind=includedincluded = true なら、自分の投票がカウントされたことを意味します。kind=seenincluded = true なら、自分の投票が prover に提示されたことを意味します。

検証手順

  1. チャンクからビット値を抽出し、自分の投票がカウントされたか確認
  2. チャンクのリーフハッシュを計算: SHA-256(0x00 || "stark-ballot:leaf|v1" || chunk)
  3. 監査パスに沿ってルートまで再計算:
    • 兄弟の位置が leftSHA-256(0x01 || sibling || current)
    • 兄弟の位置が rightSHA-256(0x01 || current || sibling)
  4. 計算されたルートが、kind に対応するジャーナル上のルートと一致するか確認
    • kind=includedincludedBitmapRoot
    • kind=seenseenBitmapRoot
flowchart TD
  CK[チャンク受信] --> EX[ビット抽出<br/>included = true/false]
  CK --> LH["リーフハッシュ計算"]
  LH --> AP[監査パスに沿って<br/>ルートを再計算]
  AP --> CMP{計算ルート =<br/>bitmap root ?}
  CMP -->|一致| V[証明有効]
  CMP -->|不一致| IV[証明無効]

zkVM ゲストとの連携

ビットマップルートは zkVM ゲストプログラム内で計算され、ジャーナルにコミットされます。

ゲストプログラムは以下の手順を実行します:

  1. 各投票に対してコミットメントの再計算と包含証明の検証を実施
  2. prover に提示された投票インデックスを seenBitmap に記録
  3. 検証に成功して集計対象になった投票インデックスを includedBitmap に記録
  4. それぞれのビットマップを LSB-first でパッキングし、32 バイトチャンクに分割
  5. CT スタイルのハッシュ規則で Merkle ルートを計算
  6. seenBitmapRootincludedBitmapRoot をジャーナルにコミット

この計算はゲスト内で行われるため、STARK 証明がビットマップの正しさも保証します。サーバーが事後的にビットマップを改ざんしても、ジャーナルのルート値と一致しなくなるため検出されます。

サーバーのビットマップデータ管理

サーバーはビットマップ Merkle 証明を提供するために、最終化時に zkVM 出力に基づく bitmap データを保持します。

現行実装では、sync finalize でも async finalize でも、zkVM 実行から includedBitmap が得られた場合にのみ証明用データを保存します。seenBitmapseenBitmapRoot が得られた場合は、それも private artifact として保存します。validVotes から簡易ビットマップを推定する fallback は現在の実装にはありません。

安全性ゲート

サーバーが保持するビットマップデータから計算したルートと、ジャーナル上の対応ルート(includedBitmapRoot または seenBitmapRoot)が一致しない場合、ビットマップ証明の提供は無効化されます。これにより、サーバーが不正なビットマップデータを使って偽の証明を生成することを防止します。

そのため、zkVM 出力に基づく bitmap データが存在しない場合や、保存済みデータの root がジャーナルと一致しない場合は、証明を提供せず counted_my_vote_includednot_run 相当になります。

さらに現行実装では、counted_my_vote_included の評価には信頼できる voteReceipt.bulletinIndex が必要です。この値は、store から cast-time 証跡(voteReceipt / userVote.proof)を再構成できた場合にのみ得られます。bitmap データが保持されていても cast-time 証跡を復元できなければ bitmap proof は実行されず、counted_my_vote_includednot_run(証拠不足による fail-closed)となります。

async finalize 経路でも、prover 出力の *-bitmap.json*-seen-bitmap.json は private な included-bitmap.json / seen-bitmap.json として保持されます。これらは public bundle.zip には含めず、必要に応じて S3 上の sibling object から復元されます。

検証パイプラインにおける役割

ビットマップ Merkle 証明は、Counted-as-Recorded 段階のチェックとして使用されます。

チェック ID検証内容
counted_my_vote_includedビットマップ Merkle 証明により、自分の投票インデックスがカウントされたことを確認する

seenBitmapRoot が利用可能な場合は、このチェックが「prover に提示されたが無効化された」と「そもそも提示されなかった」も区別して説明します。

各チェックの判定ロジックは チェック一覧 > Counted-as-Recorded を参照してください。

プライバシーに関する注意

チャンクレベルの情報漏洩

ビットマップ Merkle 証明では、対象ビットを含む 32 バイトチャンク全体がクライアントに提供されます。1 チャンクは 256 ビット分のカウント状態を含むため、近傍のインデックスのカウント状態が同時に開示されます。

本 PoC では 64 票が 1 チャンクに収まるため、チャンクを受け取った投票者は全 64 票のカウント状態を知ることができます。この漏洩の影響評価は PoC の意図的な制約 > ビットマップチャンク漏洩 を参照してください。

PoC における許容性

本システムでは 63 票がボット(自動投票)であり、ボットのカウント状態が開示されても実質的なプライバシー侵害は生じません。人間の投票者が多数参加するシステムでは、以下のような緩和策が考えられます:

  • チャンクサイズの縮小(より多くのリーフ、より深いツリー)
  • ゼロ知識証明を用いたビット開示の最小化
  • 投票者の明示的な同意に基づく開示

zkVM 設計

RISC Zero zkVM を用いた集計証明パイプラインの全体設計を解説します。

この部に含まれる章

zkVM の基礎

RISC Zero zkVM の基本概念、本システムでの選択理由、パイプラインの全体データフローと保証境界を解説します。

なぜ RISC Zero か

  • Trusted setup 不要で扱いやすい。
  • RISC-V 上で通常の Rust コードを使えるため、実装と監査の往復がしやすい。
  • Image ID による実行バイナリ照合と組み合わせやすい。

アーキテクチャ概要

zkVM パイプラインは 3 つのコンポーネントで構成されます。

flowchart TB
  subgraph C1["第1フェーズ: 入力構築"]
    SES[セッションデータ] --> IB[入力ビルダー]
    IB --> INP[ZkVMInput]
  end

  subgraph C2["第2フェーズ: 証明生成"]
    INP --> HOST[ホストプログラム]
    HOST --> GUEST["ゲストプログラム<br/>(zkVM 内実行)"]
    GUEST --> JNL[ジャーナル]
    HOST --> RCP[レシート<br/>STARK 証明]
  end

  subgraph C3["第3フェーズ: 検証"]
    RCP --> VS[検証サービス]
    VS --> VR[検証レポート]
  end
コンポーネント言語責務
ゲストプログラムRustzkVM 内で投票を検証・集計し、結果をジャーナルにコミットする
ホストプログラムRust入力を受け取り、zkVM を起動して STARK 証明(レシート)を生成する
検証サービスRustレシートの STARK 検証を実行し、期待される Image ID との一致を確認する

zkVM とは

RISC Zero zkVM は、RISC-V(RV32IM)命令列として実行されるゲストプログラムに対して、その実行が正しいことを STARK 証明で示すゼロ知識 VM です。

本システムでは「投票検証と集計」をゲストとして実行し、ホストがレシート(seal + journal)を生成します。第三者は Receipt::verify(image_id) により、指定したゲストバイナリ(Image ID)で実行された結果であることを検証できます。

30 秒で要点

観点要点
何を証明するか「このゲストコードをこの入力で実行した結果がジャーナルである」こと
何を公開するかジャーナル(公開出力)とレシート(証明本体)
どこが信頼アンカーかImage ID(どのゲストを検証対象にするか)
この PoC での意味集計値・除外件数・入力整合性を暗号学的に検証可能にする

ジャーナルは証明に束縛される公開出力です。配布対象ファイル(bundle.zip 等)との違いは バンドル構造 を参照してください。

RISC Zero zkVM 実装の要点(簡潔版)

RISC Zero 公式ドキュメントに基づく実装上の要点:

  1. 実行モデル: ゲストは RV32IM として決定論的に実行され、外部との境界は syscall (ecall) で扱う
  2. 証明の分割: 長い実行はセグメント証明に分割される(大規模実行を扱うため)
  3. 再帰合成: セグメント証明を再帰的に合成して、最終的に短いレシートへ圧縮する
  4. 検証 API: Receipt::verify(image_id) が、証明本体と Image ID の束縛を同時に検証する
  5. 公開出力の束縛: journal は証明に束縛されるため、検証成功後に改ざんできない

ジャーナルとレシート

  • ジャーナル: ゲストが公開出力としてコミットするデータ(検証済み集計、除外情報、入力コミットメントなど)
  • レシート: ジャーナルと STARK 証明(seal)のペア。検証成功時、ジャーナルは正しいゲスト実行結果として受理できる
flowchart TB
  R[レシート]
  R --> S[Seal<br/>STARK 証明]
  R --> J[ジャーナル<br/>公開出力]

各項目の意味と、対応する検証チェック:

ジャーナル項目代表フィールド主な確認ポイント
検証済み集計verifiedTallycounted_tally_consistent
除外/欠落情報missingSlots / invalidPresentedSlots / excludedSlotscounted_missing_indices_zero
入力整合性inputCommitmentcounted_input_commitment_match
STH 束縛sthDigestrecorded_sth_third_party(設定時)
個票包含証明の根includedBitmapRootcounted_my_vote_included
提示状態ビットマップ根seenBitmapRoot除外理由の切り分けに使用

ジャーナルのフィールド名(missingSlots 等)と一部チェック名(counted_missing_indices_zero 等)が異なるのは後方互換のためです。詳細は ゲストプログラム を参照してください。

数学ミニ補足(読み飛ばし可)

STARK の直感的理解に必要な最小限の説明だけを記します。

1. 巡回ドメイン(cyclic domain)

有限体 F の乗法群の部分群 H = {1, w, w^2, ..., w^(n-1)} を評価点集合(ドメイン)として使います。RISC Zero はこれを cyclic domain と呼びます。実行トレース(レジスタ値や遷移)は、この H 上で評価される多項式として扱われます。

2. 有限体上の多項式除算

制約違反の有無は、概念的には次の形で整理できます。

  • 制約多項式を C(x) とする
  • 評価領域 H の零化多項式を Z_H(x) とする(典型例: Z_H(x) = x^n - 1
  • すべての点で制約を満たすなら C(h)=0h in H)となり、C(x)Z_H(x) で割り切れる
  • したがって Q(x) = C(x) / Z_H(x) が多項式として成立するかを検査すれば、制約満足性をチェックできる

実際の STARK では、これを FRI(Fast Reed-Solomon IOP)と Merkle コミットメントを組み合わせて効率的に検証します。

この PoC での保証境界

Receipt::verify(image_id) の成功は強い保証ですが、それ単体で「全票が提示された」ことまでは保証しません。本 PoC では以下を組み合わせて最終判定します。

  1. STARK 検証成功(正しいゲスト実行)
  2. excludedSlots == 0(除外スロットなし)
  3. 一貫性証明・STH 整合の成功(設定時)
  4. 必須チェックが not_run / pending / running のままでは不合格

このため、Verified 表示は「証明 + 完全性チェック」がそろった場合にのみ成立します。

参考資料(RISC Zero 公式)

データフロー

投票セッションの開始からレシート検証までの全体的なデータフローを示します。

sequenceDiagram
  participant V as 投票者
  participant S as サーバー
  participant B as 掲示板
  participant H as ホスト
  participant G as ゲスト (zkVM)
  participant VS as 検証サービス

  Note over V,B: 投票フェーズ
  V->>S: 投票意図(選択肢・乱数)+ コミットメント
  S->>B: 掲示板に追記
  S-->>V: 投票レシート (インデックス, ルート)
  Note over S: ボット投票を自動追加

  Note over S,G: 集計フェーズ
  S->>S: 入力ビルダーで ZkVMInput を構築
  S->>H: ZkVMInput を渡す
  H->>G: ゲスト実行を開始
  G->>G: 各投票のコミットメント検証
  G->>G: 各投票の包含証明検証
  G->>G: 集計 + ビットマップ計算
  G-->>H: ジャーナル出力
  H-->>S: STARK レシート + ジャーナル

  Note over S,VS: 検証フェーズ
  S->>VS: STARK レシート + 期待 Image ID
  VS->>VS: Receipt::verify(image_id)
  VS-->>S: 検証レポート
  S-->>V: 検証結果を提供

ゲストプログラム

zkVM 内で実行されるゲストプログラムの設計を解説します。

ゲストプログラムは、投票コミットメントの再計算、RFC 6962 包含証明の検証、集計の実行、ビットマップルートの計算を行い、結果をジャーナルにコミットします。

概要

ゲストプログラムは RISC Zero zkVM 上で動作する Rust プログラムです。ホストから投票データ(選択肢・乱数・コミットメント・Merkle パスと選挙メタデータ)を受け取り、以下の処理を行います:

  1. 各投票の正当性検証(コミットメント再計算 + 包含証明)
  2. 有効投票の集計
  3. カウント状態と提示状態のビットマップ計算
  4. 入力コミットメントと STH ダイジェストの計算
  5. 結果のジャーナルへのコミット

ゲスト内の処理はすべて STARK 証明に含まれるため、出力(ジャーナル)の正しさが暗号学的に保証されます。

入力構造

ゲストプログラムが受け取る入力(AggregatorInput)の構造を示します。

フィールド説明
election_id16 バイト選挙の UUID v4 バイナリ表現
bulletin_root32 バイト掲示板 Merkle ツリーの最終ルート
tree_sizeu32掲示板のリーフ数(= 投票スロット数)
log_id32 バイト掲示板のログ識別子
timestampu64入力構築時に採用された最新 STH スナップショットの Unix タイムスタンプ
total_expectedu32想定される総投票数
election_config_hash32 バイト選挙設定のハッシュ値
votesVoteWithProof[ ]投票データと Merkle パスの配列

VoteWithProof は以下のフィールドを持ちます:

フィールド説明
indexu32掲示板上のインデックス
choiceu8選択肢(0 = A, 1 = B, 2 = C, 3 = D, 4 = E)
random32 バイトコミットメント計算に使用した乱数
commitment32 バイト投票コミットメント値
merkle_path32 バイト[ ]RFC 6962 Merkle 包含証明のパスノード

処理パイプライン

ゲストプログラムの処理は、入力検証、投票検証・集計、出力構築の 3 フェーズで構成されます。

flowchart TD
  subgraph "フェーズ 1: 入力検証"
    I1[入力デシリアライズ] --> I2{掲示板ルート<br/>が非ゼロ?}
    I2 -->|Yes| I3{ツリーサイズ<br/>が正の値?}
    I2 -->|No| FAIL[不正入力]
    I3 -->|Yes| NEXT[フェーズ 2 へ]
    I3 -->|No| FAIL
  end

  subgraph "フェーズ 2: 投票検証と集計"
    NEXT --> LOOP[各投票に対して]
    LOOP --> V1[6 段階検証]
    V1 -->|有効| TALLY[集計に加算]
    V1 -->|無効| EXCL[除外リストに追加]
    TALLY --> BIT[ビットマップ更新]
  end

  subgraph "フェーズ 3: 出力構築"
    LOOP -->|全投票完了| O1[ビットマップルート計算]
    O1 --> O2[入力コミットメント計算]
    O2 --> O3[STH ダイジェスト計算]
    O3 --> O4[ジャーナルにコミット]
  end

Note: 現行実装の入力検証で即座に reject されるのは、bulletin_root がゼロ値、または tree_size が 0 の場合だけです。votes.length > tree_size のようなケースは事前 reject されず、各レコードが通常どおり処理され、重複や範囲外は後述の rejectedRecords に反映されます。

投票の 6 段階検証

各投票に対して、以下の 6 つの検証が順に実行されます。いずれかが失敗した投票は即座に「無効」として除外され、以降の検証はスキップされます。

flowchart TD
  V[投票] --> C1{"第1検証<br/>インデックス範囲チェック"}
  C1 -->|失敗| INV[無効として除外]
  C1 -->|成功| C2{"第2検証<br/>インデックス重複チェック"}
  C2 -->|失敗| INV
  C2 -->|成功| C3{"第3検証<br/>選択肢範囲チェック"}
  C3 -->|失敗| INV
  C3 -->|成功| C4{"第4検証<br/>コミットメント再計算と照合"}
  C4 -->|失敗| INV
  C4 -->|成功| C5{"第5検証<br/>コミットメント重複チェック"}
  C5 -->|失敗| INV
  C5 -->|成功| C6{"第6検証<br/>包含証明検証"}
  C6 -->|失敗| INV
  C6 -->|成功| VALID[有効: 集計に加算]

1. インデックス範囲チェック

投票のインデックスが 0 以上 tree_size 未満であることを確認します。範囲外のインデックスは掲示板上に存在し得ないため、不正入力として検出されます。

2. インデックス重複チェック

同一インデックスの投票が既に処理されていないことを確認します。重複するインデックスは二重カウント攻撃を意味するため、2 番目以降の同一インデックスは除外されます。

3. 選択肢範囲チェック

選択肢の値が 0 から 4(A から E)の範囲内であることを確認します。

4. コミットメント再計算と照合

ゲスト内で投票者の(選択肢, 乱数, 選挙 ID)からコミットメントを再計算し、入力として渡されたコミットメント値と照合します。

この検証により、投票者が主張する選択肢が掲示板上のコミットメントと一致することが保証されます。コミットメントの計算規則は コミットメントスキーム を参照してください。

5. コミットメント重複チェック

同一コミットメントが既に処理されていないことを確認します。コミットメント値が重複した場合は、入力の異常または二重投入の兆候として無効化されます。

6. RFC 6962 包含証明検証

投票のコミットメントが掲示板 Merkle ツリーに含まれることを、RFC 6962 PATH 関数ベースの CT スタイル包含証明で検証します。投票のインデックスと Merkle パスから掲示板ルートを再計算し、入力の bulletin_root と一致するかを確認します。

ハッシュ規則は CT Merkle ツリー の RFC 6962 参照規則に合わせます:

  • リーフ: SHA-256(0x00 || "stark-ballot:leaf|v1" || data)
  • ノード: SHA-256(0x01 || left || right)

集計ロジック

6 段階検証をすべて通過した投票は「有効」として集計に加算されます。

  • 集計は選択肢ごとの配列(5 要素)で管理
  • 有効投票のインデックスに対応するビットマップのビットを true に設定
  • 無効投票はカウントされず、ビットマップのビットも false のまま

スロット / レコード分離モデル

現行のゲストプログラムは、掲示板スロットに対する完全性と、入力レコードの異常を別々に記録します。

flowchart LR
  TS["ツリーサイズ<br/>(全スロット)"]
  TS --> CNT["カウント済み<br/>validVotes"]
  TS --> INV["提示されたが未計上<br/>invalidPresentedSlots"]
  TS --> MIS["未提示<br/>missingSlots"]
  REC["入力レコード"] --> REJ["却下レコード<br/>rejectedRecords"]
指標条件意味
validVotes6 段階検証をすべて通過した範囲内かつ初出の投票集計に含まれたスロット数
invalidPresentedSlots範囲内かつ初出のスロットが提示されたが、最終的に計上されなかった提示はされたがカウントに失敗したスロット数
missingSlots範囲内スロットが一度も提示されなかったサーバーが prover に提示しなかったスロット数
rejectedRecords検証に失敗したレコード全体重複 index、範囲外 index、重複 commitment なども含むレコード単位の却下数

スロット単位の 3 分類は、現行実装では常に次の関係を満たします:

validVotes + invalidPresentedSlots + missingSlots = treeSize

fail-closed 判定に使われる除外数は、スロット単位の excludedSlots です:

excludedSlots = missingSlots + invalidPresentedSlots

excludedSlots > 0 は検証失敗の決定的な指標です。1 スロットでも未提示または未計上であれば、集計結果の完全性が損なわれていることを意味します。

一方で rejectedRecords はレコード単位の補助指標です。たとえば次のようなケースでは rejectedRecords は増えても excludedSlots は増えません。

  • 既に正しくカウント済みのスロットに対する重複インデックスレコード
  • tree_size の外側を指す範囲外レコード

以下のフィールドは現行ゲストの正規出力ではなく、TypeScript 側で導出される後方互換エイリアスです。

後方互換エイリアス(TS 側で導出)導出元
excludedCountexcludedSlots
missingIndicesmissingSlots
invalidIndicesinvalidPresentedSlots
countedIndicesvalidVotes

ジャーナル出力

ゲストプログラムがジャーナルにコミットする出力構造(VerificationOutput)を示します。

フィールド説明
electionIdUUID対象選挙 ID(入力の election_id をエコー)
electionConfigHash32 バイト選挙設定ハッシュ(入力の election_config_hash をエコー)
bulletinRoot32 バイト掲示板ルート(入力の bulletin_root をエコー)
treeSizeu32掲示板のツリーサイズ(入力をエコー)
totalExpectedu32想定総投票数(入力をエコー)
sthDigest32 バイトSTH ダイジェスト
verifiedTallyu32[5]選択肢 A〜E ごとの得票数
totalVotesu32zkVM が受け取った投票レコード数
validVotesu32検証に成功した投票数
invalidVotesu32検証に失敗した投票数
seenIndicesCountu32範囲内かつ初出のインデックスとして処理した件数
missingSlotsu32一度も提示されなかった掲示板スロット数
invalidPresentedSlotsu32提示はされたが計上されなかった範囲内スロット数
rejectedRecordsu32却下されたレコード数(重複・範囲外・各種検証失敗を含む)
seenBitmapRoot32 バイトprover に提示されたインデックス集合の ビットマップ Merkle ルート
includedBitmapRoot32 バイト実際にカウントされたインデックス集合の ビットマップ Merkle ルート
excludedSlotsu32除外されたスロットの総数(= missingSlots + invalidPresentedSlots)
inputCommitment32 バイト入力コミットメント
methodVersionu32ゲストプログラムのバージョン(現行 = 12 / v1.2)

ジャーナルの信頼モデル

ジャーナルの各フィールドは、対応する STARK 証明により「ゲストプログラムが正しく計算した結果」であることが保証されます。

ジャーナル項目STARK 証明で保証される内容補足
verifiedTally有効投票のみを正しく集計した結果であるvalidVotes / invalidVotes との整合もジャーナル上で確認可能
excludedSlots未提示または未計上のスロット数がゲストの計算結果と一致するexcludedSlots > 0 は完全性違反の重要シグナル
rejectedRecords却下されたレコード数がゲストの計算結果と一致する重複・範囲外・検証失敗の説明に使う補助情報で、スロット単位の判定とは分離される
inputCommitmentゲストが処理した入力データを正準エンコードで束縛した値である公開入力側から再計算して照合できる
seenBitmapRootprover に提示された範囲内かつ初出のインデックス集合から計算したルートであるincludedBitmapRoot と併用すると未提示 / 提示されたが未計上 / カウント済みを区別できる
includedBitmapRoot実際にカウントされたインデックス集合から計算したルートである自票包含の証明(bitmap proof)の検証基準になる
sthDigestその実行で参照した掲示板状態から計算した値である第三者 STH 合意そのものは別チェックで確認する

第三者はレシートの STARK 検証を行うだけで、上記の保証を取得できます。ゲストプログラムのロジックを信頼する必要はありますが、ホストやサーバーの正直性を信頼する必要はありません。

ビットマップルートの計算

ゲストプログラムは投票検証と並行して、2 種類のビットマップを構築します。

  1. seenBitmapincludedBitmap の 2 つのブール配列を初期化(全 false
  2. 範囲内かつ一意インデックスとして処理された票のインデックスに対応する seenBitmap のビットを true に設定
  3. 6 段階検証を通過した票のインデックスに対応する includedBitmap のビットを true に設定
  4. 各ビットマップを LSB-first でバイト列にパッキング
  5. パック後の長さが 32 バイト以下なら、ゼロ埋めした 1 リーフとして CT スタイルの leaf hash を計算
  6. 33 バイト以上なら 32 バイトチャンクに分割し、それぞれを leaf とする CT スタイルの Merkle ツリーを構築
  7. 2 つのルート値(seenBitmapRootincludedBitmapRoot)をジャーナルにコミット

この 2 つのルートを使うことで、公開検証側は「prover に提示されたが無効化された票」と「そもそも prover に提示されなかった票」を区別できます。

ビットマップの詳細な構造とハッシュ規則は ビットマップ Merkle を参照してください。

入力コミットメントと STH ダイジェスト

ゲストプログラムは投票処理の後、2 つの追加ハッシュ値を計算してジャーナルにコミットします。

入力コミットメント

ゲストに渡された入力のうち、公開フィールドを正準エンコーディングで連結し SHA-256 で圧縮します。現行実装では固定のドメインタグと format version を先頭に付与した上で、electionIdbulletinRoottreeSizetotalExpectedvotesCount と各投票の index・コミットメント値・Merkle パスを束縛します。投票列はハッシュ前にインデックス順へ正規化されます。

第三者は public-input.json などの公開検証用レコードから同じ値を再計算し、ジャーナルの値と照合することで、zkVM が処理した入力の同一性を検証できます。

詳細は 入力コミットメント を参照してください。

STH ダイジェスト

掲示板のログ ID、ツリーサイズ、タイムスタンプ、ルートハッシュを結合して SHA-256 で圧縮します。このダイジェストは第三者の STH ソースとの照合に使用され、サーバーが異なる投票者に異なる掲示板ビューを提示する分割ビュー攻撃を緩和します。

詳細は STH ダイジェスト を参照してください。

ゲストプログラムのバージョニング

ゲストプログラムにはバージョン番号が割り当てられ、ジャーナルの methodVersion フィールドに記録されます。現行のジャーナル契約は 12(v1.2)です。

バージョン番号は Image ID の管理と連動しており、ゲストプログラムの変更は新しい Image ID の生成を伴います。検証時には、期待 Image ID との一致が確認されます。

ホストと証明生成

ホストプログラムによる zkVM 入力構築と STARK 証明生成の仕組みを解説します。

同期モード(ローカルプロセス起動)と非同期モード(ECS Fargate タスク)の 2 つの証明パスがあり、どちらも同一のホストバイナリを使用します。入力構築からレシート出力までのフローを、両モードの差異とともに説明します。

パイプライン全体像

証明生成パイプラインは、入力構築、ホスト実行、出力処理の 3 フェーズで構成されます。

flowchart TD
  subgraph "第1フェーズ: 入力構築 (TypeScript)"
    SD[セッションデータ] --> IB[入力ビルダー]
    IB --> ZI[ZkVMInput]
    ZI --> SER[シリアライズ<br/>JSON ファイル]
  end

  subgraph "第2フェーズ: ホスト実行 (Rust)"
    SER --> HOST[ホストバイナリ]
    HOST --> ENV[ExecutorEnv 構築]
    ENV --> PROVER[デフォルトプローバー]
    PROVER --> GUEST[ゲスト実行]
    GUEST --> PROOF[STARK 証明生成]
  end

  subgraph "第3フェーズ: 出力処理"
    PROOF --> RCP["レシート JSON<br/>(seal + journal)"]
    PROOF --> OUT["出力 JSON<br/>(デコード済みジャーナル)"]
  end

入力構築

セッションデータからの抽出

入力ビルダーは、投票セッションに蓄積されたデータから zkVM 入力を構築します。

flowchart LR
  subgraph セッションデータ
    EID[選挙 ID]
    VOTES["投票データ<br/>(選択肢, 乱数, コミットメント)"]
    BULL["掲示板<br/>(ルート履歴, 包含証明)"]
    LID[ログ ID]
  end

  subgraph ZkVMInput
    EID2[election_id]
    BR[bulletin_root]
    TS[tree_size]
    TE[total_expected]
    VWP["votes[]<br/>(VoteWithProof)"]
    LID2[log_id]
    TSTAMP[timestamp]
  end

  EID --> EID2
  BULL --> BR
  BULL --> TS
  VOTES --> VWP
  LID --> LID2

入力構築で行われる主要な処理:

  1. 掲示板の最新 STH スナップショット取得: ルートハッシュ、ツリーサイズ、タイムスタンプを取得
  2. 投票データの変換: 各投票の選択肢を整数に変換(A=0, B=1, C=2, D=3, E=4)
  3. Merkle パスの解決: 各投票について、掲示板から最新の包含証明を取得
  4. 総投票数の設定: ボット投票数 + ユーザー投票数(本 PoC では 63 + 1 = 64)

Merkle パスの解決戦略

各投票の Merkle パスは、以下の優先順位で解決されます:

flowchart TD
  START[Merkle パス解決] --> P1{掲示板から<br/>包含証明を取得可能?}
  P1 -->|Yes| USE1[掲示板の証明を使用]
  P1 -->|No| P2{投票データに<br/>事前計算パスが存在?}
  P2 -->|Yes| CHK{proofMode = rfc6962<br/>かつ treeSize が一致?}
  CHK -->|Yes| USE2[事前計算パスを使用]
  CHK -->|No| ERR[エラー]
  P2 -->|No| ERR

ホストプログラムの実行

ホストバイナリの役割

ホストバイナリは Rust で記述された CLI プログラムです。以下の処理を行います:

  1. JSON 形式の入力ファイルを読み込み
  2. JSON のバイト配列表現を Rust の固定長配列型へ変換(Vec<u8>[u8; 16/32]
  3. ExecutorEnv に入力をシリアライズして設定
  4. デフォルトプローバーを使用して zkVM ゲストを実行
  5. レシート(STARK 証明 + ジャーナル)を取得
  6. ジャーナルをデコードし、出力ファイルに書き出し

入力 JSON は TypeScript 側のエグゼキューターが事前に正規化して生成します(UUID/ハッシュ文字列をバイト配列へ変換)。

出力ファイル

ホストバイナリは常に 2 つの JSON ファイルを出力し、条件を満たした場合は private bitmap artifact も追加で出力します。

ファイル内容
レシート JSON{ "receipt": ..., "image_id": "0x..." } 形式のラッパー JSON
出力 JSONデコード済みのジャーナル(集計結果、除外情報、各種ハッシュ値)

レシート JSON には top-level image_id フィールドも含まれます。検証サービスでの使われ方は 検証サービス を参照してください。

また、ホストはビットマップの整合性を確認し、一致した場合のみ以下の private artifact を出力します。

ファイル内容
*-bitmap.jsoncounted bitmap の厳密 artifact(includedBitmapRoot と対応)
*-seen-bitmap.jsonpresented bitmap の厳密 artifact(seenBitmapRoot と対応)

同期モード

同期モードでは、TypeScript のサーバーサイドプロセスからホストバイナリを直接起動します。

sequenceDiagram
  participant S as サーバー (TypeScript)
  participant E as エグゼキューター
  participant H as ホストバイナリ (Rust)
  participant FS as ファイルシステム

  S->>E: executeZkVM(input)
  E->>FS: 入力 JSON を一時ファイルに書き出し
  E->>H: 子プロセスとして起動
  Note over H: zkVM ゲスト実行<br/>+ STARK 証明生成
  H->>FS: レシート JSON + 出力 JSON を書き出し
  H-->>E: プロセス終了
  E->>FS: 出力ファイルを読み取り
  E->>FS: 一時ファイルを削除
  E-->>S: ZkVMExecutionResult

同期モードの特性

項目
起動方式Node.js child_process.exec
タイムアウト10 分(600 秒)
一時ファイルリポジトリ直下の .zkvm-temp/ 配下
環境変数RISC0_DEV_MODERUST_LOG をパススルー
エラー処理終了コード非ゼロ、タイムアウト、ファイル不在で失敗

結果の変換

エグゼキューターは出力 JSON のフィールドを TypeScript の命名規則へ正規化し、文字列だけでなくバイト配列形式の値も受理します。ハッシュ系フィールドは 0x 付き 16 進文字列に、election_id は UUID 文字列に変換して ZkVMExecutionResult を構築します。

非同期モード

AWS 環境では、証明生成を ECS Fargate タスクとして非同期に実行します。STARK 証明の生成に数分を要するため、Lambda のタイムアウト制限を回避し、専用のコンピューティングリソースを割り当てます。

sequenceDiagram
  participant S as サーバー
  participant SQS as SQS
  participant SFN as Step Functions
  participant ECS as ECS Fargate
  participant S3 as S3
  participant CB as コールバック Lambda

  S->>SQS: ファイナライズリクエスト
  SQS->>SFN: ディスパッチ Lambda<br/>→ SFN 実行開始
  Note over SFN: イメージ署名チェック
  SFN->>ECS: プローバータスク起動
  ECS->>S3: 入力 JSON ダウンロード
  Note over ECS: ホストバイナリ実行<br/>+ STARK 証明生成
  ECS->>S3: レシート・ジャーナル・<br/>バンドルをアップロード
  ECS-->>SFN: タスク完了
  SFN->>CB: 成功コールバック
  CB->>CB: セッションデータ更新

非同期モードの処理フロー

  1. 入力の準備: ディスパッチ Lambda が入力 JSON を S3 にアップロードし、Step Functions 実行を開始
  2. イメージ署名チェック: プローバーコンテナイメージの署名を検証し、承認されたイメージのみ実行を許可
  3. プローバータスク: ECS Fargate タスクが起動し、S3 から入力をダウンロードしてホストバイナリを実行
  4. 出力バンドル: レシート、ジャーナル、public-input.jsonelection-manifest.jsonclose-statement.json を生成し、整合性検査を通過したものだけを bundle.zip にまとめて S3 にアップロード
  5. コールバック: 成功/失敗に応じてコールバック Lambda がセッションデータを更新

配布対象バンドルの構築

非同期モードでは、ホストバイナリの出力から秘密データを含まない配布対象バンドル(bundle.zip)を構築します。

ここでいう「配布対象」は機密性の分類です。用語の意味と取得経路は バンドル構造 を参照してください。

ファイル内容配布対象
receipt.jsonSTARK レシートのラッパー JSONYes
journal.jsonジャーナルの正準 JSON 表現Yes
public-input.json秘密データを含まない検証用レコードYes
election-manifest.json選挙設定の公開監査用スナップショットYes
close-statement.json集計締切時点のログ境界を表す公開監査レコードYes

秘密データを含む完全入力は非同期実行時のワーク入力として S3 や一時領域に存在し得ますが、bundle.zip には含まれません。 public-input.jsonelection-manifest.jsonclose-statement.json は、journal.json と proof-bound data に対する整合性検査を通過した場合にのみバンドルに含まれます。

public-input.json の項目と inputCommitment の関係は 入力コミットメント、配布経路は バンドル構造 を参照してください。

非同期モードの特性

項目
タイムアウト15 分(デフォルト、環境変数で変更可能)
リトライS3 アップロードは指数バックオフで 3 回リトライ
エラー処理Step Functions がタスク失敗を検出し、失敗コールバックを実行
ステータス確認クライアントは /api/sessions/:id/status でポーリング

開発モードの動作

RISC0_DEV_MODE=1 を設定すると、RISC Zero は STARK 証明を生成せず、フェイクレシートを返します。

項目開発モード (RISC0_DEV_MODE=1)本番モード
証明の種類フェイクレシート本物の STARK 証明
実行時間約 100 ミリ秒約 370 秒(64 票の場合)
安全性なし(検証を省略)暗号学的に完全
検証サービスDevMode として検出完全な STARK 検証を実行

開発モードで生成されたレシートは InnerReceipt::Fake 型となります。検証サービスでは通常 DevMode として扱いますが、image_id 不一致などの事前条件違反があれば Failed になります。本番環境でフェイクレシートが混入した場合は検証失敗です。

flowchart TD
  ENV{RISC0_DEV_MODE?}
  ENV -->|"= 1"| DEV["開発モード<br/>フェイクレシート生成<br/>約 100ms"]
  ENV -->|未設定| PROD["本番モード<br/>STARK 証明生成<br/>約 370 秒"]
  DEV --> FAKE["InnerReceipt::Fake"]
  PROD --> REAL["InnerReceipt::Composite<br/>+ seal データ"]

開発モードは以下の用途に限定されます:

  • ローカル開発での高速フィードバック
  • E2E テストの高速実行
  • UI 開発時のモック

開発モードのレシートは 検証サービスFake として検出され、通常は DevMode 扱いになります(image_id 不一致などの事前条件違反時は Failed)。いずれにせよ本番モードの検証基準は満たしません。

検証サービス

Rust ベースの STARK レシート検証サービスの設計を解説します。

レシートの STARK 検証をサーバー側で行い、結果をレポートとしてクライアントに提供する Rust コンポーネントです。

概要

STARK 証明の検証は計算コストが証明生成に比べて低いものの、ブラウザ上で RISC Zero の検証ロジックを実行することは現時点では実用的ではありません。そのため、本システムではサーバー側で検証を行い、その結果をレポートとしてクライアントに提供する設計を採用しています。

flowchart LR
  subgraph 入力
    RCP[レシート / バンドル]
    EID["期待 Image ID"]
  end

  RCP --> VS[検証サービス]
  EID --> VS
  VS --> RPT[検証レポート]

検証フロー

検証サービスは以下の手順でレシートを検証します。

flowchart TD
  START[レシート / バンドル読み込み] --> FORMAT{入力形式<br/>の判定}
  FORMAT -->|フラット JSON| F1[直接パース]
  FORMAT -->|ネスト JSON| F2[receipt フィールドを抽出]
  FORMAT -->|ディレクトリ| F3[receipt.json または<br/>*-receipt.json を探索]
  FORMAT -->|ZIP アーカイブ| F4[末尾が receipt.json のファイルを探索]
  F1 --> EXTRACT[Image ID 抽出]
  F2 --> EXTRACT
  F3 --> EXTRACT
  F4 --> EXTRACT

  EXTRACT --> MODE[InnerReceipt を判定<br/>Fake は dev_mode 候補として記録]
  MODE --> PRESENT{メタデータ image_id<br/>が存在?}
  PRESENT -->|欠落 + 非 Fake| FAIL0[failed]
  PRESENT -->|欠落 + Fake| VERIFY
  PRESENT -->|存在| MATCH{メタデータ Image ID<br/>= 期待 Image ID ?}
  MATCH -->|不一致| FAIL1[failed:<br/>Image ID 不一致]
  MATCH -->|一致| VERIFY["Receipt::verify(expected_image_id)<br/>STARK 検証実行"]
  VERIFY -->|成功 かつ Fake| DEVMODE[dev_mode]
  VERIFY -->|失敗 かつ Fake + InvalidProof| DEVMODE
  VERIFY -->|成功 かつ 非 Fake| SUCCESS[success]
  VERIFY -->|失敗(その他)| FAIL2[failed]

1. レシートの読み込みとパース

検証サービスは単一のレシート JSON だけでなく、レシートを含むバンドルディレクトリや ZIP にも対応しています。

形式説明
フラット JSONレシートオブジェクトが直接 JSON のトップレベルにある
ネスト JSON{ "receipt": {...}, "image_id": "0x..." } 構造
ディレクトリreceipt.json または *-receipt.json を探索して読み込む
ZIP アーカイブエントリ名の末尾が receipt.json のファイルを探索して読み込む

image_id はラッパーの top-level フィールドであり、レシート本体の内部フィールドではありません。

同期ファイナライズ経路では、proof bundle ディレクトリ全体を verifier-service に渡し、その中の receipt.json を解決させる実装になっています。

2. 開発モードの検出

レシートの内部構造(InnerReceipt)が Fake 型の場合、開発モードで生成されたレシートです。ただし即 dev_mode にはならず、Image ID 照合を経て最終ステータスが決まります。分岐の詳細は上のフローチャートを参照してください。

3. Image ID の照合

ラッパーの image_id を期待値と照合し、不一致なら failed として即時拒否します。Image ID の管理については Image ID を参照してください。

4. STARK 検証の実行

Image ID の照合に成功した後、RISC Zero SDK の Receipt::verify(expected_image_id) メソッドを使用して STARK 証明の暗号学的検証を行います。

この検証は以下のことを証明します:

  • レシートに含まれる seal(証明データ)が有効である
  • ジャーナルの内容が、指定された Image ID のゲストプログラムの正当な実行結果である
  • 証明の生成時にデータの改ざんが行われていない

検証レポート

検証サービスは、検証が最後まで到達した試行について JSON レポートを出力します。

引数不正や bundle 不在のような一般エラーは exit code 1 で終了し、JSON レポートは出力しません。

フィールド説明
status列挙型success / failed / dev_mode
verifier_version文字列verifier-service のバージョン
verified_at文字列RFC 3339 形式の検証完了時刻
duration_ms数値検証処理時間(ミリ秒)
expected_image_id文字列検証に使用した期待 Image ID
receipt_image_id文字列?入力 JSON の top-level image_id から抽出した値
bundle_path文字列入力 bundle パスの basename のみ
receipt_path文字列解決されたレシートファイル名の basename のみ
dev_mode_receipt真偽値フェイクレシートであるか
errors文字列[]診断文字列の配列。空の場合は省略される

ステータスの判定基準

flowchart TD
  S{検証結果}
  S -->|"metadata image_id 不一致"| F1["failed<br/>errors: 不一致の診断文字列"]
  S -->|"Receipt::verify(expected_image_id) 成功 + 非 Fake"| OK["success"]
  S -->|"Receipt::verify(expected_image_id) 成功 + Fake"| DM["dev_mode"]
  S -->|"Receipt::verify(expected_image_id) 失敗 + Fake + InvalidProof"| DM2["dev_mode"]
  S -->|"Receipt::verify(expected_image_id) 失敗(その他)"| F2["failed<br/>errors: 検証失敗の診断文字列"]

errors は固定のエラーコード一覧ではなく、実装が積む自由形式の診断文字列です。

ステータス意味UI への影響
successSTARK 検証が成功し、Image ID も一致STARK Verified を表示可能
failedImage ID 不一致または STARK 検証失敗検証失敗として表示
dev_mode開発モードのフェイクレシート開発モード警告を表示

デプロイメントモデル

検証サービス(Rust バイナリ verifier-service)は、呼び出し経路ごとに実行場所が異なります。

  • 同期ファイナライズ(POST /api/finalize): real executor 利用時のみ API サーバープロセスがローカルに配置されたバイナリを直接実行(mock executor 利用時は verifier-service を呼ばず dev_mode 扱い)
  • 明示的検証(POST /api/verification/run): verifier-service-runner Lambda が S3 バンドルを展開してバイナリを実行
sequenceDiagram
  participant C as クライアント
  participant API as API サーバー
  participant RUNNER as verifier-service-runner Lambda
  participant VS as verifier-service バイナリ
  participant S3 as S3

  C->>API: POST /api/verification/run
  API->>RUNNER: 実行要求(sessionId, executionId, expectedImageId)
  RUNNER->>S3: レシートバンドルを取得・展開
  RUNNER->>VS: レシート + 期待 Image ID + reportPath
  VS->>VS: STARK 検証実行 + verification.json 書き出し
  VS-->>RUNNER: 検証レポート
  RUNNER-->>API: 検証レポート
  API->>API: verificationResult / execution 状態を更新
  API-->>C: 検証結果

呼び出しパターン

検証サービスの呼び出しには 2 つのパターンがあります。

パターントリガー説明
同期実行同期ファイナライズ (POST /api/finalize)real executor 時のみ、サーバー内の proof-bundle ディレクトリを verifier-service に渡して実行
明示的実行クライアントが検証を要求POST /api/verification/run 経由で verifier-service-runner Lambda が S3 バンドルを展開して実行

非同期ファイナライズのコールバック Lambda は、結果の復元と保存を担当します。STARK 検証は自動実行されず、POST /api/verification/run で実行します。

検証パイプラインにおける役割

検証サービスは、4 段階検証モデルの最終段階である STARK 検証を担当します。

チェック ID検証内容
stark_image_id_matchレシートに記録された Image ID が期待値と一致するか
stark_receipt_verifySTARK 証明が暗号学的に有効であるか

stark_image_id_match は verifier report の expected_image_idreceipt_image_id の一致を主に検証します。検証フロー側では、これに加えて claimed / comparison 側の Image ID との整合も確認します。

これらのチェックが両方成功した場合に限り、「STARK Verified」のステータスが付与されます。詳細は 4 段階検証モデル を参照してください。

セキュリティ上の考慮事項

サーバー側検証の信頼境界

検証サービスはサーバー側で実行されるため、クライアントはサーバーの検証結果を信頼する必要があります。この PoC における信頼モデルは以下の通りです:

  • STARK 証明自体は秘密データを含まない検証データ: レシートと Image ID があれば、第三者が独立に検証可能
  • 検証サービスは利便性のための委任: ブラウザでの STARK 検証が実用的になれば、クライアント側のみで完結させることも理論上は可能
  • 配布対象バンドル: レシートと public-input.jsonZIP ローカル検証(Ubuntu) の手順で独立検証できる

verification.json の非公開性

verification.json は配布対象バンドル(bundle.zip)には含まれません。必要に応じてレポート用の capability 保護エンドポイント経由で配布されますが、第三者検証ではレシートファイルを直接使った独立検証が推奨されます。

Image ID

zkVM ゲストバイナリの暗号的識別子(Image ID)の管理と検証を解説します。

Image ID はゲストプログラムの ELF バイナリから決定的に導出される 256 ビットのハッシュ値です。レシート検証時に期待値との一致を確認することで、正しいプログラムの実行結果であることを保証します。

概要

RISC Zero zkVM では、ゲストプログラムの ELF バイナリが Image ID と呼ばれる 256 ビットの識別子に変換されます。この変換は決定的であり、同一のバイナリからは常に同一の Image ID が生成されます。

Receipt::verify(image_id) はレシートが指定した Image ID のゲスト実行結果であることを暗号学的に検証します。期待する Image ID と一致しないレシートは拒否されます。ラッパーメタデータとの照合手順は 検証サービス を参照してください。

flowchart LR
  ELF["ゲスト ELF バイナリ"] --> HASH["RISC Zero<br/>Image ID 導出"]
  HASH --> IID["Image ID<br/>(256 ビット)"]

  IID --> EMBED["ホストバイナリに埋め込み"]
  IID --> MAP["マッピングファイルに記録"]
  IID --> VS["検証サービスの期待値"]

Image ID の導出

Image ID は RISC Zero のビルドシステムによってコンパイル時に自動生成されます。

決定論的導出

同一のゲストソースコードであっても、以下の要因により異なる Image ID が生成され得ます:

要因影響
ゲストコードの変更ロジックの変更は異なるバイナリを生成
コンパイラバージョンRust ツールチェインのバージョン差異
ターゲットアーキテクチャ同一コードでも x86_64 と ARM64 で異なる Image ID
RISC Zero SDK バージョンSDK の変更がゲストバイナリの構造に影響

アーキテクチャによる差異

本システムでは、同一バージョンのゲストに対して ARM64 用 (expectedImageID) と x86_64 用 (expectedImageID_x86_64) の 2 つの Image ID をマッピング上で管理できます。

アーキテクチャ用途
ARM64ECS Fargate (Graviton) での本番証明生成
x86_64ローカル開発、CI/CD 環境での証明生成

アプリ/サーバー実装では、WSL 環境でのみ expectedImageID_x86_64 を自動選択し、それ以外は expectedImageID を使います。CLI テストハーネスの自動選択ルールはこれと異なります。いずれの場合も EXPECTED_IMAGE_ID 環境変数で明示オーバーライドできます。

Image ID マッピング

期待される Image ID は、バージョンごとにマッピングファイルで管理されます。

マッピングの構造

マッピングファイルには、各バージョンの Image ID、説明、機能リストが記録されます。

フィールド説明
methodVersionゲストプログラムのバージョン番号
expectedImageIDARM64 環境での Image ID
expectedImageID_x86_64x86_64 環境での Image ID
descriptionバージョンの説明
featuresこのバージョンで実装された機能リスト
current現在有効なバージョン番号
deprecated非推奨バージョンの一覧

バージョン履歴の管理

マッピングファイルはバージョンの履歴を保持します。current フィールドが現在有効なバージョンを指し、deprecated フィールドが過去のバージョンを列挙します。

現行実装では current12 で、11 までが deprecated 側へ移っています。

flowchart LR
  subgraph マッピングファイル
    V8["v8<br/>(deprecated)"]
    V9["v9<br/>(deprecated)"]
    V10["v10<br/>(deprecated)"]
    V11["v11<br/>(deprecated)"]
    V12["v12<br/>(current)"]
  end

  V12 --> ARM["ARM64 Image ID"]
  V12 --> X86["x86_64 Image ID"]

Image ID の解決

検証時に使用する期待 Image ID は、methodVersion を明示するかどうかで挙動が分かれます。現行の検証実行フローでは、正規化済みのジャーナル から methodVersion を取得して resolveExpectedImageId(methodVersion) を呼びます。public-input.json へのフォールバックは現行実装では使っていません。

flowchart TD
  START[Image ID 解決] --> P1{環境変数<br/>EXPECTED_IMAGE_ID<br/>が設定されている?}
  P1 -->|Yes| USE1[環境変数の値を使用]
  P1 -->|No| P2{methodVersion を<br/>明示している?}
  P2 -->|Yes| P3{現行サポート対象の<br/>methodVersion か?}
  P3 -->|No| ERR1[エラーで中断]
  P3 -->|Yes| LOAD1[その version の<br/>マッピングを読み込み]
  LOAD1 --> P4{読み込み成功?}
  P4 -->|No| ERR2[エラーで中断]
  P4 -->|Yes| P5{WSL 実行環境かつ<br/>expectedImageID_x86_64 が存在?}
  P5 -->|Yes| X86A[expectedImageID_x86_64 を使用]
  P5 -->|No| ARMA[expectedImageID を使用]
  P2 -->|No| LOAD2[current の<br/>マッピングを読み込み]
  LOAD2 --> P6{読み込み成功?}
  P6 -->|No| DEF[既定値へフォールバック]
  P6 -->|Yes| P7{WSL 実行環境かつ<br/>expectedImageID_x86_64 が存在?}
  P7 -->|Yes| X86B[expectedImageID_x86_64 を使用]
  P7 -->|No| ARMB[expectedImageID を使用]

要点は次のとおりです。

  • 現行の /api/verification/run 系フローでは、current journal contract の methodVersion を使って解決するため、未対応バージョンやマッピング読み込み失敗はfail-closed(安全側に倒して失敗)でエラーになります。
  • 組み込みの既定値へのフォールバックは、methodVersion を明示しない汎用呼び出しでのみ使われます。
  • 環境変数 EXPECTED_IMAGE_ID は、どちらの経路でも最優先の明示オーバーライドです。

検証パイプラインにおける役割

Image ID は 4 段階検証モデルの STARK 検証段階で使用されます。

Image ID 関連チェック

現行実装では、STARK 検証段階で次の 2 つの必須チェックが Image ID に関与します。

  • stark_image_id_match: receipt.json ラッパーの image_id と期待値を照合する
  • stark_receipt_verify: 同じ期待 Image ID を使って Receipt::verify(expectedImageID) を実行する

詳細は 検証サービス を参照してください。

Image ID が不一致の場合、以下のいずれかの状況を意味します:

原因対処
マッピングが古いゲストの再ビルド後にマッピングを更新する
異なるゲストで証明が生成されたレシートの出所を調査する
アーキテクチャの不一致正しいアーキテクチャの Image ID で照合する

Image ID の更新手順

ゲストプログラムを変更した場合、Image ID を更新する必要があります。

  1. ゲストコードを変更する
  2. zkVM ゲストをビルドし、新しい Image ID を取得する
  3. public/imageId-mapping.json を更新する(必要に応じて expectedImageID_x86_64 も更新)
  4. フォールバック定数を持つ関連コードも更新する(現行実装では src/lib/verification/expected-image-id.ts
  5. プローバーイメージとマッピングを同時にデプロイする

更新の同期要件

Image ID の更新は、プローバーイメージのデプロイとマッピングファイルの更新を同時に行う必要があります。

不整合の状態結果
新プローバー + 旧マッピング検証時に Image ID 不一致で失敗
旧プローバー + 新マッピング検証時に Image ID 不一致で失敗
新プローバー + 新マッピング正常動作

組み込みの既定 Image ID も残っているため、更新手順の step 4 にあるとおり、ゲスト変更時はマッピングと定数を同時に更新してください。

本リポジトリの現行運用では、Image ID 更新時に旧バージョンとの検証互換は維持しません。

  • プローバーイメージと imageId-mapping.json は同一リリースで切り替える
  • 通常の /api/verification/run フローでは、現行の journal contract のみ受け付ける
  • 旧成果物は Image ID 照合に進む前に、未対応の journal contract として失敗し得る

セキュリティ上の位置づけ

Image ID は、zkVM の信頼モデルにおける重要な信頼アンカーです。

  • Image ID を知っている検証者は、ゲストプログラムのロジックを信頼できる: レシートが有効であれば、そのロジックが正しく実行されたことが保証される
  • Image ID の管理が破綻すると、検証の信頼性が失われる: 攻撃者が独自のゲストプログラムで有効なレシートを生成し、その Image ID がマッピングに混入すると、不正な集計が「検証済み」として受理される

マッピングファイルは公開リポジトリにコミットされ、変更履歴が追跡可能です。AWS 構成では、イメージ署名検証と組み合わせることで、承認されたプローバーイメージのみが使用されることを保証しています。イメージ署名の詳細は イメージ署名 を参照してください。

検証パイプライン

投票の完全性を 4 段階で検証するパイプラインの設計と実装を解説します。

この部に含まれる章

設計と実行フロー

検証パイプラインの設計原則、全体構造、実行フローを解説します。

設計原則

本システムの検証パイプラインは、以下の 3 つの原則に基づいて設計されています。

原則 1: 必要な検証が未実行なら Verified を表示しない

required チェックが not_run(未実行)、pending(依存待ち)、running(実行中)のいずれかにある場合、システムは「Verified」を表示しません。証拠の不在や未解決状態を成功として扱わないという姿勢です。

原則 2: 失敗した検証は即座にブロックする

いずれかの必須チェックが失敗すれば、「Verified」表示は即座にブロックされます。代表的な失敗条件:

  • excludedSlots > 0(除外されたスロットが存在する)
  • 整合性証明の失敗
  • 公開監査アーティファクトとの不一致
  • 第三者 STH 合意の不成立(設定時)

原則 3: チェック評価はサーバー中心、集約はサーバーとクライアントの双方で実施

  • 22 個の検証チェックは主にサーバー側(GET /api/verify)で評価されます。
  • Cast-as-Intended のみクライアントがローカルで上書き評価します(サーバーは not_run を返却)。
  • Recorded-as-Cast は cast-time 証跡voteReceiptuserVote.proof)を前提とします。store から再構成できない場合でも /api/verify200 を返し、関連チェックを not_run にして全体判定を missing_evidence 側へ fail-closed に倒します。
  • STARK 検証は専用サービス(POST /api/verification/run)で実行され、GET /api/verify がその結果を読み取ります。
  • verificationSteps[].status は required チェック群を基準に導出されますが、現行実装では journaluserVote.proof.treeSize が不在の場合に verificationSteps[].statusnot_run に上書きするガード条件もあります。
  • deriveVerificationSummary はサーバー側の /api/verify とクライアント側の /verify の両方で使われます。サーバーは verificationStatus を fail-closed(安全側に倒す方向)に補正し、unsupported な verifier status でも verificationSteps / verificationChecks を含む 200 応答を返します。クライアントは summary に加えて STARK timeout や transport failure も最終失敗表示に反映します。UI 側の補助分岐については ゲーティングロジック を参照してください。

検証パイプラインの全体構造

1. 段階の依存関係

  1. Stage 1 — Cast-as-Intended
  2. Stage 2 — Recorded-as-Cast
  3. Stage 3 — Counted-as-Recorded
  4. Stage 4 — STARK Verification
  5. 結果表示

2. 実行責務(どこで評価・検証されるか)

flowchart TD
  subgraph SERVER["サーバー側"]
    VFY["GET /api/verify<br/>Stage 2-4 の 22 チェックを組み立て<br/>cast-time 証跡不在時は fail-closed<br/>(Cast は not_run)"]
    RUN["POST /api/verification/run<br/>bundle 参照と expected Image ID で<br/>Stage 4 を検証"]
  end

  subgraph CLIENT["クライアント(UI)"]
    UI["Cast のローカル再評価<br/>STARK 解決後に step 表示を開始<br/>summary と明示的 proof failure を反映"]
  end

  VFY --> UI
  RUN --> UI

検証の実行フロー

検証導線では通常 /result から /verify へ進みます。

  1. /result は継続用のクライアント状態(verificationRequestedAt と正準な finalization snapshot)を保存し、必要なら POST /api/verification/run を先行起動します
  2. /verify はその継続状態がある場合に検証シーケンスを続行します。STARK が未開始ならシーケンス内で起動できます
  3. 継続状態がなく STARK が not_run のまま直接 /verify へアクセスした場合は、自動続行せずブロックします
  4. /verify の UI シーケンスは、step を順に見せる前に STARK が terminal status に到達するまでポーリングします。timeout や transport failure は STARK failure として扱われます
sequenceDiagram
  participant U as ブラウザ
  participant R as /result
  participant V as /verify
  participant A as API サーバー
  participant VS as 検証サービス

  U->>R: 「検証へ進む」をクリック
  R->>A: POST /api/verification/run(必要時)
  A->>VS: bundle 参照 + expected Image ID
  VS->>VS: Receipt::verify(imageId)
  VS-->>A: 検証レポート保存
  R-->>V: /verify へ遷移

  loop STARK 完了までポーリング
    V->>A: GET /api/verify
    A-->>V: 検証ペイロード<br/>(ステップ, チェック, 証明材料)
  end

  Note over V: 継続には verificationRequestedAt と finalization snapshot が必要<br/>not_run の direct access はブロック<br/>step 表示は STARK 解決後に開始

4 段階の概要

段階名称証明する内容検証の実行場所
Stage 1Cast-as-Intended投票者の意図通りにコミットメントが生成されたクライアント(/verify 画面でローカル再計算)
Stage 2Recorded-as-Castコミットメントが追記専用掲示板に正しく記録されたサーバー(GET /api/verify
Stage 3Counted-as-Recorded記録された全投票が正しく集計に含まれたサーバー(GET /api/verify
Stage 4STARK VerificationzkVM の実行が正しく行われたことの暗号学的証明サーバー(POST /api/verification/run

クライアントは Cast-as-Intended のローカル再計算、STARK 解決後の step 表示、最終表示の整形を担当します。サーバー側の fail-closed 補正と cast-time 証跡不在時の動作は上記「原則 3」を参照してください。

各ステージの verificationSteps[].status は、そのステージで required 扱いになるチェック群から導出されます。たとえば Recorded-as-Cast では、STH ソースが設定されている場合に限り recorded_sth_third_party も必須条件に昇格します。ただし、一部のステージにはデータ不在時のガード条件があります(上記「原則 3」参照)。特に Recorded-as-Cast は userVote.proof.treeSize がなければ not_run になり、Counted-as-Recorded は journal がなければ failed でない限り not_run に補正されます。

各段階の詳細は 4 段階検証モデル を参照してください。

検証チェック数

パイプライン全体で 22 個の検証チェックが定義されており、各チェックには一意の ID が割り当てられています。チェック ID の単一ソースは src/lib/verification/verification-checks.ts です。

段階チェック数
Cast-as-Intended4
Recorded-as-Cast6
Counted-as-Recorded10
STARK Verification2
合計22

Counted-as-Recorded の required チェックには counted_election_manifest_consistentcounted_close_statement_consistent も含まれます。これにより、公開された election-manifest.json / close-statement.json と検証対象データの整合も stage success の条件に含まれます。

チェックの詳細は チェック一覧 を参照してください。

4 段階検証モデル

E2E 検証可能投票の 4 段階検証モデルの設計と各段階の保証を解説します。

段階間の依存関係

概念モデルとしては 4 段階を順に評価しますが、実行場所は段階ごとに異なります(Stage 1 はクライアント、Stage 2-4 はサーバー中心)。実行責務の詳細は 設計と実行フロー を参照してください。

flowchart LR
  subgraph "証拠の生成"
    V["投票時<br/>投票レシート発行"]
    F["集計時<br/>zkVM 実行"]
  end

  subgraph "4 段階検証"
    S1["Stage 1<br/>Cast-as-Intended"]
    S2["Stage 2<br/>Recorded-as-Cast"]
    S3["Stage 3<br/>Counted-as-Recorded"]
    S4["Stage 4<br/>STARK Verification"]
  end

  V --> S1
  V --> S2
  F --> S3
  F --> S4

  S1 ~~~ S2
  S2 ~~~ S3
  S3 ~~~ S4

Stage 1: Cast-as-Intended

目的

投票者が意図した選択肢のコミットメントが、サーバーから返却された投票レシートと一致することを確認します。これにより、サーバーが投票者の選択を差し替える攻撃を検出します。

検証する内容

/verify 画面のクライアントコードがローカルに保持された 3 つの値(選挙 ID、選択肢、乱数)からコミットメントを再計算し、投票レシートのコミットメント値と照合します。

flowchart LR
  subgraph "投票時に確定したデータ"
    EID[選挙 ID]
    CH[選択肢]
    RND[乱数]
  end

  subgraph "再計算"
    HASH["SHA-256<br/>(ドメインタグ || 選挙ID || 選択肢 || 乱数)"]
  end

  subgraph "照合"
    CMP{"一致?"}
    REC[投票レシートの<br/>コミットメント]
  end

  EID --> HASH
  CH --> HASH
  RND --> HASH
  HASH --> CMP
  REC --> CMP

必要な証拠

証拠保管場所説明
選挙 IDクライアントセッション(localStorage.starkBallotSessionセッション作成時に確定した UUID
選択肢クライアントセッション(localStorage.starkBallotSession投票者が選択した値(A〜E)
乱数クライアントセッション(localStorage.starkBallotSession投票時にクライアントが生成した 32 バイト乱数
投票レシートGET /api/verify 応答(voteReceipt投票レシート(commitment, voteId, bulletinIndex, bulletinRootAtCast)

voteReceiptcast-time 証跡を store から再構成できた場合にだけ /api/verify から返ります。再構成できない場合の動作は 設計原則 3 を参照してください。

失敗モード

症状原因深刻度
ローカル証拠の欠落localStorage 消去や別端末アクセスで投票時データを復元できない検証不能
投票レシート証跡の欠落store から cast-time 証跡を再構成できず、voteReceipt が省略検証不能
コミットメント不一致投票時データと投票レシートの不整合、またはエンコーディングの不整合重大
選択肢の範囲外不正な入力(AE の範囲外)重大
乱数フォーマット不正32 バイト hex でない重大

限界

Cast-as-Intended は以下の証拠に依存します:

  • クライアント保持のローカル証拠(選挙 ID、選択肢、乱数)
  • /api/verify が返す投票レシート(voteReceipt

いずれかが欠ける場合、この段階は not_run になり最終判定は Verified になりません。


Stage 2: Recorded-as-Cast

目的

投票者のコミットメントが、追記専用の掲示板(CT Merkle ツリー)に正しく記録されていることを確認します。さらに、掲示板が追記専用性を維持していること(過去のエントリが削除・改変されていないこと)を検証します。

検証する内容

この段階には 3 系統の検証があります。UI ステップとの対応関係は ゲーティングロジック を参照してください。

flowchart TB
  subgraph "2a: 包含証明"
    IP["包含証明の検証<br/>自分のコミットメントが<br/>ツリーに存在するか"]
  end

  subgraph "2b: 整合性証明"
    CP["整合性証明の検証<br/>投票時のルートから<br/>最終ルートへの追記専用性"]
  end

  subgraph "2c: 第三者 STH 検証"
    STH["STH 合意の検証<br/>独立したソース間で<br/>ツリー状態が一致するか"]
  end

  IP --> RESULT{"Recorded 系の証拠を総合評価"}
  CP --> RESULT
  STH --> RESULT

2a: 包含証明(Inclusion Proof)

RFC 6962 の PATH 関数に基づく CT スタイルの Merkle 包含証明を検証し、投票者のコミットメントが掲示板のツリーに含まれていることを確認します。検証者はリーフハッシュと監査パスからルートハッシュを再計算し、期待されるルートと照合します。

2b: 整合性証明(Consistency Proof)

投票時点のツリー状態(ルートとサイズ)から、最終的なツリー状態への遷移が追記のみで行われたことを検証します。RFC 6962 の SUBPROOF アルゴリズムに基づく整合性証明により、サーバーが過去のエントリを密かに削除したり順序を変更したりするスプリットビュー攻撃を検出します。

2c: 第三者 STH 検証(オプション)

複数の独立した STH(Signed Tree Head)ソースに問い合わせ、合意が成立しているかを確認します。これにより、サーバーが検証者ごとに異なるツリー状態を提示するスプリットビュー攻撃を検出します。照合条件の詳細は チェック一覧 を参照してください。

必要な証拠

証拠取得元説明
包含証明の検証結果/api/verifyサーバー側で RFC 6962 包含証明を評価したチェック結果
整合性証明の検証結果/api/verifyサーバー側で RFC 6962 整合性証明を評価したチェック結果
投票時のルートハッシュ投票レシート投票受理時のツリールート
投票時のツリーサイズ(oldSize)/api/verifyuserVote.proof.treeSize整合性証明の oldSize
最終ルートハッシュ/最終ツリーサイズ/api/verify集計時の最終状態
独立検証用の包含証明材料(任意)/api/bulletin/:voteId/proofクライアント外で個別に包含証明を再検証するための材料
補助 tooling の整合性証明材料(任意)/api/bulletin/consistency-proofsecondary tooling 向け。現行 /verify ページの verdict authority には含めない
STH スナップショット設定済み STH ソース(例: /api/sth + 外部)第三者ソースの照合対象(必須は digest、root/treeSize は返却時のみ)

userVote.proof.treeSize は整合性証明における authoritative な oldSize であり、現行実装では voteReceipt.bulletinIndex + 1 との一致も要求します。cast-time 証跡が欠ける場合の fail-closed 動作は 設計原則 3 を参照してください。

失敗モード

症状原因深刻度
包含証明の検証失敗ツリーサイズ/インデックスの不一致、掲示板のリセット重大
整合性証明の検証失敗追記専用性の違反(スプリットビュー攻撃の可能性)重大
cast-time 証跡の欠落store から voteReceipt / userVote.proof を再構成できず、Recorded が未実行検証不能
第三者 STH 合意の不成立サーバーが検証者ごとに異なるツリーを提示重大(有効時)
ルートが履歴に存在しないルート履歴の不整合重大

Stage 3: Counted-as-Recorded

目的

掲示板に記録された全投票が、zkVM の集計処理に正しく含まれたことを確認します。投票の除外、欠落、重複がないことに加え、公開された claimed tally(表示用集計値)が zkVM の verifiedTally と一致することを検証し、集計結果の完全性と整合性を保証します。

検証する内容

この段階では 10 個の required チェックが全て success であることを要求します。

verificationSteps[].status も最終サマリーも、Counted stage で required 扱いになるチェック群全体から導出されます。journal がない場合は guard により stage 自体が not_run になります。UI ステップとの対応や journal 省略時の詳細は ゲーティングロジック を参照してください。

flowchart TD
  subgraph PUBLIC["public 系 required (6)"]
    P["counted_input_sanity<br/>counted_unique_indices<br/>counted_unique_<br/>commitments<br/>counted_election_<br/>manifest_consistent<br/>counted_close_<br/>statement_consistent<br/>counted_input_<br/>commitment_match"]
  end

  subgraph ZK["zk 系 required (4)"]
    Z["counted_tally_consistent<br/>counted_missing_<br/>indices_zero<br/>counted_expected_<br/>vs_tree_size<br/>counted_my_vote_<br/>included"]
  end

  RESULT{"10 required 全て success?"}
  PASS["Counted: success"]
  FAIL["Verified をブロック"]

  P --> RESULT
  Z --> RESULT
  RESULT -->|Yes| PASS
  RESULT -->|No| FAIL

必要な証拠

証拠取得元説明
zkVM ジャーナル(詳細)/api/verify?includeJournal=1集計結果、除外情報、inputCommitmentincludedBitmapRootseenBitmapRoot などの詳細
集計サマリー(通常応答)/api/verifymissingSlots / invalidPresentedSlots / rejectedRecords / excludedSlots / totalExpected / treeSize などの上位値
公開入力サマリーサーバー内部評価用public-input.json 相当から組み立てた、秘密データを含まない入力要約
選挙マニフェスト公開 bundle (election-manifest.json)electionIdelectionConfigHash を束縛する公開 artifact
締め処理ステートメント公開 bundle (close-statement.json)logId / treeSize / bulletinRoot / timestamp / sthDigest を束縛する公開 artifact
ビットマップ証明材料/api/bitmap-proofkind=includedkind=seen を使い分け、自分のインデックスが counted されたことや prover に提示されたかを説明する材料
ビットマップルートジャーナルzkVM ゲストが計算した includedBitmapRootseenBitmapRoot

公開入力サマリーはサーバー内部表現であり、レスポンスにそのまま含まれません。inputCommitment が束縛するのは public-input.json の部分集合です。Counted stage では journal と slot-based なフィールド(excludedSlots 等)を正とし、missingIndices / invalidIndices / excludedCount は互換用としてのみ扱います。解決順序の詳細は チェック一覧 を参照してください。各チェックの判定ロジックは チェック一覧 を参照してください。

重要な判定: 除外数(excludedSlots / excludedCount

excludedSlots は fail-closed(安全側に倒す)判定に使う除外数です。excludedCount は互換性のために残る旧名称で、同じ値を指します。

  • excludedSlots == 0: 除外なし(正常)
  • excludedSlots > 0: 掲示板スロットの未提示または計上失敗(即座に検証失敗)

counted_missing_indices_zero が除外数を解決し、0 でなければ failed になります。解決の優先順序は チェック一覧 を参照してください。除外数が残っている限り、最終判定は「Verified」を表示しません。

失敗モード

症状原因深刻度
excludedSlots > 0欠落スロットまたは計上失敗スロットが存在する重大(即座にブロック)
欠落スロット / invalid presented slot一部の bulletin slot が prover に提示されなかった、または提示後に計上されなかった重大
公開集計値の不一致公開表示された tally.counts が zkVM の verifiedTally と一致しない重大(claimed tally 改ざんシナリオ S2/S4 で発火)
集計合計の不一致verifiedTally の合計が validVotes または tally.totalVotes と一致しない重大
選挙マニフェスト不整合electionId または electionConfigHash が verification inputs と一致しない重大(必須チェック失敗)
締め処理ステートメント不整合logId / timestamp / sthDigest / bulletinRoot / treeSize が一致しない重大(必須チェック失敗)
入力コミットメント不一致公開入力のうち inputCommitment 対象フィールドと zkVM 実行で使用された入力が異なる重大
自票のビットマップ証明が失敗または欠落bit が 0、proof source 不可、またはルート不一致重大(required check が failed/not_run
ツリーサイズの不一致totalExpectedtreeSize が異なる(暗黙の除外、または close/input side の不整合を示す)重大(必須チェック失敗)

Stage 4: STARK Verification

目的

zkVM の実行が正しく行われたことを STARK 証明(レシート)の暗号学的検証により確認します。レシートの検証に成功すれば、ジャーナルの内容(集計結果、除外情報、入力コミットメント等)がゲストプログラムの正しい実行の結果であることが保証されます。

検証する内容

flowchart LR
  subgraph "入力"
    RCP[レシート<br/>Seal + Journal]
    EID[期待 Image ID]
  end

  subgraph "Rust 検証サービス"
    VF["Receipt::verify(imageId)"]
  end

  subgraph "結果"
    OK["success<br/>暗号学的に検証済み"]
    NG["failed<br/>証明が無効"]
    DM["dev_mode<br/>フェイクレシート"]
  end

  RCP --> VF
  EID --> VF
  VF --> OK
  VF --> NG
  VF --> DM

検証の 2 段階

STARK Verification では 2 つのチェックを実行します。

Image ID 照合: 検証サービスの report が利用できる場合、receipt_image_id が期待 Image ID と一致することを確認します。加えてホストが主張する Image ID(imageId)や comparison-only の journal.imageId とも矛盾しないことを検証します(解決順の詳細は チェック一覧stark_image_id_match を参照)。Image ID はゲストプログラムから導出される暗号的識別子であり、プログラムの改変やホスト主張値の食い違いを検出します。

レシート検証: RISC Zero の Receipt::verify() を呼び出し、Seal(STARK 証明)がジャーナルと Image ID に対して暗号学的に正当であることを検証します。この検証は計算量が多いため、サーバー側の Rust 検証サービスで実行されます。

必要な証拠

証拠取得元説明
レシート(Seal + Journal)証明バンドルzkVM ホストが生成した STARK 証明
期待 Image IDサーバー側で解決ゲストプログラムの暗号的識別子(解決順は チェック一覧 参照)
ホスト主張値と比較用メタデータ検証コンテキストと reportimageId, journal.imageId, verificationReport.receipt_image_id を相互照合し、主張の食い違いを検出

開発モードの検出

RISC0_DEV_MODE=1 で生成されたレシートは InnerReceipt::Fake 型であり、暗号学的な保証を持ちません。検証サービスはこれを dev_mode ステータスとして報告します。チェック評価では、設定に応じて dev_modesuccess または not_run に正規化されます(既定では not_run 側)。

失敗モード

症状原因深刻度
Image ID 不一致マッピングが古い、ホスト主張値が誤っている、またはプローバーイメージが異なる重大
レシート検証失敗証明が暗号学的に無効重大
開発モード検出フェイクレシートが混入重大

UI ステップと最終判定

UI ステップの対応チェック、集約ルール、最終 verdict の決定方法は ゲーティングロジック を参照してください。

チェック一覧

全検証チェック ID の定義、判定ロジック、失敗時の影響を一覧で解説します。

各チェックは一意の ID を持ち、success / failed / not_run / running / pending のステータスで管理されます。not_run のチェックが残っている場合、「Verified」は表示されません。

チェックの属性

各チェックには以下の属性が定義されています。

属性説明
IDチェックの一意な識別子(スネークケース)
カテゴリ所属する検証段階
証拠種別チェックに使用するデータの出所
重要度required(必須)または optional(任意)
派生元他のチェックから結果を導出する場合のソースチェック ID

証拠種別

種別説明
local投票者の端末に保持されたユーザー固有データ(localStorage の投票意図など)
public掲示板や capability 保護 API から取得する、秘密データを含まない検証用データ
zkzkVM が束縛した公開証拠(ジャーナル、receipt 検証結果、bitmap root に基づく証明など)

重要度

重要度説明
required「Verified」表示に必須。失敗すれば即座にブロック
optional補助的な検証。通常は単独で失敗扱いにしないが、実行時条件により blocking に昇格する場合がある

代表例は recorded_sth_third_party です。

注意: verificationChecks と UI 表示用の verificationSteps は 1 対 1 ではありません。対応関係は ゲーティングロジック を参照してください。


Cast-as-Intended(4 チェック)

投票者の意図通りにコミットメントが生成されたかを検証するチェック群です。 このカテゴリはクライアントが voteReceipt/api/verify 応答)とローカル投票意図(electionId/myVote/myRand)を使って評価します。

ID説明証拠種別重要度
cast_receipt_present投票レシートが存在し、voteId とコミットメントを含むlocalrequired
cast_choice_range選択肢が有効範囲内(A〜E)localrequired
cast_random_format乱数が 32 バイトの 16 進数文字列localrequired
cast_commitment_match投票時データから再計算したコミットメントが投票レシートと一致localrequired

判定ロジックの詳細

cast_receipt_present

voteReceipt が存在し、voteIdcommitment フィールドが存在することを確認します(このチェック単体では UUID/hex 形式までは検証しません)。

cast_choice_range

投票時データの選択肢が AE のいずれかであることを確認します。範囲外の値は不正な入力として failed となります。

cast_random_format

投票時データの乱数が 32 バイト(64 文字)の 16 進数文字列であることを確認します。0x プレフィックスの有無は正規化により吸収されます。

cast_commitment_match

投票時データ(選挙 ID、選択肢、乱数)からコミットメントを再計算し、投票レシートのコミットメント値と照合します。ドメインタグ "stark-ballot:commit|v1.0" を含む正準フォーマットに従います。


Recorded-as-Cast(6 チェック)

コミットメントが掲示板に正しく記録されたかを検証するチェック群です。

ID説明証拠種別重要度
recorded_commitment_in_bulletinコミットメントが掲示板ツリーに存在するpublicoptional
recorded_index_in_range掲示板インデックスが 0 以上かつツリーサイズ未満publicrequired
recorded_root_at_cast_consistent投票時のルートが最終ツリーの正当なプレフィックスであるpublicoptional
recorded_inclusion_proofRFC 6962 包含証明が暗号学的に検証成功publicrequired
recorded_consistency_proofRFC 6962 整合性証明が暗号学的に検証成功publicrequired
recorded_sth_third_party第三者 STH ソース間で合意が成立(比較可能応答間)publicoptional

判定ロジックの詳細

recorded_commitment_in_bulletin

包含証明(recorded_inclusion_proof)の結果から派生します。包含証明が成功すれば、コミットメントがツリーに存在することが暗号学的に証明されています。

recorded_index_in_range

掲示板インデックスが 0 <= index < treeSize の範囲内であることを確認します。範囲外のインデックスは、データの不整合を示します。

recorded_root_at_cast_consistent

整合性証明(recorded_consistency_proof)の結果から派生します。整合性証明が成功すれば、投票時のルートが最終ツリーの有効な追記専用プレフィックスであることが証明されています。

recorded_inclusion_proof

投票者のコミットメントに対する RFC 6962 包含証明(監査パス)を検証します。cast-time 証跡voteReceiptuserVote.proof)が揃っている場合、まず cast snapshot の一貫性(leafIndextreeSizebulletinRootAtCast が receipt と矛盾しないこと)を確認したうえで、リーフハッシュと監査パスから cast 時点のルートを再計算して照合します。証跡が揃わない場合は not_run となり、全体判定は fail-closed で missing_evidence 側へ倒れます。

recorded_consistency_proof

投票時のツリー(oldSize, oldRoot)から最終ツリー(newSize, newRoot)への RFC 6962 整合性証明を検証します。cast-time 証跡を前提に、まず cast snapshot の一貫性(leafIndextreeSizebulletinRootAtCast)を receipt と照合し、さらに bulletin provider から取得した old/new 両時点の root が期待値と一致することを確認します。これにより、投票時ルートが最終ツリーの追記専用プレフィックスであることを保証します。証跡が欠ける場合は not_run となり、全体判定は fail-closed に扱われます。

recorded_sth_third_party

設定された STH ソースからスナップショットを取得し、比較可能な応答同士で合意を確認します。判定は matchingSources >= minMatches(デフォルト: 2)に加えて、比較対象になった応答間の全会一致(consensus)が必要です。照合対象は STH ダイジェストが必須で、bulletinRoot / treeSize は各ソースが返した場合に追加で照合されます。STH ソースが未設定の場合は not_run になります。

このチェック定義の criticalityoptional ですが、サマリー判定では STH ソースが設定されている場合に実質的な必須条件として扱われます。

派生チェックについて

recorded_commitment_in_bulletinrecorded_root_at_cast_consistent は、それぞれ recorded_inclusion_proofrecorded_consistency_proof から結果を派生します。これは、包含証明の検証成功がそのまま「コミットメントの存在」の証明となり、整合性証明の検証成功がそのまま「ルートの一貫性」の証明となるためです。

派生チェックの重要度が optional であるのは、派生元の required チェックが既にカバーしているためです。


Counted-as-Recorded(10 チェック)

記録された全投票が正しく集計に含まれたかを検証するチェック群です。

ID説明証拠種別重要度
counted_input_sanity公開入力サマリーが有効publicrequired
counted_unique_indices入力中の全インデックスが一意publicrequired
counted_unique_commitments入力中の全コミットメントが一意publicrequired
counted_tally_consistentclaimed tally と zkVM の検証済み集計が一致(fallback: 合計整合)zkrequired
counted_missing_indices_zero解決済み fail-closed exclusion count(excludedSlots 優先)が 0zkrequired
counted_expected_vs_tree_sizetotalExpected がツリーサイズと一致zkrequired
counted_election_manifest_consistentelection-manifest.json が自己整合し、選挙 ID / electionConfigHash と一致publicrequired
counted_close_statement_consistentclose-statement.json が自己整合し、log/tree/timestamp/root/sthDigest と一致publicrequired
counted_my_vote_includedbitmap 証明により自分のインデックスが counted 側に含まれたことを確認zkrequired
counted_input_commitment_match公開入力から計算した入力コミットメントがジャーナルの値と一致publicrequired

判定ロジックの詳細

counted_input_sanity

public-input.json 相当から組み立てた公開入力サマリーが存在し、スキーマ検証に成功していることを確認します。加えて、treeSize が正の整数、votesCount <= treeSizebulletinRoot が 32 バイト hex かつゼロ値でないことを要求します。

counted_unique_indices

zkVM に渡された全投票のインデックスが重複なく一意であることを確認します。重複インデックスは、同一投票の二重カウントを示す可能性があります。

counted_unique_commitments

zkVM に渡された全投票のコミットメントが重複なく一意であることを確認します。重複コミットメントは、同一コミットメントに対する複数投票を示す可能性があります。

counted_tally_consistent

主経路では、公開される claimed tally(tally.counts)が zkVM の verifiedTally と選択肢ごとに一致し、かつ verifiedTally の合計が tally.totalVotes と一致することを確認します。これにより「公開表示された集計値」と「zkVM が証明した集計値」の不一致を検出します。claimed tally が利用できない場合は、フォールバックとして journal.verifiedTally の合計と journal.validVotes の一致を検証します。

counted_missing_indices_zero

fail-closed(安全側に倒す)な除外数が 0 であることを確認します。除外数は以下の優先順で解決します。

  1. journal がある場合: journal.excludedSlots を使用し、必要に応じて旧フィールドへの互換値も導出
  2. journal がない場合: excludedSlotsexcludedCountmissingSlots + invalidPresentedSlotsmissingIndices + invalidIndices の順で探索

rejectedRecords は説明用の補助値であり、この判定には使いません。解決値が 0 でない場合、または journal 側の値が無効な場合は即座に検証失敗とします。

counted_expected_vs_tree_size

totalExpected(期待される投票数)が掲示板のツリーサイズと一致することを確認します。不一致は、暗黙の投票除外を示す可能性があります。

counted_election_manifest_consistent

election-manifest.jsonelectionConfigHash を再計算し、manifest 自身の宣言値と一致することを確認します。そのうえで electionIdelectionConfigHash を、セッション値・publicInputSummary・ジャーナルに含まれる対応値と相互照合します。

counted_close_statement_consistent

close-statement.json から sthDigest を再計算し、宣言された snapshot と一致することを確認します。そのうえで timestamppublicInputSummary.timestamp と一致し、logId / treeSize / bulletinRoot / sthDigest が検証入力やジャーナルと矛盾しないことを確認します。

counted_my_vote_included

includedBitmapRoot に対する bitmap Merkle 証明を検証し、投票者のインデックスに対応するビットが 1(counted に含まれた)であることを確認します。

seenBitmapRoot もある場合は /api/bitmap-proof?kind=included|seen の両方を使い、presented but invalid / not presented to the prover / unknown excluded を説明可能にします。

証明材料が取得できない場合は not_run になり、補助 note が付くことがあります。

counted_input_commitment_match

公開入力から再構成した入力コミットメント対象フィールドの正準ハッシュを計算し、zkVM ジャーナルに記録された入力コミットメント値と照合します。現行実装で対象となるのは electionIdbulletinRoottreeSizetotalExpectedvotesCount と、各投票の indexcommitmentmerklePath です。これは public-input.json 全体の単純なハッシュではありません。


STARK Verification(2 チェック)

STARK 証明の暗号学的正当性を検証するチェック群です。

ID説明証拠種別重要度
stark_image_id_matchverifier-confirmed な Image ID と expected / host-side metadata が整合zkrequired
stark_receipt_verifySTARK レシートが暗号学的に検証成功zkrequired

判定ロジックの詳細

stark_image_id_match

verifier-service が返す expected_image_idreceipt_image_id が一致することを確認します。

加えて、以下の補助値が存在する場合は相互矛盾がないことも検証します。

  • imageId(ホスト主張値)
  • journal.imageId(比較用メタデータ)

いずれかが receipt_image_id と食い違う場合は失敗となります。

期待 Image ID の解決順:

  1. EXPECTED_IMAGE_ID 環境変数
  2. public/imageId-mapping.json の current mapping(WSL では expectedImageID_x86_64 が自動選択される場合あり)
  3. 組み込み fallback(mapping を読めない場合のみ)

stark_receipt_verify

サーバー側の Rust 検証サービスが Receipt::verify(image_id) を実行し、Seal(STARK 証明)が暗号学的に正当であることを確認します。チェック結果としては success / failed / not_run / running が使われ、dev_mode は許可設定に応じて success または not_run に正規化されます。


チェックステータスの遷移

stateDiagram-v2
  [*] --> not_run: 初期状態
  not_run --> pending: 依存条件の待機
  not_run --> running: 検証開始
  pending --> running: 依存条件の解消
  running --> success: 検証成功
  running --> failed: 検証失敗
ステータス説明「Verified」への影響
not_run関連データが未取得、または検証未開始ブロック
pending依存する検証の完了を待機中ブロック
running検証を実行中ブロック
success検証成功許可
failed検証失敗ブロック

全チェック早見表

#チェック IDカテゴリ証拠重要度派生元
1cast_receipt_presentCastlocalrequired
2cast_choice_rangeCastlocalrequired
3cast_random_formatCastlocalrequired
4cast_commitment_matchCastlocalrequired
5recorded_commitment_in_bulletinRecordedpublicoptionalrecorded_inclusion_proof
6recorded_index_in_rangeRecordedpublicrequired
7recorded_root_at_cast_consistentRecordedpublicoptionalrecorded_consistency_proof
8recorded_inclusion_proofRecordedpublicrequired
9recorded_consistency_proofRecordedpublicrequired
10recorded_sth_third_partyRecordedpublicoptional
11counted_input_sanityCountedpublicrequired
12counted_unique_indicesCountedpublicrequired
13counted_unique_commitmentsCountedpublicrequired
14counted_tally_consistentCountedzkrequired
15counted_missing_indices_zeroCountedzkrequired
16counted_expected_vs_tree_sizeCountedzkrequired
17counted_election_manifest_consistentCountedpublicrequired
18counted_close_statement_consistentCountedpublicrequired
19counted_my_vote_includedCountedzkrequired
20counted_input_commitment_matchCountedpublicrequired
21stark_image_id_matchSTARKzkrequired
22stark_receipt_verifySTARKzkrequired

バンドル構造

証明バンドルの構成、公開許可リスト、非公開アーティファクトの保護を解説します。同期モードと非同期モードの違いも含みます。

概要

証明バンドルは、zkVM の実行結果を検証可能な形で保存・配布するためのアーティファクト群です。バンドルには公開可能なファイルと非公開ファイルが含まれ、厳格な許可リストによって配布対象となるファイルが制御されます。

本書で public / 「公開可能」と記述する場合、秘密情報を含まず第三者検証に利用可能であるという機密性の分類を指します。無認証で誰でも取得できることは意味しません。アクセス経路の詳細は バンドルのアクセス方法 を参照してください。

flowchart TB
  subgraph "バンドルディレクトリ"
    subgraph "公開可能アーティファクト"
      PI["public-input.json<br/>公開入力"]
      EM["election-manifest.json<br/>選挙マニフェスト"]
      CS["close-statement.json<br/>締切ステートメント"]
      RC["receipt.json<br/>STARK レシート"]
      JN["journal.json<br/>zkVM ジャーナル"]
      MT["metadata.json<br/>メタデータ(syncのみ)"]
      STH["sth.json<br/>STH スナップショット"]
      CP["consistency-proof.json<br/>整合性証明"]
    end

    subgraph "非公開アーティファクト"
      IN["input.json<br/>秘匿入力(ウィットネス)"]
      VR["verification.json<br/>検証レポート"]
      IB["included-bitmap.json<br/>厳密 counted bitmap"]
      SB["seen-bitmap.json<br/>厳密 presented bitmap"]
    end

    BZ["bundle.zip<br/>配布対象アーカイブ"]
  end

  PI --> BZ
  EM --> BZ
  CS --> BZ
  RC --> BZ
  JN --> BZ
  MT --> BZ
  STH -.-> BZ
  CP -.-> BZ
  IN -.-x BZ
  VR -.-x BZ
  IB -.-x BZ
  SB -.-x BZ

公開許可リスト

バンドルアーカイブ(bundle.zip)に含められるファイルは、許可リストによって厳格に制限されています。

公開可能なアーティファクト

ファイル内容用途
public-input.jsonzkVM 検証に使う秘密データを含まない検証用レコード第三者が入力コミットメントを再計算するため
election-manifest.json選挙設定の公開監査用スナップショット選挙設定と electionConfigHash の照合
close-statement.json集計締切時点のログ境界を表す公開監査レコードlogId / treeSize / bulletinRoot の照合
receipt.jsonホストが出力する { receipt, image_id } ラッパー JSON第三者がレシートを独立に検証するため
journal.jsonzkVM ゲストの公開出力(集計結果、除外情報、ビットマップルート等)集計結果と整合性データの確認
metadata.jsonバンドルの作成日時、セッション ID、メソッドバージョン(sync のみ)バンドルの来歴追跡
sth.json第三者 STH 検証のスナップショット(任意)STH 合意の再現可能な証拠
consistency-proof.jsonRFC 6962 整合性証明(任意)追記専用性の独立検証

sth.jsonconsistency-proof.json は許可リストに含まれますが、現行フローでは通常生成されません。

非公開アーティファクト

ファイル内容非公開の理由
input.jsonzkVM への完全な入力(選択肢・乱数・コミットメント・Merkle パス等)投票の秘匿入力(ウィットネス)を含む
verification.json検証サービスの詳細レポートbundle.zip の許可リスト外(必要時は専用エンドポイントで参照)
included-bitmap.json厳密な counted bitmap artifact個票 explainability 用の private artifact であり公開対象外
seen-bitmap.json厳密な presented bitmap artifact個票 explainability 用の private artifact であり公開対象外

input.json が公開されると投票の秘匿性が失われます。verification.json は必要時のみ専用の capability 保護エンドポイント経由で扱います。included-bitmap.jsonseen-bitmap.json/api/bitmap-proof の trusted source ですが、public bundle.zip には含めません。


公開入力の構造

public-input.json は、第三者検証に必要で、かつ選択肢と乱数を含まない入力側レコードです。input.json の単純なサブセットではありません。

フィールド説明
schemaスキーマ識別子("stark-ballot.public_input"
versionスキーマバージョン("1.0"
electionId選挙 ID(UUID)
electionConfigHash選挙設定のハッシュ
bulletinRoot掲示板の最終ルートハッシュ
treeSize掲示板のツリーサイズ
totalExpected期待される投票数
logId掲示板ログ ID
timestamp集計時のタイムスタンプ
methodVersionzkVM メソッドバージョン
votes各投票のインデックス、コミットメント、Merkle パスの配列

votes 配列には各投票のインデックスとコミットメント、Merkle パスが含まれますが、選択肢と乱数は含まれません。これにより、入力コミットメントの再計算が可能でありながら、投票内容の秘匿性は維持されます。

public-input.jsoninputCommitment は同一ではなく、後者が直接束縛するのはこのレコードの一部です。計算対象フィールドと非対象フィールドの整理は 入力コミットメント を参照してください。


同期バンドルと非同期バンドルの違い

証明生成には同期モード(ローカル実行)と非同期モード(ECS Fargate)の 2 つのパスがあり、生成されるバンドルの構成が異なります。

同期モード:

  1. ローカルプロセスでホストバイナリ実行
  2. TypeScript で全アーティファクト生成
  3. 検証サービスを呼び出し verification.json を保存
  4. 許可リストで bundle.zip を作成

非同期モード:

  1. ECS Fargate でホストバイナリ実行
  2. entrypoint.sh で公開可能アーティファクト生成
  3. bundle.zip を S3 にアップロード
  4. コールバック Lambda で結果をセッションに保存

両モードとも、public-input.jsonelection-manifest.jsonclose-statement.json は生成しただけでは採用されません。journal.json と正準な証明束縛データ(proof-bound data)に対する整合性検査を通過した場合にのみ bundle 化され、不一致があれば fail-closed(安全側に倒して)処理を中断します。

項目同期モード非同期モード
実行環境ローカルプロセス / LambdaECS Fargate コンテナ
input.json生成される(非公開保存)ワーク入力として S3 に置かれる(配布対象外)
public-input.jsonTypeScript で生成entrypoint.sh 内で生成
election-manifest.jsonTypeScript で生成entrypoint.sh 内で生成
close-statement.jsonTypeScript で生成entrypoint.sh 内で生成
journal.jsonTypeScript で生成*-output.json から bundle.zip 用に生成
receipt.jsonホストの { receipt, image_id } 出力を保存*-receipt.jsonreceipt.json としてコピーして同梱
included-bitmap.json生成される場合は private に保持生成される場合は隣接オブジェクト(sibling object)として保持
seen-bitmap.json生成される場合は private に保持生成される場合は隣接オブジェクトとして保持
metadata.json生成される生成されない
verification.json検証サービス呼び出し後に保存finalize コールバック時点では生成されない。後続の /api/verification/run で sibling object として追加されうる
bundle.zip許可リストに基づき作成receipt.json / journal.json / public-input.json / election-manifest.json / close-statement.json を同梱して作成
保存先ローカルファイルシステム(+ 任意で S3)S3
presigned URLS3 アップロード時に生成コールバック Lambda が生成

非同期バンドルの生成フロー

非同期モードでは、ECS Fargate コンテナの entrypoint.sh が以下の手順でバンドルを構築します。

  1. S3 から入力 JSON をダウンロード
  2. ホストバイナリを実行し、レシートと出力を生成
  3. 出力から journal.json を変換生成
  4. 入力と出力から public-input.jsonelection-manifest.jsonclose-statement.json を構築
  5. journal.jsonpublic-input.json / election-manifest.json / close-statement.json の整合性を検証し、ドリフトがあれば bundle 作成を中止
  6. receipt.jsonjournal.jsonpublic-input.jsonelection-manifest.jsonclose-statement.jsonbundle.zip にアーカイブ
  7. *-receipt.json / *-output.json / public-input.json / election-manifest.json / close-statement.json / included-bitmap.json / seen-bitmap.json / bundle.zip などを S3 にアップロード

コールバック Lambda は S3 の bundle.zip からジャーナル、レシート、public-input.jsonelection-manifest.jsonclose-statement.json を復元します。利用可能な場合は隣接オブジェクトの bitmap artifact も取り込み、presigned URL の生成と監査用データの保存を行います。


バンドルディレクトリ構造

同期モード(ローカルファイルシステム)

{VERIFIER_WORK_DIR}/
  {sessionId}/
    {executionId}/
      input.json               ← 非公開: ウィットネス
      public-input.json        ← 公開可能
      election-manifest.json   ← 公開可能
      close-statement.json     ← 公開可能
      journal.json             ← 公開可能
      receipt.json             ← 公開可能
      metadata.json            ← 公開可能
      included-bitmap.json     ← 非公開: 厳密 counted bitmap artifact
      seen-bitmap.json         ← 非公開: 厳密 presented bitmap artifact
      verification.json        ← 非公開: 検証レポート
      bundle.zip               ← 配布対象: 許可リストファイルのアーカイブ

非同期モード(S3)

s3://{BUCKET}/sessions/{sessionId}/{executionId}/
  input.json                 ← 非公開: ワーク入力
  {inputBase}-receipt.json   ← ホストの生出力
  {inputBase}-output.json    ← ホストの生出力
  {inputBase}-journal.json   ← ホストが生成した場合のみ
  public-input.json          ← 公開可能
  election-manifest.json     ← 公開可能
  close-statement.json       ← 公開可能
  included-bitmap.json       ← 非公開: 厳密 counted bitmap artifact
  seen-bitmap.json           ← 非公開: 厳密 presented bitmap artifact
  bundle.zip                 ← 配布対象: 内部は receipt.json / journal.json / public-input.json / election-manifest.json / close-statement.json
  verification.json          ← `/api/verification/run` 後に追加される場合がある

inputBase はコンテナ実行時に生成される一時入力ファイル名に依存するため、非同期モードの S3 オブジェクト名は固定の receipt.json / journal.json になりません。


バンドルのアクセス方法

ダウンロードエンドポイント

エンドポイント内容
GET /api/verification/bundles/:sessionId/:executionIdbundle.zip のダウンロード
GET /api/verification/bundles/:sessionId/:executionId/reportverification.json の取得

両エンドポイントとも X-Session-Capability ヘッダーが必須です。つまり、ダウンロードは capability 保護 API から開始されます。

加えて、executionId はそのセッションで現在許可されている verificationExecutionId と一致しなければなりません。不一致や未設定の場合、API は 404 を返します。

S3 にアップロードされたバンドルは、必要に応じて API から presigned URL へリダイレクトして配布されます。presigned URL は有効期限付きであり、期限切れの場合はクライアントが /api/verify?refreshS3=1 で新しい URL を取得できます。

アーカイブの再現性

同期モード(verification-bundle.ts)の bundle.zip は再現性を確保するため、以下の措置を講じています。

  • エントリのタイムスタンプをゼロに固定
  • 許可リストに一致するファイルのみを含める
  • ファイル名のアルファベット順でエントリを追加

非同期モード(docker/entrypoint.sh)は zip -r で作成され、上記の再現性制御とは実装が異なります。


セキュリティ上の制約

パストラバーサル防止

バンドルのパスセグメント(セッション ID、実行 ID)は英数字とハイフンのみに制限されています。.. を含むパスや許可されていない文字を含むパスは拒否されます。

非公開ファイルの保護

input.jsonverification.jsonincluded-bitmap.jsonseen-bitmap.jsonbundle.zip には含まれません。これらのファイルがバンドルディレクトリに存在していても、公開 bundle contract に含まれていないため、アーカイブ作成時に除外されます。

ゲーティングロジック

「Verified」表示の条件と、表示を阻止する不変条件を解説します。

「必要な検証が未実行または失敗なら Verified を表示しない」 という原則に基づき、各検証チェックの結果がどのように最終判定に集約されるかを説明します。

最終判定の種類

検証パイプラインの結果は、主経路では deriveVerificationSummary によって集約され、UI 上は以下の 3 トーンに整理されます。なお /verify ページには STARK タイムアウト時の失敗表示など、集約結果に対する限定的な上書きもあります。

表示ステータス主な条件UI 表示
Verifiedrequired 条件が満たされ、optional チェックの劣化もない(fully_verified緑色
Verification Failed証明失敗、票除外、Recorded/Counted/Cast の必須失敗、または公開集計値と検証済み tally の不一致が確定した場合赤色
Warningrequired チェックが進行中、証拠不足がある、または optional チェックのみ劣化している場合黄色

ステータスの判定順序

優先順判定条件最終ステータス
1required チェックに pending / running があるWarning (in_progress)
2STARK 証明系ロールが failedVerification Failed
3completeness ロールが faileduser_vote_excluded / votes_excluded / votes_excluded_unknownVerification Failed
4Recorded-as-Cast の required チェックが failedVerification Failed
5tally consistency だけが失敗し、proof / completeness / user inclusion / input integrity / Recorded required が成功Verification Failed (published_tally_mismatch)
6Counted-as-Recorded または Cast-as-Intended の required チェックが failedVerification Failed
7(a) required に not_run がある (b) 必須ロール不足 (c) 未知チェック (d) required 定義の未解決 のいずれかWarning (missing_evidence)
8optional チェックに failed / not_run があるWarning (verified_with_limitations)
9上記いずれにも該当しないVerified

補助判定のゲーティング(secondary tooling の validateVotingIntegrity

validateVotingIntegrity はクライアント側の補助判定として実装されています。この関数は 5 つのゲートを順に評価し、いずれかが失敗した時点で canShowVerified = false を返します。

ただし shipped /verify ページは WS3.5 以降この helper を verdict path から外しており、最終サマリ表示は verificationChecks の集約(deriveVerificationSummary)とローカル cast-as-intended 補完で決まります。validateVotingIntegrity は secondary tooling / historical helper として残っています。

ゲート 1: 整合性証明

RFC 6962 の整合性証明により、掲示板が追記専用であることを検証します。

条件結果
整合性証明が暗号学的に検証成功通過
証明の検証失敗canShowVerified = false(スプリットビュー攻撃の可能性)
ルートの不一致(old/new)canShowVerified = false
API エラーで証明を取得できないcanShowVerified = false

ゲート 2: 完全性(Completeness)

zkVM の集計に全投票が含まれたことを検証します。

条件結果
解決済み除外数が 0通過
解決済み除外数が 0 より大きいcanShowVerified = false最重要不変条件
除外数の解決に必要な値が欠落・不正canShowVerified = false

除外数は excludedSlots を最優先し、旧フィールドは後方互換としてのみ参照します。解決の優先順序は チェック一覧 を参照してください。

解決済みの除外数が 0 より大きい場合は、投票が未提示または提示後に集計から落ちたことを意味します。これは投票システムにおいて最も深刻な不正であり、いかなる場合も「Verified」を表示してはなりません。

ゲート 3: 警告の収集

validateVotingIntegrity では totalExpected != treeSizerejectedRecords > 0 を警告として収集します(fail-closed な除外数が 0 の場合)。

ここで警告対象となるのは rejectedRecords(提示されたが計上されなかった record の数)です。invalidPresentedSlots は除外数に含まれるため、警告ではなくゲート 2 で判定されます。

ただし、verificationChecks 側では counted_expected_vs_tree_size が必須チェックであり、totalExpected != treeSizefailed として最終的に Verified をブロックします。

ゲート 4: 第三者 STH 検証(有効時のみ)

STH ソースが設定されている場合のみ評価されます。

条件結果
STH ソースが未設定このゲートをスキップ
比較可能な応答群で合意成立 かつ matchingSources >= minMatches通過
合意が成立しないcanShowVerified = false
一致ソース数が最小要件未満canShowVerified = false

照合条件の詳細は チェック一覧 を参照してください。

ゲート 5: ユーザーインデックスの範囲検証

投票者のインデックスがツリーサイズの範囲内であることを確認します。

条件結果
userIndex < treeSize通過
userIndex >= treeSizecanShowVerified = false

STARK 検証のゲーティング

STARK 検証は整合性検証とは独立に評価されます。

STARK ステータス説明最終判定への影響
success暗号学的に検証成功他の必須チェックも success なら Verified 可能
failed検証失敗Verified をブロック
dev_mode開発モードのフェイクレシートallowDevModeVerification=true なら success、それ以外は not_run
not_run未実行missing_evidence(Warning)扱い。Verified をブロック
running実行中in_progress(Warning)扱い。Verified をブロック

STARK が not_run のまま最終判定が Verified になる経路はありません(整合性チェックだけでは Verified に到達できません)。

zkGate: STARK 結果に基づく Counted チェックの制御

STARK 検証の結果は、Counted-as-Recorded 段階のチェック評価にも影響します。これを zkGate と呼びます。

STARK 解決後ステータスCounted チェックへの反映
runningpending
not_runnot_run
failedfailed
successゲートなしで通常評価

dev_mode は事前に success または not_run に正規化されてから zkGate に入力されます。


ステップとチェックの対応関係

UI に表示される 4 つのステップは、現行実装では 22 個のチェック定義から派生します。

ただし、verificationSteps[].status は「その stage で required 扱いになるチェック群」から導出され、verificationSteps[].inputs は stage 内の 全チェック定義 から集約されます。

ステップrequired として集約されるチェック ID
Cast-as-Intendedcast_receipt_present, cast_choice_range, cast_random_format, cast_commitment_match
Recorded-as-Castrecorded_index_in_range, recorded_inclusion_proof, recorded_consistency_proof、および STH source 設定時の recorded_sth_third_party
Counted-as-Recordedcounted_input_sanity, counted_unique_indices, counted_unique_commitments, counted_tally_consistent, counted_missing_indices_zero, counted_expected_vs_tree_size, counted_election_manifest_consistent, counted_close_statement_consistent, counted_my_vote_included, counted_input_commitment_match
STARK Verificationstark_image_id_match, stark_receipt_verify

補足:

  • recorded_commitment_in_bulletinrecorded_inclusion_proof から、recorded_root_at_cast_consistentrecorded_consistency_proof から導出される表示用チェックです。チェック一覧には現れますが、単独では step status を決定しません。
  • recorded_sth_third_party は通常は optional ですが、STH source が設定されている場合だけ required に昇格し、Recorded-as-Cast の step status と最終判定をブロックし得ます。

ステップのステータスは、required 扱いになったチェックのステータスから次の順序で集約されます。

集約ルール条件
failedrequired チェックのいずれかが failed
runningfailed がなく、required チェックのいずれかが running
pendingfailed/running がなく、いずれかが pending
successrequired チェックがすべて success
not_run上記のいずれにも該当しない

さらに現行実装には、単純集約だけではない 3 つの補正があります。

  • counted_as_recordedjournal が存在しない場合、required チェックに failed がない限り not_run に補正されます。
  • recorded_as_castuserVote.proof.treeSize がない場合、not_run に補正されます。
  • /api/verifycastSource='client'verificationSteps / verificationChecks を組み立てるため、API 応答上の Cast-as-Intended はいったん not_run です。その後ブラウザ側で保存済みセッション情報から Cast チェックを再評価して上書きし、最終的な UI 表示と summary にはそのローカル結果が反映されます。

UI シーケンスとポーリング

検証ページでは、4 つのステップが順次アニメーション表示されます。

sequenceDiagram
  participant U as ブラウザ
  participant S as サーバー

  Note over U: ページロード時
  U->>S: GET /api/verify
  S-->>U: 検証ペイロード

  Note over U: STARK 完了まで待機

  alt verificationStatus = not_run
    U->>S: POST /api/verification/run
    S-->>U: 実行受付
  end

  loop STARK ポーリング
    U->>S: GET /api/verify
    S-->>U: verificationStatus 確認
  end

  Note over U: STARK 解決後に<br/>ステップを順次表示

  Note over U: Step 1: Cast-as-Intended
  Note over U: Step 2: Recorded-as-Cast
  Note over U: Step 3: Counted-as-Recorded
  Note over U: Step 4: STARK Verification

  Note over U: 最終判定を表示

重要な制約として、STARK 検証が完了するまで、ステップの表示シーケンスは開始されません。これは、STARK の結果が Counted チェックの評価(zkGate)に影響するためです。


不変条件のまとめ

以下の不変条件は、コードの変更によっても決して緩和してはなりません。

不変条件根拠
解決済み fail-closed 除外数 > 0 → Verified を表示しない投票除外は最も深刻な不正
整合性証明の失敗 → Verified を表示しない追記専用性が保証されない
STH 合意の不成立(有効時) → Verified を表示しないスプリットビュー攻撃の可能性
not_run チェックの存在 → Verified を表示しない証拠の不在を成功として扱わない
STARK 検証の失敗 → Verified を表示しないジャーナルの正当性が保証されない
非公開アーティファクトをバンドルに含めない投票の秘匿性を維持

これらの不変条件は、改ざんシナリオ(S0〜S5)の検出を保証する基盤です。各シナリオがどの不変条件によって検出されるかは、改ざんシナリオ を参照してください。

改ざんシナリオ

STARK Ballot Simulator は、E2E 検証可能投票の教育的デモとして 6 つの改ざんシナリオ(S0〜S5)を提供します。各シナリオは投票システムに対する特定の攻撃を模擬し、検証パイプラインがどのチェックで異常を検出するかを実演します。

この部に含まれる章

シナリオ一覧

改ざんシナリオ S0〜S5 の定義と、実装上どこを改変するかを整理します。ここでは「zkVM 入力」「主張集計(claimed tally)」「ジャーナル統計(missing/invalid/excluded)」の関係を中心に説明します。

教育モードの目的

改ざんシナリオは、暗号的検証が実際に機能することを確認するために設計されています。

  • 正常ケース(S0)を基準として、検証パイプラインが通過する状態を確認する
  • 攻撃シナリオ(S1〜S5)を適用して、どの不変条件が破れると検証が失敗するかを確認する

攻撃の 2 類型

  • 入力改ざん (tamperMode=input): S1 / S3 / S5
  • 主張改ざん (tamperMode=claim): S2 / S4

この図は「改ざんがどこに入るか」の分類のみを示します。どのチェックで失敗するかの詳細は 検出メカニズム を参照してください。


実装上の共通前提

  • 1 回の finalize で選択されるシナリオは 1 つ(S0〜S5)
    • UI: /aggregate は single-select(S0〜S5 のラジオボタン)
    • API: POST /api/finalizescenarioId を 1 つ受け取る
  • totalExpected は 64(ユーザー 1 + ボット 63)
  • 掲示板(CT Merkle)は追記専用で、シナリオ適用で既存エントリは削除しない
  • tamperModenone / input / claim の 3 種

注意事項:

  • 本章は実 API 経路(/api/finalizefinalize-sessionfinalize-sync|async)を基準に説明する
  • finalize 実行モードは FINALIZE_ASYNC_MODE で切り替わる(false: 同期, true: 非同期)。AWS 運用では通常 true
  • NEXT_PUBLIC_USE_MOCK_API=true の mock API fixture は本章と異なるチェック結果を返すことがある
  • 本章の「主な失敗点」は STARK 検証が success の局面を前提とする(zkGate の詳細は検出メカニズムを参照)

tamperMode により、zkVM 入力へ反映されるかどうかが決まります。

flowchart TD
  A[シナリオ選択] --> B{tamperMode}
  B -->|none / claim| C[元の votes を zkVM 入力へ]
  B -->|input| D[modifiedVotes を zkVM 入力へ]
  C --> E[zkVM 実行]
  D --> E

S0: 正常(改ざんなし)

改ざんを適用しない基準シナリオです。

項目
tamperModenone
zkVM 入力票数64
claimed と verified一致
excludedSlots0

S1: ユーザー票の除外

ユーザー票(インデックス 0)を modifiedVotes から削除し、63 票を zkVM に渡します。

項目
tamperModeinput
zkVM 入力票数63
claimed と verified一致(どちらも 63 票入力ベース)
ジャーナル統計missingSlots=1, invalidPresentedSlots=0, excludedSlots=1

ポイント:

  • 掲示板上のユーザー票エントリは残る
  • 検出は主に完全性チェック(excludedSlots > 0
  • ビットマップ証明が利用可能なら counted_my_vote_included でも検出可能

S2: ユーザー票に関する主張集計の改ざん

ユーザー票に対する「主張集計(表示する tally)」のみ改ざんします。zkVM には元の 64 票を渡します。

項目
tamperModeclaim
zkVM 入力票数64(元データ)
claimed と verified不一致(ユーザー選択肢が -1、別候補が +1)
excludedSlots0(通常)
inputCommitmentzkVM 入力由来のため通常は一致

ポイント:

  • 「票の中身を zkVM 入力で差し替える」実装ではない
  • レシートや STARK 証明は通常どおり有効
  • 検出の主因は counted_tally_consistent の失敗

S3: ボット票の除外

現行実装ではボット票インデックス 1targetBotId 初期値)を削除し、63 票を zkVM に渡します。

項目
tamperModeinput
zkVM 入力票数63
claimed と verified一致(どちらも 63 票入力ベース)
ジャーナル統計missingSlots=1, invalidPresentedSlots=0, excludedSlots=1

S1 との違い:

  • S1: ユーザー自身の未集計をビットマップで直接示せる
  • S3: ユーザー票は含まれるが、集計全体の完全性違反で検出される

S4: ボット票に関する主張集計の改ざん

1 票のボット票に関する「主張集計」だけを改ざんします。zkVM 入力は元の 64 票のままです。

項目
tamperModeclaim
zkVM 入力票数64(元データ)
claimed と verified不一致(対象ボットの元候補が -1、別候補が +1)
excludedSlots0(通常)
inputCommitmentzkVM 入力由来のため通常は一致

ポイント:

  • S2 と同様に、改ざん対象は tally.counts
  • 検出の主因は counted_tally_consistent の失敗

S5: ランダムエラー注入

64 票からランダムに 1 票を選び、50% で「除外」または「再集計(別候補化)」を行います。

実装上の重要点:

  • tamperMode は常に input
  • そのため zkVM 入力は常に modifiedVotes が使われる
  • 除外パスでは missingSlots が増え、再集計パスでは不正票化により invalidPresentedSlots が増えるため、いずれも excludedSlots > 0 になる
  • 再集計パスでは counted_tally_consistent も失敗する(claimedCounts は 64 票ベース、verifiedTally は commitment 不一致の票を除外した 63 票ベース)
分岐zkVM 入力代表的な統計
除外パス63 票missingSlots=1, invalidPresentedSlots=0, excludedSlots=1
再集計パス64 票missingSlots=0, invalidPresentedSlots=1, excludedSlots=1

再集計パスでも excludedSlots が 0 にならないのは、zkVM 側で不正票として除外されるためです。


シナリオ一覧表

シナリオ類型tamperModezkVM 入力主な失敗点(STARK 解決後)
S0正常none元の 64 票なし
S1除外input63 票(ユーザー除外)excludedSlots > 0
S2主張改ざんclaim元の 64 票claimed ≠ verified
S3除外input63 票(ボット除外)excludedSlots > 0
S4主張改ざんclaim元の 64 票claimed ≠ verified
S5ランダム(実装上 input)input63 または 64 票excludedSlots > 0(再集計では claimed ≠ verified も発生)

ジャーナル統計の扱い(sync / async 共通)

現行実装では、missingSlots / invalidPresentedSlots / excludedSlots / validVotes などのジャーナル統計は、sync / async いずれも zkVM が返した proof-derived な値をそのまま使います

  • sync / async いずれも、finalize 後にジャーナル統計を上書きしない
  • async finalize はコールバックで bundle.zip から復元した journal をそのまま使う

シナリオ由来の ignoredCount / recountedCount / claimedCounts は presentation 用であり、journal の統計値を書き換えません。

  • ignoredCount / recountedCounttamperSummarytamperedCount に反映
  • claimedCounts → S2/S4 や S5 再集計分岐での表示用 tally

tamperMode=claim(S2/S4)でも同様に、journal 統計は zkVM の値のままです。


集計フローへの挿入点

flowchart TB
  A[セッション votes 読み込み] --> B[シナリオ適用]
  B --> C{tamperMode}
  C -- input --> D[modifiedVotes で zkVM 入力生成]
  C -- claim --> E[元の votes で zkVM 入力生成]
  D --> F[zkVM 実行]
  E --> F
  F --> G[finalizationResult 保存]
  B --> H[claimedCounts 計算]
  H --> G

検出メカニズム

各改ざんシナリオに対して、検証パイプラインがどのチェックで失敗するかを整理します。本章は実 API の判定ロジックを基準にしており、STARK 検証が success になった後の挙動を前提とします。

mock API fixture(NEXT_PUBLIC_USE_MOCK_API=true)では異なるチェック結果を返すことがあります。現行 API は castSource=client のため、cast_* チェックはシナリオに関係なく not_run です。

Counted 系チェックの zkGate について

/api/verify の Counted 系チェックには、STARK 前に評価できる項目と、STARK 状態でゲートされる項目が混在します。

  • counted_input_sanity / counted_unique_indices / counted_unique_commitmentspublicInputSummary があれば STARK 未解決でも評価されます
  • counted_tally_consistent / counted_missing_indices_zero / counted_expected_vs_tree_size / counted_election_manifest_consistent / counted_close_statement_consistent / counted_my_vote_included / counted_input_commitment_match は zkGate の対象です
  • STARK 未解決(not_run/running)の間、zkGate 対象チェックは not_run または pending になります
  • verificationStatus=failed では、zkGate 対象チェックも failed になり得ます

検出の 2 つの原理

  • 原理1: 完全性違反 (excludedSlots > 0) → counted_missing_indices_zero が失敗(主に S1/S3/S5)
  • 原理2: 主張集計の不整合 (claimed ≠ verified) → counted_tally_consistent が失敗(主に S2/S4)

シナリオ別の主な失敗チェック(STARK 解決後)

シナリオ主に失敗するチェック説明
S0なし正常系
S1counted_missing_indices_zeroユーザー票除外により excludedSlots=1
S2counted_tally_consistentclaimed tally と verified tally が不一致
S3counted_missing_indices_zero現行実装では botId=1 のボット票除外により excludedSlots=1
S4counted_tally_consistentclaimed tally と verified tally が不一致
S5counted_missing_indices_zeroexcludedSlots>0 が必ず発生し、除外・再集計どちらでも完全性違反として検出される

補足:

  • S1 では、ビットマップ証明が利用可能な場合 counted_my_vote_included も失敗し得ます
  • S2/S4 では、counted_input_commitment_match は通常成功します(zkVM 入力を改変していないため)
  • S5 の再集計分岐では counted_tally_consistent も失敗します。choice だけ差し替えた票は commitment 不一致で zkVM が除外するため、verifiedTallyclaimedCounts が一致しなくなります

4 段階検証モデルとの対応(/api/verify 応答)

検証段階S0S1S2S3S4S5
Cast-as-Intendednot_runnot_runnot_runnot_runnot_runnot_run
Recorded-as-Cast
Counted-as-Recorded
STARK Verification

この表は「シナリオ適用による典型挙動」を示します。running や追加の not_run は運用状態や証拠不足により別途発生します。 なお、ここでの STARK Verification は段階別の表示ステータスです。全体の verificationStatus は fail-closed ルールにより failed になることがあります。


チェックID(主要項目)マトリクス(STARK 解決後)

チェック IDS0S1S2S3S4S5
cast_commitment_matchnot_runnot_runnot_runnot_runnot_runnot_run
counted_tally_consistent分岐依存
counted_missing_indices_zero
counted_my_vote_included❌ または not_run分岐依存
counted_input_commitment_match
  • S5 の counted_my_vote_included は、ランダム対象がユーザー票(除外または再集計)なら失敗し得ます。証拠不足時は not_run になります
  • S5 の counted_tally_consistent は、除外パスでは成功、再集計パスでは失敗します(commitment 不一致で zkVM が票を除外するため verifiedTally ≠ claimedCounts
  • いずれの分岐でも主な失敗は counted_missing_indices_zero です

S2/S4 で何が起きるか

S2/S4 は「入力改ざん」ではなく「主張集計改ざん」です。

flowchart TD
  A["tamperMode=claim (S2/S4)"]
  A --> V1["元の votes を zkVM 入力へ"]
  V1 --> V2["verifiedTally"]
  A --> C1["claimedCounts を改変"]
  C1 --> C2["API の tally.counts"]
  V2 --> X{"claimed と verified は一致?"}
  C2 --> X
  X -->|不一致| F["counted_tally_consistent = failed"]

このため counted_input_commitment_match の失敗は通常発生しません(zkVM 入力は元票)。


S5 の実装依存ポイント

S5 はランダムに除外または再集計を選びますが、実装上は常に tamperMode=input です。つまり claim tamper ではなく、改変後の votes が zkVM 入力に入ります。ジャーナル統計は sync / async どちらでも zkVM が返した値をそのまま使います(詳細はシナリオ一覧 > ジャーナル統計の扱いを参照)。

  • 除外分岐: missingSlots=1 となり、counted_missing_indices_zero が失敗
  • 再集計分岐: invalidPresentedSlots=1 となり、counted_missing_indices_zero が失敗
  • 再集計分岐では counted_tally_consistent も失敗します。claimedCounts は改変後の 64 票から算出されますが、zkVM は commitment 不一致の票を除外するため verifiedTally は 63 票ベースになります

ビットマップ証明の役割

counted_my_vote_included は、チェック定義上 required のユーザー包含チェックです。

  • S1(ユーザー票除外)では、証明が利用可能なら失敗して「自分の票が未集計」であることを直接示せる
  • 証拠不足で not_run になる場合でも、最終判定は Verified になりません:
    • 完全性違反が同時にある場合 → votes_excluded_unknown
    • 完全性違反が無く required evidence が欠ける場合 → missing_evidence

最終判定(Verified 表示)

最終表示は verification-summary のルールで決定されます。

flowchart TB
  A[検証チェック集合] --> B{必須チェック failed?}
  B -- yes --> F[failed 系ステータス]
  B -- no --> C{必須チェック not_run / running?}
  C -- yes --> W[in_progress / missing_evidence]
  C -- no --> D{任意チェックに劣化あり?}
  D -- yes --> L[verified_with_limitations]
  D -- no --> V[fully_verified]

代表的な失敗ステータス:

  • user_vote_excluded / votes_excluded / votes_excluded_unknown: 完全性違反(S1/S3/S5)
  • published_tally_mismatch: claimed と verified の不一致(S2/S4)
  • counted_integrity_failed: Counted 系必須チェック失敗の一般ケース

AWS アーキテクチャ

STARK Ballot Simulator の AWS インフラストラクチャ設計を解説します。本システムは Amplify Gen 2 と Terraform 管理のプローバーインフラを組み合わせたハイブリッド構成を採用しています。

この部に含まれる章

設計思想とサービス一覧

AWS インフラストラクチャのハイブリッド構成の設計思想、環境分離、全体構成、サービス一覧を解説します。

設計思想

なぜハイブリッド構成か

STARK 証明の生成には 16 vCPU / 32 GB メモリで約 6 分を要します。この特性は、hono-api Lambda の 60 秒タイムアウトや通常の Web リクエストの処理パターンに合わず、同期的な API 応答には載せられません。

そこで本システムでは、責務に応じてインフラ管理を分離しています。

管理ツール責務理由
Amplify Gen 2Web ホスティング、API(Lambda)、データ(AppSync + DynamoDB)、認証基盤(Cognito + IAM)フロントエンド + API のデプロイサイクルが速い
TerraformECS Fargate、Step Functions、SQS、S3、ECR、CodeBuild、VPC計算リソースの精密な制御と、イメージ署名のようなセキュリティゲートの定義が必要

環境分離

developmain の 2 環境を運用し、主要なアプリケーション実行系リソースは Terraform ワークスペースと Amplify ブランチデプロイで分離しています。

項目developmain
証明モード実 STARK 証明(64 票で約 370 秒)実 STARK 証明(64 票で約 370 秒)
S3 ライフサイクル7 日30 日
ログ保持期間7 日14 日
CloudTrail無効有効(90 日保持)

ただし、全リソースが環境ごとに二重化されているわけではありません。RISC Zero ツールチェーン用の ECR リポジトリと CodeBuild プロジェクトは aws.shared provider で共有され、環境別に分かれるのは prover 用 ECR、S3、SQS、Step Functions、ECS、CloudTrail などの実行系リソースです。

注: RISC0_DEV_MODE=1 / USE_MOCK_ZKVM=true は主にローカル同期実行向けの設定です。Terraform 管理の非同期プローバーパス(SQS → Step Functions → ECS)では、実 STARK 証明を前提にしています。

全体構成図

AWS 全体構成図 図: STARK Ballot Simulator の AWS 全体構成。Amplify 管理領域(上)と Terraform 管理領域(下)のハイブリッド構成。

サービス一覧

本システムで使用する主要な AWS サービスと、その役割の概要です。

Amplify 管理

サービスリソース役割
Amplify HostingWeb アプリNext.js のビルド・ホスティング
API Gateway (HTTP API)stark-ballot-simulator-hono-api/api/* ルートのプロキシ
Lambdahono-apiHono フレームワークによる API 処理
Lambdaprover-dispatch-proxySQS 受信 → input.json を S3 保存 → Step Functions 起動
Lambdafinalize-callback-runnerStep Functions コールバック → セッション更新
Lambdaverifier-service-runnerSTARK レシート検証の実行
AppSync + DynamoDBデータモデルセッション・投票・集計結果の永続化
CognitoIdentity Pool / User Pool認証基盤(未認証 ID 無効)

Terraform 管理

サービスリソース役割
ECS FargateプローバータスクzkVM ホストバイナリによる STARK 証明生成
Step Functionsプローバーディスパッチャーイメージ署名検証 → ECS 実行 → コールバック
SQSワークキュー + DLQ非同期証明リクエストのバッファリング
S3証明バンドルバケット入力・実行成果物・検証用バンドル(bundle.zip など)の保存
ECRイメージリポジトリプローバーコンテナイメージの管理
CodeBuild環境別プローバー + 共有 toolchain builderDocker イメージのビルド(署名は ECR 設定に依存)
Lambdacheck-image-signatureECR イメージ署名の実行前検証
VPCパブリックサブネットECS タスクのネットワーク
CloudWatchログ群ECS / Step Functions / CodeBuild のログ
CloudTrail監査証跡(main のみ)API 呼び出しの監査ログ

Amplify と Terraform の境界

2 つのインフラ管理ツール間の連携は、ARN と環境変数によって行われます。

flowchart TB
  subgraph TF["Terraform 管理"]
    OUT["出力値<br/>SFN ARN / SQS URL / S3 バケット名"]
    IN["入力変数<br/>finalize_callback_lambda_arn"]
  end

  subgraph AMP["Amplify Gen 2 管理"]
    ENV["環境変数"]
    CB["finalize-callback-runner<br/>Lambda ARN"]
  end

  OUT -.対応する値.-> ENV
  CB -.ARN を入力変数へ設定.-> IN

Terraform の出力値(Step Functions ARN、SQS URL、S3 バケット名など)は、Amplify の環境変数(app-level / branch override)に手動で反映し、Lambda 関数から参照します。自動同期はされないため、Terraform 出力の変更時は Amplify 側も合わせて更新が必要です。関連する詳細:

トポロジー

AWS サービス構成と各コンポーネント間の通信フローを解説します。

本システムは 5 つの論理レイヤ(Web、API、Data、Prover、Storage)で構成され、各レイヤは明確な責務を持ちます。

レイヤ別トポロジー

Web レイヤ

Amplify Hosting が Next.js アプリケーションのビルドとホスティングを担当します。GitHub リポジトリのブランチ(develop / main)と Amplify の環境が 1 対 1 で対応し、プッシュをトリガーとする自動デプロイが行われます。

API レイヤ

API Gateway(HTTP API)が /api/* パスパターンのリクエストを受け取り、単一の Lambda 関数(hono-api)にプロキシします。hono-api は Hono フレームワーク上に構築されており、ルーティング、セッション管理、Turnstile 検証、レート制限を処理します。

flowchart LR
  Client["クライアント"] --> APIGW["API Gateway<br/>(HTTP API)"]
  APIGW --> HONO["hono-api<br/>Lambda"]
  HONO --> APPSYNC["AppSync<br/>(Data)"]
  HONO --> SQS["SQS<br/>(非同期証明)"]
  HONO --> S3["S3<br/>(バンドル取得)"]

  subgraph "リクエストパイプライン"
    direction TB
    SESS["セッション検証"]
    TURN["Turnstile 検証"]
    RATE["レート制限"]
    HAND["ハンドラー実行"]
    SESS --> TURN --> RATE --> HAND
  end

CORS 設定では X-Session-IDX-Session-Capability ヘッダーを許可し、セッションスコープと capability トークンによるリクエスト制御を実現しています。

なお、POST /api/verification/runhono-api が専用の verifier-service-runner Lambda を同期起動する構成です。S3 上の bundle.zip 取得と verifier-service の実行は、この専用 Lambda 側で行われます。

Data レイヤ

AppSync(GraphQL API)と DynamoDB が、セッション・投票・集計結果のデータ永続化を担当します。データプレーンは allow.resource(...) により hono-api / prover-dispatch-proxy / finalize-callback-runner からの SigV4 呼び出しに限定され、未認証アクセスは無効です。

注: Amplify Data の検証要件として fallback group は定義していますが、エンドユーザーには割り当てていません。

主要なデータモデルは以下の 2 つです(集計結果は VotingSession.finalizationResultJson に格納)。

モデルキー/識別子主要フィールドTTL
VotingSessionidelectionId, botCount, finalized, userVoteIndex, finalizationResultJsonttl
Vote識別子: sessionId + voteIndexchoice, random, commitment, rootAtCast, isUserVote

レート制限用に 2 つの追加テーブル(RateLimitEvents、RateLimitCounters)が PAY_PER_REQUEST モードで運用されています。

Prover レイヤ

STARK 証明の生成を担当するレイヤです。SQS キュー、Step Functions ステートマシン、ECS Fargate タスクで構成されます。詳細は 非同期プローバー を参照してください。

flowchart LR
  SQS["SQS<br/>ワークキュー"] --> DP["prover-dispatch-proxy<br/>Lambda"]
  DP --> SFN["Step Functions<br/>ディスパッチャー"]
  SFN --> SIG["check-image-signature<br/>Lambda"]
  SFN --> ECS["ECS Fargate<br/>16 vCPU / 32 GB"]
  SFN --> CB["finalize-callback-runner<br/>Lambda"]
  ECS --> S3["S3<br/>証明バンドル"]

ECS タスクは ARM64 アーキテクチャの Fargate で実行され、専用の VPC(10.0.0.0/16)内のパブリックサブネットに配置されます。セキュリティグループは HTTPS エグレスのみを許可し、インバウンドトラフィックは一切受け付けません。

Storage レイヤ

S3 バケットが、証明バンドルに含まれる配布対象アーティファクトと非公開ワーク入力の保存を担当します。

項目設定
バケット命名stark-ballot-simulator-proof-bundles-{環境名}
暗号化AES256(サーバーサイド暗号化)
パブリックアクセス全ブロック
ライフサイクルdevelop: 7 日、main: 30 日で自動削除
バージョニング無効(PoC では不要)

オブジェクトのパスは既定では sessions/{sessionId}/{executionId}/ 配下に構造化されています。Terraform 変数 s3_proof_prefix で先頭プレフィックスは変更できますが、現行実装では Amplify 側の一部 IAM/CLI 補助ポリシーが sessions/ を前提としているため、変更時は関連権限も合わせて更新が必要です。

ファイル扱い説明
input.json非公開zkVM への完全な入力(プライベートウィットネス)
*-receipt.json内部保存zkVM host の生出力(レシート)。現行フローでは配布対象ではないが、bundle.zip 用の元データ
*-output.json内部保存zkVM host の生出力(集計結果)。現行フローでは配布対象ではないが、journal.json 生成の元データ
public-input.json公開可能zkVM 検証に使う秘密データを含まない検証用レコード
election-manifest.json公開可能選挙設定の公開監査用スナップショット
close-statement.json公開可能集計締切時点のログ境界を表す公開監査レコード
included-bitmap.json非公開厳密な counted bitmap。隣接オブジェクト(sibling object)として保持、bundle.zip には含まない
seen-bitmap.json非公開厳密な presented bitmap。隣接オブジェクト(sibling object)として保持、bundle.zip には含まない
bundle.zip公開可能receipt.json + journal.json + public-input.json + election-manifest.json + close-statement.json の配布対象アーカイブ
verification.json非公開検証サービスの出力。POST /api/verification/run 後に S3 sibling object として追加されうる

receipt.jsonjournal.json は S3 直下には保存されず、bundle.zip の内部エントリとして配布されます。 非同期 finalize では public-input.jsonelection-manifest.jsonclose-statement.json は個別ファイルとしても S3 に保存され、bundle.zip にも同梱されます。 *-receipt.json*-output.json は現行の配布対象ではありませんが、input.json のような秘匿入力ではなく、配布用アーティファクトを組み立てる前段の内部保存データです。 S3 バケット自体はパブリックアクセス全ブロックです。ここでの「公開可能」は機密性上の区分であり、配布経路は バンドル構造 を参照してください。

エンドツーエンドのデータフロー

投票から検証までの全データフローを、レイヤ間の通信として示します。

sequenceDiagram
  participant C as クライアント
  participant W as Web レイヤ<br/>(Amplify Hosting)
  participant A as API レイヤ<br/>(API GW + Lambda)
  participant V as 検証レイヤ<br/>(verifier-service-runner)
  participant D as Data レイヤ<br/>(AppSync + DDB)
  participant P as Prover レイヤ<br/>(SQS → SFN → ECS)
  participant S as Storage レイヤ<br/>(S3)

  Note over C,D: 投票フェーズ
  C->>W: ページ読み込み
  C->>A: POST /api/session
  A->>D: セッション作成
  C->>A: POST /api/vote
  A->>D: 投票 + コミットメント保存
  A-->>C: 投票レシート返却
  Note over A,D: ボット投票を自動追加

  Note over C,P: 集計フェーズ
  C->>A: POST /api/finalize
  alt FINALIZE_ASYNC_MODE=true
    A->>P: SQS メッセージ送信
    A-->>C: 202 Accepted
    P->>P: イメージ署名検証
    P->>P: ECS タスクで証明生成
    P->>S: input.json + 実行成果物 + 公開監査アーティファクト + bundle.zip 保存
    P->>D: コールバックで結果書き込み
  else FINALIZE_ASYNC_MODE=false
    A->>A: 同期で zkVM 実行
    A-->>C: 200 OK(集計結果)
  end

  Note over C,S: 検証フェーズ
  C->>A: GET /api/verify
  A->>D: セッション + 集計結果取得
  A-->>C: 検証ペイロード返却
  C->>A: POST /api/verification/run
  A->>V: verifier-service-runner を同期起動
  V->>S: bundle.zip 取得
  V->>V: verifier-service 実行
  opt USE_S3=true
    V->>S: verification.json と更新済み bundle.zip を保存
  end
  V-->>A: 検証結果返却
  A-->>C: 検証結果返却

verifier-service-runner は S3 書き戻しが有効な構成では、verification.json に加えて更新済みの bundle.zip を保存します。bitmap artifact が利用可能な場合は、それらの private sibling object も同じ execution 配下に保持されます。

ネットワーク構成

VPC

Terraform が管理する VPC は、ECS Fargate タスク専用です。Amplify 管理のリソース(Lambda、AppSync)は VPC 外で動作します。

flowchart TD
  subgraph VPC["VPC (10.0.0.0/16)"]
    subgraph AZ1["ap-northeast-1a"]
      S1["パブリックサブネット<br/>10.0.1.0/24"]
    end
    subgraph AZ2["ap-northeast-1c"]
      S2["パブリックサブネット<br/>10.0.2.0/24"]
    end
    SG["セキュリティグループ<br/>Egress: 443 のみ"]
  end

  IGW["インターネット<br/>ゲートウェイ"]
  ECR["ECR<br/>(イメージ取得)"]
  S3["S3<br/>(バンドル保存)"]

  VPC --> IGW
  IGW --> ECR
  IGW --> S3

ECS タスクはパブリック IP を持ちますが、セキュリティグループがインバウンドを全拒否するため、外部からのアクセスはできません。アウトバウンドは HTTPS(ポート 443)のみ許可され、ECR からのイメージ取得と S3 へのアップロードに使用されます。

監視とロギング

CloudWatch ログ群

ログ群対象保持期間
/aws/ecs/{project}-prover-{env}ECS Fargate タスクdevelop: 7 日 / main: 14 日
/aws/stepfunctions/{project}-prover-{env}Step Functions 実行develop: 7 日 / main: 14 日
/aws/codebuild/{project}-fargate-prover-{env}環境別 prover CodeBuilddevelop: 7 日 / main: 14 日
/aws/codebuild/stark-ballot-simulator-risc0-toolchain-builder共有 toolchain CodeBuild14 日
/aws/lambda/{project}-check-image-signature-{env}イメージ署名検証 Lambdadevelop: 7 日 / main: 14 日
/aws/lambda/*hono-api* などの Amplify 生成ログ群Amplify 管理 Lambda 4 種develop: 7 日 / main: 14 日
/aws/apigateway/{project}-hono-api-{env}API Gateway アクセスログdevelop: 7 日 / main: 14 日

Amplify 管理 Lambda の実際のロググループ名には app / branch 由来の識別子が入るため、運用時は runbook の discovery クエリで特定します。

CloudTrail(main 環境のみ)

main 環境では CloudTrail による全リージョン監査ログが有効です。

項目設定
対象リージョン全リージョン
ログ検証有効
保存先専用 S3 バケット + CloudWatch Logs
保持期間90 日

非同期プローバー

SQS → Step Functions → ECS Fargate による非同期証明パイプラインの設計を解説します。

STARK 証明の生成は 64 票で約 6 分を要するため、同期的な HTTP リクエスト内では完了できません。非同期パイプラインにより、Web リクエストのタイムアウトを回避しつつ、高負荷な証明生成を安全に実行します。

この章は FINALIZE_ASYNC_MODE=true で動作する非同期経路を対象としています。

パイプライン全体像

flowchart TB
  API["POST /api/finalize"] --> SQS["SQS<br/>ワークキュー"]
  SQS --> DP["prover-dispatch-proxy<br/>Lambda"]
  DP --> S3U["S3<br/>input.json 保存"]
  DP --> SFN["Step Functions<br/>StartExecution"]
  SFN --> SIG["check-image-signature<br/>Lambda"]
  SIG --> CHK{"署名 COMPLETE?"}
  CHK -->|Yes| ECS["ECS Fargate<br/>RunTask"]
  CHK -->|No| SFNFAIL["Step Functions<br/>署名失敗分岐"]
  ECS --> S3W["S3<br/>bundle / sibling artifacts 保存"]
  ECS --> SFNRET["Step Functions<br/>成功/失敗を判定"]
  SFNFAIL --> CB["finalize-callback-runner<br/>Lambda"]
  SFNRET --> CB
  CB --> DDB["AppSync/DynamoDB<br/>セッション更新"]

詳細な失敗分岐(署名検証 NG、プローバー実行失敗)は、後続のステートマシン図で示します。

各フェーズの詳細

フェーズ 1: リクエスト受付

クライアントが POST /api/finalize を呼び出すと、API ハンドラーは以下の処理を行います。

以下は FINALIZE_ASYNC_MODE=true の場合です。

  1. セッションの状態を検証(全投票が完了していること)
  2. zkVM 入力を構築(入力ビルダーが投票データ + Merkle パスを組み立て)
  3. セッションの finalizationState を「pending」に更新
  4. SQS キューにメッセージを送信
  5. クライアントに 202 Accepted を返却

クライアントはその後、GET /api/sessions/:id/status をポーリングして進捗を確認します。

フェーズ 2: ディスパッチ

prover-dispatch-proxy Lambda が SQS メッセージを受信し、以下を実行します。

  1. zkVM 入力 JSON を S3 にアップロード(sessions/{sessionId}/{executionId}/input.json
  2. Step Functions のステートマシンを StartExecution で起動
  3. セッションの finalizationState を「running」に更新し、executionId を記録

注: S3 パスプレフィックスは Terraform 変数 s3_proof_prefix で変更可能です。ただし Amplify 側の環境変数・一部 IAM/CLI 補助ポリシー・verifier-service-runner が sessions/ を前提としているため、変更時はそれらも合わせて更新が必要です。

フェーズ 3: 証明生成

Step Functions ステートマシンが 3 つのステップを順次実行します。

stateDiagram-v2
  [*] --> VerifyImageSignature
  VerifyImageSignature --> CheckImageSignature
  CheckImageSignature --> RunProver: 署名 COMPLETE
  CheckImageSignature --> FinalizeSignatureFailed: 署名 NG
  RunProver --> FinalizeSucceeded: 成功
  RunProver --> FinalizeFailed: 失敗
  FinalizeSignatureFailed --> [*]
  FinalizeSucceeded --> [*]
  FinalizeFailed --> [*]

VerifyImageSignature

check-image-signature Lambda を呼び出し、ECR イメージのダイジェストに対する AWS Signer 署名のステータスを確認します。詳細は イメージ署名 を参照してください。

CheckImageSignature

Choice ステートで署名ステータスを判定します。COMPLETE であれば RunProver に進み、それ以外は FinalizeSignatureFailed に遷移してコールバック Lambda に失敗を通知します。

RunProver

ECS Fargate タスクを ecs:runTask.sync(同期モード)で起動します。Step Functions はタスクの完了を待機し、成功/失敗に応じて対応するコールバックステートに遷移します。

現行の Terraform 実装には TIMED_OUT 専用ステートはなく、Step Functions の終端は FinalizeSucceeded / FinalizeFailed / FinalizeSignatureFailed の 3 つです。callback Lambda は将来の拡張に備えて TIMED_OUT も受理できますが、現行の State Machine からは送出されません。

ECS タスクのコンテナには、Step Functions の入力からセッション固有の環境変数が注入されます。

環境変数値の由来説明
ENV_NAMETerraform 変数環境名(develop / main)
S3_PROOF_BUCKETTerraform 変数証明バンドルバケット名
INPUT_S3_BUCKETTerraform 変数入力ファイルのバケット(同上)
INPUT_S3_KEYStep Functions 入力セッション固有の入力パス
OUTPUT_S3_BUCKETTerraform 変数出力先バケット(同上)
OUTPUT_S3_PREFIXStep Functions 入力sessions/{sessionId}/{executionId}/

フェーズ 4: 結果通知

Step Functions が finalize-callback-runner Lambda を呼び出し、以下の情報をセッションに書き戻します。

  • 成功時: bundle metadata(s3BundleKey、presigned URL、有効期限、アップロード時刻)と、bundle.zip / bitmap artifact から復元した finalizationResult
  • 失敗時: 失敗状態とエラー情報(イメージ署名失敗、プローバーエラーなど)

ECS タスクの実行フロー

ECS Fargate タスク内のコンテナは、エントリポイントスクリプトにより以下の処理を順次実行します。

  1. S3 から入力 JSON をダウンロード
  2. 入力の構造を検証(必須フィールド確認)
  3. zkVM ホストバイナリを実行(タイムアウト: 900 秒)
  4. ジャーナルを JSON に変換
  5. public-input.jsonelection-manifest.jsonclose-statement.json を構築
  6. journal.json と公開監査アーティファクトの整合性を検証
  7. bundle.zip を作成(receipt.json + journal.json + public-input.json + election-manifest.json + close-statement.json
  8. 生成物と private sibling artifacts を S3 にアップロード(リトライ付き)

入力の検証

エントリポイントは、zkVM 入力 JSON に必要なフィールドが存在することを確認します。欠落があればタスクは即座に失敗し、Step Functions が失敗コールバックを実行します。

zkVM ホストバイナリの実行

コンテナ内の /opt/zkvm/bin/host がプローバーとして起動されます。タイムアウトは 900 秒(15 分)です。本番モード(RISC0_DEV_MODE 未設定)では実際の STARK 証明が生成され、64 票で約 370 秒を要します。

S3 アップロード

生成されたアーティファクトは、指数バックオフ付きのリトライ(最大 3 回、基底 2 秒)で S3 にアップロードされます。主にアップロードされるファイルは以下です。

ファイル説明
*-receipt.jsonzkVM host の生出力(レシート)
*-output.jsonzkVM host の生出力(集計結果)
*-journal.jsonzkVM host が出力した場合のみ残る生の journal artifact
public-input.jsonエントリポイントが構築する、秘密データを含まない検証用レコード
election-manifest.json選挙設定の公開監査用スナップショット
close-statement.json集計締切時点のログ境界を表す公開監査レコード
included-bitmap.json厳密な counted bitmap。bundle.zip には含めず、隣接オブジェクト(sibling object)として保持
seen-bitmap.json厳密な presented bitmap。bundle.zip には含めず、隣接オブジェクト(sibling object)として保持
bundle.zipreceipt.json + journal.json + public-input.json + election-manifest.json + close-statement.json の配布対象アーカイブ

配布と callback 復元の主経路は bundle.zip 内の receipt.json / journal.json / 公開監査アーティファクトです。bitmap artifact は利用可能な場合に sibling object として callback から追加復元されます。配布経路の詳細は バンドル構造 を参照してください。

SQS キュー設計

ワークキュー

項目設定理由
可視性タイムアウト1000 秒zkVM 実行タイムアウト(900 秒)+ バッファ
メッセージ保持期間4 日一時的な障害からの回復猶予
ロングポーリング20 秒Lambda のポーリングコスト最適化
暗号化SQS マネージド SSEデフォルト暗号化

デッドレターキュー(DLQ)

3 回の受信失敗後、メッセージは DLQ に移動されます。DLQ のメッセージ保持期間は 14 日で、手動での障害調査と再処理に使用されます。

ECS Fargate タスク仕様

項目設定
CPU16 vCPU(16384 ユニット)
メモリ32 GB(32768 MiB)
アーキテクチャARM64(Graviton)
ネットワークモードawsvpc
起動モデルRunTask(サービスなし、1 回限りのタスク)
イメージ指定ダイジェスト固定(@sha256:...
ログドライバーCloudWatch Logs(awslogs)

ARM64 アーキテクチャの選択は、RISC Zero の STARK 証明生成における Graviton プロセッサのコスト効率に基づいています。非 GPU 前提のこの構成は PoC の意図的な制約です。詳細は PoC の意図的な制約 > 非 GPU 前提の証明実行 を参照してください。

クライアント側のポーリング

非同期証明の進捗は、クライアントが GET /api/sessions/:id/status をポーリングして確認します。

stateDiagram-v2
  [*] --> pending: POST /api/finalize → 202
  pending --> running: dispatch-proxy が SFN を起動
  running --> succeeded: callback-runner が結果を書き込み
  running --> failed: エラー発生
  succeeded --> [*]
  failed --> [*]
ステータス説明
pendingファイナライズ要求を受理済み(実装上は pending 更新後に SQS 送信)
runningStep Functions が実行中
succeeded証明生成と結果の書き戻しが完了
failedコールバック経由で失敗が書き戻された状態(署名検証失敗、プローバーエラー等)
timeoutfinalize-callback-runnerTIMED_OUT を受理した場合の状態(現行 State Machine では通常未使用)

注: prover-dispatch-proxy が Step Functions 起動前に失敗した場合、コールバックが走らないため pending のまま再試行/DLQ 待ちになることがあります。

障害時の調査導線

非同期証明がスタックした場合の最小調査パスです。

flowchart TD
  START["finalize がスタック"] --> Q1{"SQS に<br/>メッセージあり?"}
  Q1 -->|No| A1["API → SQS の送信を確認<br/>環境変数 PROVER_WORK_QUEUE_URL"]
  Q1 -->|Yes| Q2{"dispatch-proxy<br/>ログに成功あり?"}
  Q2 -->|No| A2["Lambda ログを確認<br/>SQS → Lambda のトリガー設定"]
  Q2 -->|Yes| Q3{"SFN の<br/>実行状態は?"}
  Q3 -->|署名失敗| A3["ECR イメージ署名を確認<br/>Signer プロファイル設定"]
  Q3 -->|ECS 失敗| Q4{"ECS タスク<br/>ログに出力あり?"}
  Q4 -->|No| A4["タスク起動失敗<br/>IAM / サブネット / イメージ URI"]
  Q4 -->|Yes| A5["エントリポイントエラーを確認<br/>入力検証 / プローバー実行"]
  Q3 -->|コールバック失敗| A6["callback-runner ログを確認<br/>AppSync 書き込み権限"]

イメージ署名

AWS Signer によるプローバーコンテナイメージの署名と実行前確認を解説します。

STARK 証明は「特定のゲストプログラムが正しく実行された」ことを保証しますが、そもそもそのゲストプログラムを含むコンテナイメージ自体が改ざんされていないことも保証する必要があります。イメージ署名は、信頼されたビルドパイプラインが生成したイメージのみが証明生成に使用されることを担保するセキュリティゲートです。

脅威モデル

イメージ署名が防止する攻撃シナリオを示します。

署名なしの場合

  1. 攻撃者が ECR イメージを改ざんしたものに置換
  2. 改ざんされたプローバーが不正な集計結果を生成
  3. 不正な結果に対して有効な STARK 証明が存在

署名ありの場合

  1. 攻撃者が ECR イメージを改ざんしたものに置換
  2. Step Functions が署名ステータスを確認
  3. 署名なし/未完了を検出 → タスク起動を拒否

STARK 証明は Image ID(ゲストバイナリの暗号的識別子)に紐づきますが、イメージ署名はそれとは別のレイヤで「コンテナイメージ全体の完全性」を保証します。両者は相補的な防御を形成します。

保証の種類メカニズム検出対象
ゲストプログラムの同一性Image ID(RISC Zero)ゲストバイナリの改変
コンテナイメージの完全性AWS Signerイメージ全体の改変(ホスト含む)

署名フロー

ビルドと署名

CodeBuild がプローバーコンテナイメージをビルドして ECR に push し、その後 ECR 上で解決されたイメージ digest を Terraform の ecs_image_uri に反映します。Step Functions は、その digest 固定のイメージ参照に対して署名ステータスを確認します。 ECR マネージド署名が有効な環境では、push 後に AWS Signer プロファイルに基づく署名ステータスが対象 digest に付与されます。

sequenceDiagram
  participant GH as GitHub
  participant CB as CodeBuild
  participant ECR as ECR
  participant SGN as AWS Signer

  GH->>CB: ソースコード取得
  CB->>CB: Docker イメージビルド<br/>(ARM64)
  CB->>ECR: イメージをプッシュ<br/>(タグ付き)
  ECR-->>CB: digest を解決
  ECR->>SGN: (ECR managed signing 有効時)署名処理
  SGN->>ECR: 署名ステータス更新
  Note over ECR: deploy/runtime では<br/>digest 固定で参照

注: このリポジトリでコード化されているのは署名ステータス確認(DescribeImageSigningStatus)です。
署名付与そのもの(ECR managed signing の有効化)は、ECR 側の設定・運用が前提です。

CodeBuild の build/push では運用上のタグを使用できますが、Terraform に渡す ecs_image_uri と Step Functions が署名確認する対象は常にダイジェスト固定(@sha256:<64-hex>)です。これにより、タグの上書きによるイメージのすり替えを防止します。

実行前確認

Step Functions ステートマシンの最初のステートで、check-image-signature Lambda がイメージの署名ステータスを確認します。

stateDiagram-v2
  [*] --> VerifyImageSignature: Step Functions 開始
  VerifyImageSignature --> CheckSignatureResult

  state CheckSignatureResult <<choice>>
  CheckSignatureResult --> RunProver: status = COMPLETE
  CheckSignatureResult --> SignatureFailed: status ≠ COMPLETE

  state SignatureFailed {
    [*] --> CallbackFailed: エラー情報を通知
  }

  state RunProver {
    [*] --> ECSTask: 署名ステータス確認済みイメージで実行
  }

check-image-signature Lambda は以下の処理を行います。

  1. ECR の DescribeImageSigningStatus API を呼び出す
  2. 指定されたリポジトリ名とイメージダイジェストに対する署名ステータスを取得
  3. 取得した statusCOMPLETE / それ以外)を Step Functions に返す

署名ステータスが COMPLETE でない場合、Step Functions の Choice ステートが FinalizeSignatureFailed に遷移し、コールバック Lambda に ImageSignatureVerificationFailed エラーを通知します。ECS タスクは一切起動されません。

ステータス確認と暗号学的検証の違い 本システムの実行前チェックは、ECR の DescribeImageSigningStatus API が返す署名ステータス(COMPLETE / それ以外)を確認するものであり、署名値そのものの暗号学的検証(署名の正当性確認や証明書チェーンの検証)ではありません。 これは、AWS ECR が署名のステータス照会 API は提供する一方、署名を暗号学的に独立検証する API を提供していないことによるものです。ECR managed signing の信頼モデルは、署名の付与・管理を AWS インフラに委任し、利用者はステータスを参照する設計です。 独立した署名検証が必要な場合は、Notation 等の外部ツールの併用が選択肢となります。

ECR リポジトリとイメージ管理

リポジトリ構成

リポジトリ用途ライフサイクル
stark-ballot-simulator/zkvm-prover-{env}プローバーコンテナイメージ最新 10 イメージを保持
stark-ballot-simulator/risc0-toolchainRISC Zero ツールチェーンベースイメージ最新 5 イメージを保持

両リポジトリとも、プッシュ時の脆弱性スキャン(Scan on Push)が有効です。

ダイジェスト固定

Terraform の ecs_image_uri 変数には、ダイジェスト固定の URI のみが許可されます。バリデーションルールにより @sha256:<64-hex> 形式が強制されます。

Step Functions の定義に含まれるイメージダイジェストは、Terraform の変数から以下のように抽出されます。

  • リポジトリ名: URI の @ より前の部分からレジストリホストを除去
  • ダイジェスト: URI の @ より後の部分(sha256:...

この分解により、check-image-signature Lambda は正確なリポジトリとダイジェストの組み合わせで署名ステータスを確認できます。

ビルドパイプライン

CodeBuild プロジェクト

2 つの CodeBuild プロジェクトがイメージのビルドを担当します。

プロジェクトビルド対象タイムアウトインスタンス
stark-ballot-simulator-fargate-prover-{env}プローバーイメージ30 分ARM64 Small
stark-ballot-simulator-risc0-toolchain-builderベースイメージ120 分ARM64 Large

RISC Zero ツールチェーンのビルドは低頻度(ツールチェーンバージョン更新時のみ)ですが、ビルドに時間を要するため Large インスタンスと長いタイムアウトが設定されています。

CodeBuild の IAM 権限

CodeBuild ロールには以下の権限が付与されています。

権限カテゴリ対象 API目的
ECRGetAuthorizationToken, PutImageイメージのプッシュ
AWS SignerSignPayload, GetSigningProfile署名連携用の権限(運用/拡張時)
CloudWatch LogsCreateLogGroup, PutLogEventsビルドログの出力
S3GetObject, PutObjectビルドアーティファクト
CodeStar Connectionscodestar-connections:UseConnection, GetConnectionToken接続方式切り替えに備えた権限(現行 CodeBuild source は GITHUB

Image ID との関係

イメージ署名と Image ID は異なるレイヤのセキュリティメカニズムですが、共に「正しいプログラムが実行されたこと」の信頼チェーンを構成します。

flowchart TD
  subgraph "ビルド時"
    BUILD["コンテナイメージ<br/>ビルド"] --> SIGN["AWS Signer で<br/>イメージ署名"]
    BUILD --> IMGID["Image ID 計算<br/>(ゲストバイナリから導出)"]
    IMGID --> MAP["imageId-mapping.json<br/>に記録"]
  end

  subgraph "実行時"
    VERIFY_SIG["イメージ署名ステータス確認<br/>(Step Functions)"] --> RUN["プローバー実行"]
    RUN --> RECEIPT["レシート生成<br/>(Image ID を含む)"]
  end

  subgraph "検証時"
    RECEIPT --> VERIFY_RECEIPT["レシート検証<br/>(verifier-service)"]
    MAP --> VERIFY_RECEIPT
    VERIFY_RECEIPT --> MATCH{"Image ID<br/>一致?"}
  end

  SIGN --> VERIFY_SIG
検証ポイントタイミング検証主体失敗時の動作
イメージ署名証明生成前Step Functions + LambdaECS タスクの起動拒否
Image ID 照合検証時verifier-service検証失敗の報告

Terraform

IaC(Infrastructure as Code)による AWS リソース管理の設計を解説します。

Terraform は、証明生成パイプラインに関わる AWS リソース(ECS、Step Functions、SQS、S3、ECR、CodeBuild、VPC、Lambda)を宣言的に管理します。Amplify Gen 2 が管理するリソース(Web ホスティング、AppSync、hono-api Lambda 等)とは明確に分離されています。

ディレクトリ構成

Terraform の構成ファイルは terraform/ ディレクトリに配置され、機能別に分割されています。

ファイル管理対象
backend.tfS3 ステートバックエンド + S3 lockfile
versions.tfTerraform / プロバイダーのバージョン制約
main.tfローカル変数、環境設定、データソース
variables.tf入力変数の定義とバリデーション
outputs.tf他ツール連携用の出力値
terraform.tfvars.example公開向け sanitized tfvars 例
develop.tfvarsdevelop 用の tracked 設定
main.tfvarsmain 用の tracked 設定
iam.tfIAM ロール / ポリシー(ECS、Step Functions、CodeBuild)
ecs.tfECS クラスター + Fargate タスク定義
step_functions.tfステートマシン定義(ASL)
sqs.tfワークキュー + デッドレターキュー
s3.tf証明バンドルバケット + ライフサイクル
ecr.tfECR リポジトリ + ライフサイクルポリシー
codebuild.tfビルドプロジェクト(プローバー + ツールチェーン)
lambda_check_image_signature.tfイメージ署名検証 Lambda
lambda/check-image-signature/イメージ署名検証 Lambda のソース
vpc.tfVPC + サブネット + インターネットゲートウェイ
security_groups.tfECS タスク用セキュリティグループ
cloudwatch.tfログ群 + 保持期間設定
cloudtrail.tf監査証跡(main 環境のみ)

環境分離

ワークスペース戦略

developmain の 2 環境を、Terraform ワークスペースと tfvars ファイルの組み合わせで管理します。

flowchart LR
  subgraph "Terraform State"
    S3["S3 バケット<br/>terraform-state"]
    S3 --> DEV["develop<br/>workspace"]
    S3 --> MAIN["main<br/>workspace"]
  end

  subgraph "tfvars"
    DEVF["develop.tfvars"]
    MAINF["main.tfvars"]
  end

  DEVF --> DEV
  MAINF --> MAIN

environment 変数はバリデーションにより develop または main のみが許可されます。環境ごとの差分は locals で定義された設定マップにより解決されます。

設定developmain
S3 ライフサイクル7 日30 日
ログ保持期間7 日14 日
CloudTrail無効有効(90 日)

ワークスペースの確認

環境の取り違えを防ぐため、操作前にワークスペースの確認が推奨されます。

ステート管理

S3 バックエンド

Terraform ステートは S3 バケットに保存され、use_lockfile = true による S3 lockfile でステートの同時変更を防止します。

項目設定
ステートバケットstark-ballot-simulator-terraform-state
ステートキーterraform.tfstate
ロック方式S3 lockfile (use_lockfile = true)
リージョンap-northeast-1
暗号化AES256

named workspace ごとに state path と lockfile path が分かれるため、developmain を同じ backend bucket で安全に運用できます。

認証方式

Terraform の実行は STS AssumeRole を前提としています。
具体的な認証ツール(AWS IAM Identity Center / aws-vault など)は運用環境に依存し、Terraform コードの責務外です。

flowchart LR
  EXEC["実行環境<br/>(ローカル/CI)"] --> STS["AWS STS<br/>AssumeRole"]
  STS --> ROLE["Terraform 実行ロール<br/>IAM ロール"]
  ROLE --> TF["Terraform 実行"]
項目設定
認証方式STS AssumeRole
IAM ロールTerraform 実行用ロール(名称は環境依存)
資格情報の保護方式組織の標準(SSO / aws-vault / Keychain / KMS など)
権限最小権限を原則とする

主要な入力変数

Terraform の実行に必要な変数と、そのバリデーションルールの概要です。

必須変数

変数説明バリデーション
environmentデプロイ環境develop または main
ecs_image_uriプローバーイメージ URIダイジェスト固定形式(@sha256:<64-hex>
finalize_callback_lambda_arnコールバック Lambda の ARN
ecr_signing_profile_arnAWS Signer プロファイルの ARN空文字不可
codestar_connection_arnCodeStar Connections ARN(IAM ポリシーで参照)空文字不可

注: 現行の CodeBuild sourceGITHUB タイプ(location 指定)で構成されています。

オプション変数

変数デフォルト説明
aws_regionap-northeast-1デプロイリージョン
project_namestark-ballot-simulatorリソース命名プレフィックス
ecs_cpu16384Fargate の CPU ユニット
ecs_memory32768Fargate のメモリ(MiB)
s3_proof_prefixsessions/S3 パスプレフィックス
s3_cors_allowed_origins[]CORS 許可オリジン
risc0_toolchain_*実装側デフォルトあり共有 toolchain builder の pin 設定

現行の private repo では develop.tfvars / main.tfvars を tracked し、terraform.tfvars.example は公開向けの sanitized example として扱います。

出力値

Terraform の出力値は、Amplify 環境変数や運用ツールから参照されます。

出力参照元用途
prover_state_machine_arnAmplify 環境変数dispatch-proxy が SFN を起動
prover_work_queue_arnAmplify 環境変数PROVER_WORK_QUEUE_ARN 参照
prover_work_queue_urlAmplify 環境変数API が SQS にメッセージ送信
s3_bucket_nameAmplify 環境変数Lambda が S3 にアクセス
ecr_repository_url運用者 / CLIプローバーイメージの push 先確認
risc0_toolchain_repository_url運用者 / CLI共有 toolchain イメージの push 先確認

IAM 設計

最小権限の原則に基づき、各コンポーネントに専用の IAM ロールが割り当てられています。

flowchart TD
  subgraph "信頼されるサービス (Service Principal)"
    ECSSVC["ecs-tasks.amazonaws.com"]
    STATESVC["states.${aws_region}.amazonaws.com"]
    CBSVC["codebuild.amazonaws.com"]
    LAMSVC["lambda.amazonaws.com"]
  end

  subgraph "IAM ロール"
    ETE["ecs_task_execution"]
    ET["ecs_task"]
    SFN["step_functions"]
    CB["codebuild"]
    CIS["check_image_signature"]
  end

  ECSSVC --> ETE
  ECSSVC --> ET
  STATESVC --> SFN
  CBSVC --> CB
  LAMSVC --> CIS
ロール信頼サービス主要権限
ecs_task_executionecs-tasksECR イメージ取得、CloudWatch Logs 書き込み
ecs_taskecs-tasksS3 var.s3_proof_prefix 配下への読み書き(既定: sessions/*
step_functionsstates.${aws_region}.amazonaws.comECS RunTask、Lambda Invoke、ログ
codebuildcodebuildECR 操作、AWS Signer、ログ
check_image_signaturelambdaECR 署名ステータス照会、ログ

スコープの制限

  • ECS タスクロールの S3 権限は var.s3_proof_prefix 配下に制限(既定: sessions/*
  • Step Functions ロールの ECS 権限は特定クラスター ARN に制限
  • Step Functions ロールの iam:PassRole は ECS 関連ロールのみに制限

Amplify との連携ポイント

Terraform と Amplify は相互に独立して管理されますが、以下のポイントで連携します。

flowchart TB
  subgraph TF["Terraform"]
    SFN_ARN["Step Functions ARN"]
    SQS_URL["SQS キュー URL"]
    S3_NAME["S3 バケット名"]
    CB_INPUT["入力変数<br/>finalize_callback_lambda_arn"]
  end

  subgraph AMP["Amplify"]
    ENV["環境変数"]
    CB_ARN["finalize-callback-runner<br/>Lambda ARN"]
  end

  SFN_ARN --> ENV
  SQS_URL --> ENV
  S3_NAME --> ENV
  CB_ARN -. "IaC input" .-> CB_INPUT
方向情報設定方法
Terraform → AmplifySFN ARN、SQS URL、S3 バケット名Terraform 出力値 → Amplify 環境変数
Amplify → Terraformcallback Lambda ARNTerraform 入力変数 finalize_callback_lambda_arn

この双方向の参照により、Amplify が管理する Lambda を Terraform が管理する Step Functions から呼び出すことが可能になります。

バージョン制約

ツールバージョン
Terraform>= 1.10.0
AWS プロバイダー~> 6.0
Archive プロバイダー2.x(terraform/.terraform.lock.hcl で解決)

API リファレンス

この章では、公開ドキュメントとして扱うべき API(ブラウザクライアントと第三者検証で利用するエンドポイント)を、現行実装ベースで説明します。

この部に含まれる章

エンドポイント一覧

この文書は、公開ドキュメントとして扱う API を対象に、現行実装のレスポンス形状・要件を記載します。

対象外(内部向け)

以下は内部運用/デバッグ用途のため、この文書の詳細対象外です。

  • GET /api/debug/enable
  • POST /api/finalize/callback

デュアルランタイム構成

API ハンドラはフレームワーク非依存の共通実装で、Next.js と Hono(Lambda) の両方から利用されます。

ランタイム用途主な入口
Next.js Route Handlerローカル開発 / SSRsrc/app/api/**/route.ts
Hono on LambdaAWS デプロイ APIamplify/functions/hono-api/handler.ts

公開対象 API 一覧

メソッドパス主用途X-Session-IDX-Session-Capability
POST/api/sessionセッション作成不要不要
POST/api/vote投票送信必須不要
GET/api/progressボット投票進捗必須必須
POST/api/finalize集計/証明生成必須必須
POST/api/finalize/cancel非同期集計キャンセル必須必須
GET/api/sessions/:sessionId/status非同期集計ステータス不要必須
GET/api/verify検証ペイロード取得必須必須
POST/api/verification/runSTARK 検証実行必須必須
GET/api/verification/bundles/:sessionId/:executionIdバンドル ZIP 取得不要必須
GET/api/verification/bundles/:sessionId/:executionId/report検証レポート取得不要必須
GET/api/bulletin掲示板一覧(inspection)必須必須
GET/api/bulletin/:voteId/proof最小包含証明必須必須
GET/api/bulletin/consistency-proof整合性証明(tooling)必須必須
GET/api/botdata/:idボット投票データ(tooling)必須必須
GET/api/bitmap-proofビットマップ証明材料必須必須
GET/api/sthSTH スナップショット必須必須
GET/api/zkvm-input-hashzkVM 入力コミットメント不要必須

共通の実装上の注意

ミドルウェア構成

ミドルウェアは「全リクエスト共通の固定チェーン」ではなく、エンドポイント実装ごとに必要な検証を呼び出す方式です。セッションヘッダーの要否は上記一覧テーブルを参照してください。

区分代表エンドポイントTurnstileレート制限
セッション作成POST /api/session環境設定次第セッション作成用
投票POST /api/voteあり投票用
集計POST /api/finalizeありzkVM 用
非同期集計キャンセルPOST /api/finalize/cancelなしキャンセル専用
検証実行POST /api/verification/runなしzkVM 用
読み取り(セッションスコープ)GET /api/progress, GET /api/bulletin*, GET /api/botdata/:id, GET /api/bitmap-proof, GET /api/sth, GET /api/verifyなしなし
読み取り(path/query で session を指す)GET /api/sessions/:sessionId/status, GET /api/verification/bundles/..., GET /api/zkvm-input-hashなしなし

共通セッションエラー(ヘッダースコープ)

X-Session-ID / X-Session-Capability をヘッダーで受け取る大多数のエンドポイントは、validateSessionWithCapability() を経由し、以下の共通エラーを返し得ます。各エンドポイントの「主なエラー」欄ではこれらを省略し、固有エラーのみ記載します。

  • SESSION_ID_REQUIRED (400)
  • SESSION_CAPABILITY_REQUIRED (401)
  • SESSION_CAPABILITY_INVALID (401)
  • SESSION_CAPABILITY_EXPIRED (401)
  • SESSION_NOT_FOUND (404)

パス/クエリで session を指定するエンドポイント(/api/sessions/:sessionId/status, /api/verification/bundles/..., /api/zkvm-input-hash)も capability 検証は共通ですが、session 特定エラーの形式が異なるため、各エンドポイントで個別に記載します。

ボディサイズ制限

共通 JSON パーサーを使うエンドポイントは API_REQUEST_BODY_LIMIT_BYTES(既定 16 KiB)を超えると PAYLOAD_TOO_LARGE (413) を返します。

例外:

  • POST /api/finalize/cancel は現状 request.json() を直接使うため、この共通サイズ制限の対象外です。JSON 不正や payload 不正は handler 固有の 400 を返します。

エラーレスポンスの形式

多くのエンドポイントは errorResponse(...) を通して以下の形式を返します。

  • error(エラーコード)
  • message(メッセージ)
  • statusCode(HTTP ステータス)
  • 必要に応じて details など

例えば以下のエンドポイントでは、session/capability 失敗は標準形式ですが、handler 内部の個別検証では { error: "..." } 系の独自形式を返す場合があります。

  • POST /api/finalize/cancel
  • GET /api/sessions/:sessionId/status
  • GET /api/bulletin/consistency-proof
  • GET /api/bitmap-proof
  • GET /api/zkvm-input-hash

基本フロー API

POST /api/session

新規セッションを作成します。X-Session-ID は不要です。

レスポンス(200):

  • data.sessionId
  • data.electionId
  • data.electionConfigHash
  • data.logId
  • data.capabilityToken

備考:

  • SESSION_CREATE_TURNSTILE_REQUIRED=1 の場合、turnstileToken が必要です。

POST /api/vote

ユーザー投票を保存し、ボット投票を非同期開始します。

要件:

  • ヘッダー: X-Session-ID 必須
  • ボディ: commitment, vote, randturnstileToken は開発設定により省略可)
  • Turnstile 検証あり
  • 投票用レート制限あり

レスポンス(200):

  • data.voteId
  • data.commitment
  • data.bulletinIndex
  • data.bulletinRootAtCast
  • data.timestamp

主なエラー:

  • CAPTCHA_FAILED (403)
  • GLOBAL_LIMIT_EXCEEDED (503)
  • ALREADY_VOTED (400)
  • SESSION_FINALIZED (400)
  • INVALID_REQUEST (400; リクエスト形式不正)
  • INVALID_COMMITMENT (400)
  • DUPLICATE_VOTE (409)
  • PAYLOAD_TOO_LARGE (413)

GET /api/progress

投票進捗を取得します。

レスポンス(200):

  • data.count
  • data.total
  • data.completed
  • data.userVoted
  • data.finalized

主なエラー: 共通セッションエラー(ヘッダースコープ)のみ。

POST /api/finalize

集計と証明生成を開始します。同期/非同期の 2 形態があります。

要件:

  • ヘッダー: X-Session-ID 必須、X-Session-Capability 必須
  • ボディ: scenarioIdS0-S5), turnstileToken(環境により必須)
  • Turnstile 検証あり
  • zkVM レート制限あり

レスポンス(200, 同期):

  • data.sessionId
  • data.tally
  • data.bulletinRoot
  • data.verifiedTally
  • data.voteReceipt
  • data.receipt(同期成功レスポンスで返却)
  • data.receiptPublication(保存時)
  • data.imageId
  • data.userVote
  • data.missingSlots
  • data.invalidPresentedSlots
  • data.rejectedRecords
  • data.missingIndices
  • data.invalidIndices
  • data.countedIndices
  • data.totalExpected
  • data.treeSize
  • data.excludedSlots
  • data.excludedCount
  • data.sthDigest
  • data.seenBitmapRoot(条件付き)
  • data.includedBitmapRoot
  • data.inputCommitment
  • data.seenIndicesCount
  • data.journal
  • data.verificationStatus
  • data.verificationBundleUrl / data.verificationReportUrl / data.verificationReport(条件付き)
  • data.verificationExecutionId(条件付き)
  • data.s3BundleUrl / data.s3BundleKey / data.s3UploadedAt / data.s3BundleExpiresAt(条件付き)
  • data.tamperSummary(条件付き)

補足:

  • verificationBundleUrl は秘密データを除外した配布用アーカイブの capability 認証付き取得先です。s3BundleUrl は同内容の短命な署名付き URL です。いずれも無認証では取得できません。

レスポンス(202, 非同期):

  • executionId
  • statusUrl
  • state
  • queuenull の場合あり)

主なエラー:

  • CAPTCHA_FAILED (403)
  • INVALID_REQUEST (400)
  • VERIFICATION_FAILED (400; CT proof unavailable)
  • USER_NOT_VOTED (400)
  • VOTING_NOT_COMPLETE (400)
  • SESSION_ALREADY_FINALIZED (400)
  • ZKVM_RATE_LIMIT_EXCEEDED (429)
  • GLOBAL_LIMIT_EXCEEDED (503)
  • PAYLOAD_TOO_LARGE (413)
  • Invalid ImageID (400; 独自形式 { error: "Invalid ImageID", details: { expected, actual } })

POST /api/finalize/cancel

進行中の非同期集計をキャンセルします。

要件:

  • FINALIZE_ASYNC_MODE=true のときのみ有効
  • ヘッダー: X-Session-IDx-session-id も受理)
  • ヘッダー: X-Session-Capability 必須
  • ボディ: executionId 必須、reason 任意
  • キャンセル専用レート制限あり

レスポンス(200):

  • state

主なエラー:

  • GLOBAL_LIMIT_EXCEEDED (503)

handler 固有エラー(独自形式):

  • 404: Async finalization disabled
  • 400: Invalid JSON body / payload 不正
  • 409: 現在状態ではキャンセル不可
  • 501: ストアが cancellation 非対応

GET /api/sessions/:sessionId/status

非同期集計の状態を返します。

要件:

  • パスパラメータ sessionId 必須
  • ヘッダー: X-Session-Capability 必須

レスポンス(200):

  • sessionId
  • finalizationStatenull の場合あり)
  • queuenull の場合あり)
  • progress(条件付き)
  • finalizationResultnull の場合あり)
  • stepFunctionsnull の場合あり)
  • asyncFinalizationModeenabled / disabled

主なエラー:

  • SESSION_CAPABILITY_REQUIRED (401)
  • SESSION_CAPABILITY_INVALID (401)
  • SESSION_CAPABILITY_EXPIRED (401)

handler 固有エラー(独自形式):

  • 400: Session ID is required
  • 404: Session not found

検証 API

GET /api/verify

検証画面向けの統合ペイロードを返します。

要件:

  • クエリ: refreshS3=1, includeJournal=1(任意)

レスポンス(200):

  • data.electionId
  • data.electionConfigHash
  • data.logId
  • data.tally
  • data.bulletinRoot
  • data.scenarioId
  • data.verificationStatus
  • data.verificationBundleUrl / data.verificationReport(条件付き)
  • data.verificationSteps / data.verificationChecks
  • data.s3BundleUrl / data.s3UploadedAt / data.s3BundleExpiresAt(条件付き)
  • data.imageId
  • data.tamperDetected
  • data.verifiedTally
  • data.missingSlots
  • data.invalidPresentedSlots
  • data.rejectedRecords
  • data.missingIndices
  • data.invalidIndices
  • data.countedIndices
  • data.totalExpected
  • data.treeSize
  • data.excludedSlots
  • data.excludedCount
  • data.sthDigest
  • data.seenBitmapRoot(条件付き)
  • data.includedBitmapRoot
  • data.inputCommitment
  • data.seenIndicesCount(条件付き)
  • data.journalStatus
  • data.journalincludeJournal=1 のとき)
  • data.voteReceipt(条件付き)
  • data.userVote
  • data.botVotesSummary(条件付き)
  • data.verificationExecutionId(条件付き)
  • data.tamperSummary(条件付き)

fail-closed 応答:

  • verificationStatus が許容セット外でも、ストアから取得可能な finalized session に対しては 200 で通常の data payload を返します。data.verificationStatusfailed に正規化され、verificationSteps / verificationChecks に fail-closed な結果が入ります。

主なエラー:

  • SESSION_NOT_FINALIZED (400)
  • USER_NOT_VOTED (400)

POST /api/verification/run

サーバー側で STARK レシート検証を実行します。

要件:

  • ボディ: JSON オブジェクト(通常は空オブジェクト {}
  • zkVM レート制限あり

レスポンス(200):

  • data.verificationStatussuccess / failed / dev_mode / not_run / running
  • data.verificationExecutionId
  • data.estimatedDurationMs
  • data.idempotent

挙動:

  • 既存結果がある場合や実行中の場合は、再実行せず idempotent: true を返します。

主なエラー:

  • SESSION_NOT_FINALIZED (400)
  • INVALID_REQUEST (400)
  • ZKVM_RATE_LIMIT_EXCEEDED (429)
  • GLOBAL_LIMIT_EXCEEDED (503)
  • PAYLOAD_TOO_LARGE (413)
  • INTERNAL_ERROR (500)

バンドル取得 API

両エンドポイントとも X-Session-Capability ヘッダーによる capability 保護が必須です。S3 配信が有効な場合、または Lambda ランタイムでは 302 で期限付き presigned URL にリダイレクトします。

GET /api/verification/bundles/:sessionId/:executionId

秘密データを含まない配布対象アーカイブ bundle.zip を返します。

レスポンス:

  • 200: ZIP バイナリ
  • 302: S3 presigned URL へリダイレクト

主なエラー:

  • SESSION_CAPABILITY_REQUIRED (401)
  • SESSION_CAPABILITY_INVALID (401)
  • SESSION_CAPABILITY_EXPIRED (401)
  • 400: パラメータ不正
  • 404: バンドル未検出
  • 500: ダウンロード URL 生成失敗 / 読み込み失敗

GET /api/verification/bundles/:sessionId/:executionId/report

検証レポート verification.json を返します。これは capability 保護された非公開レポートであり、公開バンドルには含まれません。

レスポンス:

  • 200: JSON
  • 302: S3 presigned URL へリダイレクト

主なエラー:

  • SESSION_CAPABILITY_REQUIRED (401)
  • SESSION_CAPABILITY_INVALID (401)
  • SESSION_CAPABILITY_EXPIRED (401)
  • 400: パラメータ不正
  • 404: レポート未検出
  • 500: ダウンロード URL 生成失敗 / 読み込み失敗

掲示板 API

GET /api/bulletin

掲示板一覧を返します。

要件:

  • クエリ: offset, limit(任意)

レスポンス(200):

  • commitments
  • bulletinRoot
  • treeSize
  • timestamp
  • rootHistory(条件付き)
  • nextOffset / hasMore(ページング時)

主なエラー:

  • INVALID_OFFSET (400)
  • INVALID_LIMIT (400)
  • INVALID_REQUEST (400; details=BULLETIN_STATE_UNAVAILABLE)

GET /api/bulletin/:voteId/proof

最小形式の包含証明を返します。

要件:

  • セッションは finalized 必須
  • パス: voteId(UUID v4 形式)

アクセス制御:

  • 原則は「そのセッションのユーザー投票」のみ
  • 例外としてシナリオ S3/S4 では対象ボット票を許可

レスポンス(200):

  • voteId
  • proof.leafIndex
  • proof.merklePath
  • proof.treeSize
  • proof.bulletinRootAtCast
  • proof.proofMode

キャッシュ:

  • Cache-Control: private, no-store
  • Vary: X-Session-ID, X-Session-Capability

主なエラー:

  • INVALID_VOTE_ID (400)
  • SESSION_NOT_FINALIZED (400)
  • VOTE_NOT_FOUND (404)
  • VERIFICATION_FAILED (400; CT proof unavailable)

GET /api/bulletin/consistency-proof

RFC6962 整合性証明を返します。

補足:

  • shipped /verify ページの verdict authority には含まれません
  • 現在は secondary tooling / inspection 用の surface として扱います

要件:

  • クエリ: oldSize, newSize 必須

レスポンス(200):

  • oldSize
  • newSize
  • rootAtOldSize
  • rootAtNewSize
  • proofNodes
  • oldSubtreeHashes / appendSubtreeHashes(条件付き)
  • timestamp

主なエラー(handler 固有、独自形式):

  • 400: oldSize / newSize 欠落・不正・範囲外、掲示板未初期化、proof 生成失敗
  • 500: consistency proof 生成中の内部エラー

GET /api/botdata/:id

ボット投票(1..63)のデータと証明を返します。

補足:

  • 将来の bot verification UI の building block であり、現行 /verify ページの authority には含まれません

要件:

  • セッション finalized 必須

レスポンス(200):

  • data.id
  • data.vote
  • data.random
  • data.commitment
  • data.voteId
  • data.timestamp
  • data.proofleafIndex, merklePath, treeSize, bulletinRootAtCast, proofMode

主なエラー:

  • INVALID_BOT_ID (400)
  • SESSION_NOT_FINALIZED (400)
  • BOT_DATA_NOT_FOUND (404)
  • INTERNAL_ERROR (500; CT proof を組み立てられない場合を含む)

補助 API

GET /api/bitmap-proof

ビットマップ証明の材料を返します。

要件:

  • クエリ: i(0 以上整数)必須
  • クエリ: kind 任意(included / seen、省略時は included

備考:

  • 全ストア実装で sessionId をキーに保存済み bitmap を参照します。
  • included は counted された index、seen は prover に提示された index を対象にします。

レスポンス(200):

  • leafChunk
  • auditPath

キャッシュ:

  • ETag 対応
  • If-None-Match 一致時 304
  • Cache-Control: private, max-age=86400, stale-while-revalidate=3600, immutable
  • Vary: X-Session-ID, X-Session-Capability

主なエラー(handler 固有、独自形式):

  • INVALID_INDEX (400)
  • INVALID_BITMAP_KIND (400)
  • BITMAP_NOT_FOUND (404)
  • INTERNAL_ERROR (500)

GET /api/sth

STH スナップショットを返します。

要件:

  • finalized セッションのみ

レスポンス(200):

  • sth.sthDigest
  • sth.bulletinRoot
  • sth.treeSize
  • sth.timestamp
  • sth.logId

備考:

  • 現行実装の sth.timestamp はジャーナル内時刻ではなく、session.lastActivity を返します。

主なエラー:

  • SESSION_NOT_FINALIZED (404; このエンドポイント固有の扱い)
  • INTERNAL_ERROR (500; 例: finalized 済みだが journal が欠落している場合)

GET /api/zkvm-input-hash

セッション由来の zkVM 入力コミットメントを返します。

要件:

  • クエリ: sessionId 必須
  • ヘッダー: X-Session-Capability 必須
  • クエリ: includeData 任意(true / 1 / yes で有効)
  • includeData=true は debug authorization が必要

レスポンス(200):

  • inputCommitment
  • dataincludeData 有効時のみ)

主なエラー:

  • SESSION_CAPABILITY_REQUIRED (401)
  • SESSION_CAPABILITY_INVALID (401)
  • SESSION_CAPABILITY_EXPIRED (401)

handler 固有エラー(独自形式):

  • INVALID_REQUEST (400)
  • SESSION_NOT_FOUND (404)
  • SESSION_NOT_FINALIZED (400)
  • CT_PROOF_UNAVAILABLE (400)
  • INCLUDE_DATA_FORBIDDEN (403)
  • INTERNAL_ERROR (500)

セッションライフサイクル

この文書は、セッション管理の実装を クライアント側とサーバー側に分けて説明します。

1. 管理責務の分離

管理面主な保存先主な責務
クライアント共有localStorage (starkBallotSession, starkBallotSessionSchemaVersion)画面遷移フェーズ、クライアント TTL、検証継続状態、UI 復元、スキーマ無効化
クライアントタブ単位sessionStorage (starkBallotSessionLock)タブごとの session identity lock、stale tab の fail-closed
サーバーVoteStore 実装(Mock/File/Amplify)投票データ、掲示板、集計結果、検証結果

クライアントとサーバーのセッション対応付けには sessionIdX-Session-Capability(署名トークン)が使われます。 ヘッダー / path / query の使い分けはエンドポイント一覧を参照してください。

補足:

  • ensureClientStorageSchema()starkBallotSessionSchemaVersion を確認し、不一致時は starkBallotSessionstark-ballot-knowledgestarkBallotSessionLock をまとめてクリアします。

2. クライアント側フェーズ

クライアントセッション(src/lib/session/client.ts)のフェーズは以下の 3 つです。

  • voting
  • finalizing
  • verifying

主な遷移トリガー:

  • POST /api/session 後に generateSessionId(sessionId, capabilityToken)voting 開始
  • aggregate 画面で非同期集計の pending/running を検知すると identity-scoped helper で phase: 'finalizing' を保存
  • aggregate または result 画面で canonical(現行契約で受理可能な集計スナップショット)な finalizeResult を保存できると identity-scoped helper で phase: 'verifying' へ進む
  • /result から /verify へ進む時は verificationRequestedAt を保存し、POST /api/verification/run を必要に応じて先行起動する
  • /verify の継続判定:
    1. verificationRequestedAt と canonical な finalizeResult の両方がそろっていれば継続扱い(hasContinuationAuthority
    2. 上記がなくても、サーバー返却の STARK 状態が not_run 以外なら進行できる
    3. hasContinuationAuthority 不成立かつ STARK が not_run の場合はブロックする

3. クライアント TTL 実装

SESSION_PHASE_TIMEOUTS_MS:

  • voting: 30 分
  • finalizing: 30 分
  • verifying: 24 時間

TTL 更新の実装ポイント:

  • generateSessionId(...): 新規作成
  • saveSessionData(...) / saveSessionDataForIdentity(...): フェーズを加味して expiresAt を再計算
  • updateLastActivity(...) / updateLastActivityForIdentity(...): 現在フェーズで expiresAt を再延長

期限切れ判定:

  • checkTimeout()getSessionData*()saveSessionData*()updateLastActivity*() は有効期限超過を検出すると clearSession() を実行

補足:

  • 専用の heartbeat API はなく、verify 画面でクライアントが 60 秒間隔で updateLastActivityForIdentity() を呼びローカル TTL を延長します。
  • sanitizeSessionData() は非 canonical な finalizeResult を保存しません。phase: 'verifying' なのに有効な finalizeResult がない場合は verificationRequestedAt を削除し、phasevoting に巻き戻します。

4. サーバー側セッション状態

サーバーは SessionData に以下を保持します。

  • 投票(votes, userVoteIndex, botCount
  • 集計状態(finalizationState
  • 集計結果(finalizationResult
  • 最終活動時刻(lastActivity

finalizationState.status は以下を取り得ます。

  • pending
  • running
  • succeeded
  • failed
  • timeout

5. サーバー側 TTL / 失効の実装差分

サーバー側の失効挙動はストア実装で異なります。

ストア失効/TTL の実装
MockSessionStoregetActiveSessionCount() 呼び出し時に lastActivity から 5 分超を掃除
FileMockSessionStoregetActiveSessionCount() 呼び出し時に同様に 5 分超を掃除
AmplifySessionStoreTTL 属性を保存。初期は AMPLIFY_DATA_TTL_SECONDS(既定 1800 秒)、finalized を伴う保存では AMPLIFY_DATA_VERIFICATION_TTL_SECONDS(既定 86400 秒)へ延長

補足:

  • Amplify の TTL 延長対象は finalizeSession(), markFinalizationSucceeded(), saveBitmapData() など finalized: true を伴う保存。finalizationResult のみの後続更新では延長 TTL は適用されず、通常 TTL で再保存されます。

重要事項:

  • /api/sessionMAX_SESSIONS を参照し、上限到達時は SESSION_LIMIT_EXCEEDED を返します。

6. セッションヘッダースコープ

エンドポイントごとの X-Session-ID / X-Session-Capability の要否はエンドポイント一覧を参照してください。

POST /api/session のみヘッダー不要で、それ以外の公開 API は少なくとも一方が必須です。

7. マルチタブ時の実務上の注意

localStorage は同一オリジンで共有されますが、現行実装は sessionStorage の tab lock を併用し、別タブがセッションを差し替えたら stale tab を fail-closed にします。

代表的な結果:

  • 片方のタブで投票済み後、別タブで再投票すると ALREADY_VOTED
  • 片方のタブで集計完了後、別タブで再集計すると SESSION_ALREADY_FINALIZED
  • aggregate / result / verify / bot progress の stale tab は storage イベントを検知し、sessionReplaced として停止します
  • セッション作成を並行すると starkBallotSession 自体は共有更新されますが、先に開いていたタブは starkBallotSessionLock と不一致になり継続利用できません

第三者検証ガイド

リポジトリ非公開に関する注意 本リポジトリ(stark-ballot-simulator)は現時点で非公開です。ソースコードへのアクセスが必要な手順(verifier-service のビルド、imageId-mapping.json の参照、election-manifest.json / close-statement.json の整合確認、inputCommitment 再計算)は、リポジトリが公開されるまで実行できません。 ソースコード不要の手順(bundle.zip の展開、journal.json の完全性チェック)は、ダウンロード済みの ZIP のみで実行可能です。

この章は、アプリの検証ページでダウンロードした bundle.zip を使って、第三者がローカルで行える最小監査手順をまとめたものです。

この章の手順だけで /verify 画面の最終判定を完全再現するわけではありません。ここで確認できるのは主に次の 5 点です。

  • receipt.json に対する STARK レシート検証
  • journal.json に対する除外件数・集計整合の確認
  • journal.json に対する totalExpected == treeSize の確認
  • election-manifest.json / close-statement.json の公開監査アーティファクト整合確認
  • public-input.json に対する inputCommitment 再計算

一方で、UI の最終判定に関わる次の材料は bundle.zip 単体では通常そろいません。

  • 掲示板の包含証明 / 整合性証明
  • 自票 inclusion 用のビットマップ証明
  • 有効化されている場合の第三者 STH ソース照合

この章で扱う範囲

  • 検証ページから取得した bundle.zip のローカル検証
  • Ubuntu 環境での Rust セットアップ
  • verifier-service を使った STARK レシート検証
  • journal.json の完全性チェック(excludedSlotstotalExpected など)
  • 公開監査アーティファクト(election-manifest.json / close-statement.json)の整合確認
  • public-input.json に対する inputCommitment 再計算

この章で扱わない範囲

  • アプリケーション本体のビルド・デプロイ
  • AWS インフラや非同期 finalize の運用トラブル対応
  • アプリを一から複製して再現実行するための手順

上記の調査が必要な場合は、次のページを参照してください。

最低限確認する不変条件

項目合格条件
STARK レシートverifier-service verifystatus: "success"
投票の除外有無excludedSlots == 0 かつ missingSlots == 0 かつ invalidPresentedSlots == 0
期待投票数整合totalExpected == treeSize
公開監査アーティファクトelection-manifest.jsonclose-statement.json の自己整合・相互整合が成立
入力整合性inputCommitment の再計算値が journal.json と一致

手順

ZIP ローカル検証(Ubuntu)

この手順は、検証ページでダウンロードした bundle.zip を対象に、Ubuntu 上で第三者が行える最小監査のガイドです。

0. 前提

  • 検証ページから bundle.zip をダウンロード済みであること
  • Ubuntu 22.04 / 24.04
  • このリポジトリ(stark-ballot-simulator)のソースを取得済みであること

リポジトリ非公開に関する注意 本リポジトリは現時点で非公開のため、ソースコードの取得はできません。 Step 3(bundle.zip を展開)と Step 6(journal.json の完全性チェック)はソースコード不要で実行できます。それ以外のステップはリポジトリ公開後に実行可能になります。

  • (Step 7-8 を実行する場合)Node.js 22+ と pnpm が利用可能であること
  • (Step 7-8 を実行する場合)$REPO_ROOTpnpm i を実行済みであること

検証ページのダウンロードは、s3BundleUrlverificationBundleUrl の候補から実行されます。S3 URL が期限切れの場合は refreshS3=1 で再取得されます。ここで扱う bundle.zippublic-input.jsonpublic は、「秘密データを含まない配布対象」という意味です。用語と取得経路は バンドル構造 を参照してください。

以降の手順では、リポジトリルートを REPO_ROOT として扱います。実際のクローン先に合わせて先に設定してください。

export REPO_ROOT="$HOME/stark-ballot-simulator"
export AUDIT_ROOT="$HOME/stark-audit"
cd "$REPO_ROOT"

1. Ubuntu セットアップ(Rust)

sudo apt update
sudo apt install -y build-essential pkg-config libssl-dev unzip jq curl ca-certificates

curl https://sh.rustup.rs -sSf | sh -s -- -y
source "$HOME/.cargo/env"

RUST_CHANNEL="$(awk -F'\"' '/^channel *=/ {print $2}' "$REPO_ROOT/rust-toolchain.toml")"
rustup toolchain install "$RUST_CHANNEL"
rustup default "$RUST_CHANNEL"
echo "rust_channel=$RUST_CHANNEL"

rustc --version
cargo --version

2. verifier-service をビルド

cd "$REPO_ROOT/verifier-service"
cargo build --release

生成物:

  • verifier-service/target/release/verifier-service

3. bundle.zip を展開

mkdir -p "$AUDIT_ROOT"
cp ~/Downloads/stark-ballot-verification-*.zip "$AUDIT_ROOT/bundle.zip"
cd "$AUDIT_ROOT"

unzip -o bundle.zip -d bundle
ls -1 bundle

最低限、以下のファイルが必要です。

  • bundle/receipt.json
  • bundle/journal.json
  • bundle/public-input.json
  • bundle/election-manifest.json
  • bundle/close-statement.json

metadata.json は同期モードでのみ含まれる場合があります。

4. 期待 Image ID を決定

journal.jsonmethodVersionreceipt.jsonimage_id を使って、public/imageId-mapping.json 上の候補から期待 Image ID を選びます。

METHOD_VERSION="$(jq -r '.methodVersion' bundle/journal.json)"
RECEIPT_IMAGE_ID="$(jq -r '.image_id // .imageId // .receipt.image_id // .receipt.imageId // empty' bundle/receipt.json | tr '[:upper:]' '[:lower:]')"

ARM_IMAGE_ID="$(jq -r --arg v "$METHOD_VERSION" '.mappings[$v].expectedImageID // empty' "$REPO_ROOT/public/imageId-mapping.json" | tr '[:upper:]' '[:lower:]')"
X86_IMAGE_ID="$(jq -r --arg v "$METHOD_VERSION" '.mappings[$v].expectedImageID_x86_64 // empty' "$REPO_ROOT/public/imageId-mapping.json" | tr '[:upper:]' '[:lower:]')"

case "$RECEIPT_IMAGE_ID" in
  "$ARM_IMAGE_ID")
    EXPECTED_IMAGE_ID="$ARM_IMAGE_ID"
    ;;
  "$X86_IMAGE_ID")
    EXPECTED_IMAGE_ID="$X86_IMAGE_ID"
    ;;
  "")
    echo "receipt_image_id is missing; choose the expected Image ID manually"
    exit 1
    ;;
  *)
    echo "receipt_image_id is not present in imageId-mapping.json for methodVersion=$METHOD_VERSION"
    exit 1
    ;;
esac

echo "methodVersion=$METHOD_VERSION"
echo "receiptImageId=$RECEIPT_IMAGE_ID"
echo "expectedImageId=$EXPECTED_IMAGE_ID"

既知の本番 bundle であることが分かっている場合は、通常 expectedImageID(ARM64 側)が選ばれます。ローカル x86_64 で生成した receipt を監査する場合は、expectedImageID_x86_64 が選ばれることがあります。

5. STARK レシートを検証

"$REPO_ROOT/verifier-service/target/release/verifier-service" verify \
  --bundle ./bundle.zip \
  --image-id "$EXPECTED_IMAGE_ID" \
  --output ./verification.json

echo "exit_code=$?"
jq '{status, expected_image_id, receipt_image_id, dev_mode_receipt, errors}' ./verification.json

判定:

  • exit_code=0 かつ status="success": 合格
  • exit_code=2 または status="dev_mode": フェイクレシート(本番検証としては不合格)
  • exit_code=3 または status="failed": 不合格

6. journal.json の完全性チェック

jq '{excludedSlots, missingSlots, invalidPresentedSlots, rejectedRecords, totalExpected, treeSize, totalVotes, validVotes, verifiedTally}' bundle/journal.json

jq -e '.excludedSlots == 0 and .missingSlots == 0 and .invalidPresentedSlots == 0' bundle/journal.json >/dev/null \
  && echo 'integrity_counts=ok' \
  || echo 'integrity_counts=ng'

jq -e '.totalExpected == .treeSize' bundle/journal.json >/dev/null \
  && echo 'expected_vs_tree=ok' \
  || echo 'expected_vs_tree=ng'

jq -e '(.verifiedTally | add) == .validVotes' bundle/journal.json >/dev/null \
  && echo 'tally_sum=ok' \
  || echo 'tally_sum=ng'

excludedSlots > 0 または missingSlots > 0 または invalidPresentedSlots > 0 は、検証失敗として扱います。加えて totalExpected != treeSize も、現行の必須チェックでは検証失敗です。

7. 公開監査アーティファクトの整合性チェック

election-manifest.jsonclose-statement.json は、現行の公開バンドルに含まれる Counted 段階の必須チェック用アーティファクトです。ここでは次を確認します。

  • election-manifest.jsonelectionConfigHash を再計算し、manifest 自身の宣言値と一致すること
  • manifest の electionId / electionConfigHashpublic-input.json / journal.json と矛盾しないこと
  • close-statement.json から sthDigest を再計算し、宣言された snapshot と一致すること
  • close statement の logId / treeSize / timestamp / bulletinRoot / sthDigestpublic-input.json / journal.json と矛盾しないこと
cd "$REPO_ROOT"

pnpm tsx -e "
import fs from 'node:fs';
import { buildCloseStatement, recomputeElectionManifestHash } from './src/lib/verification/public-audit-artifacts';

const [manifestPath, closePath, journalPath, publicInputPath] = process.argv.slice(1);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
const closeStatement = JSON.parse(fs.readFileSync(closePath, 'utf-8'));
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf-8'));
const publicInput = JSON.parse(fs.readFileSync(publicInputPath, 'utf-8'));

const normalize = (value) => String(value).toLowerCase();
const sameHex = (left, right) => normalize(left) === normalize(right);

const recomputedManifestHash = recomputeElectionManifestHash(manifest);
const rebuiltCloseStatement = buildCloseStatement({
  logId: closeStatement.logId,
  treeSize: closeStatement.treeSize,
  timestamp: closeStatement.timestamp,
  bulletinRoot: closeStatement.bulletinRoot,
});

const checks = {
  manifest_hash_ok: sameHex(recomputedManifestHash, manifest.electionConfigHash),
  manifest_election_id_ok:
    String(manifest.electionId) === String(publicInput.electionId) &&
    String(manifest.electionId) === String(journal.electionId),
  manifest_config_hash_ok:
    sameHex(manifest.electionConfigHash, publicInput.electionConfigHash) &&
    sameHex(manifest.electionConfigHash, journal.electionConfigHash),
  close_digest_ok: sameHex(rebuiltCloseStatement.sthDigest, closeStatement.sthDigest),
  close_timestamp_ok: closeStatement.timestamp === publicInput.timestamp,
  close_log_id_ok: sameHex(closeStatement.logId, publicInput.logId),
  close_tree_size_ok: closeStatement.treeSize === publicInput.treeSize && closeStatement.treeSize === journal.treeSize,
  close_bulletin_root_ok:
    sameHex(closeStatement.bulletinRoot, publicInput.bulletinRoot) &&
    sameHex(closeStatement.bulletinRoot, journal.bulletinRoot),
  close_sth_digest_ok: sameHex(closeStatement.sthDigest, journal.sthDigest),
};

console.log(JSON.stringify(checks, null, 2));
process.exit(Object.values(checks).every(Boolean) ? 0 : 1);
" \
  "$AUDIT_ROOT/bundle/election-manifest.json" \
  "$AUDIT_ROOT/bundle/close-statement.json" \
  "$AUDIT_ROOT/bundle/journal.json" \
  "$AUDIT_ROOT/bundle/public-input.json"

echo "exit_code=$?"

判定:

  • exit_code=0 かつ全項目が true: 合格
  • いずれかが false: counted_election_manifest_consistent または counted_close_statement_consistent 相当の失敗

8. inputCommitment 再計算

public-input.json から再計算した値が journal.jsoninputCommitment と一致することを確認します。 このステップには Node.js / pnpm と、$REPO_ROOT での pnpm i が必要です。

RECALC="$(cd "$REPO_ROOT" && pnpm tsx -e "import fs from 'node:fs'; import { computeInputCommitmentFromPublicInput } from './src/lib/zkvm/types'; const p = JSON.parse(fs.readFileSync(process.argv[1], 'utf-8')); console.log(computeInputCommitmentFromPublicInput(p));" "$AUDIT_ROOT/bundle/public-input.json")"
JOURNAL_COMMITMENT="$(jq -r '.inputCommitment' "$AUDIT_ROOT/bundle/journal.json")"

echo "recalculated=$RECALC"
echo "journal=$JOURNAL_COMMITMENT"

[ "${RECALC,,}" = "${JOURNAL_COMMITMENT,,}" ] && echo 'input_commitment=ok' || echo 'input_commitment=ng'

合格条件(bundle.zip 単体で確認できる最小セット)

  • verifier-service の結果が success
  • excludedSlots == 0 かつ missingSlots == 0 かつ invalidPresentedSlots == 0
  • totalExpected == treeSize
  • election-manifest.jsonclose-statement.json の整合チェックがすべて通る
  • inputCommitment の再計算値が journal.json と一致する

この手順のどれかが失敗した場合、Counted / STARK 段階の必須チェックを満たしていないため、Verified にはなりません。

一方で、この手順だけで /verify の最終判定を完全再現するわけではありません。bundle.zip 単体ではそろわない検証材料については 第三者検証ガイド の冒頭を参照してください。

この手順の対象範囲は 第三者検証ガイド を参照してください。

設計判断

PoC で意図的に受け入れた制約と、設計を通じて得た知見を解説します。

「何を PoC 都合で割り切ったか」「構築を通じて何がわかったか」を明文化し、実装・運用・監査の前提を共有します。

判断の記録方針

各章は目的に応じて、記録軸を使い分けます。

記録軸
PoC の意図的な制約制約の内容 / 受け入れた理由 / 影響範囲
設計ふりかえり背景 / 知見 / 改善方針

この部に含まれる章

関連する章

PoC の意図的な制約

本章では、公開版 PoC で意図的に受け入れている制約を 3 点に絞って説明します。

「バグではなく設計上の割り切り」であることを明確化します。

制約の全体像

カテゴリ制約
スケーラビリティ固定票数 64(1 ユーザー + 63 ボット)
プライバシービットマップチャンク漏洩
実行基盤非 GPU 前提の証明実行(ECS Fargate)

1. 固定票数 64(1 ユーザー + 63 ボット)

項目内容
制約の内容BOT_COUNT=63MERKLE_TREE_DEPTH=6(2^6 = 64 リーフ)で固定
受け入れた理由単一ユーザーでも掲示板・Merkle・zkVM・検証 UI までの E2E フローを再現できる最小構成
影響範囲入力サイズ、証明時間、ツリー深度、検証表示

PoC の主目的は「検証可能投票の成立条件を end-to-end で示すこと」であり、まずは 64 票固定でパイプライン全体の再現性を優先しています。


2. ビットマップチャンク漏洩

項目内容
制約の内容ビットマップ Merkle で 32 バイト(256 ビット)チャンク全体を返すため、対象ビット以外の counted 状態も同時に見える
受け入れた理由64 票が 1 チャンクに収まり、63 票がボットのため情報価値が限定的
漏洩する情報現行の 64 票構成では、全 64 票が集計に含まれたか否か

本来はチャンクを小さくし、木を深くすることで漏洩を抑制できます。PoC では zkVM 実行時間とのトレードオフを優先し、現在のチャンク設計を採用しています。

詳細: ビットマップ Merkle


3. 非 GPU 前提の証明実行(ECS Fargate)

項目内容
制約の内容STARK 証明生成を CPU 前提で実行(ECS Fargate 16 vCPU / 32 GB)
受け入れた理由GPU インスタンスは高価かつ起動に時間がかかるため、非同期キュー + Fargate が現実的だった
影響範囲公開 AWS 構成では FINALIZE_ASYNC_MODE=true を前提に POST /api/finalize が非同期化され、証明完了まで数分待機が発生

コードベース自体は同期パスも保持しており、FINALIZE_ASYNC_MODE=false のローカル・テスト構成では同期レスポンスを返せます。

詳細: AWS アーキテクチャ非同期プローバー

設計ふりかえり

構築を通じて得た構造上の知見を記録します。

「PoC の制約」が意図的な割り切りを扱うのに対し、本章は実装・運用を経て見えた改善余地を共有します。

記録方針

各項目は「背景(現状の構造とその成立経緯)」「知見(構築・運用を経て見えた課題)」「改善方針(次のフェーズで取りうるアプローチ)」の 3 軸で記述します。

知見の全体像

#項目カテゴリ
1Store インターフェースの肥大化永続化層
2SessionData の責務混在型設計
3設定の組み合わせ爆発構成管理
4Verified 判定ロジックの分散検証パイプライン
5/api/verify の責務過多API 設計
6検証ドメインへの I/O 混入アーキテクチャ境界
7Silent fallback の危険性起動・初期化

1. Store インターフェースの肥大化

背景

VoteStore インターフェースは 15 メソッド(+ optional 4)を持ち、3 つの大きな実装(Mock / FileMock / Amplify)が存在する。

知見

セッション管理・投票操作・ファイナライズ状態遷移・成果物保存の 4 責務が 1 インターフェースに混在している。特にファイナライズ状態遷移の 5 メソッド(markFinalizationQueuedmarkFinalizationTimedOut)は全実装で類似のバリデーションロジックを個別に持つ。

改善方針

インターフェース分離原則(ISP)を適用し、Session / Vote / FinalizationState / Artifact の 4 インターフェースに分割する。ファイナライズ状態遷移は共通のステートマシンとして抽出し、各 Store が永続化のみを担う構造にする。

詳細: セッションライフサイクル


2. SessionData の責務混在

背景

SessionData 型は 16 フィールド(うち 9 が optional)を持つ。ネストされた finalizationResult だけで 30 サブフィールドを含む。

知見

永続データ(sessionIdelectionId)、実行時状態(votesbulletinlastActivity)、検証結果(finalizationResultfinalizationState)が 1 型に同居している。optional フィールドの多さは、ライフサイクルの各段階で「存在するはずだが型で保証されない」フィールドがあることを意味する。

改善方針

Session(最小の identity)、VoteLog(append-only の投票記録)、FinalizationJob(非同期ジョブの状態)、VerificationArtifact(検証結果と成果物)に型を分割する。各段階で必要なフィールドを required として型安全に扱う。

詳細: セッションライフサイクル


3. 設定の組み合わせ爆発

背景

.env.local.example に 62 変数が定義されている。USE_MOCK_ZKVMRISC0_DEV_MODEFINALIZE_ASYNC_MODE などの切り替えフラグが独立して存在する。

知見

フラグの組み合わせによって意図が曖昧になる状態が生じうる(例: USE_MOCK_ZKVM=trueRISC0_DEV_MODE=1 の同時有効)。zkvm-mode.ts で production 時の不正モードは検出できるが、起動時に全組み合わせを網羅的に検証していない。

改善方針

プロファイルベースの設定体系(local / dev / staging / prod)を導入し、個別フラグを廃止する。プロファイルが各フラグの値を暗黙的に決定し、シークレットのみを環境変数として残す。起動時バリデーションで無効な組み合わせを fail-fast で検出する。


4. Verified 判定ロジックの分散

背景

「Verified を表示してよいか」の判定ロジックが複数箇所に分散している。主系は verification-summary.tsderiveVerificationSummary(チェック群から総合判定)と page.tsxoverallStatusOverride(UI 表示制御)で、補助系として consistency-verifier.tsvalidateVotingIntegrity がフォールバック経路を持つ。さらに verify.ts ハンドラにも verificationStatus の許可ステータス判定が存在する。

知見

判定基準の責務境界が明確でない。チェック評価(engine)、総合判定(summary)、最終表示 override(UI)が別々に進化しており、仕様変更時に複数モジュールを同時修正する必要がある。フォールバック経路(integrityStatus)も残っているため、仕様の二重管理が起きやすい。

改善方針

VerificationPolicy を単一モジュールに集約し、「Verified を出す条件」を 1 箇所で定義する。API と UI の双方がこのモジュールを参照する構造にし、判定基準の分散を解消する。

詳細: ゲーティングロジック


5. /api/verify の責務過多

背景

verify.ts ハンドラは 600 行超で、単一の GET エンドポイントとしては大きい。

知見

セッション検証、finalizationResult からの 20 以上のフィールド抽出と正規化(143 行)、S3 メタデータの条件付きリフレッシュ、検証ステップの生成、改ざん検出状態の計算、レスポンスの組み立てが 1 ハンドラに集中している。

改善方針

Command/Query 分離を適用する。データ取得と正規化を /api/verification/snapshot(Query)、判定実行を /api/verification/evaluate に分離し、各ハンドラの責務を限定する。

詳細: エンドポイント一覧


6. 検証ドメインへの I/O 混入

背景

consistency-verifier.ts は検証ドメインロジックを担うが、内部で fetch() を直接呼び出している(整合性証明の取得および STH の第三者検証)。

知見

ドメインロジックが HTTP 可用性に依存しており、テストではモックが必須になる。また、fetch 失敗時のエラー伝播が暗黙的で、呼び出し元の useVerificationPipeline は例外を捕捉して状態を null にするのみである。本プロジェクトの方針「ドメイン層は Result パターン、境界でのみ例外捕捉」に反する。

改善方針

検証関数を純関数化し、必要なデータ(整合性証明、STH レスポンス等)を全て引数で受け取る構造にする。I/O はアプリケーション層(hooks または handler)に移動し、ドメイン層を HTTP 非依存にする。

詳細: 検証パイプライン


7. Silent fallback の危険性

背景

storeInstance.ts では AmplifySessionStore の初期化が try/catch で囲まれており、失敗時に MockSessionStore へフォールバックする。

知見

catch が全例外を捕捉し、logger.warn でログを出力するのみで処理を継続する。デプロイ環境で Amplify エンドポイントの誤設定があった場合、インメモリ Store で動作し、セッション消失やデータ不整合が表面化しにくくなる。

改善方針

テスト環境以外では fail-fast(例外の再送出)を原則とする。validate.ts の起動時バリデーションと組み合わせた二重防御とし、silent な品質劣化を防止する。

用語集

本書で使用する主要な用語の定義です。暗号・検証の基礎用語と実装・運用の主要用語に分けて掲載しています。


暗号プリミティブ

コミットメント(Vote Commitment)

ドメイン分離タグ、選挙 ID、投票選択肢、乱数を結合して SHA-256 でハッシュした値。投票内容を秘匿しつつ(隠蔽性)、後から変更できないことを保証する(束縛性)。投票の Cast-as-Intended 検証の起点となる。

詳細: コミットメントスキーム

Merkle ルート(Bulletin Root)

掲示板上の全投票コミットメントから RFC 6962 の規則に従って計算されるハッシュ値。掲示板の特定時点における状態を一意に表現する。新しい投票が追加されるたびに更新される。

Merkle パス(Audit Path)

特定のリーフ(投票コミットメント)からルートまでを再構成するために必要な兄弟ノードのハッシュ列。包含証明の構成要素であり、対数オーダーの検証コストを実現する。

包含証明(Inclusion Proof)

特定の投票コミットメントが掲示板に含まれていることを暗号学的に証明するデータ。リーフインデックス、監査パス、ツリーサイズから構成される。RFC 6962 のハッシュ規則に従い、リーフとパスからルートを再計算して期待値と照合する。

詳細: CT Merkle ツリー

整合性証明(Consistency Proof)

RFC 6962 で定義された、2 つの時点のツリーが追記関係にあることを暗号学的に証明するデータ。古いツリーが新しいツリーのプレフィックスであること(投票の削除・並べ替えが行われていないこと)を保証する。

詳細: CT Merkle ツリー

入力コミットメント(Input Commitment)

zkVM が処理した公開可能な入力フィールドの一部を、固定のドメインタグと version を含む正準エンコーディングで SHA-256 ハッシュした値。現行実装では electionIdbulletinRoottreeSizetotalExpectedvotesCount、各投票の index・コミットメント・Merkle パスを束縛し、public-input.json より狭い部分集合を対象とする。

詳細: 入力コミットメント

STH ダイジェスト(Signed Tree Head Digest)

掲示板のログ ID、ツリーサイズ、タイムスタンプ、ルートハッシュを結合して SHA-256 でハッシュした値。特定の時点における掲示板の状態を一意に識別し、複数の独立した監視者間で掲示板の一貫性を検証するために使用する。

詳細: STH ダイジェスト

包含ビットマップルート(Included Bitmap Root)

zkVM ゲストが生成するビットマップ(各投票インデックスが集計に含まれたか否か)の Merkle ルート。投票者は自分のインデックスに対応するビットが 1 であることを Merkle 証明で確認できる。

詳細: ビットマップ Merkle

提示ビットマップルート(Seen Bitmap Root)

zkVM ゲストに提示された投票インデックスを表すビットマップの Merkle ルート。includedBitmapRoot と組み合わせることで、自票が「counted された」「提示されたが無効だった」「そもそも prover に提示されなかった」のどれかを説明できる。

詳細: ビットマップ Merkle

正準エンコーディング(Canonical Encoding)

固定のドメインタグ・バージョン番号・フィールド順を含む決定論的なバイト列表現。同一の入力から常に同一のバイト列が得られることを保証する。本システムではコミットメントと入力コミットメントの計算に使用する。

ドメイン分離タグ(Domain Separation Tag)

ハッシュ計算において異なる用途のデータが衝突しないように付与するプレフィックス文字列。本システムでは、コミットメント、入力コミットメント、CT Merkle のリーフ・ノードハッシュにそれぞれ固有のタグを使用する。

投票レシート(Vote Receipt)

投票受理時にサーバーが返す応答データ。voteIdcommitmentbulletinIndexbulletinRootAtCast を含む。検証では voteReceipt として参照される。投票者がローカルに保持する投票時データ(選挙 ID、選択肢、乱数)とは別物であり、zkVM が生成する STARK レシート(Receipt)とも別物。Cast-as-Intended 検証では、投票時データからコミットメントを再計算し、投票レシートのコミットメント値と照合する。


STARK 証明

STARK(Scalable Transparent ARgument of Knowledge)

Trusted setup(信頼されたセットアップ)を必要としない暗号証明方式。ハッシュベースの構成により耐量子計算機性に優位がある。本システムでは RISC Zero zkVM によって投票集計の正当性を証明するために使用する。

RISC Zero zkVM

RISC-V アーキテクチャ上で通常の Rust コードを実行し、その実行が正しく行われたことの STARK 証明を生成するゼロ知識仮想マシン。ゲストプログラムとホストプログラムから構成される。

レシート(Receipt)

zkVM が生成する暗号証明オブジェクト。内部に Seal(STARK 証明本体)とジャーナル(公開出力)を含む。Receipt::verify(image_id) によって、特定のゲストプログラムが正しく実行されたことを第三者が検証できる。

ジャーナル(Journal)

zkVM ゲストプログラムの公開出力。現行契約は methodVersion=12 で、検証済み集計結果、missingSlots / invalidPresentedSlots / excludedSlotsinputCommitmentincludedBitmapRootseenBitmapRoot などを含む。レシートに暗号学的に束縛されており、改ざんできない。

Image ID

コンパイル済みのゲストプログラムバイナリを一意に識別するハッシュ値。レシート検証時に期待される Image ID と照合することで、意図したゲストプログラムによって生成された証明であることを確認する。プローバーイメージの更新時に同期して更新が必要。

詳細: Image ID

ゲストプログラム(Guest Program)

zkVM 内部で実行される Rust プログラム。投票データの検証と集計を行い、結果をジャーナルとして出力する。ゲストの実行内容は STARK 証明によって保証される。

ホストプログラム(Host Program)

zkVM の外部で動作し、ゲストプログラムの実行と証明生成を制御する Rust プログラム。入力データの読み込み、zkVM の起動、レシートとジャーナルの出力を担う。

検証サービス(Verifier Service)

Rust で実装された独立した STARK レシート検証プログラム。Receipt::verify(expected_image_id) を実行し、レシートの暗号学的正当性を確認する。結果は verification.json として保存される。

詳細: 検証サービス

フェイクレシート(Fake Receipt)

RISC0_DEV_MODE=1 で生成される暗号学的保証のないレシート。開発効率のためのモックであり、検証サービスは InnerReceipt::Fake を自動検出して dev_mode ステータスを返す。本番環境では使用してはならない。

ジャーナル契約(Journal Contract)

methodVersion で識別されるジャーナル出力構造の仕様。ゲストプログラムが出力するフィールドの集合と意味を定義する。現行契約は methodVersion=12(v1.2)。ゲストプログラムの変更は新しい Image ID の生成を伴い、検証時には期待 Image ID との一致が確認される。


検証パイプライン

E2E 検証可能投票(End-to-End Verifiable Voting)

投票者が自分の投票について「意図通りに投じた」「正しく記録された」「正しく集計された」の 3 段階を独立に検証できる投票方式。システム運営者を信頼せずとも投票の完全性を確認できることが目標。

Cast-as-Intended(意図通りの投票)

検証の第 1 段階。投票者がローカルに保持する投票時データ(選挙 ID、選択肢、乱数)からコミットメントを再計算し、投票レシート(voteReceipt)のコミットメント値と照合することで、投票時に意図した選択が正しくコミットメントに反映されたことを確認する。クライアント側で完結する。

Recorded-as-Cast(記録通りの保存)

検証の第 2 段階。コミットメントが掲示板に正しく記録されたことを、RFC 6962 の包含証明と整合性証明によって確認する。掲示板が追記専用であること(投票が削除・改変されていないこと)を暗号学的に保証する。

cast-time 証跡(Cast-Time CT Artifact)

投票受理時に CT ツリーへ書き込んだ時点の証跡。具体的には voteReceipt(投票レシート)と userVote.proof(包含証明パラメータ: leafIndextreeSizeauditPath)の 2 つを指す。Recorded-as-Cast の検証では両方が必要。Cast-as-Intended では voteReceipt のみを使用する。/api/verify は store から再構成できた場合にだけこれらを返し、再構成できない場合は関連チェックを not_run として fail-closed に扱う。

Counted-as-Recorded(記録通りの集計)

検証の第 3 段階。掲示板に記録された全投票が zkVM の集計に過不足なく含まれたことを確認する。除外されたスロットがないこと(excludedSlots == 0)は最重要不変条件。

STARK 検証(STARK Verification)

検証の第 4 段階。STARK レシートが暗号学的に正当であること、および期待される Image ID で生成されたことを確認する。ジャーナルの内容が正しい実行結果であることの最終的な保証。

検証チェック(Verification Check)

検証パイプラインを構成する個別の原子的な検証項目。現行実装では 22 個のチェックがあり、それぞれ一意の ID、所属する検証段階、証拠種別、重要度を持つ。Counted-as-Recorded には counted_election_manifest_consistentcounted_close_statement_consistent も含まれ、公開監査アーティファクトとの整合も required 条件となっている。

詳細: チェック一覧

ゲーティングロジック(Gating Logic)

検証チェックの結果を集約し、「Verified」「Verification Failed」「Warning」のいずれを表示するかを決定するロジック。required 扱いのチェックが 1 つでも failed なら Verified は表示されず、not_run / pending / running や必須証拠欠落でも Verified にはならない。Stage のステータスも、その Stage で required 扱いになるチェック群全体から導出される。

詳細: ゲーティングロジック

公開監査アーティファクト

election-manifest.json(選挙設定の公開監査用スナップショット)と close-statement.json(集計締切時点のログ境界を表す公開監査レコード)の総称。Counted-as-Recorded 段階の必須チェック(counted_election_manifest_consistentcounted_close_statement_consistent)で整合性が検証される。

zkGate

STARK 検証の結果に基づいて Counted-as-Recorded チェックの評価を制御するゲート。STARK 未解決(not_run / running)の間、zkGate 対象チェックは not_run または pending になる。STARK が failed の場合、zkGate 対象チェックも failed になり得る。

詳細: ゲーティングロジック

証拠種別

検証チェックに使用するデータの出所を示す分類。local(投票時に確定したユーザー固有データ)、public(掲示板や capability 保護 API から取得する、秘密データを含まない検証用データ)、zk(zkVM ジャーナルに含まれるデータ)、demo(教育用シミュレーション由来データ)の 4 種別がある。

重要度(Criticality)

検証チェックの必須性を示す分類。required(失敗・未実行・未解決なら Verified をブロック)と optional(補助的で、単独では Verified をブロックしない)の 2 段階。なお recorded_sth_third_party のように、設定状況に応じて optional から blocking な required 相当に昇格するチェックもある。


掲示板と透明性

掲示板(Public Bulletin Board)

全投票コミットメントを時系列で記録する追記専用のログ。RFC 6962 の Certificate Transparency モデルに基づき、包含証明と整合性証明によって第三者が監査可能な透明性を実現する。

詳細: CT Merkle ツリー

RFC 6962

Certificate Transparency(証明書の透明性)の標準規格。追記専用の Merkle ツリー、リーフハッシュ(0x00 プレフィックス)とノードハッシュ(0x01 プレフィックス)のドメイン分離、包含証明、整合性証明の仕様を定義する。本システムの掲示板は、この規格のハッシュ規則と証明アルゴリズムを参照した CT スタイル実装を採用している。

STH(Signed Tree Head)

掲示板の特定時点における状態の要約。ログ ID、ツリーサイズ、タイムスタンプ、ルートハッシュを含む。複数の独立したソースからの STH を比較することで、サーバーが異なるクライアントに異なるツリーを提示するスプリットビュー攻撃を検出する。

スプリットビュー攻撃(Split-View Attack)

掲示板サーバーが異なるクライアントに異なるツリー状態を提示する攻撃。特定の投票者に対してのみ投票を除外したツリーを見せることで、不正を隠蔽しようとする。整合性証明と STH の第三者検証によって検出される。

ルート履歴(Root History)

掲示板のルートハッシュ、ツリーサイズ、タイムスタンプの時系列記録。投票時のルートが最終ツリーの有効なプレフィックスであることを、整合性証明で検証する際に参照する。


改ざんシナリオ

改ざんシナリオ(Tamper Scenario)

検証システムが不正をどのように検出するかを教育的に示すシミュレーション。S0(正常)から S5(複合改ざん)まで 6 種類が定義されている。

詳細: 改ざんシナリオ

投票除外(Vote Exclusion)

一部の投票を集計から意図的に除外する攻撃。zkVM ジャーナルの excludedSlots > 0 として検出される。本システムの最重要不変条件により、投票除外がある場合は「Verified」を表示しない。

主張集計改ざん(Claimed-Tally Tampering)

公開表示する集計値(claimed tally)を、zkVM が証明した実際の集計値と異なる値に書き換える攻撃の教育的シミュレーション。zkVM の入力・レシート・ジャーナルは正常なまま、公開表示のみを改ざんする。counted_tally_consistent チェックで検出される。

excludedSlots

zkVM ジャーナルに含まれる、除外されたスロットの総数。0 でなければならない。0 より大きい場合は投票の未提示または未計上が発生しており、いかなる場合も「Verified」を表示してはならない。API レスポンスでは互換エイリアス excludedCount としても露出される。


インフラストラクチャ

ECS Fargate

AWS のサーバーレスコンテナ実行環境。本システムでは STARK 証明生成に必要な大量のメモリ(32 GB)と CPU(16 vCPU)を提供するために使用する。アイドル時のコストは 0。

詳細: 非同期プローバー

Step Functions

AWS のワークフローオーケストレーションサービス。イメージ署名検証 → ECS プローバー実行 → コールバックの一連のフローを管理する。

非同期証明モード(Async Proving)

SQS → Step Functions → ECS Fargate の経路で STARK 証明を非同期に生成するモード。集計リクエスト(POST /api/finalize)は 202 Accepted を返し、クライアントはステータスポーリングで完了を待つ。

同期証明モード(Sync Proving)

ローカルプロセスで zkVM ホストバイナリを直接実行し、STARK 証明を同期的に生成するモード。開発環境で使用される。

イメージ署名検証(Image Signing)

ECS タスクで使用するプローバーコンテナイメージが、信頼できるビルドパイプラインから生成されたことを検証する仕組み。AWS Signer を使用し、Step Functions のゲートとして機能する。

詳細: イメージ署名

証明バンドル(Proof Bundle)

zkVM の実行結果を検証可能な形で保存・配布するためのアーティファクト群。本書で public /「公開可能」と呼ぶファイルは、秘密情報を含まず検証に利用可能であるという機密性の分類を指し、無認証公開は意味しない。公開許可リストに基づいて bundle.zip が作成され、現行の公開 bundle には public-input.jsonelection-manifest.jsonclose-statement.jsonreceipt.jsonjournal.json などが含まれる。一方で input.jsonverification.jsonincluded-bitmap.jsonseen-bitmap.json は private artifact として公開対象から除外される。

詳細: バンドル構造

隣接オブジェクト(Sibling Object)

S3 上で bundle.zip と同じ prefix(sessions/{sessionId}/{executionId}/)に配置される非 bundle ファイル。included-bitmap.jsonseen-bitmap.jsonverification.json など。bundle.zip には含まれないが、コールバック復元や検証レポート配信で利用される。


セッションと API

セッション(Session)

一連の投票フロー(セッション作成 → 投票 → 集計 → 検証)を管理する単位。一意の sessionId(UUID v4)で識別される。投票・集計中は 30 分、検証中は 24 時間の有効期限を持つ。

選挙(Election)

投票の論理的な単位。一意の electionId(UUID v4)で識別され、コミットメントのドメイン分離に使用される。選挙設定ハッシュ(electionConfigHash)が期待投票数などの設定を束縛する。

集計確定(Finalization)

全投票の収集後に zkVM 入力を構築し、STARK 証明を生成するプロセス。同期モード(ローカル実行)と非同期モード(ECS Fargate)の 2 つの実行パスがある。

X-Session-ID

多くの API リクエストで付与される HTTP ヘッダー。セッションスコーピングに使用され、異なるセッション間のデータアクセスを防止する。POST /api/session は新規セッション作成のため不要。

X-Session-Capability

POST /api/session のレスポンスで返る署名付きセッショントークンを運ぶ HTTP ヘッダー。このヘッダーが運ぶ値を capability トークンと呼ぶ。/api/progress/api/finalize/api/verify/api/verification/run/api/bulletin/*/api/botdata/:id/api/bitmap-proof/api/sth/api/sessions/:sessionId/status/api/verification/bundles/.../api/zkvm-input-hash など session-scoped / capability 保護 API で必須。

Turnstile

Cloudflare が提供する CAPTCHA サービス。/api/vote/api/finalize で Bot による不正アクセスを防止するために使用する。TURNSTILE_BYPASS=1 は fail-closed で、AWS_BRANCH などのランタイムマーカーが明示的に非本番(develop/dev)と判定できる場合のみ有効。


定数

定数名説明
BOT_COUNT63サーバーが自動生成するボット投票数
MERKLE_TREE_DEPTH6Merkle ツリーの深度(2^6 = 64 リーフに対応)
VOTE_CHOICESA, B, C, D, E投票で選択可能な選択肢
コミットドメインタグstark-ballot:commit|v1.0コミットメントハッシュのドメイン分離タグ
入力ドメインタグstark-ballot:input|v1.0入力コミットメントのドメイン分離タグ
リーフドメインタグstark-ballot:leaf|v1CT Merkle リーフハッシュのドメイン分離タグ

参考文献

本システムの設計領域に関連する主要な文献を掲載しています。


[1] J. Benaloh, R. Rivest, P. Y. A. Ryan, P. Stark, V. Teague, P. Vora. “End-to-end verifiability,” arXiv:1504.03778, 2015. https://arxiv.org/abs/1504.03778

E2E 検証可能投票の基本モデル(Cast-as-Intended / Recorded-as-Cast / Counted-as-Recorded)を定義した文献。本システムの 4 段階検証モデルはこのフレームワークと同じ構造を採用している。

[2] M. Harrison, T. Haines. “On the Applicability of STARKs to Counted-as-Collected Verification in Existing Homomorphic E-Voting Systems,” in Financial Cryptography and Data Security: FC 2024 International Workshops, LNCS, Springer, 2024. https://doi.org/10.1007/978-3-031-69231-4_4

STARK 証明を投票集計の検証に適用する設計根拠を示した論文。本システムの Counted-as-Recorded 段階における zkVM 証明設計と関連が深い。

[3] V. Farzaliyev, J. Willemson. “End-To-End Verifiable Internet Voting with Partially Private Bulletin Boards,” in Electronic Voting: E-Vote-ID 2025, LNCS, vol. 16028, Springer, 2025. https://doi.org/10.1007/978-3-032-05036-6_5

[2] の研究を拡張し、STARK ベースの E2E 検証可能投票において Cast-as-Intended 検証と掲示板のプライバシー設計を統合した論文。本システムの掲示板構成と検証パイプライン設計に関連するテーマを扱っている。

[4] B. Laurie, A. Langley, E. Kasper. “Certificate Transparency,” RFC 6962, IETF, 2013. https://datatracker.ietf.org/doc/html/rfc6962

追記専用 Merkle ツリーの包含証明・整合性証明を定義した標準仕様。本システムの掲示板は、この仕様のハッシュ規則(0x00 リーフ / 0x01 ノード)と証明アルゴリズムに基づく CT スタイル実装を採用している。

ライセンス

STARK Ballot Simulator は Apache License 2.0 の下で提供されています。

  • SPDX: Apache-2.0
  • 詳細: リポジトリルートの LICENSE

依存ソフトウェアのライセンスは各パッケージ定義に従います。