📘 Academy原文準拠 | Phase 3 · Unit 2 · Lesson 2.2 Witnesses and Private Data 内容に忠実な日本語版です。原文(英語)・図・動画は公式 Academy(外部リンク・別タブで開きます)を正本に。
Lesson 1.3 で作った hello-world 契約は、メッセージをチェーン上に保存していました。でもそのメッセージはまるごと公開で、だれでも読めました。
Lesson 2.1 では、トランザクションがシステムの中をどう流れるかを学びました。けれど、どちらのレッスンも Midnight が本当に答えたかった問い ——「どうやってデータを秘密に保つか」—— には触れていませんでした。
その答えが witness(ウィットネス) です。
witness は、Compact の回路がオフチェーンの秘密データにアクセスするための仕組みです。しかも、そのデータがブロックチェーンに一度も触れることなく使えます。Midnight で意味のあるものを作るには、witness の理解が欠かせません。witness が無ければ、あなたの契約はほかのチェーンの契約と何も変わらないからです。
witness とは実際なにか(What a witness actually is)
Compact における witness とは、契約コードの中で宣言されるが、実装はされない関数です。これは橋(bridge)であり、外部関数インターフェース(FFI: foreign function interface)に精神的に近いもので、オンチェーンの回路ロジックと、オフチェーンの TypeScript コードをつなぎます。
例:
Compact コンパイラはこれを見て、こう考えます。「この関数の実装は、だれか別のところが用意してくれる。私はそれが何を受け取り、何を返すかだけ分かっていればよい」。実装は契約の中ではなく、あなたの TypeScript の DApp コードの中に住みます。
これは、たいていのブロックチェーンのスマートコントラクトの作りとは根本的に違います。Ethereum では、Solidity の契約は完全に自己完結していて、ロジックもデータも全部チェーン上(またはパラメータとして渡される形)にあります。
Midnight では、契約は「自分がどんな秘密データを必要とするか」を宣言し、実行時にあなたの DApp が witness を通じてそのデータを提供します。データは証明生成のために ZK 回路へ流れ込みますが、ブロックチェーンには決して公開されません。
なぜ witness が存在するのか(Why witnesses exist)
投票の契約を考えてみましょう。「ある投票者は投票する資格がある」ことを、その人が誰かを明かさずに証明したいとします。投票者の秘密鍵は、証明を生成するために回路へ入る必要があります。でも、その鍵はチェーン上に決して現れてはいけません。
witness が無いと、悪い選択肢が2つしかありません。秘密鍵をチェーンに置く(プライバシーが壊れる)か、回路のパラメータとして渡す(これも露出してしまう)か。
witness は第3の道をくれます。秘密鍵は TypeScript コードからローカルで取得され、ZK 証明の計算に使われ、そして捨てられます。
証明は「あなたが有効な鍵を持っていた」ことを示しますが、それがどの鍵だったかは明かしません。
公式ドキュメントはこう明言しています。「witness は Compact で宣言され、Compact の型シグネチャを与えられる。witness の実装は、なんらかの汎用プログラミング言語で提供されることが意図されている」。標準的な Midnight ツールチェーンでは、witness の実装は TypeScript / JavaScript で書かれ、生成された TypeScript の契約 API と @midnight/compact-runtime ライブラリを使います。
witness 関数の解剖(The anatomy of a witness function)
実際の例を、bulletin board 契約(外部リンク・別タブで開きます)から見てみましょう。これは Midnight の公式サンプルの一つで、これから先のレッスンで実際に作っていくものです。
ここでは色々なことが起きています。witness が何を可能にしているかを分解しましょう。
localSecretKey() という witness は、post と takeDown の両方の回路の中で呼ばれています。post の中では、ユーザーの秘密鍵を取得し、persistentHash を使ってそこから公開鍵を導出し、その公開鍵を owner として台帳に保存します(disclose() で囲んで)。
takeDown の中では、同じ秘密鍵を取得して、呼び出し元が元の所有者であることを証明します。導出された公開鍵が、台帳に保存されているものと一致しなければなりません。
秘密鍵そのものは、チェーン上に決して現れません。現れるのは、導出された公開鍵だけです。
ZK 証明は「回路を呼んだ人は、保存されている公開鍵を生み出す秘密鍵を知っている」ことを、その鍵が何かは明かさずに確認します。
witness を実装する TypeScript(The TypeScript implementing witnesses)
Compact コンパイラが契約を処理すると、あなたの DApp が満たすべき TypeScript のインターフェースを生成します。bulletin board の場合、コンパイラはこんなものを作ります。
あなたの TypeScript 実装は、このインターフェースに一致させる必要がありますが、大事な追加要素があります —— WitnessContext です。これが bulletin board サンプルの実際の実装です。
この実装には、理解すべき大事なことが3つあります。
WitnessContext というパラメータ
すべての witness 関数は、第1引数として WitnessContext<L, PS> を受け取ります。ここで L は台帳の型(Compact 契約から生成される)、PS はあなたの秘密状態(private state)の型です。WitnessContext は3つのものへのアクセスを与えてくれます。
ledger… いま見えている、射影された台帳の状態privateState… あなたのローカルな秘密状態contractAddress… 呼び出されている契約のアドレス
bulletin board は privateState だけ必要なので、そのフィールドだけを分割代入で取り出しています。
戻り値はタプル
witness 関数は、Compact コードが期待する値を単に返すのではありません。タプルを返します: [newPrivateState, returnValue]。第1要素は更新後の秘密状態(変わらなければ同じ状態)、第2要素は Compact の回路へ返される値です。これが、witness が1回の呼び出しで秘密状態を読みかつ更新できる仕組みです。
秘密状態はあなたの型
BBoardPrivateState はあなたが定義します。必要なものを何でも入れられます。秘密鍵、ユーザーの好み、キャッシュしたデータ、DApp に必要なものは何でも。秘密状態プロバイダ(デフォルトは LevelDB)が、このデータをユーザーのマシン上で暗号化してローカルに永続化します。
disclose() の要件(The disclose() requirement)
bulletin board 契約の中で、値が disclose() で囲まれているのに気づいたかもしれません。これは Midnight の最も重要な安全装置のひとつです。
デフォルトで、Compact は witness 由来のあらゆる値を秘密として扱います。コンパイラは、witness データの「汚染(taint)」をすべての操作を通じて追跡します。witness 由来の値を台帳に保存しようとしたり、export された回路から返そうとしたり、disclose() で囲まずに別の契約へ渡そうとしたりすると、コンパイラはコンパイルを拒否します。そして、宣言されていない開示がどこで起きるかを正確に教えるエラーを出します。
これは内部的に「witness protection program(証人保護プログラム)」と呼ばれています。witness データをコード中で追跡する抽象インタプリタとして実装されています。狙いは、プライバシーをデフォルトにし、開示は明示的で意図的な選択にすることです。
たとえば、これはコンパイルできます。
でも、これはできません。
エラーメッセージは、何が起きたか・どこで起きたかを正確に教えてくれます。秘密データを公開するには、意識的に決断しなければなりません。
コンパイラは witness 由来のデータがコード中をどう流れるかを追跡し、明示的な disclose() 無しに開示しようとするものをすべて指摘します。直接の代入だけでなく、より複雑な式(条件分岐を含む)も追います。だから、witness 由来のデータを明らかにする地点で disclose(e) を呼ぶことを、あなたが意識的に選ばなければなりません。
witness は信頼できない入力(Witnesses are untrusted input)
ドキュメントには、見落としやすい重要なセキュリティ上のポイントがあります。「契約の中で、どの witness 関数のコードも、あなた自身の実装に書いたコードである、と仮定してはならない。どの DApp も、あなたの witness 関数に対して好きな実装を提供できる。それらの結果は信頼できない入力として扱うべきである」。
よく考えると、これは理にかなっています。
witness はユーザーのマシン上で動くからです。
悪意あるユーザーは、自分の DApp を改造して、witness から好きな値を返させることができます。あなたの契約ロジックは、これに対処できなければなりません。もし witness が、保存されている公開鍵と一致しない秘密鍵を返したら、takeDown の中の assert 文がそれを捕まえ、回路は失敗します。
だからこそ、bulletin board の takeDown 回路は witness をただ信頼しません。返ってきた秘密鍵から公開鍵を導出し、台帳に保存されているものと照合します。
もし誰かが間違った鍵を提供すれば、アサーションが失敗し、回路は無効な証明を生み、トランザクションは拒否されます。
witness を使わない契約(Contracts without witnesses)
先に触れたように、すべての契約が witness を必要とするわけではありません。公式サンプルの counter 契約には、秘密状態がまったくありません。カウンタの値は完全に公開です。
Lesson 24 の hello-world 契約も同様でした。回路パラメータ newMessage に disclose() を使い、メッセージを公開にしていました。もしメッセージを秘密に保ちたいなら、別のアプローチを取ることになります。それは次のレッスン(shielded と unshielded の操作)で扱います。
秘密状態はどこに保存されるか(How private state is stored)
witness 関数は秘密状態にアクセスしますが、その状態は実際どこに住んでいるのでしょうか。LevelPrivateStateProvider を使ってローカルに保存されます。
これは、あなたのローカルファイルシステム上に暗号化された LevelDB データベースを作ります。
暗号化には、ウォレットの暗号化用公開鍵(ウォレット SDK を使うときに推奨)か、独自のパスワード(最低16文字)のどちらかを使います。秘密状態プロバイダはセッションをまたいでデータを永続化するので、DApp を再起動して契約に再接続しても、あなたの秘密状態はまだそこにあります。
契約アドレスごとに、それぞれ専用の秘密状態のエントリを持ちます。WitnessContext の contractAddress フィールドのおかげで、witness はどの契約インスタンスとやり取りしているかに応じて振る舞いを変えられます。同じ契約を複数デプロイして DApp が管理する場合に便利です。
witness はトランザクションの流れにどう収まるか(How witnesses fit in the transaction flow)
Lesson 2.1 で学んだことと witness を組み合わせると、全体像はこうなります。
- あなたの DApp が
contract.callTx.post("Hello")を呼ぶ。 - SDK が回路をローカルで実行する。回路が
localSecretKey()に到達すると、あなたの TypeScript の witness 実装を呼ぶ。 - witness は
[privateState, secretKey]を返す。回路はその秘密鍵を使って公開鍵を導出し、実行を続ける。 - 結果は未証明(unproven)のトランザクションで、そこでは秘密鍵が秘密の入力、導出された公開鍵が公開の出力になっている。
- proof server が ZK 証明を生成する。これは「回路が正しく実行されたこと(witness が提供した値がすべてのアサーションを満たすことを含む)」を、witness の値そのものは明かさずに確認する。証明済みのトランザクションが提出され、ネットワークが証明を検証する。ネットワークは秘密鍵を一度も見ることなく、状態遷移が正しいと確認できる。
秘密鍵は、ステップ 2〜5 のあいだ、あなたのマシンのメモリ上に短いあいだだけ存在しました。ZK 回路への入力として使われ、ローカルの proof server に送られる未証明トランザクションのデータに現れ、そして消えました。
それはあなたのマシンを一度も離れませんでした。Midnight ノードへ向かうネットワークパケットの中に、一度も現れませんでした。
つぎの予告(What’s next)
これで、あなたの TypeScript の DApp と Compact の回路のあいだでデータがどう流れるかが分かりました。次のレッスンでは、shielded と unshielded の操作 —— Midnight がトークン転送とデータの見え方を扱う2つの基本的な方法 —— と、ネットワークレベルでプライベートなトランザクションを可能にする ZSwap プロトコルを見ていきます。
開発者として押さえる点
- witness =宣言だけ・実装は外。Compact で型シグネチャを宣言し(例
witness localSecretKey(): Bytes<32>;)、実装は TypeScript 側(FFI のような橋)。秘密データはチェーンに出さず、ZK 回路にだけ流す - 戻り値はタプル
[newPrivateState, returnValue]。第1引数はWitnessContext<L, PS>で、ledger/privateState/contractAddressにアクセスできる。秘密状態の型(例BBoardPrivateState)は自分で定義する disclose()必須。witness 由来の値は既定で秘密扱い(taint 追跡=「witness protection program」)。台帳書き込み・export 回路の戻り・他契約への引き渡しはdisclose()で囲まないとコンパイルできない- witness の結果は信頼できない入力。悪意ある DApp は好きな値を返せる。
takeDownのように、返ってきた鍵から公開鍵を導出して台帳の値とassertで照合する - 秘密状態は
LevelPrivateStateProviderでローカル暗号化保存。ウォレットの公開鍵か16文字以上のパスワードで暗号化。契約アドレスごとに別エントリ - すべての契約が witness を要るわけではない(counter は完全公開)。秘密が要るときだけ使う
やさしい版・公式へ
- やさしい版:状態モデル(state model)
- 公式:Academy Courses(外部リンク・別タブで開きます)(Phase 3 / Unit 2 / 2.2)
- 公式ドキュメント:Compact リファレンス(witness / disclose)(外部リンク・別タブで開きます) / サンプル example-bboard(bulletin board)(外部リンク・別タブで開きます)
つぎに読むページ
➡️ 原文準拠コースの入口へ戻る。このコースについて(次のレッスンは順次追加します)