关于 certified-variables
面向 Internet Computer 开发者的认证变量技能,介绍如何为查询结果生成证书与 Merkle 见证,并由客户端验证,降低单个副本伪造安全相关数据的风险。
主要功能
- 01为变量数据生成认证哈希
- 02使用 Merkle 树提供逐键见证
- 03在客户端验证证书和见证
- 04覆盖 Rust、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
详细介绍
已认证变量
查询调用只会由一个副本作出响应,不经过共识,因此有故障或恶意的副本可能返回伪造数据。认证可以弥补这一缺口:在更新调用期间,canister 会将一个 32 字节的哈希(通常是其数据的 Merkle 树根)存入子网的已认证状态中,查询响应则携带由子网阈值 BLS 密钥签名的证书。客户端验证证书和 Merkle 见证后,即可获得快速查询响应,其可信度与更新调用相同。
谁验证什么
首先要确定这一点。只有经过检查,认证才能发挥作用。
| 响应 | 验证方 | 所需客户端代码 |
|---|---|---|
来自前端 canister 或 http_request 的 HTTP,通过 <id>.icp.net 或自定义域名访问 | HTTP 网关 | 无 |
相同响应,通过 <id>.raw.icp.net 或你自己的 HTTP 客户端访问 | 无人 | @dfinity/response-verification(verifyRequestResponsePair) |
通过 actor 发起的更新调用(agent.update) | 共识;agent 会检查响应证书 | 无 |
| 通过 actor 发起的 Candid 查询调用 | 只有应答节点的签名 | 已认证数据 + @dfinity/certificate-verification(本技能) |
即使页面来自会验证的主机,网关也绝不会验证 Candid API 调用(/api/...):在 ic0.app、icp0.io 或本地来源上,agent 会通过页面来源发送这些调用,网关只会代理它们。当客户端会根据查询结果采取行动时,应对该查询进行认证:例如余额、权限、价格,以及任何与安全相关的内容。另一种方式是将该方法作为更新调用,并接受共识带来的延迟。实现 ICRC-3 的账本已经认证了其最新位置:icrc3_get_tip_certificate 会返回 opt { certificate; hash_tree },它可以像下面的任何见证一样,通过 verifyCertification 验证(标签 last_block_index 是一个 LEB128 数字,last_block_hash 则是其哈希,因此应对它们进行解码,而不是使用助手的 UTF-8 比较)。
由 certified-assets canister 提供的静态前端(@dfinity/static-site 配方)会自动认证:请为此加载 static-site 技能。
版本
| 端 | 包 | 版本 | 用途 |
|---|---|---|---|
| Rust | ic-cdk | 0.20 | certified_data_set / data_certificate |
| Rust | ic-certification(特性 serde) | 4 | 使用见证的 RbTree Merkle 映射 |
| Rust | ic-http-certification | 4 | 认证 http_request 响应 |
| Motoko | core | 2.6 | mo:core/CertifiedData |
| Motoko | ic-certification(mops) | 1.1 | 使用见证的 CertTree Merkle 树 |
| Motoko | sha2(mops) | 0.2 | 对单个已认证值进行哈希 |
| 前端 | @icp-sdk/core | ^6 | agent、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 一同维护,也是官方示例所使用的库。
根密钥
客户端验证需要 canister 所运行网络的根密钥:
- 浏览器: 从
ic_envcookie(@icp-sdk/core/agent/canister-env)读取safeGetCanisterEnv()?.IC_ROOT_KEY,该值由前端 canister 在本地网络和主网中设置。它是提供当前页面的网络的密钥;若要验证另一个网络上的 canister(例如,在本地开发服务器上验证主网数据),请使用该网络的密钥。它的可信度仅与页面相当:在验证型主机名上,网关会同时验证 cookie 和页面;但来自raw主机名的页面可能携带伪造的密钥,因此,客户端若要验证raw响应,需要通过独立途径获取密钥。 - Node 脚本和测试: 从
icp network status --json获取root_key,并将十六进制解码为字节。 - 主网: 使用 agent 的内置默认值(创建 agent 时未指定
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、更新方法、回复/拒绝回调和系统任务(heartbeat、global timer、低 Wasm 内存钩子)中调用;在其他任何地方调用都会触发陷阱,包括任何查询(即使将查询作为更新调用)和清理回调。安装时认证数据为空,因此请在init(Rust)或 actor 主体(Motoko)中认证初始状态,否则查询在首次写入前都会无法通过验证。变更后忘记设置会留下过期哈希,结果也会随之过时。批量写入只需在最后一次插入后调用一次certified_data_set。 -
期望从非查询调用中获取证书。 在更新调用中,
data_certificate()/CertifiedData.getCertificate()会返回None/null,将查询方法作为更新调用时也同样如此。icp canister call默认发送更新调用,除非传入--query:如果不传,下面的 Rust 示例会在其expect处触发陷阱,Motoko 示例则会返回certificate = null。前端代码通过 actor 调用query方法时,会自动发送查询调用。 -
升级时丢失树,而非认证数据。 认证数据本身会在升级后保留(安装和重新安装时为空)。不会保留的是存放在堆上的 Merkle 树:升级时,下面的 Rust
RbTree会被清空,而旧哈希仍然存在,因此#[post_upgrade]必须重建树(在实际应用中,从稳定存储中重建,参见stable-memory技能)并再次调用certified_data_set。MotokoCertTree.Store会随 actor 持久化,因此升级后无需重新设置。 -
为错误的键构建见证。 见证必须揭示正在查询的确切路径;
users/alice的见证无法证明users/bob的任何信息。 -
将所有非
Found查询结果都视为“缺失”。lookup_path会返回一种状态:Found(值已证明)、Absent(不存在已证明)或Unknown/Error(见证未覆盖该路径)。只有Absent能证明键不存在。将Unknown归为“未找到”(例如使用对三种状态都会返回undefined的lookupResultToBuffer),会让副本发送针对其他键的见证,从而使实际存在的值看起来像是缺失。 -
跳过新鲜度检查。 证书中的
/time表示子网为其签名的时间;如果不设上限,包含过期数据的旧证书可能会被重放。verifyCertification会强制执行maxCertificateTimeOffsetMs(5 分钟是合理值);Certificate.create默认强制执行 ±5 分钟(maxAgeInMinutes)。 -
将 Motoko
CertTree.Ops对象声明为 stable。 在持久化 actor 中,let ct = CertTree.Ops(certStore)会因M0131而失败(variable ct is declared stable but has non-stable type)。请声明为transient let ct = CertTree.Ops(certStore);;只有CertTree.Store是 stable 的。 -
认证 HTTP 响应时未包含其
IC-CertificateExpression标头。 使用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 软件包中的 CertTree(mops add ic-certification)是一种可为每个键生成见证的 Merkle 树:
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 canister
通过自身的 http_request 提供 HTTP 服务的 canister,必须使用 ic-http-certification 对每个响应进行认证,以便 HTTP 网关验证该响应。编写此类 canister 前,请阅读 references/http-certification.md:其中包含一个完整且精简的 canister(在 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。Candid 的 blob 字段在 @icp-sdk/bindgen 绑定中会以 Uint8Array 形式提供。bindgen 会将 opt 记录字段转换为可选属性,字段为空时其值为 undefined。这些辅助函数会直接使用 getter 返回的响应: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 需要见证树,因此请直接使用 Certificate.create 验证 Motoko 单值示例。它会检查签名和 ±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;
}
dfinity/examples motoko/cert-var 是一个可运行的单值示例,包含使用 Certificate.create 进行验证的浏览器前端。它对原始的 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 后必须抛出异常。