certified-variables について
Internet Computer の開発者向けに、クエリデータを認証する方法を説明するスキルです。証明書と Merkle ウィットネスの生成・検証を通じて、セキュリティ上重要な結果を単一レプリカの不正から保護します。
主な機能
- 01ハッシュによるデータ認証
- 02キー単位の Merkle ウィットネス
- 03クライアントでの証明書とウィットネスの検証
- 04Rust、Motoko、TypeScript に対応
- 05認証済み HTTP レスポンスの要件
活用シーン
- 01残高、権限、価格のクエリ検証
- 02キー・バリューデータの値の証明
- 03認証済み HTTP canister レスポンスの実装
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill certified-variablesインストール後、Claude Code で/certified-variablesを入力して読み込まれたか確認します。
同梱ファイルreferences/http-certification.md
詳細
認証済み変数
クエリ呼び出しには、コンセンサスを経ずに単一のレプリカが応答するため、障害のある、または悪意のあるレプリカが偽造データを返す可能性があります。認証によってこの問題を解消できます。更新呼び出しの際、カナスターはサブネットの認証済み状態に32バイトのハッシュ(通常はデータに対する Merkle ツリーのルート)を保存し、クエリ応答にはサブネットのしきい値 BLS キーによる署名付き証明書が含まれます。クライアントは証明書と Merkle 証明を検証し、更新呼び出しと同等の信頼性を持つ高速なクエリ応答を得られます。
何を誰が検証するか
まず、これを決めてください。認証は、何かがそれを検証して初めて役に立ちます。
| 応答 | 検証者 | 必要なクライアントコード |
|---|---|---|
<id>.icp.net またはカスタムドメイン経由の、フロントエンドカナスターまたは http_request からの HTTP | HTTP ゲートウェイ | なし |
同じ応答を <id>.raw.icp.net または独自の HTTP クライアント経由で取得 | なし | @dfinity/response-verification(verifyRequestResponsePair) |
アクター経由の更新呼び出し(agent.update) | コンセンサス。エージェントが応答証明書を検証 | なし |
| アクター経由の Candid クエリ呼び出し | 応答したノードの署名のみ | 認証済みデータ + @dfinity/certificate-verification(このスキル) |
ゲートウェイは、Candid API 呼び出し(/api/...)を決して検証しません。検証ホストからページを取得した場合も同様です。ic0.app、icp0.io、またはローカルのオリジンでは、エージェントはページのオリジン経由で API 呼び出しを送り、ゲートウェイはそれをプロキシするだけです。クライアントが結果に基づいて動作するクエリは認証してください。たとえば、残高、権限、価格など、セキュリティに関わる情報です。もう一つの方法は、メソッドを更新呼び出しとして呼び出し、コンセンサスにかかる遅延を受け入れることです。ICRC-3 を実装する台帳はすでに tip を認証しています。icrc3_get_tip_certificate は opt { certificate; hash_tree } を返し、以下の証明と同様に verifyCertification で検証できます(ラベルは last_block_index(LEB128 数値)と last_block_hash なので、ヘルパーの UTF-8 比較を使わず、デコードしてください)。
認証済みアセットカナスターが配信する静的フロントエンド(@dfinity/static-site レシピ)は自動的に認証されます。これらについては static-site スキルを読み込んでください。
バージョン
| 側 | パッケージ | バージョン | 用途 |
|---|---|---|---|
| Rust | ic-cdk | 0.20 | certified_data_set / data_certificate |
| Rust | ic-certification(機能 serde) | 4 | 証明付き Merkle マップ RbTree |
| Rust | ic-http-certification | 4 | http_request 応答の認証 |
| Motoko | core | 2.6 | mo:core/CertifiedData |
| Motoko | ic-certification(mops) | 1.1 | 証明付き Merkle ツリー CertTree |
| Motoko | sha2(mops) | 0.2 | 単一の認証済み値のハッシュ化 |
| フロントエンド | @icp-sdk/core | ^6 | エージェント、Certificate、lookup_path |
| フロントエンド | @dfinity/certificate-verification | ^4 | 証明の検証(@icp-sdk/core ^6 とピア依存、Uint8Array を受け取る) |
ic-certified-map 0.4 は同じ RbTree/AsHashTree API を公開しており、現在も動作しますが、ic-certification は ic-http-certification と併せて保守されており、公式の例で使用されています。
ルートキー
クライアント側で検証するには、キャニスターが稼働しているネットワークのルートキーが必要です。
- ブラウザー:
ic_envCookie(@icp-sdk/core/agent/canister-env)から取得したsafeGetCanisterEnv()?.IC_ROOT_KEYを使います。この Cookie は、ローカルネットワークとメインネットの両方で、フロントエンドキャニスターによって設定されます。これはページを配信しているネットワークのキーです。別のネットワーク上のキャニスターを検証する場合(例:ローカルの開発サーバーからメインネットのデータを検証する場合)は、そのネットワークのキーを使用してください。キーの信頼性はページと同程度です。検証用ホスト名では、ゲートウェイがページと併せて Cookie も検証しますが、rawホスト名のページには偽造されたキーが含まれている可能性があるため、rawのレスポンスを検証するクライアントは、独立した方法で入手したキーを使用する必要があります。 - Node のスクリプトとテスト:
icp network status --jsonのroot_keyを、16 進数からバイト列にデコードして使用します。 - メインネット: エージェントの組み込みデフォルト値(
rootKeyを指定せずに作成したエージェントのagent.rootKey)を使用します:308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d9685f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484b01291091c5f87b98883463f98091a0baaae
リリースするコードでは、fetchRootKey() を決して呼び出さないでください。レプリカが送信するキーをそのまま信頼してしまいます(canister-security スキルを参照)。
注意点
-
32 バイトを超えるデータを認証しようとする。
certified_data_set/CertifiedData.setが受け付けるのは最大 32 バイトです。データから Merkle ツリーを構築し、そのルートハッシュを認証してください。ツリーを使うと、キーごとの証明を提供できます。 -
クエリで認証データを設定する、またはインストール時と変更のたびに設定しない。
certified_data_setは、canister_init、canister_post_upgrade、canister_pre_upgrade、更新メソッド、応答/拒否コールバック、システムタスク(ハートビート、グローバルタイマー、Wasm メモリ低下時のフック)から呼び出せます。それ以外の場所では、クエリ(更新として呼び出されたクエリも含む)やクリーンアップコールバックを含め、トラップします。インストール時の認証データは空なので、init(Rust)またはアクターボディ(Motoko)で初期状態を認証しないと、最初の書き込みまでクエリの検証に失敗します。変更後の設定を忘れると、古いハッシュが残り、同じ結果になります。複数の書き込みをまとめる場合は、最後の挿入後にcertified_data_setを 1 回呼び出すだけで十分です。 -
クエリ呼び出しではない呼び出しから証明書が返ると期待する。
data_certificate()/CertifiedData.getCertificate()は、更新呼び出しではNone/nullを返します。更新呼び出しとして呼び出されたクエリメソッドも同様です。icp canister callは--queryを指定しない限り更新呼び出しを送信します。指定しない場合、以下の Rust の例はexpectでトラップし、Motoko の例はcertificate = nullを返します。フロントエンドコードがアクターを介してqueryメソッドを呼び出す場合は、自動的にクエリ呼び出しが送信されます。 -
認証データではなくツリーがアップグレード後に失われる。 認証データ自体はアップグレード後も保持されます(インストールと再インストールでは空になります)。保持されないのは、ヒープ上に保存された Merkle ツリーです。以下の Rust の
RbTreeはアップグレード時に消去されますが、古いハッシュは設定されたままなので、#[post_upgrade]でツリーを再構築し(実際のアプリでは安定ストレージから復元します。stable-memoryスキルを参照)、もう一度certified_data_setを呼び出す必要があります。Motoko のCertTree.Storeはアクターとともに永続化されるため、アップグレード後に再設定する必要はありません。 -
誤ったキーの証明を構築する。 証明は、クエリ対象の正確なパスを明らかにする必要があります。
users/aliceの証明では、users/bobについて何も証明できません。 -
Found以外のすべての検索結果を「存在しない」と扱う。lookup_pathはステータスを返します。Found(値が証明された)、Absent(不在が証明された)、Unknown/Error(証明がそのパスをカバーしていない)のいずれかです。キーが存在しないことを証明するのはAbsentだけです。Unknownを「見つからない」としてまとめて扱うと(たとえば、3 つすべてに対してundefinedを返すlookupResultToBufferを使用すると)、レプリカが別のキーに対する証明を送信し、実際に存在する値がないように見せかけることができます。 -
鮮度チェックを省略する。 証明書の
/timeはサブネットが署名した時刻です。上限を設けないと、古いデータを含む古い証明書を再送される可能性があります。verifyCertificationはmaxCertificateTimeOffsetMsを適用します(5 分が妥当な値です)。Certificate.createはデフォルトで ±5 分(maxAgeInMinutes)を適用します。 -
Motoko の
CertTree.Opsオブジェクトを stable として宣言する。 persistent actor では、let ct = CertTree.Ops(certStore)はM0131(variable ct is declared stable but has non-stable type)で失敗します。transient let ct = CertTree.Ops(certStore);と宣言してください。stable にするのはCertTree.Storeだけです。 -
IC-CertificateExpressionヘッダーを付けずに HTTP レスポンスを認証する。ic-http-certificationでは、CEL 式を含むヘッダーを認証対象のレスポンスに含める必要があります。含まれていない場合、HttpCertification::response_only/fullはCertificateExpressionHeaderMissingを返します。その正確なレスポンスと、add_v2_certificate_headerが追加するIC-Certificateヘッダーを配信してください。そうしないと、HTTP ゲートウェイはbackend_response_verificationで拒否します。
Canister
Rust
[package]
name = "certified_vars_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
candid = "0.10"
ic-cdk = "0.20"
ic-certification = { version = "4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
ciborium = "0.2"
use candid::{CandidType, Deserialize};
use ic_cdk::{init, post_upgrade, query, update};
use ic_certification::{AsHashTree, RbTree};
use serde_bytes::ByteBuf;
use std::cell::RefCell;
thread_local! {
// RbTree is a Merkle-tree-backed map: keys and values are byte slices
static TREE: RefCell<RbTree<Vec<u8>, Vec<u8>>> = RefCell::new(RbTree::new());
}
// Update the certified data hash after any modification
fn update_certified_data() {
TREE.with(|tree| {
let tree = tree.borrow();
// root_hash() returns a 32-byte SHA-256 hash of the entire tree
ic_cdk::api::certified_data_set(&tree.root_hash());
});
}
#[init]
fn init() {
update_certified_data();
}
#[post_upgrade]
fn post_upgrade() {
// This example keeps TREE on the heap only: it is empty after an upgrade, while the
// old certified hash is kept. A real canister reinserts its entries from stable storage
// here first; this one re-certifies the empty tree so the hash matches it again.
update_certified_data();
}
#[update]
fn set(key: String, value: String) {
TREE.with(|tree| {
let mut tree = tree.borrow_mut();
tree.insert(key.as_bytes().to_vec(), value.as_bytes().to_vec());
});
// Must update certified hash after every data change
update_certified_data();
}
#[update]
fn delete(key: String) {
TREE.with(|tree| {
let mut tree = tree.borrow_mut();
tree.delete(key.as_bytes());
});
update_certified_data();
}
#[derive(CandidType, Deserialize)]
struct CertifiedResponse {
value: Option<String>,
certificate: ByteBuf, // subnet BLS signature
witness: ByteBuf, // Merkle proof for this key
}
#[query]
fn get(key: String) -> CertifiedResponse {
// data_certificate() is only available in query calls (icp canister call --query)
let certificate = ic_cdk::api::data_certificate()
.expect("data_certificate only available in query calls");
TREE.with(|tree| {
let tree = tree.borrow();
// Look up the value
let value = tree.get(key.as_bytes())
.map(|v| String::from_utf8(v.clone()).unwrap());
// Build a witness (Merkle proof) for this specific key
let witness = tree.witness(key.as_bytes());
// Serialize the witness as CBOR
let mut witness_buf = vec![];
ciborium::into_writer(&witness, &mut witness_buf)
.expect("Failed to serialize witness as CBOR");
CertifiedResponse {
value,
certificate: ByteBuf::from(certificate),
witness: ByteBuf::from(witness_buf),
}
})
}
// Required by the icp-cli Rust recipe, which extracts the Candid interface from the wasm
ic_cdk::export_candid!();
Motoko:単一の値
単一の値には Merkle ツリーは不要です。そのハッシュを認証し、クライアントは sha256(value) を証明書の certified_data と比較します(「証明なしの単一の値」を参照)。
import CertifiedData "mo:core/CertifiedData";
import Text "mo:core/Text";
// Requires: mops add sha2
import Sha256 "mo:sha2/Sha256";
persistent actor {
// Simple certified single-value example:
var certifiedValue : Text = "";
// Certify the hash of the current value (max 32 bytes; traps in a query call)
func certify() {
CertifiedData.set(Sha256.fromBlob(#sha256, Text.encodeUtf8(certifiedValue)));
};
// Certify the initial value at install: certified data starts empty, not as sha256("")
certify();
// Set a certified value (update call only)
public func setCertifiedValue(value : Text) : async () {
certifiedValue := value;
certify();
};
// Get the certified value with its certificate (query call)
public query func getCertifiedValue() : async {
value : Text;
certificate : ?Blob;
} {
{
value = certifiedValue;
certificate = CertifiedData.getCertificate();
}
};
};
Motoko:証明情報付きキー・バリューストア
ic-certification mops パッケージ(mops add ic-certification)の CertTree は、キーごとの証明情報を生成するマークルツリーです。
import CertifiedData "mo:core/CertifiedData";
import Blob "mo:core/Blob";
import Text "mo:core/Text";
// Requires: mops add ic-certification
import CertTree "mo:ic-certification/CertTree";
persistent actor {
// CertTree.Store is stable -- the tree persists across upgrades, and so does the certified data
let certStore : CertTree.Store = CertTree.newStore();
// Ops is an object with functions, not stable data: it must be transient
transient let ct = CertTree.Ops(certStore);
// Set certified data on init
ct.setCertifiedData();
// Set a key-value pair and update certification
public func set(key : Text, value : Text) : async () {
ct.put([Text.encodeUtf8(key)], Text.encodeUtf8(value));
// CRITICAL: call after every mutation to update the subnet-certified root hash
ct.setCertifiedData();
};
// Delete a key and update certification
public func delete(key : Text) : async () {
ct.delete([Text.encodeUtf8(key)]);
ct.setCertifiedData();
};
// Query with certificate and Merkle witness for the requested key
public query func get(key : Text) : async {
value : ?Blob;
certificate : ?Blob;
witness : Blob;
} {
let path = [Text.encodeUtf8(key)];
// reveal() generates a Merkle proof for this specific path
let witness = ct.reveal(path);
{
value = ct.lookup(path);
certificate = CertifiedData.getCertificate();
witness = ct.encodeWitness(witness);
}
};
};
カスタム http_request キャニスター
独自の http_request から HTTP を提供するキャニスターは、HTTP ゲートウェイが検証できるよう、各レスポンスを ic-http-certification で認証する必要があります。作成する前に references/http-certification.md をお読みください。そこには、完全で最小限のキャニスター(init/post_upgrade で認証し、http_request で証明情報を添付し、それ以外のすべてのパスに対して認証済み 404 を返す)と、落とし穴 9 のヘッダールールが記載されています。ゲートウェイがリクエストできるすべてのパスに、認証済みレスポンスが必要です。未認証のエラーや 404 は backend_response_verification で拒否されます。
クライアント検証(TypeScript)
@icp-sdk/core にはすべてのプリミティブ(Certificate.create、Cbor、reconstruct、lookup_path)が含まれています。@dfinity/certificate-verification は証明情報のケース向けにこれらをラップし、証明書の署名を検証し、/time を確認し、証明情報をデコードして、そのルートハッシュが証明書の certified_data と一致するか確認します。@icp-sdk/bindgen バインディングでは、Candid の blob フィールドは Uint8Array として受け取ります。bindgen は opt レコードフィールドをオプショナルプロパティに変換し、値が空の場合は undefined にします。ヘルパーにはゲッターのレスポンスをそのまま渡します。Motoko の ?Blob の値や証明書は Uint8Array | undefined で、証明情報ヘルパーは Uint8Array の値を UTF-8 としてデコードします。ルートキーは「Root Key」から取得してください。
証明情報を使用する場合
import { verifyCertification } from "@dfinity/certificate-verification";
import { lookup_path, LookupPathStatus } from "@icp-sdk/core/agent";
import { Principal } from "@icp-sdk/core/principal";
const MAX_CERT_TIME_OFFSET_MS = 5 * 60 * 1000; // 5 minutes
export async function getVerifiedValue(
rootKey: Uint8Array,
canisterId: string,
key: string,
// value is opt text (Rust) or ?blob (Motoko); certificate is a blob (Rust) or ?blob (Motoko)
response: {
value?: string | Uint8Array | null;
certificate?: Uint8Array | null;
witness: Uint8Array;
},
): Promise<string | null> {
if (!response.certificate) throw new Error("no certificate: call the getter as a query");
const value =
response.value instanceof Uint8Array
? new TextDecoder().decode(response.value)
: (response.value ?? null);
// Checks signature, time and root hash; throws CertificateTimeError or CertificateVerificationError.
const tree = await verifyCertification({
canisterId: Principal.fromText(canisterId),
encodedCertificate: response.certificate,
encodedTree: response.witness,
rootKey,
maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS,
});
// The path must match how the canister inserted the key (here: UTF-8 bytes).
const result = lookup_path([new TextEncoder().encode(key)], tree);
switch (result.status) {
case LookupPathStatus.Found: {
const verified = new TextDecoder().decode(result.value);
if (value !== verified) throw new Error("value does not match witness");
return verified;
}
case LookupPathStatus.Absent:
if (value !== null) throw new Error("witness proves the key is absent");
return null;
default:
// Unknown/Error: the witness does not cover this key, so it proves nothing
throw new Error(`witness does not cover key (${result.status})`);
}
}
証明情報なしの単一値
verifyCertification には証明情報ツリーが必要なため、Motoko の単一値の例は Certificate.create を直接使って検証してください。署名と ±5 分の鮮度ウィンドウを確認します。
import { Certificate, lookupResultToBuffer, uint8Equals } from "@icp-sdk/core/agent";
import { Principal } from "@icp-sdk/core/principal";
export async function verifySingleValue(
rootKey: Uint8Array,
canisterId: string,
// certificate is ?blob in the Motoko getter; empty means it did not run as a query call
response: { value: string; certificate?: Uint8Array | null },
): Promise<string> {
if (!response.certificate) throw new Error("no certificate: call the getter as a query");
const principal = Principal.fromText(canisterId);
const cert = await Certificate.create({
certificate: response.certificate,
rootKey,
principal: { canisterId: principal },
});
const certifiedData = lookupResultToBuffer(
cert.lookup_path(["canister", principal.toUint8Array(), "certified_data"]),
);
// Recompute what the canister certified: sha256 of the UTF-8 value
const hash = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(response.value)),
);
if (!certifiedData || !uint8Equals(certifiedData, hash)) {
throw new Error("value does not match certified data");
}
return response.value;
}
Certificate.create を使って検証するブラウザフロントエンド付きの、実行可能な単一値の例は dfinity/examples motoko/cert-var です。この例ではハッシュではなく、4 バイトの生の Nat32 を認証します。
動作確認
icp canister call backend set '("greeting", "hello world")'
icp canister call --query backend get '("greeting")'
# Expected: certificate = blob "..." (Rust) / opt blob "..." (Motoko); null or a trap means the call was not a query
次に、レスポンスに対してクライアント関数を実行してください。値が返され、response.value を変更すると例外がスローされる必要があります。