mcpField
C
certified-variables

certified-variables

github.com/dfinity安全39296 次安装今天更新
代码仓库

关于 certified-variables

面向 Internet Computer 开发者的认证变量技能,介绍如何为查询结果生成证书与 Merkle 见证,并由客户端验证,降低单个副本伪造安全相关数据的风险。

主要功能

  1. 01为变量数据生成认证哈希
  2. 02使用 Merkle 树提供逐键见证
  3. 03在客户端验证证书和见证
  4. 04覆盖 Rust、Motoko 与 TypeScript
  5. 05说明认证 HTTP 响应的要求

适用场景

  1. 01验证余额、权限或价格查询
  2. 02为键值数据提供可验证证明
  3. 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 技能。

版本

端包版本用途
Rustic-cdk0.20certified_data_set / data_certificate
Rustic-certification(特性 serde)4使用见证的 RbTree Merkle 映射
Rustic-http-certification4认证 http_request 响应
Motokocore2.6mo:core/CertifiedData
Motokoic-certification(mops)1.1使用见证的 CertTree Merkle 树
Motokosha2(mops)0.2对单个已认证值进行哈希
前端@icp-sdk/core^6agent、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_env cookie(@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 技能)。

常见陷阱

  1. 认证超过 32 字节的数据。 certified_data_set / CertifiedData.set 最多接受 32 字节。请基于数据构建 Merkle 树并认证其根哈希;该树会为每个键提供证明。

  2. 在查询中设置认证数据,或安装时及每次变更后未设置。 certified_data_set 可在 canister_init、canister_post_upgrade、canister_pre_upgrade、更新方法、回复/拒绝回调和系统任务(heartbeat、global timer、低 Wasm 内存钩子)中调用;在其他任何地方调用都会触发陷阱,包括任何查询(即使将查询作为更新调用)和清理回调。安装时认证数据为空,因此请在 init(Rust)或 actor 主体(Motoko)中认证初始状态,否则查询在首次写入前都会无法通过验证。变更后忘记设置会留下过期哈希,结果也会随之过时。批量写入只需在最后一次插入后调用一次 certified_data_set。

  3. 期望从非查询调用中获取证书。 在更新调用中,data_certificate() / CertifiedData.getCertificate() 会返回 None/null,将查询方法作为更新调用时也同样如此。icp canister call 默认发送更新调用,除非传入 --query:如果不传,下面的 Rust 示例会在其 expect 处触发陷阱,Motoko 示例则会返回 certificate = null。前端代码通过 actor 调用 query 方法时,会自动发送查询调用。

  4. 升级时丢失树,而非认证数据。 认证数据本身会在升级后保留(安装和重新安装时为空)。不会保留的是存放在堆上的 Merkle 树:升级时,下面的 Rust RbTree 会被清空,而旧哈希仍然存在,因此 #[post_upgrade] 必须重建树(在实际应用中,从稳定存储中重建,参见 stable-memory 技能)并再次调用 certified_data_set。Motoko CertTree.Store 会随 actor 持久化,因此升级后无需重新设置。

  5. 为错误的键构建见证。 见证必须揭示正在查询的确切路径;users/alice 的见证无法证明 users/bob 的任何信息。

  6. 将所有非 Found 查询结果都视为“缺失”。 lookup_path 会返回一种状态:Found(值已证明)、Absent(不存在已证明)或 Unknown/Error(见证未覆盖该路径)。只有 Absent 能证明键不存在。将 Unknown 归为“未找到”(例如使用对三种状态都会返回 undefined 的 lookupResultToBuffer),会让副本发送针对其他键的见证,从而使实际存在的值看起来像是缺失。

  7. 跳过新鲜度检查。 证书中的 /time 表示子网为其签名的时间;如果不设上限,包含过期数据的旧证书可能会被重放。verifyCertification 会强制执行 maxCertificateTimeOffsetMs(5 分钟是合理值);Certificate.create 默认强制执行 ±5 分钟(maxAgeInMinutes)。

  8. 将 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 的。

  9. 认证 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 后必须抛出异常。

评论

支持 Markdown