Certified Variables
A query call is answered by a single replica, without consensus, so a faulty or malicious replica can return fabricated data. Certification closes that gap: during update calls the canister stores a 32-byte hash (usually the root of a Merkle tree over its data) in the subnet's certified state, and query responses carry a certificate signed by the subnet's threshold BLS key. Clients verify the certificate and a Merkle witness, and get a fast query response that is as trustworthy as an update call.
Who Verifies What
Decide this first. Certification only helps if something checks it.
| Response | Verified by | Client code needed |
|----------|-------------|--------------------|
| HTTP from a frontend canister or http_request, via <id>.icp.net or a custom domain | the HTTP gateway | none |
| The same, via <id>.raw.icp.net or your own HTTP client | nobody | @dfinity/response-verification (verifyRequestResponsePair) |
| Update call through an actor (agent.update) | consensus; the agent checks the response certificate | none |
| Candid query call through an actor | only the answering node's signature | certified data + @dfinity/certificate-verification (this skill) |
The gateway never verifies Candid API calls (/api/...), even when the page came from a verifying host: on ic0.app, icp0.io or local origins the agent sends them through the page origin, and the gateway only proxies them. Certify a query when a client acts on its result: balances, permissions, prices, anything security-relevant. The alternative is to call the method as an update and accept consensus latency. Ledgers implementing ICRC-3 already certify their tip: icrc3_get_tip_certificate returns opt { certificate; hash_tree }, which verifies with verifyCertification like any witness below (labels last_block_index, a LEB128 number, and last_block_hash, so decode them instead of the helper's UTF-8 compare).
Static frontends served by the certified-assets canister (@dfinity/static-site recipe) are certified automatically: load the static-site skill for those.
Versions
| Side | Package | Version | Used for |
|------|---------|---------|----------|
| Rust | ic-cdk | 0.20 | certified_data_set / data_certificate |
| Rust | ic-certification (feature serde) | 4 | RbTree Merkle map with witnesses |
| Rust | ic-http-certification | 4 | certifying http_request responses |
| Motoko | core | 2.6 | mo:core/CertifiedData |
| Motoko | ic-certification (mops) | 1.1 | CertTree Merkle tree with witnesses |
| Motoko | sha2 (mops) | 0.2 | hashing a single certified value |
| Frontend | @icp-sdk/core | ^6 | agent, Certificate, lookup_path |
| Frontend | @dfinity/certificate-verification | ^4 | witness verification (peers @icp-sdk/core ^6, takes Uint8Array) |
ic-certified-map 0.4 exposes the same RbTree/AsHashTree API and still works, but ic-certification is maintained alongside ic-http-certification and is what the official examples use.
Root Key
Client-side verification needs the root key of the network the canister runs on:
- Browser:
safeGetCanisterEnv()?.IC_ROOT_KEYfrom theic_envcookie (@icp-sdk/core/agent/canister-env), set by the frontend canister on local networks and mainnet alike. It is the key of the network serving the page; to verify a canister on another network (e.g. mainnet data from a local dev server), use that network's key. It is only as trustworthy as the page: on a verifying hostname the gateway verifies the cookie along with the page, but a page from arawhostname can carry a forged key, so a client verifyingrawresponses needs an independently obtained key. - Node scripts and tests:
root_keyfromicp network status --json, hex-decoded to bytes. - Mainnet: the agent's built-in default (
agent.rootKeyon an agent created withoutrootKey):308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d9685f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484b01291091c5f87b98883463f98091a0baaae
Never call fetchRootKey() in shipped code: it trusts whatever key the replica sends (see the canister-security skill).
Pitfalls
-
Certifying more than 32 bytes.
certified_data_set/CertifiedData.setaccept at most 32 bytes. Build a Merkle tree over the data and certify its root hash; the tree provides per-key proofs. -
Setting certified data in a query, or not at install and after every change.
certified_data_setcan be called fromcanister_init,canister_post_upgrade,canister_pre_upgrade, update methods, reply/reject callbacks, and system tasks (heartbeat, global timer, low-Wasm-memory hook); it traps anywhere else, including any query (even one called as an update) and cleanup callbacks. Certified data starts empty on install, so certify the initial state ininit(Rust) or the actor body (Motoko), or queries fail verification until the first write. Forgetting to set it after a mutation leaves a stale hash with the same result. Batch writes need only onecertified_data_setafter the last insert. -
Expecting a certificate from a call that is not a query call.
data_certificate()/CertifiedData.getCertificate()returnNone/nullin update calls, and a query method invoked as an update call counts as one.icp canister callsends an update call unless you pass--query: without it, the Rust example below traps on itsexpect, and the Motoko examples returncertificate = null. Frontend code calling aquerymethod through an actor sends a query call automatically. -
Losing the tree on upgrade, not the certified data. The certified data itself survives upgrades (install and reinstall start it empty). What does not survive is a Merkle tree kept on the heap: the Rust
RbTreebelow is wiped on upgrade while the old hash stays set, so#[post_upgrade]must rebuild the tree (from stable storage in a real app, see thestable-memoryskill) and callcertified_data_setagain. A MotokoCertTree.Storepersists with the actor, so nothing needs re-setting after an upgrade. -
Building the witness for the wrong key. The witness must reveal the exact path being queried; a witness for
users/aliceproves nothing aboutusers/bob. -
Treating every non-
Foundlookup as "absent".lookup_pathreturns a status:Found(value proven),Absent(absence proven), orUnknown/Error(the witness does not cover the path). OnlyAbsentproves a key does not exist. CollapsingUnknowninto "not found" (for example withlookupResultToBuffer, which returnsundefinedfor all three) lets a replica send a witness for a different key and make a real value look missing. -
Skipping the freshness check. The certificate's
/timeis when the subnet signed it; without a bound, a stale certificate with outdated data can be replayed.verifyCertificationenforcesmaxCertificateTimeOffsetMs(5 minutes is a sensible value);Certificate.createenforces ±5 minutes by default (maxAgeInMinutes). -
Declaring the Motoko
CertTree.Opsobject as stable. In a persistent actor,let ct = CertTree.Ops(certStore)fails withM0131(variable ct is declared stable but has non-stable type). Declare ittransient let ct = CertTree.Ops(certStore);; only theCertTree.Storeis stable. -
Certifying an HTTP response without its
IC-CertificateExpressionheader. Withic-http-certification, the header carrying the CEL expression must be part of the response you certify;HttpCertification::response_only/fullreturnCertificateExpressionHeaderMissingotherwise. Serve exactly that response, plus theIC-Certificateheader fromadd_v2_certificate_header, or the HTTP gateway rejects it withbackend_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: single value
A single value needs no Merkle tree: certify its hash, and the client compares sha256(value) with the certificate's certified_data (see "Single value without a witness").
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: key-value store with witnesses
CertTree from the ic-certification mops package (mops add ic-certification) is a Merkle tree that produces per-key witnesses:
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);
}
};
};
Custom http_request canisters
Canisters that serve HTTP from their own http_request must certify each response with ic-http-certification so the HTTP gateway can verify it. Read references/http-certification.md before writing one: it has a complete, minimal canister (certify in init/post_upgrade, attach the witness in http_request, and a certified 404 for every other path) and the header rules from pitfall 9. Every path the gateway can request needs a certified response: an uncertified error or 404 is rejected with backend_response_verification.
Client Verification (TypeScript)
@icp-sdk/core ships every primitive (Certificate.create, Cbor, reconstruct, lookup_path); @dfinity/certificate-verification wraps them for the witness case: it verifies the certificate signature, checks /time, decodes the witness, and checks that its root hash equals the certificate's certified_data. Candid blob fields arrive as Uint8Array in @icp-sdk/bindgen bindings. bindgen turns opt record fields into optional properties that are undefined when empty. The helpers take the getters' responses as returned: a Motoko ?Blob value or certificate is Uint8Array | undefined, and the witness helper decodes a Uint8Array value as UTF-8. Take the root key from "Root Key".
With a witness
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})`);
}
}
Single value without a witness
verifyCertification needs a witness tree, so verify the Motoko single-value example with Certificate.create directly. It checks the signature and the ±5 minute freshness window:
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;
}
A runnable single-value example with a browser frontend that verifies with Certificate.create is dfinity/examples motoko/cert-var. It certifies the raw 4-byte Nat32 instead of a hash.
Checking It Works
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
Then run the client function against a response: it must return the value, and must throw once response.value is changed.
微信扫一扫