はじめに
最終更新: 2026-04-02
このドキュメントは、STARK Ballot Simulator の公開向けガイドです。
目的
- システムの全体像を短時間で把握できるようにする
- 暗号プロトコルと検証パイプラインの設計根拠を説明する
- 検証手順を再現できる情報を提供する
想定読者
- 暗号検証・監査に関心のある技術者
- 本アプリケーションに興味のある技術者
本書の読み方
- まず 全体像 でシステムの概要を掴む
- 暗号プロトコル でコミットメント・Merkle ツリー等の基盤を理解する
- zkVM 設計 でゲストプログラムと証明生成の仕組みを学ぶ
- 検証パイプライン で 4 段階検証モデルの全体を把握する
- 改ざんシナリオ で教育的シミュレーションの動作を確認する
- AWS アーキテクチャ で非同期証明インフラを理解する
- API リファレンス でエンドポイント仕様を参照する
- 実際に検証する場合は 第三者検証ガイド で
bundle.zipを使ったローカル検証手順を実行する - 設計上の判断については 設計判断 を参照する
- 設計根拠の一次資料は 参考文献 を参照する
全体像
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 入力の正準エンコーディングまで、検証可能性の基盤となる暗号構成要素を網羅します。
この部に含まれる章
- コミットメントスキーム — 投票コミットメントの構成と安全性
- CT Merkle ツリー — CT スタイルの追記専用掲示板
- 入力コミットメント — zkVM 入力の正準エンコーディング
- STH ダイジェスト — 分割ビュー緩和のためのツリーヘッドダイジェスト
- ビットマップ Merkle — 投票カウント証明のためのビットマップツリー
コミットメントスキーム
投票者の選択を公開データから秘匿しつつ束縛するコミットメントスキームの設計を解説します。
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/>"stark-ballot:commit|v1.0""] --> 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" |
| 選挙 ID | 16 バイト | 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-Recorded | zkVM ゲストが 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 アルゴリズムは、任意のサイズのデータセットからルートハッシュを計算します。
アルゴリズムの定義
- 空のツリー:
MTH({}) = SHA-256()(空入力のハッシュ) - 単一リーフ:
MTH({d₀}) = LeafHash(d₀) - 複数リーフ: サイズ 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 フィールド名 proofNodesmerklePathrootHashbulletinRootAtCast
/api/verifyのuserVote.proofや/api/bulletin/:voteId/proofがこの構造に対応します。
PATH アルゴリズム
RFC 6962 の PATH 関数に従い、監査パスを再帰的に生成します。
- ツリーをサイズ k(n 未満の最大の 2 のべき乗)で左右に分割
- 対象リーフが左部分木にある場合(
index < k):- 左部分木の PATH を再帰計算
- 右部分木のハッシュを監査パスに追加
- 対象リーフが右部分木にある場合(
index >= k):- 右部分木の PATH を再帰計算(インデックスを
index - kに調整) - 左部分木のハッシュを監査パスに追加
- 右部分木の PATH を再帰計算(インデックスを
検証手順
検証者は以下の手順で包含を確認します:
- コミットメントのリーフハッシュを計算:
LeafHash(commitment) - PATH アルゴリズムと同じ木構造に従い、監査パスのノードを順に結合
- 計算されたルートが期待するルートハッシュと一致するか確認
Recorded-as-Cast では、包含証明に加えて以下の cast-time 一貫性も確認します:
leafIndexがレシートのbulletinIndexと一致することtreeSizeがbulletinIndex + 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-proofはproofNodesに加えてrootAtOldSizeとrootAtNewSizeも返します。Recorded-as-Cast の評価では、これらがレシートのbulletinRootAtCastと最終bulletinRootに一致することも確認します。
SUBPROOF アルゴリズム
RFC 6962 の SUBPROOF 関数に基づき、整合性証明を再帰的に生成します。
m = nかつ古いツリーが完全部分木: 空の証明を返すm = nかつ完全部分木でない: 部分木のルートハッシュを返す- k を n 未満の最大の 2 のべき乗とし:
m <= kの場合: 左部分木の SUBPROOF + 右部分木のハッシュm > kの場合: 右部分木の SUBPROOF + 左部分木のハッシュ
検証の意味
整合性証明の検証が成功することは、以下を意味します:
- 古いツリーのすべてのリーフが、新しいツリーにも同じ位置・同じ値で存在する
- 新しいツリーは古いツリーの末尾にリーフを追加しただけで構成されている
- 古いツリーのルートハッシュと新しいツリーのルートハッシュの両方が、提供された証明ノードから独立に再構成できる
これにより、サーバーが過去に記録した投票を密かに削除したり順序を変更したりする攻撃を検出できます。
検証パイプラインにおける役割
CT Merkle ツリーは、4 段階検証モデルの主に 2 段階で使用されます。
| 検証段階 | CT Merkle の役割 |
|---|---|
| Recorded-as-Cast | 包含証明でコミットメントの記録を確認し、整合性証明で追記専用性を確認する |
| Counted-as-Recorded | zkVM ゲストが同じハッシュ規則で各 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 入力のうち現行実装で束縛する公開可能な検証フィールドを単一のハッシュ値に集約するプロトコルです。可変部分として electionId、bulletinRoot、treeSize、totalExpected、votesCount と、各投票の 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 証明は「ゲストプログラムが正しく実行された」ことを証明しますが、「どの入力に対して実行されたか」は証明のスコープ外です。入力コミットメントがなければ、悪意あるサーバーは以下の攻撃が可能になります:
- 投票を除外した入力で zkVM を実行し、有効な STARK 証明を取得する
- 公開用の入力データには除外されていない投票を含めて提示する
- 第三者は 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 LE | v1.0 = 10 |
| 選挙 ID | 16 バイト | UUID バイナリ | 選挙スコープの識別子 |
| 掲示板ルート | 32 バイト | ハッシュ値 | 最終的な Merkle ルート |
| ツリーサイズ | 4 バイト | u32 LE | 掲示板のリーフ数 |
| 期待投票数 | 4 バイト | u32 LE | 想定される総投票数 |
| 投票数 | 4 バイト | u32 LE | 実際に含まれる投票数 |
| 各投票インデックス | 4 バイト | u32 LE | 掲示板上の位置 |
| コミットメント長 | 2 バイト | u16 LE | 固定値 32 |
| コミットメント | 32 バイト | ハッシュ値 | 投票コミットメント |
| パス長 | 2 バイト | u16 LE | Merkle パスのノード数 |
| パスノード | 各 32 バイト | ハッシュ値 | 包含証明の兄弟ハッシュ |
public-input.json と公開監査アーティファクトとの関係
public-input.json は、zkVM 検証に使う秘密データを含まない検証用レコードです。現行実装では schema、version、electionId、electionConfigHash、bulletinRoot、treeSize、totalExpected、logId、timestamp、methodVersion と、各投票の index・コミットメント値・Merkle パスを含みます。
ただし、public-input.json に含まれる全フィールドが入力コミットメントの計算対象に入るわけではありません。現行実装で直接束縛されるのは、概要で示した対象フィールドに固定のドメインタグと version を加えた部分です。electionConfigHash、logId、timestamp、methodVersion は入力コミットメントには直接含まれません。
現行実装では、公開パラメータの照合は public-input.json 単体では完結せず、proof bundle に含まれる election-manifest.json と close-statement.json も組み合わせて検証されます。入力コミットメント対象外のフィールドは、次のように別経路で照合されます。
electionConfigHash→counted_election_manifest_consistent(manifest と journal 等を照合)logId・timestamp→counted_close_statement_consistent(close statement と journal 等を照合)methodVersion→ 入力コミットメントには含まれない。journal 互換性チェックや Image ID 解決に使用
したがって、counted_input_commitment_match は公開パラメータ照合の中核ですが、唯一のクロスチェックではありません。残りのパラメータは上記チェックで補完的に検証されます。
正準化規則
エンコーディングの決定性を保証するために、以下の規則が厳守されます。
ソート規則
投票はインデックスの昇順にソートしてからエンコードする必要があります。
入力データの投票順序は任意であり得ますが、エンコーディング前にインデックスでソートすることで、同じ投票集合から常に同一のバイト列が生成されます。この規則に違反すると、TypeScript と Rust で異なるハッシュ値が計算され、検証が失敗します。
前提となる不変条件:
- 各投票の
indexは一意である(重複インデックスは不正入力)
重複インデックスが存在する入力はプロトコル違反であり、正常系の正準化対象ではありません。したがって、正準順序の主キーは index の昇順として定義されます。
実装上は、重複インデックスという異常入力に対して Rust 側で追加の tie-break(commitment と merklePath)を行いますが、これはあくまで異常系の決定性補助であり、正常系仕様を変更するものではありません。
エンディアン規則
すべての整数フィールドはリトルエンディアンでエンコードされます。
| 型 | バイト数 | エンコーディング |
|---|---|---|
| u16 | 2 | リトルエンディアン |
| u32 | 4 | リトルエンディアン |
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_consistent と counted_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 バイトです。
各フィールドの仕様
| フィールド | サイズ | エンコーディング | 説明 |
|---|---|---|---|
| ログ ID | 32 バイト | ハッシュ値 | 掲示板インスタンスの識別子 |
| ツリーサイズ | 4 バイト | u32 LE | 掲示板のリーフ数 |
| タイムスタンプ | 8 バイト | u64 LE | Unix 時刻(ミリ秒) |
| 掲示板ルート | 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 検証は以下の条件をすべて満たした場合に成功します:
- 十分な一致数: 一致するソースの数が最小要求数(コードフォールバック値: 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-Cast | recorded_sth_third_party | 独立ソースから取得した STH ダイジェストがジャーナルの値と一致するか |
| Counted-as-Recorded | counted_close_statement_consistent | close-statement.json の sthDigest が公開入力およびジャーナルの値と整合するか |
recorded_sth_third_party は既定では任意チェック(optional)ですが、STH ソースが設定されている場合は必須扱いへ昇格します。昇格後は、このチェックが成功以外の状態にある限り「Verified」にはなりません(failed・not_run・pending・running のほか、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_party は not_run(未実行)となり、第三者照合は行いません。セキュリティ上は少なくとも 2 つ以上の独立ソースを設定することが推奨されます。
相対パス(例: /api/sth)はリクエスト元のオリジンに対して解決されます。
/api/sth のような same-origin ソースを使う場合、アプリケーションはセッション capability をそのオリジンにのみ転送します。独立第三者ソースを absolute URL で構成する場合、それらはセッション認証に依存しない公開 STH エンドポイントであることが前提です。
開発用の .env.local.example では次の値が設定されています:
NEXT_PUBLIC_STH_SOURCES=/api/sthNEXT_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) | ...
| ビット位置 | バイトインデックス | バイト内ビット位置 |
|---|---|---|
| 0 | 0 | 0 (LSB) |
| 1 | 0 | 1 |
| 7 | 0 | 7 (MSB) |
| 8 | 1 | 0 (LSB) |
| 63 | 7 | 7 (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 ツリーの章で解説したものと同一です。
ツリー構築アルゴリズム
- 各 32 バイトチャンクにリーフハッシュを適用
- ボトムアップでペアを結合し、内部ノードハッシュを計算
- 奇数ノードがある場合は、そのまま次のレベルに昇格(ハッシュなし)
- ルートに到達するまで繰り返す
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 | ルートまでの兄弟ハッシュ配列(各要素にハッシュ値と位置) |
leafIndex(floor(bitIndex / 256))と bitOffset(bitIndex 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=included で included = true なら、自分の投票がカウントされたことを意味します。kind=seen で included = true なら、自分の投票が prover に提示されたことを意味します。
検証手順
- チャンクからビット値を抽出し、自分の投票がカウントされたか確認
- チャンクのリーフハッシュを計算:
SHA-256(0x00 || "stark-ballot:leaf|v1" || chunk) - 監査パスに沿ってルートまで再計算:
- 兄弟の位置が
left→SHA-256(0x01 || sibling || current) - 兄弟の位置が
right→SHA-256(0x01 || current || sibling)
- 兄弟の位置が
- 計算されたルートが、
kindに対応するジャーナル上のルートと一致するか確認kind=included→includedBitmapRootkind=seen→seenBitmapRoot
flowchart TD
CK[チャンク受信] --> EX[ビット抽出<br/>included = true/false]
CK --> LH["リーフハッシュ計算"]
LH --> AP[監査パスに沿って<br/>ルートを再計算]
AP --> CMP{計算ルート =<br/>bitmap root ?}
CMP -->|一致| V[証明有効]
CMP -->|不一致| IV[証明無効]
zkVM ゲストとの連携
ビットマップルートは zkVM ゲストプログラム内で計算され、ジャーナルにコミットされます。
ゲストプログラムは以下の手順を実行します:
- 各投票に対してコミットメントの再計算と包含証明の検証を実施
- prover に提示された投票インデックスを
seenBitmapに記録 - 検証に成功して集計対象になった投票インデックスを
includedBitmapに記録 - それぞれのビットマップを LSB-first でパッキングし、32 バイトチャンクに分割
- CT スタイルのハッシュ規則で Merkle ルートを計算
seenBitmapRootとincludedBitmapRootをジャーナルにコミット
この計算はゲスト内で行われるため、STARK 証明がビットマップの正しさも保証します。サーバーが事後的にビットマップを改ざんしても、ジャーナルのルート値と一致しなくなるため検出されます。
サーバーのビットマップデータ管理
サーバーはビットマップ Merkle 証明を提供するために、最終化時に zkVM 出力に基づく bitmap データを保持します。
現行実装では、sync finalize でも async finalize でも、zkVM 実行から includedBitmap が得られた場合にのみ証明用データを保存します。seenBitmap と seenBitmapRoot が得られた場合は、それも private artifact として保存します。validVotes から簡易ビットマップを推定する fallback は現在の実装にはありません。
安全性ゲート
サーバーが保持するビットマップデータから計算したルートと、ジャーナル上の対応ルート(includedBitmapRoot または seenBitmapRoot)が一致しない場合、ビットマップ証明の提供は無効化されます。これにより、サーバーが不正なビットマップデータを使って偽の証明を生成することを防止します。
そのため、zkVM 出力に基づく bitmap データが存在しない場合や、保存済みデータの root がジャーナルと一致しない場合は、証明を提供せず counted_my_vote_included は not_run 相当になります。
さらに現行実装では、counted_my_vote_included の評価には信頼できる voteReceipt.bulletinIndex が必要です。この値は、store から cast-time 証跡(voteReceipt / userVote.proof)を再構成できた場合にのみ得られます。bitmap データが保持されていても cast-time 証跡を復元できなければ bitmap proof は実行されず、counted_my_vote_included は not_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 の基礎 — zkVM の概念、RISC Zero の選択理由、データフロー、保証境界
- ゲストプログラム — zkVM 内で実行される検証・集計ロジック
- ホストと証明生成 — ホストプログラムと同期/非同期の証明パス
- 検証サービス — Rust ベースのレシート検証
- Image ID — ゲストバイナリの暗号的識別子と管理
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
| コンポーネント | 言語 | 責務 |
|---|---|---|
| ゲストプログラム | Rust | zkVM 内で投票を検証・集計し、結果をジャーナルにコミットする |
| ホストプログラム | 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 公式ドキュメントに基づく実装上の要点:
- 実行モデル: ゲストは RV32IM として決定論的に実行され、外部との境界は syscall (
ecall) で扱う - 証明の分割: 長い実行はセグメント証明に分割される(大規模実行を扱うため)
- 再帰合成: セグメント証明を再帰的に合成して、最終的に短いレシートへ圧縮する
- 検証 API:
Receipt::verify(image_id)が、証明本体と Image ID の束縛を同時に検証する - 公開出力の束縛:
journalは証明に束縛されるため、検証成功後に改ざんできない
ジャーナルとレシート
- ジャーナル: ゲストが公開出力としてコミットするデータ(検証済み集計、除外情報、入力コミットメントなど)
- レシート: ジャーナルと STARK 証明(seal)のペア。検証成功時、ジャーナルは正しいゲスト実行結果として受理できる
flowchart TB R[レシート] R --> S[Seal<br/>STARK 証明] R --> J[ジャーナル<br/>公開出力]
各項目の意味と、対応する検証チェック:
| ジャーナル項目 | 代表フィールド | 主な確認ポイント |
|---|---|---|
| 検証済み集計 | verifiedTally | counted_tally_consistent |
| 除外/欠落情報 | missingSlots / invalidPresentedSlots / excludedSlots | counted_missing_indices_zero |
| 入力整合性 | inputCommitment | counted_input_commitment_match |
| STH 束縛 | sthDigest | recorded_sth_third_party(設定時) |
| 個票包含証明の根 | includedBitmapRoot | counted_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)=0(h 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 では以下を組み合わせて最終判定します。
- STARK 検証成功(正しいゲスト実行)
excludedSlots == 0(除外スロットなし)- 一貫性証明・STH 整合の成功(設定時)
- 必須チェックが
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 パスと選挙メタデータ)を受け取り、以下の処理を行います:
- 各投票の正当性検証(コミットメント再計算 + 包含証明)
- 有効投票の集計
- カウント状態と提示状態のビットマップ計算
- 入力コミットメントと STH ダイジェストの計算
- 結果のジャーナルへのコミット
ゲスト内の処理はすべて STARK 証明に含まれるため、出力(ジャーナル)の正しさが暗号学的に保証されます。
入力構造
ゲストプログラムが受け取る入力(AggregatorInput)の構造を示します。
| フィールド | 型 | 説明 |
|---|---|---|
| election_id | 16 バイト | 選挙の UUID v4 バイナリ表現 |
| bulletin_root | 32 バイト | 掲示板 Merkle ツリーの最終ルート |
| tree_size | u32 | 掲示板のリーフ数(= 投票スロット数) |
| log_id | 32 バイト | 掲示板のログ識別子 |
| timestamp | u64 | 入力構築時に採用された最新 STH スナップショットの Unix タイムスタンプ |
| total_expected | u32 | 想定される総投票数 |
| election_config_hash | 32 バイト | 選挙設定のハッシュ値 |
| votes | VoteWithProof[ ] | 投票データと Merkle パスの配列 |
各 VoteWithProof は以下のフィールドを持ちます:
| フィールド | 型 | 説明 |
|---|---|---|
| index | u32 | 掲示板上のインデックス |
| choice | u8 | 選択肢(0 = A, 1 = B, 2 = C, 3 = D, 4 = E) |
| random | 32 バイト | コミットメント計算に使用した乱数 |
| commitment | 32 バイト | 投票コミットメント値 |
| merkle_path | 32 バイト[ ] | 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"]
| 指標 | 条件 | 意味 |
|---|---|---|
validVotes | 6 段階検証をすべて通過した範囲内かつ初出の投票 | 集計に含まれたスロット数 |
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 側で導出) | 導出元 |
|---|---|
excludedCount | excludedSlots |
missingIndices | missingSlots |
invalidIndices | invalidPresentedSlots |
countedIndices | validVotes |
ジャーナル出力
ゲストプログラムがジャーナルにコミットする出力構造(VerificationOutput)を示します。
| フィールド | 型 | 説明 |
|---|---|---|
| electionId | UUID | 対象選挙 ID(入力の election_id をエコー) |
| electionConfigHash | 32 バイト | 選挙設定ハッシュ(入力の election_config_hash をエコー) |
| bulletinRoot | 32 バイト | 掲示板ルート(入力の bulletin_root をエコー) |
| treeSize | u32 | 掲示板のツリーサイズ(入力をエコー) |
| totalExpected | u32 | 想定総投票数(入力をエコー) |
| sthDigest | 32 バイト | STH ダイジェスト |
| verifiedTally | u32[5] | 選択肢 A〜E ごとの得票数 |
| totalVotes | u32 | zkVM が受け取った投票レコード数 |
| validVotes | u32 | 検証に成功した投票数 |
| invalidVotes | u32 | 検証に失敗した投票数 |
| seenIndicesCount | u32 | 範囲内かつ初出のインデックスとして処理した件数 |
| missingSlots | u32 | 一度も提示されなかった掲示板スロット数 |
| invalidPresentedSlots | u32 | 提示はされたが計上されなかった範囲内スロット数 |
| rejectedRecords | u32 | 却下されたレコード数(重複・範囲外・各種検証失敗を含む) |
| seenBitmapRoot | 32 バイト | prover に提示されたインデックス集合の ビットマップ Merkle ルート |
| includedBitmapRoot | 32 バイト | 実際にカウントされたインデックス集合の ビットマップ Merkle ルート |
| excludedSlots | u32 | 除外されたスロットの総数(= missingSlots + invalidPresentedSlots) |
| inputCommitment | 32 バイト | 入力コミットメント |
| methodVersion | u32 | ゲストプログラムのバージョン(現行 = 12 / v1.2) |
ジャーナルの信頼モデル
ジャーナルの各フィールドは、対応する STARK 証明により「ゲストプログラムが正しく計算した結果」であることが保証されます。
| ジャーナル項目 | STARK 証明で保証される内容 | 補足 |
|---|---|---|
verifiedTally | 有効投票のみを正しく集計した結果である | validVotes / invalidVotes との整合もジャーナル上で確認可能 |
excludedSlots | 未提示または未計上のスロット数がゲストの計算結果と一致する | excludedSlots > 0 は完全性違反の重要シグナル |
rejectedRecords | 却下されたレコード数がゲストの計算結果と一致する | 重複・範囲外・検証失敗の説明に使う補助情報で、スロット単位の判定とは分離される |
inputCommitment | ゲストが処理した入力データを正準エンコードで束縛した値である | 公開入力側から再計算して照合できる |
seenBitmapRoot | prover に提示された範囲内かつ初出のインデックス集合から計算したルートである | includedBitmapRoot と併用すると未提示 / 提示されたが未計上 / カウント済みを区別できる |
includedBitmapRoot | 実際にカウントされたインデックス集合から計算したルートである | 自票包含の証明(bitmap proof)の検証基準になる |
sthDigest | その実行で参照した掲示板状態から計算した値である | 第三者 STH 合意そのものは別チェックで確認する |
第三者はレシートの STARK 検証を行うだけで、上記の保証を取得できます。ゲストプログラムのロジックを信頼する必要はありますが、ホストやサーバーの正直性を信頼する必要はありません。
ビットマップルートの計算
ゲストプログラムは投票検証と並行して、2 種類のビットマップを構築します。
seenBitmapとincludedBitmapの 2 つのブール配列を初期化(全false)- 範囲内かつ一意インデックスとして処理された票のインデックスに対応する
seenBitmapのビットをtrueに設定 - 6 段階検証を通過した票のインデックスに対応する
includedBitmapのビットをtrueに設定 - 各ビットマップを LSB-first でバイト列にパッキング
- パック後の長さが 32 バイト以下なら、ゼロ埋めした 1 リーフとして CT スタイルの leaf hash を計算
- 33 バイト以上なら 32 バイトチャンクに分割し、それぞれを leaf とする CT スタイルの Merkle ツリーを構築
- 2 つのルート値(
seenBitmapRootとincludedBitmapRoot)をジャーナルにコミット
この 2 つのルートを使うことで、公開検証側は「prover に提示されたが無効化された票」と「そもそも prover に提示されなかった票」を区別できます。
ビットマップの詳細な構造とハッシュ規則は ビットマップ Merkle を参照してください。
入力コミットメントと STH ダイジェスト
ゲストプログラムは投票処理の後、2 つの追加ハッシュ値を計算してジャーナルにコミットします。
入力コミットメント
ゲストに渡された入力のうち、公開フィールドを正準エンコーディングで連結し SHA-256 で圧縮します。現行実装では固定のドメインタグと format version を先頭に付与した上で、electionId・bulletinRoot・treeSize・totalExpected・votesCount と各投票の 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
入力構築で行われる主要な処理:
- 掲示板の最新 STH スナップショット取得: ルートハッシュ、ツリーサイズ、タイムスタンプを取得
- 投票データの変換: 各投票の選択肢を整数に変換(A=0, B=1, C=2, D=3, E=4)
- Merkle パスの解決: 各投票について、掲示板から最新の包含証明を取得
- 総投票数の設定: ボット投票数 + ユーザー投票数(本 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 プログラムです。以下の処理を行います:
- JSON 形式の入力ファイルを読み込み
- JSON のバイト配列表現を Rust の固定長配列型へ変換(
Vec<u8>→[u8; 16/32]) ExecutorEnvに入力をシリアライズして設定- デフォルトプローバーを使用して zkVM ゲストを実行
- レシート(STARK 証明 + ジャーナル)を取得
- ジャーナルをデコードし、出力ファイルに書き出し
入力 JSON は TypeScript 側のエグゼキューターが事前に正規化して生成します(UUID/ハッシュ文字列をバイト配列へ変換)。
出力ファイル
ホストバイナリは常に 2 つの JSON ファイルを出力し、条件を満たした場合は private bitmap artifact も追加で出力します。
| ファイル | 内容 |
|---|---|
| レシート JSON | { "receipt": ..., "image_id": "0x..." } 形式のラッパー JSON |
| 出力 JSON | デコード済みのジャーナル(集計結果、除外情報、各種ハッシュ値) |
レシート JSON には top-level image_id フィールドも含まれます。検証サービスでの使われ方は 検証サービス を参照してください。
また、ホストはビットマップの整合性を確認し、一致した場合のみ以下の private artifact を出力します。
| ファイル | 内容 |
|---|---|
*-bitmap.json | counted bitmap の厳密 artifact(includedBitmapRoot と対応) |
*-seen-bitmap.json | presented 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_MODE と RUST_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: セッションデータ更新
非同期モードの処理フロー
- 入力の準備: ディスパッチ Lambda が入力 JSON を S3 にアップロードし、Step Functions 実行を開始
- イメージ署名チェック: プローバーコンテナイメージの署名を検証し、承認されたイメージのみ実行を許可
- プローバータスク: ECS Fargate タスクが起動し、S3 から入力をダウンロードしてホストバイナリを実行
- 出力バンドル: レシート、ジャーナル、
public-input.json、election-manifest.json、close-statement.jsonを生成し、整合性検査を通過したものだけをbundle.zipにまとめて S3 にアップロード - コールバック: 成功/失敗に応じてコールバック Lambda がセッションデータを更新
配布対象バンドルの構築
非同期モードでは、ホストバイナリの出力から秘密データを含まない配布対象バンドル(bundle.zip)を構築します。
ここでいう「配布対象」は機密性の分類です。用語の意味と取得経路は バンドル構造 を参照してください。
| ファイル | 内容 | 配布対象 |
|---|---|---|
| receipt.json | STARK レシートのラッパー JSON | Yes |
| journal.json | ジャーナルの正準 JSON 表現 | Yes |
| public-input.json | 秘密データを含まない検証用レコード | Yes |
| election-manifest.json | 選挙設定の公開監査用スナップショット | Yes |
| close-statement.json | 集計締切時点のログ境界を表す公開監査レコード | Yes |
秘密データを含む完全入力は非同期実行時のワーク入力として S3 や一時領域に存在し得ますが、bundle.zip には含まれません。
public-input.json、election-manifest.json、close-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 への影響 |
|---|---|---|
success | STARK 検証が成功し、Image ID も一致 | STARK Verified を表示可能 |
failed | Image 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_verify | STARK 証明が暗号学的に有効であるか |
stark_image_id_match は verifier report の expected_image_id と receipt_image_id の一致を主に検証します。検証フロー側では、これに加えて claimed / comparison 側の Image ID との整合も確認します。
これらのチェックが両方成功した場合に限り、「STARK Verified」のステータスが付与されます。詳細は 4 段階検証モデル を参照してください。
セキュリティ上の考慮事項
サーバー側検証の信頼境界
検証サービスはサーバー側で実行されるため、クライアントはサーバーの検証結果を信頼する必要があります。この PoC における信頼モデルは以下の通りです:
- STARK 証明自体は秘密データを含まない検証データ: レシートと Image ID があれば、第三者が独立に検証可能
- 検証サービスは利便性のための委任: ブラウザでの STARK 検証が実用的になれば、クライアント側のみで完結させることも理論上は可能
- 配布対象バンドル: レシートと
public-input.jsonは ZIP ローカル検証(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 をマッピング上で管理できます。
| アーキテクチャ | 用途 |
|---|---|
| ARM64 | ECS Fargate (Graviton) での本番証明生成 |
| x86_64 | ローカル開発、CI/CD 環境での証明生成 |
アプリ/サーバー実装では、WSL 環境でのみ expectedImageID_x86_64 を自動選択し、それ以外は expectedImageID を使います。CLI テストハーネスの自動選択ルールはこれと異なります。いずれの場合も EXPECTED_IMAGE_ID 環境変数で明示オーバーライドできます。
Image ID マッピング
期待される Image ID は、バージョンごとにマッピングファイルで管理されます。
マッピングの構造
マッピングファイルには、各バージョンの Image ID、説明、機能リストが記録されます。
| フィールド | 説明 |
|---|---|
| methodVersion | ゲストプログラムのバージョン番号 |
| expectedImageID | ARM64 環境での Image ID |
| expectedImageID_x86_64 | x86_64 環境での Image ID |
| description | バージョンの説明 |
| features | このバージョンで実装された機能リスト |
| current | 現在有効なバージョン番号 |
| deprecated | 非推奨バージョンの一覧 |
バージョン履歴の管理
マッピングファイルはバージョンの履歴を保持します。current フィールドが現在有効なバージョンを指し、deprecated フィールドが過去のバージョンを列挙します。
現行実装では current は 12 で、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 を更新する必要があります。
- ゲストコードを変更する
- zkVM ゲストをビルドし、新しい Image ID を取得する
public/imageId-mapping.jsonを更新する(必要に応じてexpectedImageID_x86_64も更新)- フォールバック定数を持つ関連コードも更新する(現行実装では
src/lib/verification/expected-image-id.ts) - プローバーイメージとマッピングを同時にデプロイする
更新の同期要件
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 段階で検証するパイプラインの設計と実装を解説します。
この部に含まれる章
- 設計と実行フロー — 設計原則、パイプライン構造、実行フロー
- 4 段階検証モデル — 検証の全体設計と各段階の保証
- チェック一覧 — 全検証チェック ID とその判定ロジック
- バンドル構造 — 証明バンドルの公開・非公開アーティファクト
- ゲーティングロジック — 「Verified」表示の条件と不変条件
設計と実行フロー
検証パイプラインの設計原則、全体構造、実行フローを解説します。
設計原則
本システムの検証パイプラインは、以下の 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 証跡(
voteReceiptとuserVote.proof)を前提とします。store から再構成できない場合でも/api/verifyは200を返し、関連チェックをnot_runにして全体判定をmissing_evidence側へ fail-closed に倒します。 - STARK 検証は専用サービス(
POST /api/verification/run)で実行され、GET /api/verifyがその結果を読み取ります。 verificationSteps[].statusは required チェック群を基準に導出されますが、現行実装ではjournalやuserVote.proof.treeSizeが不在の場合にverificationSteps[].statusをnot_runに上書きするガード条件もあります。deriveVerificationSummaryはサーバー側の/api/verifyとクライアント側の/verifyの両方で使われます。サーバーはverificationStatusを fail-closed(安全側に倒す方向)に補正し、unsupported な verifier status でもverificationSteps/verificationChecksを含む200応答を返します。クライアントは summary に加えて STARK timeout や transport failure も最終失敗表示に反映します。UI 側の補助分岐については ゲーティングロジック を参照してください。
検証パイプラインの全体構造
1. 段階の依存関係
- Stage 1 — Cast-as-Intended
- Stage 2 — Recorded-as-Cast
- Stage 3 — Counted-as-Recorded
- Stage 4 — STARK Verification
- 結果表示
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 へ進みます。
/resultは継続用のクライアント状態(verificationRequestedAtと正準な finalization snapshot)を保存し、必要ならPOST /api/verification/runを先行起動します/verifyはその継続状態がある場合に検証シーケンスを続行します。STARK が未開始ならシーケンス内で起動できます- 継続状態がなく STARK が
not_runのまま直接/verifyへアクセスした場合は、自動続行せずブロックします /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 1 | Cast-as-Intended | 投票者の意図通りにコミットメントが生成された | クライアント(/verify 画面でローカル再計算) |
| Stage 2 | Recorded-as-Cast | コミットメントが追記専用掲示板に正しく記録された | サーバー(GET /api/verify) |
| Stage 3 | Counted-as-Recorded | 記録された全投票が正しく集計に含まれた | サーバー(GET /api/verify) |
| Stage 4 | STARK Verification | zkVM の実行が正しく行われたことの暗号学的証明 | サーバー(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-Intended | 4 |
| Recorded-as-Cast | 6 |
| Counted-as-Recorded | 10 |
| STARK Verification | 2 |
| 合計 | 22 |
Counted-as-Recorded の required チェックには counted_election_manifest_consistent と counted_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) |
voteReceipt は cast-time 証跡を store から再構成できた場合にだけ /api/verify から返ります。再構成できない場合の動作は 設計原則 3 を参照してください。
失敗モード
| 症状 | 原因 | 深刻度 |
|---|---|---|
| ローカル証拠の欠落 | localStorage 消去や別端末アクセスで投票時データを復元できない | 検証不能 |
| 投票レシート証跡の欠落 | store から cast-time 証跡を再構成できず、voteReceipt が省略 | 検証不能 |
| コミットメント不一致 | 投票時データと投票レシートの不整合、またはエンコーディングの不整合 | 重大 |
| 選択肢の範囲外 | 不正な入力(A〜E の範囲外) | 重大 |
| 乱数フォーマット不正 | 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/verify の userVote.proof.treeSize | 整合性証明の oldSize |
| 最終ルートハッシュ/最終ツリーサイズ | /api/verify | 集計時の最終状態 |
| 独立検証用の包含証明材料(任意) | /api/bulletin/:voteId/proof | クライアント外で個別に包含証明を再検証するための材料 |
| 補助 tooling の整合性証明材料(任意) | /api/bulletin/consistency-proof | secondary 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 | 集計結果、除外情報、inputCommitment、includedBitmapRoot、seenBitmapRoot などの詳細 |
| 集計サマリー(通常応答) | /api/verify | missingSlots / invalidPresentedSlots / rejectedRecords / excludedSlots / totalExpected / treeSize などの上位値 |
| 公開入力サマリー | サーバー内部評価用 | public-input.json 相当から組み立てた、秘密データを含まない入力要約 |
| 選挙マニフェスト | 公開 bundle (election-manifest.json) | electionId と electionConfigHash を束縛する公開 artifact |
| 締め処理ステートメント | 公開 bundle (close-statement.json) | logId / treeSize / bulletinRoot / timestamp / sthDigest を束縛する公開 artifact |
| ビットマップ証明材料 | /api/bitmap-proof | kind=included と kind=seen を使い分け、自分のインデックスが counted されたことや prover に提示されたかを説明する材料 |
| ビットマップルート | ジャーナル | zkVM ゲストが計算した includedBitmapRoot と seenBitmapRoot |
公開入力サマリーはサーバー内部表現であり、レスポンスにそのまま含まれません。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) |
| ツリーサイズの不一致 | totalExpected と treeSize が異なる(暗黙の除外、または 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 | サーバー側で解決 | ゲストプログラムの暗号的識別子(解決順は チェック一覧 参照) |
| ホスト主張値と比較用メタデータ | 検証コンテキストと report | imageId, journal.imageId, verificationReport.receipt_image_id を相互照合し、主張の食い違いを検出 |
開発モードの検出
RISC0_DEV_MODE=1 で生成されたレシートは InnerReceipt::Fake 型であり、暗号学的な保証を持ちません。検証サービスはこれを dev_mode ステータスとして報告します。チェック評価では、設定に応じて dev_mode は success または 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 から取得する、秘密データを含まない検証用データ |
zk | zkVM が束縛した公開証拠(ジャーナル、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 とコミットメントを含む | local | required |
cast_choice_range | 選択肢が有効範囲内(A〜E) | local | required |
cast_random_format | 乱数が 32 バイトの 16 進数文字列 | local | required |
cast_commitment_match | 投票時データから再計算したコミットメントが投票レシートと一致 | local | required |
判定ロジックの詳細
cast_receipt_present
voteReceipt が存在し、voteId と commitment フィールドが存在することを確認します(このチェック単体では UUID/hex 形式までは検証しません)。
cast_choice_range
投票時データの選択肢が A〜E のいずれかであることを確認します。範囲外の値は不正な入力として 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 | コミットメントが掲示板ツリーに存在する | public | optional |
recorded_index_in_range | 掲示板インデックスが 0 以上かつツリーサイズ未満 | public | required |
recorded_root_at_cast_consistent | 投票時のルートが最終ツリーの正当なプレフィックスである | public | optional |
recorded_inclusion_proof | RFC 6962 包含証明が暗号学的に検証成功 | public | required |
recorded_consistency_proof | RFC 6962 整合性証明が暗号学的に検証成功 | public | required |
recorded_sth_third_party | 第三者 STH ソース間で合意が成立(比較可能応答間) | public | optional |
判定ロジックの詳細
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 証跡(voteReceipt と userVote.proof)が揃っている場合、まず cast snapshot の一貫性(leafIndex、treeSize、bulletinRootAtCast が receipt と矛盾しないこと)を確認したうえで、リーフハッシュと監査パスから cast 時点のルートを再計算して照合します。証跡が揃わない場合は not_run となり、全体判定は fail-closed で missing_evidence 側へ倒れます。
recorded_consistency_proof
投票時のツリー(oldSize, oldRoot)から最終ツリー(newSize, newRoot)への RFC 6962 整合性証明を検証します。cast-time 証跡を前提に、まず cast snapshot の一貫性(leafIndex、treeSize、bulletinRootAtCast)を 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 になります。
このチェック定義の criticality は optional ですが、サマリー判定では STH ソースが設定されている場合に実質的な必須条件として扱われます。
派生チェックについて
recorded_commitment_in_bulletin と recorded_root_at_cast_consistent は、それぞれ recorded_inclusion_proof と recorded_consistency_proof から結果を派生します。これは、包含証明の検証成功がそのまま「コミットメントの存在」の証明となり、整合性証明の検証成功がそのまま「ルートの一貫性」の証明となるためです。
派生チェックの重要度が optional であるのは、派生元の required チェックが既にカバーしているためです。
Counted-as-Recorded(10 チェック)
記録された全投票が正しく集計に含まれたかを検証するチェック群です。
| ID | 説明 | 証拠種別 | 重要度 |
|---|---|---|---|
counted_input_sanity | 公開入力サマリーが有効 | public | required |
counted_unique_indices | 入力中の全インデックスが一意 | public | required |
counted_unique_commitments | 入力中の全コミットメントが一意 | public | required |
counted_tally_consistent | claimed tally と zkVM の検証済み集計が一致(fallback: 合計整合) | zk | required |
counted_missing_indices_zero | 解決済み fail-closed exclusion count(excludedSlots 優先)が 0 | zk | required |
counted_expected_vs_tree_size | totalExpected がツリーサイズと一致 | zk | required |
counted_election_manifest_consistent | election-manifest.json が自己整合し、選挙 ID / electionConfigHash と一致 | public | required |
counted_close_statement_consistent | close-statement.json が自己整合し、log/tree/timestamp/root/sthDigest と一致 | public | required |
counted_my_vote_included | bitmap 証明により自分のインデックスが counted 側に含まれたことを確認 | zk | required |
counted_input_commitment_match | 公開入力から計算した入力コミットメントがジャーナルの値と一致 | public | required |
判定ロジックの詳細
counted_input_sanity
public-input.json 相当から組み立てた公開入力サマリーが存在し、スキーマ検証に成功していることを確認します。加えて、treeSize が正の整数、votesCount <= treeSize、bulletinRoot が 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 であることを確認します。除外数は以下の優先順で解決します。
journalがある場合:journal.excludedSlotsを使用し、必要に応じて旧フィールドへの互換値も導出journalがない場合:excludedSlots→excludedCount→missingSlots + invalidPresentedSlots→missingIndices + invalidIndicesの順で探索
rejectedRecords は説明用の補助値であり、この判定には使いません。解決値が 0 でない場合、または journal 側の値が無効な場合は即座に検証失敗とします。
counted_expected_vs_tree_size
totalExpected(期待される投票数)が掲示板のツリーサイズと一致することを確認します。不一致は、暗黙の投票除外を示す可能性があります。
counted_election_manifest_consistent
election-manifest.json の electionConfigHash を再計算し、manifest 自身の宣言値と一致することを確認します。そのうえで electionId と electionConfigHash を、セッション値・publicInputSummary・ジャーナルに含まれる対応値と相互照合します。
counted_close_statement_consistent
close-statement.json から sthDigest を再計算し、宣言された snapshot と一致することを確認します。そのうえで timestamp が publicInputSummary.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 ジャーナルに記録された入力コミットメント値と照合します。現行実装で対象となるのは electionId、bulletinRoot、treeSize、totalExpected、votesCount と、各投票の index・commitment・merklePath です。これは public-input.json 全体の単純なハッシュではありません。
STARK Verification(2 チェック)
STARK 証明の暗号学的正当性を検証するチェック群です。
| ID | 説明 | 証拠種別 | 重要度 |
|---|---|---|---|
stark_image_id_match | verifier-confirmed な Image ID と expected / host-side metadata が整合 | zk | required |
stark_receipt_verify | STARK レシートが暗号学的に検証成功 | zk | required |
判定ロジックの詳細
stark_image_id_match
verifier-service が返す expected_image_id と receipt_image_id が一致することを確認します。
加えて、以下の補助値が存在する場合は相互矛盾がないことも検証します。
imageId(ホスト主張値)journal.imageId(比較用メタデータ)
いずれかが receipt_image_id と食い違う場合は失敗となります。
期待 Image ID の解決順:
EXPECTED_IMAGE_ID環境変数public/imageId-mapping.jsonの current mapping(WSL ではexpectedImageID_x86_64が自動選択される場合あり)- 組み込み 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 | カテゴリ | 証拠 | 重要度 | 派生元 |
|---|---|---|---|---|---|
| 1 | cast_receipt_present | Cast | local | required | — |
| 2 | cast_choice_range | Cast | local | required | — |
| 3 | cast_random_format | Cast | local | required | — |
| 4 | cast_commitment_match | Cast | local | required | — |
| 5 | recorded_commitment_in_bulletin | Recorded | public | optional | recorded_inclusion_proof |
| 6 | recorded_index_in_range | Recorded | public | required | — |
| 7 | recorded_root_at_cast_consistent | Recorded | public | optional | recorded_consistency_proof |
| 8 | recorded_inclusion_proof | Recorded | public | required | — |
| 9 | recorded_consistency_proof | Recorded | public | required | — |
| 10 | recorded_sth_third_party | Recorded | public | optional | — |
| 11 | counted_input_sanity | Counted | public | required | — |
| 12 | counted_unique_indices | Counted | public | required | — |
| 13 | counted_unique_commitments | Counted | public | required | — |
| 14 | counted_tally_consistent | Counted | zk | required | — |
| 15 | counted_missing_indices_zero | Counted | zk | required | — |
| 16 | counted_expected_vs_tree_size | Counted | zk | required | — |
| 17 | counted_election_manifest_consistent | Counted | public | required | — |
| 18 | counted_close_statement_consistent | Counted | public | required | — |
| 19 | counted_my_vote_included | Counted | zk | required | — |
| 20 | counted_input_commitment_match | Counted | public | required | — |
| 21 | stark_image_id_match | STARK | zk | required | — |
| 22 | stark_receipt_verify | STARK | zk | required | — |
バンドル構造
証明バンドルの構成、公開許可リスト、非公開アーティファクトの保護を解説します。同期モードと非同期モードの違いも含みます。
概要
証明バンドルは、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.json | zkVM 検証に使う秘密データを含まない検証用レコード | 第三者が入力コミットメントを再計算するため |
election-manifest.json | 選挙設定の公開監査用スナップショット | 選挙設定と electionConfigHash の照合 |
close-statement.json | 集計締切時点のログ境界を表す公開監査レコード | logId / treeSize / bulletinRoot の照合 |
receipt.json | ホストが出力する { receipt, image_id } ラッパー JSON | 第三者がレシートを独立に検証するため |
journal.json | zkVM ゲストの公開出力(集計結果、除外情報、ビットマップルート等) | 集計結果と整合性データの確認 |
metadata.json | バンドルの作成日時、セッション ID、メソッドバージョン(sync のみ) | バンドルの来歴追跡 |
sth.json | 第三者 STH 検証のスナップショット(任意) | STH 合意の再現可能な証拠 |
consistency-proof.json | RFC 6962 整合性証明(任意) | 追記専用性の独立検証 |
sth.json と consistency-proof.json は許可リストに含まれますが、現行フローでは通常生成されません。
非公開アーティファクト
| ファイル | 内容 | 非公開の理由 |
|---|---|---|
input.json | zkVM への完全な入力(選択肢・乱数・コミットメント・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.json と seen-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 | 集計時のタイムスタンプ |
methodVersion | zkVM メソッドバージョン |
votes | 各投票のインデックス、コミットメント、Merkle パスの配列 |
votes 配列には各投票のインデックスとコミットメント、Merkle パスが含まれますが、選択肢と乱数は含まれません。これにより、入力コミットメントの再計算が可能でありながら、投票内容の秘匿性は維持されます。
public-input.json と inputCommitment は同一ではなく、後者が直接束縛するのはこのレコードの一部です。計算対象フィールドと非対象フィールドの整理は 入力コミットメント を参照してください。
同期バンドルと非同期バンドルの違い
証明生成には同期モード(ローカル実行)と非同期モード(ECS Fargate)の 2 つのパスがあり、生成されるバンドルの構成が異なります。
同期モード:
- ローカルプロセスでホストバイナリ実行
- TypeScript で全アーティファクト生成
- 検証サービスを呼び出し verification.json を保存
- 許可リストで bundle.zip を作成
非同期モード:
- ECS Fargate でホストバイナリ実行
- entrypoint.sh で公開可能アーティファクト生成
- bundle.zip を S3 にアップロード
- コールバック Lambda で結果をセッションに保存
両モードとも、public-input.json、election-manifest.json、close-statement.json は生成しただけでは採用されません。journal.json と正準な証明束縛データ(proof-bound data)に対する整合性検査を通過した場合にのみ bundle 化され、不一致があれば fail-closed(安全側に倒して)処理を中断します。
| 項目 | 同期モード | 非同期モード |
|---|---|---|
| 実行環境 | ローカルプロセス / Lambda | ECS Fargate コンテナ |
input.json | 生成される(非公開保存) | ワーク入力として S3 に置かれる(配布対象外) |
public-input.json | TypeScript で生成 | entrypoint.sh 内で生成 |
election-manifest.json | TypeScript で生成 | entrypoint.sh 内で生成 |
close-statement.json | TypeScript で生成 | entrypoint.sh 内で生成 |
journal.json | TypeScript で生成 | *-output.json から bundle.zip 用に生成 |
receipt.json | ホストの { receipt, image_id } 出力を保存 | *-receipt.json を receipt.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 URL | S3 アップロード時に生成 | コールバック Lambda が生成 |
非同期バンドルの生成フロー
非同期モードでは、ECS Fargate コンテナの entrypoint.sh が以下の手順でバンドルを構築します。
- S3 から入力 JSON をダウンロード
- ホストバイナリを実行し、レシートと出力を生成
- 出力から
journal.jsonを変換生成 - 入力と出力から
public-input.json、election-manifest.json、close-statement.jsonを構築 journal.jsonとpublic-input.json/election-manifest.json/close-statement.jsonの整合性を検証し、ドリフトがあれば bundle 作成を中止receipt.json、journal.json、public-input.json、election-manifest.json、close-statement.jsonをbundle.zipにアーカイブ*-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.json、election-manifest.json、close-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/:executionId | bundle.zip のダウンロード |
GET /api/verification/bundles/:sessionId/:executionId/report | verification.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.json、verification.json、included-bitmap.json、seen-bitmap.json は bundle.zip には含まれません。これらのファイルがバンドルディレクトリに存在していても、公開 bundle contract に含まれていないため、アーカイブ作成時に除外されます。
ゲーティングロジック
「Verified」表示の条件と、表示を阻止する不変条件を解説します。
「必要な検証が未実行または失敗なら Verified を表示しない」 という原則に基づき、各検証チェックの結果がどのように最終判定に集約されるかを説明します。
最終判定の種類
検証パイプラインの結果は、主経路では deriveVerificationSummary によって集約され、UI 上は以下の 3 トーンに整理されます。なお /verify ページには STARK タイムアウト時の失敗表示など、集約結果に対する限定的な上書きもあります。
| 表示ステータス | 主な条件 | UI 表示 |
|---|---|---|
| Verified | required 条件が満たされ、optional チェックの劣化もない(fully_verified) | 緑色 |
| Verification Failed | 証明失敗、票除外、Recorded/Counted/Cast の必須失敗、または公開集計値と検証済み tally の不一致が確定した場合 | 赤色 |
| Warning | required チェックが進行中、証拠不足がある、または optional チェックのみ劣化している場合 | 黄色 |
ステータスの判定順序
| 優先順 | 判定条件 | 最終ステータス |
|---|---|---|
| 1 | required チェックに pending / running がある | Warning (in_progress) |
| 2 | STARK 証明系ロールが failed | Verification Failed |
| 3 | completeness ロールが failed(user_vote_excluded / votes_excluded / votes_excluded_unknown) | Verification Failed |
| 4 | Recorded-as-Cast の required チェックが failed | Verification Failed |
| 5 | tally consistency だけが失敗し、proof / completeness / user inclusion / input integrity / Recorded required が成功 | Verification Failed (published_tally_mismatch) |
| 6 | Counted-as-Recorded または Cast-as-Intended の required チェックが failed | Verification Failed |
| 7 | (a) required に not_run がある (b) 必須ロール不足 (c) 未知チェック (d) required 定義の未解決 のいずれか | Warning (missing_evidence) |
| 8 | optional チェックに 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 != treeSize や rejectedRecords > 0 を警告として収集します(fail-closed な除外数が 0 の場合)。
ここで警告対象となるのは rejectedRecords(提示されたが計上されなかった record の数)です。invalidPresentedSlots は除外数に含まれるため、警告ではなくゲート 2 で判定されます。
ただし、verificationChecks 側では counted_expected_vs_tree_size が必須チェックであり、totalExpected != treeSize は failed として最終的に Verified をブロックします。
ゲート 4: 第三者 STH 検証(有効時のみ)
STH ソースが設定されている場合のみ評価されます。
| 条件 | 結果 |
|---|---|
| STH ソースが未設定 | このゲートをスキップ |
比較可能な応答群で合意成立 かつ matchingSources >= minMatches | 通過 |
| 合意が成立しない | canShowVerified = false |
| 一致ソース数が最小要件未満 | canShowVerified = false |
照合条件の詳細は チェック一覧 を参照してください。
ゲート 5: ユーザーインデックスの範囲検証
投票者のインデックスがツリーサイズの範囲内であることを確認します。
| 条件 | 結果 |
|---|---|
userIndex < treeSize | 通過 |
userIndex >= treeSize | canShowVerified = 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 チェックへの反映 |
|---|---|
running | pending |
not_run | not_run |
failed | failed |
success | ゲートなしで通常評価 |
dev_mode は事前に success または not_run に正規化されてから zkGate に入力されます。
ステップとチェックの対応関係
UI に表示される 4 つのステップは、現行実装では 22 個のチェック定義から派生します。
ただし、verificationSteps[].status は「その stage で required 扱いになるチェック群」から導出され、verificationSteps[].inputs は stage 内の 全チェック定義 から集約されます。
| ステップ | required として集約されるチェック ID |
|---|---|
| Cast-as-Intended | cast_receipt_present, cast_choice_range, cast_random_format, cast_commitment_match |
| Recorded-as-Cast | recorded_index_in_range, recorded_inclusion_proof, recorded_consistency_proof、および STH source 設定時の recorded_sth_third_party |
| Counted-as-Recorded | counted_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 Verification | stark_image_id_match, stark_receipt_verify |
補足:
recorded_commitment_in_bulletinはrecorded_inclusion_proofから、recorded_root_at_cast_consistentはrecorded_consistency_proofから導出される表示用チェックです。チェック一覧には現れますが、単独では step status を決定しません。recorded_sth_third_partyは通常は optional ですが、STH source が設定されている場合だけ required に昇格し、Recorded-as-Cast の step status と最終判定をブロックし得ます。
ステップのステータスは、required 扱いになったチェックのステータスから次の順序で集約されます。
| 集約ルール | 条件 |
|---|---|
failed | required チェックのいずれかが failed |
running | failed がなく、required チェックのいずれかが running |
pending | failed/running がなく、いずれかが pending |
success | required チェックがすべて success |
not_run | 上記のいずれにも該当しない |
さらに現行実装には、単純集約だけではない 3 つの補正があります。
counted_as_recordedはjournalが存在しない場合、required チェックにfailedがない限りnot_runに補正されます。recorded_as_castはuserVote.proof.treeSizeがない場合、not_runに補正されます。/api/verifyはcastSource='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/finalizeはscenarioIdを 1 つ受け取る
- UI:
totalExpectedは 64(ユーザー 1 + ボット 63)- 掲示板(CT Merkle)は追記専用で、シナリオ適用で既存エントリは削除しない
tamperModeはnone/input/claimの 3 種
注意事項:
- 本章は実 API 経路(
/api/finalize→finalize-session→finalize-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: 正常(改ざんなし)
改ざんを適用しない基準シナリオです。
| 項目 | 値 |
|---|---|
| tamperMode | none |
| zkVM 入力票数 | 64 |
| claimed と verified | 一致 |
excludedSlots | 0 |
S1: ユーザー票の除外
ユーザー票(インデックス 0)を modifiedVotes から削除し、63 票を zkVM に渡します。
| 項目 | 値 |
|---|---|
| tamperMode | input |
| zkVM 入力票数 | 63 |
| claimed と verified | 一致(どちらも 63 票入力ベース) |
| ジャーナル統計 | missingSlots=1, invalidPresentedSlots=0, excludedSlots=1 |
ポイント:
- 掲示板上のユーザー票エントリは残る
- 検出は主に完全性チェック(
excludedSlots > 0) - ビットマップ証明が利用可能なら
counted_my_vote_includedでも検出可能
S2: ユーザー票に関する主張集計の改ざん
ユーザー票に対する「主張集計(表示する tally)」のみ改ざんします。zkVM には元の 64 票を渡します。
| 項目 | 値 |
|---|---|
| tamperMode | claim |
| zkVM 入力票数 | 64(元データ) |
| claimed と verified | 不一致(ユーザー選択肢が -1、別候補が +1) |
excludedSlots | 0(通常) |
inputCommitment | zkVM 入力由来のため通常は一致 |
ポイント:
- 「票の中身を zkVM 入力で差し替える」実装ではない
- レシートや STARK 証明は通常どおり有効
- 検出の主因は
counted_tally_consistentの失敗
S3: ボット票の除外
現行実装ではボット票インデックス 1(targetBotId 初期値)を削除し、63 票を zkVM に渡します。
| 項目 | 値 |
|---|---|
| tamperMode | input |
| zkVM 入力票数 | 63 |
| claimed と verified | 一致(どちらも 63 票入力ベース) |
| ジャーナル統計 | missingSlots=1, invalidPresentedSlots=0, excludedSlots=1 |
S1 との違い:
- S1: ユーザー自身の未集計をビットマップで直接示せる
- S3: ユーザー票は含まれるが、集計全体の完全性違反で検出される
S4: ボット票に関する主張集計の改ざん
1 票のボット票に関する「主張集計」だけを改ざんします。zkVM 入力は元の 64 票のままです。
| 項目 | 値 |
|---|---|
| tamperMode | claim |
| zkVM 入力票数 | 64(元データ) |
| claimed と verified | 不一致(対象ボットの元候補が -1、別候補が +1) |
excludedSlots | 0(通常) |
inputCommitment | zkVM 入力由来のため通常は一致 |
ポイント:
- 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 側で不正票として除外されるためです。
シナリオ一覧表
| シナリオ | 類型 | tamperMode | zkVM 入力 | 主な失敗点(STARK 解決後) |
|---|---|---|---|---|
| S0 | 正常 | none | 元の 64 票 | なし |
| S1 | 除外 | input | 63 票(ユーザー除外) | excludedSlots > 0 |
| S2 | 主張改ざん | claim | 元の 64 票 | claimed ≠ verified |
| S3 | 除外 | input | 63 票(ボット除外) | excludedSlots > 0 |
| S4 | 主張改ざん | claim | 元の 64 票 | claimed ≠ verified |
| S5 | ランダム(実装上 input) | input | 63 または 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/recountedCount→tamperSummaryやtamperedCountに反映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_commitmentsはpublicInputSummaryがあれば 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 | なし | 正常系 |
| S1 | counted_missing_indices_zero | ユーザー票除外により excludedSlots=1 |
| S2 | counted_tally_consistent | claimed tally と verified tally が不一致 |
| S3 | counted_missing_indices_zero | 現行実装では botId=1 のボット票除外により excludedSlots=1 |
| S4 | counted_tally_consistent | claimed tally と verified tally が不一致 |
| S5 | counted_missing_indices_zero | excludedSlots>0 が必ず発生し、除外・再集計どちらでも完全性違反として検出される |
補足:
- S1 では、ビットマップ証明が利用可能な場合
counted_my_vote_includedも失敗し得ます - S2/S4 では、
counted_input_commitment_matchは通常成功します(zkVM 入力を改変していないため) - S5 の再集計分岐では
counted_tally_consistentも失敗します。choice だけ差し替えた票は commitment 不一致で zkVM が除外するため、verifiedTallyとclaimedCountsが一致しなくなります
4 段階検証モデルとの対応(/api/verify 応答)
| 検証段階 | S0 | S1 | S2 | S3 | S4 | S5 |
|---|---|---|---|---|---|---|
| Cast-as-Intended | not_run | not_run | not_run | not_run | not_run | not_run |
| Recorded-as-Cast | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Counted-as-Recorded | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| STARK Verification | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
この表は「シナリオ適用による典型挙動」を示します。running や追加の not_run は運用状態や証拠不足により別途発生します。
なお、ここでの STARK Verification は段階別の表示ステータスです。全体の verificationStatus は fail-closed ルールにより failed になることがあります。
チェックID(主要項目)マトリクス(STARK 解決後)
| チェック ID | S0 | S1 | S2 | S3 | S4 | S5 |
|---|---|---|---|---|---|---|
cast_commitment_match | not_run | not_run | not_run | not_run | not_run | not_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 管理のプローバーインフラを組み合わせたハイブリッド構成を採用しています。
この部に含まれる章
- 設計思想とサービス一覧 — ハイブリッド構成の理由、環境分離、サービス構成
- トポロジー — レイヤ別のサービス構成と通信フロー
- 非同期プローバー — SQS → Step Functions → ECS による証明パイプライン
- イメージ署名 — AWS Signer によるコンテナイメージ検証
- Terraform — IaC による構成管理とワークスペース運用
設計思想とサービス一覧
AWS インフラストラクチャのハイブリッド構成の設計思想、環境分離、全体構成、サービス一覧を解説します。
設計思想
なぜハイブリッド構成か
STARK 証明の生成には 16 vCPU / 32 GB メモリで約 6 分を要します。この特性は、hono-api Lambda の 60 秒タイムアウトや通常の Web リクエストの処理パターンに合わず、同期的な API 応答には載せられません。
そこで本システムでは、責務に応じてインフラ管理を分離しています。
| 管理ツール | 責務 | 理由 |
|---|---|---|
| Amplify Gen 2 | Web ホスティング、API(Lambda)、データ(AppSync + DynamoDB)、認証基盤(Cognito + IAM) | フロントエンド + API のデプロイサイクルが速い |
| Terraform | ECS Fargate、Step Functions、SQS、S3、ECR、CodeBuild、VPC | 計算リソースの精密な制御と、イメージ署名のようなセキュリティゲートの定義が必要 |
環境分離
develop と main の 2 環境を運用し、主要なアプリケーション実行系リソースは Terraform ワークスペースと Amplify ブランチデプロイで分離しています。
| 項目 | develop | main |
|---|---|---|
| 証明モード | 実 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 証明を前提にしています。
全体構成図
図: STARK Ballot Simulator の AWS 全体構成。Amplify 管理領域(上)と Terraform 管理領域(下)のハイブリッド構成。
サービス一覧
本システムで使用する主要な AWS サービスと、その役割の概要です。
Amplify 管理
| サービス | リソース | 役割 |
|---|---|---|
| Amplify Hosting | Web アプリ | Next.js のビルド・ホスティング |
| API Gateway (HTTP API) | stark-ballot-simulator-hono-api | /api/* ルートのプロキシ |
| Lambda | hono-api | Hono フレームワークによる API 処理 |
| Lambda | prover-dispatch-proxy | SQS 受信 → input.json を S3 保存 → Step Functions 起動 |
| Lambda | finalize-callback-runner | Step Functions コールバック → セッション更新 |
| Lambda | verifier-service-runner | STARK レシート検証の実行 |
| AppSync + DynamoDB | データモデル | セッション・投票・集計結果の永続化 |
| Cognito | Identity Pool / User Pool | 認証基盤(未認証 ID 無効) |
Terraform 管理
| サービス | リソース | 役割 |
|---|---|---|
| ECS Fargate | プローバータスク | zkVM ホストバイナリによる STARK 証明生成 |
| Step Functions | プローバーディスパッチャー | イメージ署名検証 → ECS 実行 → コールバック |
| SQS | ワークキュー + DLQ | 非同期証明リクエストのバッファリング |
| S3 | 証明バンドルバケット | 入力・実行成果物・検証用バンドル(bundle.zip など)の保存 |
| ECR | イメージリポジトリ | プローバーコンテナイメージの管理 |
| CodeBuild | 環境別プローバー + 共有 toolchain builder | Docker イメージのビルド(署名は ECR 設定に依存) |
| Lambda | check-image-signature | ECR イメージ署名の実行前検証 |
| 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-ID と X-Session-Capability ヘッダーを許可し、セッションスコープと capability トークンによるリクエスト制御を実現しています。
なお、POST /api/verification/run は hono-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 |
|---|---|---|---|
| VotingSession | id | electionId, botCount, finalized, userVoteIndex, finalizationResultJson | ttl |
| Vote | 識別子: sessionId + voteIndex | choice, 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.json と journal.json は S3 直下には保存されず、bundle.zip の内部エントリとして配布されます。
非同期 finalize では public-input.json、election-manifest.json、close-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 CodeBuild | develop: 7 日 / main: 14 日 |
/aws/codebuild/stark-ballot-simulator-risc0-toolchain-builder | 共有 toolchain CodeBuild | 14 日 |
/aws/lambda/{project}-check-image-signature-{env} | イメージ署名検証 Lambda | develop: 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 の場合です。
- セッションの状態を検証(全投票が完了していること)
- zkVM 入力を構築(入力ビルダーが投票データ + Merkle パスを組み立て)
- セッションの
finalizationStateを「pending」に更新 - SQS キューにメッセージを送信
- クライアントに 202 Accepted を返却
クライアントはその後、GET /api/sessions/:id/status をポーリングして進捗を確認します。
フェーズ 2: ディスパッチ
prover-dispatch-proxy Lambda が SQS メッセージを受信し、以下を実行します。
- zkVM 入力 JSON を S3 にアップロード(
sessions/{sessionId}/{executionId}/input.json) - Step Functions のステートマシンを
StartExecutionで起動 - セッションの
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_NAME | Terraform 変数 | 環境名(develop / main) |
S3_PROOF_BUCKET | Terraform 変数 | 証明バンドルバケット名 |
INPUT_S3_BUCKET | Terraform 変数 | 入力ファイルのバケット(同上) |
INPUT_S3_KEY | Step Functions 入力 | セッション固有の入力パス |
OUTPUT_S3_BUCKET | Terraform 変数 | 出力先バケット(同上) |
OUTPUT_S3_PREFIX | Step Functions 入力 | sessions/{sessionId}/{executionId}/ |
フェーズ 4: 結果通知
Step Functions が finalize-callback-runner Lambda を呼び出し、以下の情報をセッションに書き戻します。
- 成功時: bundle metadata(
s3BundleKey、presigned URL、有効期限、アップロード時刻)と、bundle.zip/ bitmap artifact から復元したfinalizationResult - 失敗時: 失敗状態とエラー情報(イメージ署名失敗、プローバーエラーなど)
ECS タスクの実行フロー
ECS Fargate タスク内のコンテナは、エントリポイントスクリプトにより以下の処理を順次実行します。
- S3 から入力 JSON をダウンロード
- 入力の構造を検証(必須フィールド確認)
- zkVM ホストバイナリを実行(タイムアウト: 900 秒)
- ジャーナルを JSON に変換
public-input.json、election-manifest.json、close-statement.jsonを構築journal.jsonと公開監査アーティファクトの整合性を検証bundle.zipを作成(receipt.json+journal.json+public-input.json+election-manifest.json+close-statement.json)- 生成物と 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.json | zkVM host の生出力(レシート) |
*-output.json | zkVM host の生出力(集計結果) |
*-journal.json | zkVM 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.zip | receipt.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 タスク仕様
| 項目 | 設定 |
|---|---|
| CPU | 16 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 送信) |
running | Step Functions が実行中 |
succeeded | 証明生成と結果の書き戻しが完了 |
failed | コールバック経由で失敗が書き戻された状態(署名検証失敗、プローバーエラー等) |
timeout | finalize-callback-runner が TIMED_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 証明は「特定のゲストプログラムが正しく実行された」ことを保証しますが、そもそもそのゲストプログラムを含むコンテナイメージ自体が改ざんされていないことも保証する必要があります。イメージ署名は、信頼されたビルドパイプラインが生成したイメージのみが証明生成に使用されることを担保するセキュリティゲートです。
脅威モデル
イメージ署名が防止する攻撃シナリオを示します。
署名なしの場合
- 攻撃者が ECR イメージを改ざんしたものに置換
- 改ざんされたプローバーが不正な集計結果を生成
- 不正な結果に対して有効な STARK 証明が存在
署名ありの場合
- 攻撃者が ECR イメージを改ざんしたものに置換
- Step Functions が署名ステータスを確認
- 署名なし/未完了を検出 → タスク起動を拒否
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 は以下の処理を行います。
- ECR の
DescribeImageSigningStatusAPI を呼び出す - 指定されたリポジトリ名とイメージダイジェストに対する署名ステータスを取得
- 取得した
status(COMPLETE/ それ以外)を Step Functions に返す
署名ステータスが COMPLETE でない場合、Step Functions の Choice ステートが FinalizeSignatureFailed に遷移し、コールバック Lambda に ImageSignatureVerificationFailed エラーを通知します。ECS タスクは一切起動されません。
ステータス確認と暗号学的検証の違い 本システムの実行前チェックは、ECR の
DescribeImageSigningStatusAPI が返す署名ステータス(COMPLETE/ それ以外)を確認するものであり、署名値そのものの暗号学的検証(署名の正当性確認や証明書チェーンの検証)ではありません。 これは、AWS ECR が署名のステータス照会 API は提供する一方、署名を暗号学的に独立検証する API を提供していないことによるものです。ECR managed signing の信頼モデルは、署名の付与・管理を AWS インフラに委任し、利用者はステータスを参照する設計です。 独立した署名検証が必要な場合は、Notation 等の外部ツールの併用が選択肢となります。
ECR リポジトリとイメージ管理
リポジトリ構成
| リポジトリ | 用途 | ライフサイクル |
|---|---|---|
stark-ballot-simulator/zkvm-prover-{env} | プローバーコンテナイメージ | 最新 10 イメージを保持 |
stark-ballot-simulator/risc0-toolchain | RISC 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 | 目的 |
|---|---|---|
| ECR | GetAuthorizationToken, PutImage 等 | イメージのプッシュ |
| AWS Signer | SignPayload, GetSigningProfile | 署名連携用の権限(運用/拡張時) |
| CloudWatch Logs | CreateLogGroup, PutLogEvents 等 | ビルドログの出力 |
| S3 | GetObject, PutObject | ビルドアーティファクト |
| CodeStar Connections | codestar-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 + Lambda | ECS タスクの起動拒否 |
| 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.tf | S3 ステートバックエンド + S3 lockfile |
versions.tf | Terraform / プロバイダーのバージョン制約 |
main.tf | ローカル変数、環境設定、データソース |
variables.tf | 入力変数の定義とバリデーション |
outputs.tf | 他ツール連携用の出力値 |
terraform.tfvars.example | 公開向け sanitized tfvars 例 |
develop.tfvars | develop 用の tracked 設定 |
main.tfvars | main 用の tracked 設定 |
iam.tf | IAM ロール / ポリシー(ECS、Step Functions、CodeBuild) |
ecs.tf | ECS クラスター + Fargate タスク定義 |
step_functions.tf | ステートマシン定義(ASL) |
sqs.tf | ワークキュー + デッドレターキュー |
s3.tf | 証明バンドルバケット + ライフサイクル |
ecr.tf | ECR リポジトリ + ライフサイクルポリシー |
codebuild.tf | ビルドプロジェクト(プローバー + ツールチェーン) |
lambda_check_image_signature.tf | イメージ署名検証 Lambda |
lambda/check-image-signature/ | イメージ署名検証 Lambda のソース |
vpc.tf | VPC + サブネット + インターネットゲートウェイ |
security_groups.tf | ECS タスク用セキュリティグループ |
cloudwatch.tf | ログ群 + 保持期間設定 |
cloudtrail.tf | 監査証跡(main 環境のみ) |
環境分離
ワークスペース戦略
develop と main の 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 で定義された設定マップにより解決されます。
| 設定 | develop | main |
|---|---|---|
| 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 が分かれるため、develop と main を同じ 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_arn | AWS Signer プロファイルの ARN | 空文字不可 |
codestar_connection_arn | CodeStar Connections ARN(IAM ポリシーで参照) | 空文字不可 |
注: 現行の CodeBuild source は GITHUB タイプ(location 指定)で構成されています。
オプション変数
| 変数 | デフォルト | 説明 |
|---|---|---|
aws_region | ap-northeast-1 | デプロイリージョン |
project_name | stark-ballot-simulator | リソース命名プレフィックス |
ecs_cpu | 16384 | Fargate の CPU ユニット |
ecs_memory | 32768 | Fargate のメモリ(MiB) |
s3_proof_prefix | sessions/ | 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_arn | Amplify 環境変数 | dispatch-proxy が SFN を起動 |
prover_work_queue_arn | Amplify 環境変数 | PROVER_WORK_QUEUE_ARN 参照 |
prover_work_queue_url | Amplify 環境変数 | API が SQS にメッセージ送信 |
s3_bucket_name | Amplify 環境変数 | 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_execution | ecs-tasks | ECR イメージ取得、CloudWatch Logs 書き込み |
ecs_task | ecs-tasks | S3 var.s3_proof_prefix 配下への読み書き(既定: sessions/*) |
step_functions | states.${aws_region}.amazonaws.com | ECS RunTask、Lambda Invoke、ログ |
codebuild | codebuild | ECR 操作、AWS Signer、ログ |
check_image_signature | lambda | ECR 署名ステータス照会、ログ |
スコープの制限
- 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 → Amplify | SFN ARN、SQS URL、S3 バケット名 | Terraform 出力値 → Amplify 環境変数 |
| Amplify → Terraform | callback Lambda ARN | Terraform 入力変数 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 のリクエスト/レスポンス仕様
- セッションライフサイクル — クライアントとサーバーのセッション管理実装
エンドポイント一覧
この文書は、公開ドキュメントとして扱う API を対象に、現行実装のレスポンス形状・要件を記載します。
対象外(内部向け)
以下は内部運用/デバッグ用途のため、この文書の詳細対象外です。
GET /api/debug/enablePOST /api/finalize/callback
デュアルランタイム構成
API ハンドラはフレームワーク非依存の共通実装で、Next.js と Hono(Lambda) の両方から利用されます。
| ランタイム | 用途 | 主な入口 |
|---|---|---|
| Next.js Route Handler | ローカル開発 / SSR | src/app/api/**/route.ts |
| Hono on Lambda | AWS デプロイ API | amplify/functions/hono-api/handler.ts |
公開対象 API 一覧
| メソッド | パス | 主用途 | X-Session-ID | X-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/run | STARK 検証実行 | 必須 | 必須 |
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/sth | STH スナップショット | 必須 | 必須 |
GET | /api/zkvm-input-hash | zkVM 入力コミットメント | 不要 | 必須 |
共通の実装上の注意
ミドルウェア構成
ミドルウェアは「全リクエスト共通の固定チェーン」ではなく、エンドポイント実装ごとに必要な検証を呼び出す方式です。セッションヘッダーの要否は上記一覧テーブルを参照してください。
| 区分 | 代表エンドポイント | 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/cancelGET /api/sessions/:sessionId/statusGET /api/bulletin/consistency-proofGET /api/bitmap-proofGET /api/zkvm-input-hash
基本フロー API
POST /api/session
新規セッションを作成します。X-Session-ID は不要です。
レスポンス(200):
data.sessionIddata.electionIddata.electionConfigHashdata.logIddata.capabilityToken
備考:
SESSION_CREATE_TURNSTILE_REQUIRED=1の場合、turnstileTokenが必要です。
POST /api/vote
ユーザー投票を保存し、ボット投票を非同期開始します。
要件:
- ヘッダー:
X-Session-ID必須 - ボディ:
commitment,vote,rand(turnstileTokenは開発設定により省略可) - Turnstile 検証あり
- 投票用レート制限あり
レスポンス(200):
data.voteIddata.commitmentdata.bulletinIndexdata.bulletinRootAtCastdata.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.countdata.totaldata.completeddata.userVoteddata.finalized
主なエラー: 共通セッションエラー(ヘッダースコープ)のみ。
POST /api/finalize
集計と証明生成を開始します。同期/非同期の 2 形態があります。
要件:
- ヘッダー:
X-Session-ID必須、X-Session-Capability必須 - ボディ:
scenarioId(S0-S5),turnstileToken(環境により必須) - Turnstile 検証あり
- zkVM レート制限あり
レスポンス(200, 同期):
data.sessionIddata.tallydata.bulletinRootdata.verifiedTallydata.voteReceiptdata.receipt(同期成功レスポンスで返却)data.receiptPublication(保存時)data.imageIddata.userVotedata.missingSlotsdata.invalidPresentedSlotsdata.rejectedRecordsdata.missingIndicesdata.invalidIndicesdata.countedIndicesdata.totalExpecteddata.treeSizedata.excludedSlotsdata.excludedCountdata.sthDigestdata.seenBitmapRoot(条件付き)data.includedBitmapRootdata.inputCommitmentdata.seenIndicesCountdata.journaldata.verificationStatusdata.verificationBundleUrl/data.verificationReportUrl/data.verificationReport(条件付き)data.verificationExecutionId(条件付き)data.s3BundleUrl/data.s3BundleKey/data.s3UploadedAt/data.s3BundleExpiresAt(条件付き)data.tamperSummary(条件付き)
補足:
verificationBundleUrlは秘密データを除外した配布用アーカイブの capability 認証付き取得先です。s3BundleUrlは同内容の短命な署名付き URL です。いずれも無認証では取得できません。
レスポンス(202, 非同期):
executionIdstatusUrlstatequeue(nullの場合あり)
主なエラー:
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-ID(x-session-idも受理) - ヘッダー:
X-Session-Capability必須 - ボディ:
executionId必須、reason任意 - キャンセル専用レート制限あり
レスポンス(200):
state
主なエラー:
GLOBAL_LIMIT_EXCEEDED(503)
handler 固有エラー(独自形式):
404: Async finalization disabled400: Invalid JSON body / payload 不正409: 現在状態ではキャンセル不可501: ストアが cancellation 非対応
GET /api/sessions/:sessionId/status
非同期集計の状態を返します。
要件:
- パスパラメータ
sessionId必須 - ヘッダー:
X-Session-Capability必須
レスポンス(200):
sessionIdfinalizationState(nullの場合あり)queue(nullの場合あり)progress(条件付き)finalizationResult(nullの場合あり)stepFunctions(nullの場合あり)asyncFinalizationMode(enabled/disabled)
主なエラー:
SESSION_CAPABILITY_REQUIRED(401)SESSION_CAPABILITY_INVALID(401)SESSION_CAPABILITY_EXPIRED(401)
handler 固有エラー(独自形式):
400: Session ID is required404: Session not found
検証 API
GET /api/verify
検証画面向けの統合ペイロードを返します。
要件:
- クエリ:
refreshS3=1,includeJournal=1(任意)
レスポンス(200):
data.electionIddata.electionConfigHashdata.logIddata.tallydata.bulletinRootdata.scenarioIddata.verificationStatusdata.verificationBundleUrl/data.verificationReport(条件付き)data.verificationSteps/data.verificationChecksdata.s3BundleUrl/data.s3UploadedAt/data.s3BundleExpiresAt(条件付き)data.imageIddata.tamperDetecteddata.verifiedTallydata.missingSlotsdata.invalidPresentedSlotsdata.rejectedRecordsdata.missingIndicesdata.invalidIndicesdata.countedIndicesdata.totalExpecteddata.treeSizedata.excludedSlotsdata.excludedCountdata.sthDigestdata.seenBitmapRoot(条件付き)data.includedBitmapRootdata.inputCommitmentdata.seenIndicesCount(条件付き)data.journalStatusdata.journal(includeJournal=1のとき)data.voteReceipt(条件付き)data.userVotedata.botVotesSummary(条件付き)data.verificationExecutionId(条件付き)data.tamperSummary(条件付き)
fail-closed 応答:
verificationStatusが許容セット外でも、ストアから取得可能な finalized session に対しては200で通常のdatapayload を返します。data.verificationStatusはfailedに正規化され、verificationSteps/verificationChecksに fail-closed な結果が入ります。
主なエラー:
SESSION_NOT_FINALIZED(400)USER_NOT_VOTED(400)
POST /api/verification/run
サーバー側で STARK レシート検証を実行します。
要件:
- ボディ: JSON オブジェクト(通常は空オブジェクト
{}) - zkVM レート制限あり
レスポンス(200):
data.verificationStatus(success/failed/dev_mode/not_run/running)data.verificationExecutionIddata.estimatedDurationMsdata.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: JSON302: 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):
commitmentsbulletinRoottreeSizetimestamprootHistory(条件付き)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):
voteIdproof.leafIndexproof.merklePathproof.treeSizeproof.bulletinRootAtCastproof.proofMode
キャッシュ:
Cache-Control: private, no-storeVary: 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):
oldSizenewSizerootAtOldSizerootAtNewSizeproofNodesoldSubtreeHashes/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.iddata.votedata.randomdata.commitmentdata.voteIddata.timestampdata.proof(leafIndex,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):
leafChunkauditPath
キャッシュ:
ETag対応If-None-Match一致時304Cache-Control: private, max-age=86400, stale-while-revalidate=3600, immutableVary: 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.sthDigeststh.bulletinRootsth.treeSizesth.timestampsth.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):
inputCommitmentdata(includeData有効時のみ)
主なエラー:
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) | 投票データ、掲示板、集計結果、検証結果 |
クライアントとサーバーのセッション対応付けには sessionId と X-Session-Capability(署名トークン)が使われます。
ヘッダー / path / query の使い分けはエンドポイント一覧を参照してください。
補足:
ensureClientStorageSchema()はstarkBallotSessionSchemaVersionを確認し、不一致時はstarkBallotSession、stark-ballot-knowledge、starkBallotSessionLockをまとめてクリアします。
2. クライアント側フェーズ
クライアントセッション(src/lib/session/client.ts)のフェーズは以下の 3 つです。
votingfinalizingverifying
主な遷移トリガー:
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の継続判定:verificationRequestedAtと canonical なfinalizeResultの両方がそろっていれば継続扱い(hasContinuationAuthority)- 上記がなくても、サーバー返却の STARK 状態が
not_run以外なら進行できる 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を削除し、phaseをvotingに巻き戻します。
4. サーバー側セッション状態
サーバーは SessionData に以下を保持します。
- 投票(
votes,userVoteIndex,botCount) - 集計状態(
finalizationState) - 集計結果(
finalizationResult) - 最終活動時刻(
lastActivity)
finalizationState.status は以下を取り得ます。
pendingrunningsucceededfailedtimeout
5. サーバー側 TTL / 失効の実装差分
サーバー側の失効挙動はストア実装で異なります。
| ストア | 失効/TTL の実装 |
|---|---|
MockSessionStore | getActiveSessionCount() 呼び出し時に lastActivity から 5 分超を掃除 |
FileMockSessionStore | getActiveSessionCount() 呼び出し時に同様に 5 分超を掃除 |
AmplifySessionStore | TTL 属性を保存。初期は AMPLIFY_DATA_TTL_SECONDS(既定 1800 秒)、finalized を伴う保存では AMPLIFY_DATA_VERIFICATION_TTL_SECONDS(既定 86400 秒)へ延長 |
補足:
- Amplify の TTL 延長対象は
finalizeSession(),markFinalizationSucceeded(),saveBitmapData()などfinalized: trueを伴う保存。finalizationResultのみの後続更新では延長 TTL は適用されず、通常 TTL で再保存されます。
重要事項:
/api/sessionはMAX_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の完全性チェック(excludedSlotsやtotalExpectedなど)- 公開監査アーティファクト(
election-manifest.json/close-statement.json)の整合確認 public-input.jsonに対するinputCommitment再計算
この章で扱わない範囲
- アプリケーション本体のビルド・デプロイ
- AWS インフラや非同期 finalize の運用トラブル対応
- アプリを一から複製して再現実行するための手順
上記の調査が必要な場合は、次のページを参照してください。
- チェック一覧(チェック ID と判定ロジック)
- API エンドポイント一覧(手動検証に使う API 契約)
- 検出メカニズム(改ざんシナリオごとの失敗パターン)
- 非同期プローバー(非同期 finalize の処理と障害調査導線)
最低限確認する不変条件
| 項目 | 合格条件 |
|---|---|
| STARK レシート | verifier-service verify が status: "success" |
| 投票の除外有無 | excludedSlots == 0 かつ missingSlots == 0 かつ invalidPresentedSlots == 0 |
| 期待投票数整合 | totalExpected == treeSize |
| 公開監査アーティファクト | election-manifest.json と close-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_ROOTでpnpm iを実行済みであること
検証ページのダウンロードは、s3BundleUrl と verificationBundleUrl の候補から実行されます。S3 URL が期限切れの場合は refreshS3=1 で再取得されます。ここで扱う bundle.zip や public-input.json の public は、「秘密データを含まない配布対象」という意味です。用語と取得経路は バンドル構造 を参照してください。
以降の手順では、リポジトリルートを 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.jsonbundle/journal.jsonbundle/public-input.jsonbundle/election-manifest.jsonbundle/close-statement.json
metadata.json は同期モードでのみ含まれる場合があります。
4. 期待 Image ID を決定
journal.json の methodVersion と receipt.json の image_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.json と close-statement.json は、現行の公開バンドルに含まれる Counted 段階の必須チェック用アーティファクトです。ここでは次を確認します。
election-manifest.jsonのelectionConfigHashを再計算し、manifest 自身の宣言値と一致すること- manifest の
electionId/electionConfigHashがpublic-input.json/journal.jsonと矛盾しないこと close-statement.jsonからsthDigestを再計算し、宣言された snapshot と一致すること- close statement の
logId/treeSize/timestamp/bulletinRoot/sthDigestがpublic-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.json の inputCommitment と一致することを確認します。
このステップには 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の結果がsuccessexcludedSlots == 0かつmissingSlots == 0かつinvalidPresentedSlots == 0totalExpected == treeSizeelection-manifest.jsonとclose-statement.jsonの整合チェックがすべて通るinputCommitmentの再計算値がjournal.jsonと一致する
この手順のどれかが失敗した場合、Counted / STARK 段階の必須チェックを満たしていないため、Verified にはなりません。
一方で、この手順だけで /verify の最終判定を完全再現するわけではありません。bundle.zip 単体ではそろわない検証材料については 第三者検証ガイド の冒頭を参照してください。
この手順の対象範囲は 第三者検証ガイド を参照してください。
設計判断
PoC で意図的に受け入れた制約と、設計を通じて得た知見を解説します。
「何を PoC 都合で割り切ったか」「構築を通じて何がわかったか」を明文化し、実装・運用・監査の前提を共有します。
判断の記録方針
各章は目的に応じて、記録軸を使い分けます。
| 章 | 記録軸 |
|---|---|
| PoC の意図的な制約 | 制約の内容 / 受け入れた理由 / 影響範囲 |
| 設計ふりかえり | 背景 / 知見 / 改善方針 |
この部に含まれる章
- PoC の意図的な制約 — 公開版で明示する 3 つの制約
- 設計ふりかえり — 構築を通じて得た構造上の知見
関連する章
- 暗号プロトコル — 各プリミティブの仕様と安全性
- AWS アーキテクチャ — インフラ構成の詳細
- 検証パイプライン — 検証ロジックとゲーティング
- 参考文献 — 設計背景の一次資料一覧
PoC の意図的な制約
本章では、公開版 PoC で意図的に受け入れている制約を 3 点に絞って説明します。
「バグではなく設計上の割り切り」であることを明確化します。
制約の全体像
| カテゴリ | 制約 |
|---|---|
| スケーラビリティ | 固定票数 64(1 ユーザー + 63 ボット) |
| プライバシー | ビットマップチャンク漏洩 |
| 実行基盤 | 非 GPU 前提の証明実行(ECS Fargate) |
1. 固定票数 64(1 ユーザー + 63 ボット)
| 項目 | 内容 |
|---|---|
| 制約の内容 | BOT_COUNT=63、MERKLE_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 軸で記述します。
知見の全体像
| # | 項目 | カテゴリ |
|---|---|---|
| 1 | Store インターフェースの肥大化 | 永続化層 |
| 2 | SessionData の責務混在 | 型設計 |
| 3 | 設定の組み合わせ爆発 | 構成管理 |
| 4 | Verified 判定ロジックの分散 | 検証パイプライン |
| 5 | /api/verify の責務過多 | API 設計 |
| 6 | 検証ドメインへの I/O 混入 | アーキテクチャ境界 |
| 7 | Silent fallback の危険性 | 起動・初期化 |
1. Store インターフェースの肥大化
背景
VoteStore インターフェースは 15 メソッド(+ optional 4)を持ち、3 つの大きな実装(Mock / FileMock / Amplify)が存在する。
知見
セッション管理・投票操作・ファイナライズ状態遷移・成果物保存の 4 責務が 1 インターフェースに混在している。特にファイナライズ状態遷移の 5 メソッド(markFinalizationQueued 〜 markFinalizationTimedOut)は全実装で類似のバリデーションロジックを個別に持つ。
改善方針
インターフェース分離原則(ISP)を適用し、Session / Vote / FinalizationState / Artifact の 4 インターフェースに分割する。ファイナライズ状態遷移は共通のステートマシンとして抽出し、各 Store が永続化のみを担う構造にする。
詳細: セッションライフサイクル
2. SessionData の責務混在
背景
SessionData 型は 16 フィールド(うち 9 が optional)を持つ。ネストされた finalizationResult だけで 30 サブフィールドを含む。
知見
永続データ(sessionId、electionId)、実行時状態(votes、bulletin、lastActivity)、検証結果(finalizationResult、finalizationState)が 1 型に同居している。optional フィールドの多さは、ライフサイクルの各段階で「存在するはずだが型で保証されない」フィールドがあることを意味する。
改善方針
Session(最小の identity)、VoteLog(append-only の投票記録)、FinalizationJob(非同期ジョブの状態)、VerificationArtifact(検証結果と成果物)に型を分割する。各段階で必要なフィールドを required として型安全に扱う。
詳細: セッションライフサイクル
3. 設定の組み合わせ爆発
背景
.env.local.example に 62 変数が定義されている。USE_MOCK_ZKVM、RISC0_DEV_MODE、FINALIZE_ASYNC_MODE などの切り替えフラグが独立して存在する。
知見
フラグの組み合わせによって意図が曖昧になる状態が生じうる(例: USE_MOCK_ZKVM=true と RISC0_DEV_MODE=1 の同時有効)。zkvm-mode.ts で production 時の不正モードは検出できるが、起動時に全組み合わせを網羅的に検証していない。
改善方針
プロファイルベースの設定体系(local / dev / staging / prod)を導入し、個別フラグを廃止する。プロファイルが各フラグの値を暗黙的に決定し、シークレットのみを環境変数として残す。起動時バリデーションで無効な組み合わせを fail-fast で検出する。
4. Verified 判定ロジックの分散
背景
「Verified を表示してよいか」の判定ロジックが複数箇所に分散している。主系は verification-summary.ts の deriveVerificationSummary(チェック群から総合判定)と page.tsx の overallStatusOverride(UI 表示制御)で、補助系として consistency-verifier.ts の validateVotingIntegrity がフォールバック経路を持つ。さらに 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 ハッシュした値。現行実装では electionId、bulletinRoot、treeSize、totalExpected、votesCount、各投票の 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)
投票受理時にサーバーが返す応答データ。voteId、commitment、bulletinIndex、bulletinRootAtCast を含む。検証では 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 / excludedSlots、inputCommitment、includedBitmapRoot、seenBitmapRoot などを含む。レシートに暗号学的に束縛されており、改ざんできない。
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(包含証明パラメータ: leafIndex、treeSize、auditPath)の 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_consistent と counted_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_consistent、counted_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.json、election-manifest.json、close-statement.json、receipt.json、journal.json などが含まれる。一方で input.json、verification.json、included-bitmap.json、seen-bitmap.json は private artifact として公開対象から除外される。
詳細: バンドル構造
隣接オブジェクト(Sibling Object)
S3 上で bundle.zip と同じ prefix(sessions/{sessionId}/{executionId}/)に配置される非 bundle ファイル。included-bitmap.json、seen-bitmap.json、verification.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_COUNT | 63 | サーバーが自動生成するボット投票数 |
MERKLE_TREE_DEPTH | 6 | Merkle ツリーの深度(2^6 = 64 リーフに対応) |
VOTE_CHOICES | A, B, C, D, E | 投票で選択可能な選択肢 |
| コミットドメインタグ | stark-ballot:commit|v1.0 | コミットメントハッシュのドメイン分離タグ |
| 入力ドメインタグ | stark-ballot:input|v1.0 | 入力コミットメントのドメイン分離タグ |
| リーフドメインタグ | stark-ballot:leaf|v1 | CT 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
依存ソフトウェアのライセンスは各パッケージ定義に従います。