From e913ccd337fe9695440fba2ce93d1d34c4378b4f Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 13:59:43 +0200 Subject: [PATCH 1/7] docs: correct the certification guides against current libraries Fixes claims that were wrong or outdated: certified data survives upgrades, the certification header is IC-CertificateExpression, icp canister call needs --query to return a certificate, and raw-access redirects land on icp0.io. Code examples now compile and verify against ic-cdk 0.20, the 4.x certification crates and @dfinity/certificate-verification 4. --- docs/guides/backends/certified-variables.md | 143 ++++++++------ docs/guides/frontends/certification.md | 204 +++++++++----------- 2 files changed, 177 insertions(+), 170 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index 0b07be67..24666b1c 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -19,7 +19,7 @@ The mechanism relies on three coordinated steps: 3. **Client verification**: the client verifies the certificate signature against the IC root public key, extracts the root hash from the certificate's state tree, then confirms the witness proves the data is included under that root hash. -``` +```text UPDATE CALL (goes through consensus): 1. Canister modifies state 2. Canister builds/updates Merkle tree @@ -41,8 +41,8 @@ CLIENT: - `certified_data_set` accepts **at most 32 bytes**. You cannot certify arbitrary data directly. Build a Merkle tree over your data and certify only the 32-byte root hash. The tree provides proofs for individual values. - `certified_data_set` **must be called in update calls only**. Calling it in a query call traps. -- `data_certificate()` returns `None` in update calls: certificates are only available during query calls. -- After a canister upgrade, the certified data is cleared. Re-establish certification in both `#[init]` and `#[post_upgrade]` (Rust), or in `system func postupgrade` (Motoko). +- `data_certificate()` returns `None` in update calls, including a query method invoked as an update call. `icp canister call` sends an update call unless you pass `--query`, so always test certified getters with `icp canister call --query`. +- Certified data survives upgrades (install and reinstall start it empty). A Merkle tree kept on the heap does not: in Rust, rebuild the tree in `#[post_upgrade]` and call `certified_data_set` again. A Motoko `CertTree.Store` persists with the actor, so nothing needs re-setting. ## Rust implementation @@ -51,19 +51,19 @@ Add to `Cargo.toml`: ```toml [dependencies] candid = "0.10" -ic-cdk = "0.19" -ic-certified-map = "0.4" +ic-cdk = "0.20" +ic-certification = { version = "4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" ciborium = "0.2" ``` -`ic-certified-map` provides `RbTree`, a Merkle-tree-backed map. Each call to `tree.root_hash()` returns a 32-byte SHA-256 hash of the entire tree; `tree.witness(key)` returns a Merkle proof for a specific key. +`ic-certification` provides `RbTree`, a Merkle-tree-backed map (`ic-certified-map` 0.4 has the same API). Each call to `tree.root_hash()` returns a 32-byte SHA-256 hash of the entire tree; `tree.witness(key)` returns a Merkle proof for a specific key. ```rust use candid::{CandidType, Deserialize}; use ic_cdk::{init, post_upgrade, query, update}; -use ic_certified_map::{AsHashTree, RbTree}; +use ic_certification::{AsHashTree, RbTree}; use serde_bytes::ByteBuf; use std::cell::RefCell; @@ -86,8 +86,8 @@ fn init() { #[post_upgrade] fn post_upgrade() { - // Certified data is cleared on upgrade: must be re-established. - // Assumes tree data has already been loaded from stable memory. + // The heap TREE is empty after an upgrade, while the old certified hash is kept. + // Rebuild TREE from stable storage here, then re-set the hash to match it. update_certified_data(); } @@ -118,7 +118,7 @@ struct CertifiedResponse { #[query] fn get(key: String) -> CertifiedResponse { - // data_certificate() is only available in query calls. + // 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"); @@ -141,6 +141,9 @@ fn get(key: String) -> CertifiedResponse { } }) } + +// Required by the icp-cli Rust recipe, which extracts the Candid interface from the wasm +ic_cdk::export_candid!(); ``` ### Batch updates @@ -209,9 +212,10 @@ import Text "mo:core/Text"; persistent actor { - // CertTree.Store is stable: persists across upgrades. + // CertTree.Store is stable: the tree and the certified data persist across upgrades. let certStore : CertTree.Store = CertTree.newStore(); - let ct = CertTree.Ops(certStore); + // Ops is an object with functions, not stable data: it must be transient. + transient let ct = CertTree.Ops(certStore); // Establish initial certification. ct.setCertifiedData(); @@ -222,7 +226,7 @@ persistent actor { ct.setCertifiedData(); }; - public func remove(key : Text) : async () { + public func delete(key : Text) : async () { ct.delete([Text.encodeUtf8(key)]); ct.setCertifiedData(); }; @@ -241,20 +245,15 @@ persistent actor { } }; - // Re-establish certification after upgrade. - // (CertTree.Store is stable, so tree data survives, but certified_data is cleared.) - system func postupgrade() { - ct.setCertifiedData(); - }; }; ``` ## Client-side verification -The client must verify the certificate before trusting the data. The `@dfinity/certificate-verification` package handles the full verification flow: +The client must verify the certificate before trusting the data. The `@dfinity/certificate-verification` package (version 4, peer-depends on `@icp-sdk/core` ^6, takes `Uint8Array`) handles the full verification flow for a witness: 1. Verify the certificate BLS signature against the IC root public key -2. Check certificate freshness. The `/time` field must be within an acceptable window (recommended: 5 minutes) +2. Check certificate freshness: the `/time` field must be within `maxCertificateTimeOffsetMs` (recommended: 5 minutes) 3. CBOR-decode the witness into a hash tree 4. Reconstruct the witness root hash 5. Compare it with the `certified_data` path in the certificate @@ -262,24 +261,19 @@ The client must verify the certificate before trusting the data. The `@dfinity/c ```typescript import { verifyCertification } from "@dfinity/certificate-verification"; -import { lookup_path, lookupResultToBuffer, HashTree } from "@icp-sdk/core/agent"; +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 -async function getVerifiedValue( - rootKey: ArrayBuffer, +export async function getVerifiedValue( + rootKey: Uint8Array, canisterId: string, key: string, - response: { - value: string | null; - certificate: ArrayBuffer; - witness: ArrayBuffer; - } + response: { value: string | null; certificate: Uint8Array; witness: Uint8Array }, ): Promise { - // Steps 1-5: verify BLS signature, time, and witness hash match. - // Throws CertificateTimeError or CertificateVerificationError on failure. - const tree: HashTree = await verifyCertification({ + // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. + const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), encodedCertificate: response.certificate, encodedTree: response.witness, @@ -287,32 +281,59 @@ async function getVerifiedValue( maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS, }); - // Step 6: look up the key in the verified witness tree. - // lookup_path returns a LookupResult discriminated union; lookupResultToBuffer - // extracts the Uint8Array value or returns undefined if the key is absent. - const leafData = lookupResultToBuffer( - lookup_path([new TextEncoder().encode(key)], tree) - ); - - if (leafData === undefined) { - // Key is provably absent from the certified tree. - return null; + // Step 6: 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 (response.value !== verified) throw new Error("value does not match witness"); + return verified; + } + case LookupPathStatus.Absent: + if (response.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})`); } +} +``` - const verifiedValue = new TextDecoder().decode(leafData); +`lookup_path` returns a status, and only `Absent` proves that a key does not exist. Treat `Unknown` (the witness does not cover the key) as a failure, never as "not found". For where the root key comes from, see [Client-side certificate verification](../frontends/certification.md#client-side-certificate-verification). - // Confirm the canister-returned value matches what the witness proves. - if (response.value !== null && response.value !== verifiedValue) { - throw new Error( - "Response value does not match witness: canister returned tampered data" - ); - } +### Single value without a witness + +The simple Motoko example certifies `sha256(value)` without a Merkle tree, so there is no witness to pass to `verifyCertification`. Verify it with `Certificate.create` from `@icp-sdk/core`, which checks the signature and a ±5 minute freshness window: - return verifiedValue; +```typescript +import { Certificate, lookupResultToBuffer, uint8Equals } from "@icp-sdk/core/agent"; +import { Principal } from "@icp-sdk/core/principal"; + +export async function verifySingleValue( + rootKey: Uint8Array, + canisterId: string, + response: { value: string; certificate: Uint8Array }, +): Promise { + 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; } ``` -The JS SDK documentation covers the full `verifyCertification` API at [js.icp.build](https://js.icp.build). ## Deploy and test @@ -323,18 +344,20 @@ icp deploy backend # Set a certified value (update call: goes through consensus) icp canister call backend set '("greeting", "hello world")' -# Query the certified value -icp canister call backend get '("greeting")' -# Returns: record { value = opt "hello world"; certificate = blob "..."; witness = blob "..." } +# Query the certified value: --query is required, or no certificate is returned +icp canister call --query backend get '("greeting")' +# Returns: record { certificate = blob "..."; value = opt "hello world"; witness = blob "..." } # Delete a value icp canister call backend delete '("greeting")' -# Verify certification survives upgrade +# Check certification after an upgrade icp canister call backend set '("key", "value")' icp deploy backend # triggers upgrade -icp canister call backend get '("key")' -# Expected: certificate is non-null (postupgrade re-established certification) +icp canister call --query backend get '("key")' +# Motoko CertTree: the value survives and the certificate still verifies. +# Rust example: the heap tree is gone, so value = null, but the certificate still verifies +# (a proof of absence) because post_upgrade re-set the hash. ``` ## Common mistakes @@ -343,13 +366,17 @@ icp canister call backend get '("key")' **Not updating the hash after data changes**: if you modify the tree but forget to call `certified_data_set`, query responses will fail client verification because the certificate proves a stale hash. -**Forgetting to re-certify after upgrade**: certified data is cleared on upgrade. Both `#[init]` and `#[post_upgrade]` (Rust) or `system func postupgrade` (Motoko) must call the certification function. +**Losing the tree on upgrade**: certified data survives upgrades, but a Rust tree kept on the heap does not, while the old hash stays set. Rebuild the tree in `#[post_upgrade]` and call `certified_data_set` again. A Motoko `CertTree.Store` persists, so no hook is needed. **Building the witness for the wrong key**: the Merkle proof must correspond to the exact key being queried. A witness for `users/alice` will not verify `users/bob`. **Skipping certificate freshness checks on the client**: the certificate's `/time` field contains the subnet timestamp. Without a freshness check, an attacker could replay a stale certificate with outdated data. Always check that `certificate_time` is within an acceptable delta (5 minutes is recommended). -**Assuming `data_certificate()` is available in update calls**: it returns `None` / `null` in update calls. Only query calls can access the certificate. +**Calling the getter as an update call**: `data_certificate()` returns `None` / `null` in update calls, including a query method called as one. `icp canister call` does that unless you pass `--query`. + +**Treating every non-`Found` lookup as absent**: `lookupResultToBuffer` returns `undefined` for `Absent`, `Unknown` and `Error` alike. Only `Absent` proves a key does not exist; switch on the `lookup_path` status instead. + +**Declaring the Motoko `CertTree.Ops` object as stable**: in a persistent actor, `let ct = CertTree.Ops(certStore)` fails to compile (`variable ct is declared stable but has non-stable type`). Declare it `transient`. ## HTTP asset certification diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index 07882173..57f3fe10 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -5,7 +5,7 @@ sidebar: order: 4 --- -Query responses on ICP are answered by a single replica without going through consensus. A malicious or faulty replica could return fabricated data. **Response certification** solves this: canisters commit a cryptographic hash to the subnet's certified state, and query responses include a certificate signed by the subnet's threshold BLS key. [HTTP gateways](../../concepts/edge-infrastructure.md#http-gateways) ([boundary nodes](../../concepts/edge-infrastructure.md#api-boundary-nodes)) verify every response automatically, so users are protected without any extra client-side code: as long as the canister certifies its responses. +Query responses on ICP are answered by a single replica without going through consensus. A malicious or faulty replica could return fabricated data. **Response certification** solves this: canisters commit a cryptographic hash to the subnet's certified state, and query responses include a certificate signed by the subnet's threshold BLS key. [HTTP gateways](../../concepts/edge-infrastructure.md#http-gateways) verify every HTTP response automatically, so users are protected without any extra client-side code: as long as the canister certifies its responses. The gateway does not verify Candid calls an app makes through an agent; see [Client-side certificate verification](#client-side-certificate-verification). This guide explains how certification works at the HTTP layer, what each frontend recipe does automatically, when you need custom certification, and how to verify certificates client-side. @@ -15,11 +15,11 @@ Both frontend recipes implement **HTTP certification v2**, a protocol on top of 1. **Certification setup (update call)**: when an asset is uploaded, the canister inserts its path, response headers, and body hash into a Merkle tree and commits the tree's root hash via `certified_data_set`. The subnet includes this root hash in its certified state each consensus round. -2. **HTTP query call**: when a browser requests an asset, the canister retrieves the subnet BLS certificate via `data_certificate()`, generates a Merkle proof (witness) for the requested path, and returns the response with `IC-Certificate` and `IC-Certificate-Expression` headers containing the certificate and witness. +2. **HTTP query call**: when a browser requests an asset, the canister retrieves the subnet BLS certificate via `data_certificate()`, generates a Merkle proof (witness) for the requested path, and returns the response with `IC-Certificate` and `IC-CertificateExpression` headers containing the certificate and witness. -3. **Boundary node verification**: the HTTP gateway (boundary node) verifies the BLS signature on the certificate, extracts the certified root hash, and confirms the witness proves the response body and headers are included under that root hash. If verification fails, the gateway returns an error. +3. **Gateway verification**: the HTTP gateway verifies the BLS signature on the certificate, extracts the certified root hash, and confirms the witness proves the response body and headers are included under that root hash. If verification fails, the gateway returns an error. -``` +```text UPLOAD (update call, goes through consensus): 1. Asset body and headers are hashed 2. Hash is inserted into Merkle tree at the asset's path @@ -29,16 +29,16 @@ HTTP REQUEST (query call, single replica): 1. Browser requests an asset 2. Canister calls data_certificate() -- retrieves BLS-signed certificate 3. Canister builds Merkle witness for the requested path - 4. Response includes IC-Certificate and IC-Certificate-Expression headers + 4. Response includes IC-Certificate and IC-CertificateExpression headers -BOUNDARY NODE VERIFICATION (transparent): +HTTP GATEWAY VERIFICATION (transparent): 1. Verifies certificate BLS signature against IC root public key 2. Extracts certified_data from certificate 3. Verifies witness proves (path, headers, body hash) is in the tree 4. Forwards verified response to browser ``` -The browser receives only responses that have passed this check. Because verification happens at the boundary node, no browser-side JavaScript is needed for standard asset serving. +The browser receives only responses that have passed this check. Because verification happens at the gateway, no browser-side JavaScript is needed for standard asset serving. ## Certified vs uncertified access @@ -64,7 +64,7 @@ What you can do about the raw host depends on which canister you deployed. ] ``` -With `allow_raw_access` set to `false`, requests to the `raw.icp.net` domain are redirected to the certified domain automatically. +With `allow_raw_access` set to `false`, the canister answers requests for a raw mainnet hostname with a `308` redirect to a certified one. A request to `.raw.icp.net` or `.raw.icp0.io` lands on `.icp0.io`, and `.raw.ic0.app` on `.ic0.app`. The canister recognizes raw hostnames by the `Host` header, so a local `raw.localhost` request is not redirected. ## What each recipe certifies automatically @@ -72,7 +72,7 @@ Neither recipe needs certification code from you. What differs is how much of th **Static site.** Certifies every response it serves, including status code, body, and the headers you declare in [`_headers`](static-site/headers.md). There is no way to turn certification off and no uncertified header path, which is why redirects and headers are limited to what can be enumerated ahead of time, and why the sync plugin rejects [reserved headers](static-site/headers.md#reserved-headers) at deploy time instead of serving a value it cannot certify. Note that it adds no default headers at all: no `Cache-Control`, no CSP. Anything you want certified, you declare. -**The asset canister** inserts every uploaded file into the HTTP certification tree, sets the certified root hash after each sync, returns the `IC-Certificate` and `IC-Certificate-Expression` headers on every `http_request` query, and re-certifies on subsequent deploys. It certifies `Content-Type` plus the headers you list in `.ic-assets.json5`. +**The asset canister** inserts every uploaded file into the HTTP certification tree, sets the certified root hash after each sync, returns the `IC-Certificate` and `IC-CertificateExpression` headers on every `http_request` query, and re-certifies on subsequent deploys. It certifies `Content-Type`, `Cache-Control` when `max_age` is set, `Content-Encoding` for encoded assets, and the headers you list in `.ic-assets.json5`. ### What gets certified @@ -95,7 +95,7 @@ If you are writing a canister that serves HTTP responses directly (not through o Use custom HTTP certification when: -- Your canister serves HTTP responses via `http_request` and you need boundary nodes to verify them +- Your canister serves HTTP responses via `http_request` and you need the HTTP gateway to verify them - You need to certify dynamic responses (generated per request, not pre-uploaded assets) - You are building a canister that functions as its own frontend without using one of the frontend recipes @@ -109,17 +109,18 @@ Add to `Cargo.toml`: ```toml [dependencies] -ic-asset-certification = "3" -ic-http-certification = "3" -ic-cdk = "0.19" +candid = "0.10" +ic-asset-certification = "4" +ic-http-certification = "4" +ic-cdk = "0.20" ``` -Certify assets in your `init` and `post_upgrade` hooks: +Certify assets in your `init` and `post_upgrade` hooks. Every path the gateway can request needs a certified response, so paths without an asset fall back to a certified `404.html`: an uncertified error response is rejected by the gateway. ```rust -use ic_asset_certification::{Asset, AssetConfig, AssetRouter}; +use ic_asset_certification::{Asset, AssetConfig, AssetFallbackConfig, AssetRouter}; use ic_cdk::{init, post_upgrade, query}; -use ic_http_certification::{HttpRequest, HttpResponse}; +use ic_http_certification::{HttpRequest, HttpResponse, StatusCode}; use std::cell::RefCell; thread_local! { @@ -128,37 +129,46 @@ thread_local! { fn certify_assets() { let assets = vec![ - Asset::new("index.html", include_bytes!("../../../frontend/index.html").as_slice()), - Asset::new("app.js", include_bytes!("../../../frontend/app.js").as_slice()), + Asset::new("index.html", include_bytes!("../../frontend/index.html").as_slice()), + Asset::new("404.html", include_bytes!("../../frontend/404.html").as_slice()), + Asset::new("app.js", include_bytes!("../../frontend/app.js").as_slice()), ]; let configs = vec![ AssetConfig::File { path: "index.html".to_string(), content_type: Some("text/html".to_string()), - headers: vec![ - ("Cache-Control".to_string(), "no-cache".to_string()), - ], + headers: vec![("Cache-Control".to_string(), "no-cache".to_string())], fallback_for: vec![], aliased_by: vec!["/".to_string()], encodings: vec![], }, + // A certified 404 page for every path without an asset. + AssetConfig::File { + path: "404.html".to_string(), + content_type: Some("text/html".to_string()), + headers: vec![("Cache-Control".to_string(), "no-cache".to_string())], + fallback_for: vec![AssetFallbackConfig { + scope: "/".to_string(), + status_code: Some(StatusCode::NOT_FOUND), + }], + aliased_by: vec![], + encodings: vec![], + }, AssetConfig::Pattern { pattern: "*.js".to_string(), content_type: Some("text/javascript".to_string()), - headers: vec![ - ("Cache-Control".to_string(), "public, max-age=31536000, immutable".to_string()), - ], + headers: vec![( + "Cache-Control".to_string(), + "public, max-age=31536000, immutable".to_string(), + )], encodings: vec![], }, ]; - ROUTER.with(|router| { - let mut router = router.borrow_mut(); + ROUTER.with_borrow_mut(|router| { router.certify_assets(assets, configs).expect("Failed to certify assets"); - - // Update the canister's certified data with the tree root hash. - ic_cdk::api::certified_data_set(&router.root_hash()); + ic_cdk::api::certified_data_set(router.root_hash()); }); } @@ -167,31 +177,27 @@ fn init() { certify_assets(); } +// The router lives on the heap and is wiped on upgrade: rebuild it and re-set the root hash. #[post_upgrade] fn post_upgrade() { - // Certified data is cleared on upgrade: must be re-established. certify_assets(); } #[query] -fn http_request(request: HttpRequest) -> HttpResponse { - ROUTER.with(|router| { - let router = router.borrow(); - - // The router builds the response with IC-Certificate and - // IC-Certificate-Expression headers automatically. - match router.serve_asset( - &ic_cdk::api::data_certificate().expect("data_certificate not available"), - &request, - ) { - Ok(response) => response, - Err(_) => HttpResponse::builder() - .with_status_code(404) - .with_body(b"Not found".to_vec()) - .build(), - } +fn http_request(request: HttpRequest) -> HttpResponse<'static> { + ROUTER.with_borrow(|router| { + // Adds the IC-Certificate and IC-CertificateExpression headers. + router + .serve_asset( + &ic_cdk::api::data_certificate().expect("http_request must be a query"), + &request, + ) + // Uncertified, so the gateway rejects it: only reached if no asset or fallback matches. + .unwrap_or_else(|_| HttpResponse::not_found(b"Not found".to_vec(), vec![]).build()) }) } + +ic_cdk::export_candid!(); ``` For the full pattern including streaming, 404 fallbacks, and compressed encodings, see the [assets example](https://github.com/dfinity/response-verification/tree/main/examples/http-certification/assets) in the `response-verification` repository. @@ -202,52 +208,41 @@ For more control (certifying dynamic responses, certifying only specific headers ## Client-side certificate verification -For standard asset serving through either frontend recipe, verification is transparent on a verifying hostname: the boundary node checks every response before forwarding it to the browser, and you do not need any JavaScript verification code. On a `raw` hostname nothing checks it, which is why a raw URL is a debugging tool rather than a way to serve a site. - -For custom canisters returning certified data over the Candid interface (not HTTP), you may need to verify the certificate in JavaScript. This is the pattern covered in [Certified variables](../backends/certified-variables.md): the canister returns `(data, certificate, witness)` as Candid values, and the frontend verifies them with `@dfinity/certificate-verification`. +Which responses need client-side code depends on who verifies them: -### When client-side verification is needed +| Response | Verified by | Client code | +|----------|-------------|-------------| +| HTTP from a frontend canister or `http_request`, on a verifying host (`.icp.net`, a custom domain) | the HTTP gateway | none | +| The same response on a `raw` hostname, or fetched by your own HTTP client | nobody | [`@dfinity/response-verification`](https://www.npmjs.com/package/@dfinity/response-verification) (`verifyRequestResponsePair`) | +| Update call through an actor | consensus; the agent verifies the response certificate | none | +| Candid query call through an actor | only the signature of the node that answered | certified data, verified with `@dfinity/certificate-verification` | -- Your canister exposes a Candid query method that returns certified data (not via `http_request`) -- You want to verify certification in the browser independently, without relying on the boundary node -- You are building a custom HTTP client that does not use a standard HTTP gateway +The gateway does not verify the Candid calls an app makes through an agent, even when the app itself was served from a verifying host. A canister that returns certified data over Candid (the pattern in [Certified variables](../backends/certified-variables.md)) returns `(data, certificate, witness)`, and the client verifies them. ### Verifying a certified response -Use `@dfinity/certificate-verification` from the `response-verification` repository: +Use `@dfinity/certificate-verification` from the `response-verification` repository. Version 4 peer-depends on `@icp-sdk/core` ^6 and takes `Uint8Array` inputs: ```bash -npm install @dfinity/certificate-verification +npm install @dfinity/certificate-verification @icp-sdk/core ``` -The `verifyCertification` function performs the full six-step verification: - -1. Verify the certificate BLS signature against the IC root public key -2. Check certificate freshness: `/time` must be within `maxCertificateTimeOffsetMs` of the current time -3. CBOR-decode the witness into a hash tree -4. Reconstruct the witness root hash -5. Compare with `certified_data` in the certificate -6. Return the verified tree for value lookup +`verifyCertification` verifies the certificate's BLS signature against the root key, checks that the certificate's `/time` is within `maxCertificateTimeOffsetMs`, decodes the witness, and checks that the witness root hash equals the canister's `certified_data` in the certificate. It returns the witness tree for the lookup: ```typescript import { verifyCertification } from "@dfinity/certificate-verification"; -import { lookup_path, lookupResultToBuffer } from "@icp-sdk/core/agent"; +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 -async function getVerifiedValue( - rootKey: ArrayBuffer, +export async function getVerifiedValue( + rootKey: Uint8Array, canisterId: string, key: string, - response: { - value: string | null; - certificate: ArrayBuffer; - witness: ArrayBuffer; - } + response: { value: string | null; certificate: Uint8Array; witness: Uint8Array }, ): Promise { - // Steps 1–5: verifies BLS signature, time, and witness match. - // Throws CertificateTimeError or CertificateVerificationError on failure. + // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), encodedCertificate: response.certificate, @@ -256,50 +251,35 @@ async function getVerifiedValue( maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS, }); - // Step 6: look up the key in the verified witness tree. - const leafData = lookupResultToBuffer( - lookup_path([new TextEncoder().encode(key)], tree) - ); - - if (leafData === undefined) { - // Key is provably absent from the certified tree. - return null; + // Step 6: 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 (response.value !== verified) throw new Error("value does not match witness"); + return verified; + } + case LookupPathStatus.Absent: + if (response.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})`); } - - const verifiedValue = new TextDecoder().decode(leafData); - - // Confirm the canister-returned value matches what the witness proves. - if (response.value !== null && response.value !== verifiedValue) { - throw new Error( - "Response value does not match witness: canister returned tampered data" - ); - } - - return verifiedValue; } ``` -Obtain the root key from the agent: +`lookup_path` returns a status, and only `Absent` proves that a key does not exist. `Unknown` means the witness does not cover the key: treat it as a failure, never as "not found", or a replica can hide a real value behind a witness for another key. Candid `blob` fields arrive as `Uint8Array` in `@icp-sdk/bindgen` bindings, so the response can be passed as is. -```typescript -import { HttpAgent } from "@icp-sdk/core/agent"; - -const IS_LOCAL = process.env.NODE_ENV !== "production"; +Pass the root key of the network the canister runs on: -const agent = await HttpAgent.create({ - host: IS_LOCAL ? "http://localhost:8000" : "https://icp-api.io", - // Only fetch root key on local networks. - // On mainnet, the root key is hardcoded in the JS SDK. - // Fetching it on mainnet is a security risk: never do this in production. - shouldFetchRootKey: IS_LOCAL, -}); - -// Use agent.rootKey in verifyCertification calls -``` +- **Browser:** `safeGetCanisterEnv()?.IC_ROOT_KEY` from the `ic_env` cookie (`@icp-sdk/core/agent/canister-env`), which the frontend canister sets on local networks and mainnet alike. It is the key of the network serving the page. +- **Node scripts and tests:** the `root_key` field of `icp network status --json`, hex-decoded to bytes. +- **Mainnet:** the agent's built-in default, `agent.rootKey` on an agent created without a `rootKey` option. -> **Never call `fetchRootKey()` or set `shouldFetchRootKey: true` against mainnet.** These options let the agent fetch the root key from the replica over an unauthenticated connection: a man-in-the-middle could supply a fake root key and make forged certificates appear valid. On mainnet, the root key is hardcoded in the JS SDK. +> **Never call `fetchRootKey()` or set `shouldFetchRootKey: true` in shipped code.** They make the agent fetch the root key from the replica over an unauthenticated connection: a man-in-the-middle could supply a fake root key and make forged certificates appear valid. -For the full working example including a backend canister, see the [certified-counter example](https://github.com/dfinity/response-verification/tree/main/examples/certification/certified-counter). +For a runnable example of the verification steps in a browser (a single certified value, verified with `@icp-sdk/core` directly), see [`motoko/cert-var`](https://github.com/dfinity/examples/tree/master/motoko/cert-var). ## Common mistakes @@ -313,9 +293,9 @@ For the full working example including a backend canister, see the [certified-co **Skipping certificate freshness checks.** The certificate's `/time` field contains the subnet timestamp. Without checking that this timestamp is recent, an attacker could replay a stale certificate. Always set `maxCertificateTimeOffsetMs` to a reasonable value (5 minutes is recommended). -**Forgetting to re-certify after canister upgrade.** Certified data is cleared on upgrade. Custom canisters must call `certified_data_set` with the current tree root hash in both `#[init]` and `#[post_upgrade]` (Rust) or `system func postupgrade` (Motoko). +**Losing the certification tree on upgrade.** Certified data itself survives upgrades, but a certification tree kept on the heap (an `HttpCertificationTree` or `AssetRouter`) does not, while the old root hash stays set. Rebuild the tree in `#[post_upgrade]` and call `certified_data_set` again, as in the example above. -**Certifying responses in the canister but not updating the hash.** If you modify assets or data but forget to call `certified_data_set` with the new root hash, query responses will fail boundary node verification. +**Certifying responses in the canister but not updating the hash.** If you modify assets or data but forget to call `certified_data_set` with the new root hash, query responses will fail the HTTP gateway's verification. ## Next steps @@ -323,6 +303,6 @@ For the full working example including a backend canister, see the [certified-co - [Asset canister (legacy)](asset-canister.md): certification on the older recipe, and how to migrate - [Certified variables](../backends/certified-variables.md): certify Candid query responses from backend canisters - [Security concepts](../../concepts/security.md): why query integrity matters -- [HTTP Gateway specification](../../references/http-gateway-protocol-spec.md): how boundary nodes verify responses +- [HTTP Gateway specification](../../references/http-gateway-protocol-spec.md): how HTTP gateways verify responses From ef5395d35f9faad001c6cad18856886dc1ac962e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 14:17:18 +0200 Subject: [PATCH 2/7] docs: certify the initial value in the single-value Motoko example --- docs/guides/backends/certified-variables.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index 24666b1c..03997ad6 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -168,7 +168,7 @@ fn set_many(entries: Vec<(String, String)>) { ### Simple single-value certification -For a single certified value, hash it to 32 bytes and pass the hash to `CertifiedData.set`: +For a single certified value, hash it to 32 bytes and pass the hash to `CertifiedData.set`. Certify the initial value at install too: certified data starts empty, so without it a query fails verification until the first write. ```motoko import CertifiedData "mo:core/CertifiedData"; @@ -178,16 +178,24 @@ import Sha256 "mo:sha2/Sha256"; persistent actor { + // Simple certified single-value example: var certifiedValue : Text = ""; - // Update the certified value (update call only). + // Certify the hash of the current value (max 32 bytes; update calls and init only) + 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; - let hash = Sha256.fromBlob(#sha256, Text.encodeUtf8(value)); - CertifiedData.set(hash); + certify(); }; - // Return the value with its certificate (query call). + // Get the certified value with its certificate (query call) public query func getCertifiedValue() : async { value : Text; certificate : ?Blob; From a527dd457297c5a463f1a28368d70b058bf10c4f Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 15:53:59 +0200 Subject: [PATCH 3/7] docs: address review feedback on the certification guides Scope the gateway guarantee to verifying hostnames, describe what each certification header carries, name every context certified_data_set allows, and state that the Rust example keeps its tree on the heap only. --- docs/guides/backends/certified-variables.md | 9 +++++---- docs/guides/frontends/certification.md | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index 03997ad6..c693f0eb 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -40,7 +40,7 @@ CLIENT: ## Key constraints - `certified_data_set` accepts **at most 32 bytes**. You cannot certify arbitrary data directly. Build a Merkle tree over your data and certify only the 32-byte root hash. The tree provides proofs for individual values. -- `certified_data_set` **must be called in update calls only**. Calling it in a query call traps. +- `certified_data_set` works in every replicated context (`init`, `post_upgrade`, update calls, reply and reject callbacks, timers, heartbeat) and **traps in a query call**. - `data_certificate()` returns `None` in update calls, including a query method invoked as an update call. `icp canister call` sends an update call unless you pass `--query`, so always test certified getters with `icp canister call --query`. - Certified data survives upgrades (install and reinstall start it empty). A Merkle tree kept on the heap does not: in Rust, rebuild the tree in `#[post_upgrade]` and call `certified_data_set` again. A Motoko `CertTree.Store` persists with the actor, so nothing needs re-setting. @@ -86,8 +86,9 @@ fn init() { #[post_upgrade] fn post_upgrade() { - // The heap TREE is empty after an upgrade, while the old certified hash is kept. - // Rebuild TREE from stable storage here, then re-set the hash to match it. + // 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(); } @@ -181,7 +182,7 @@ persistent actor { // Simple certified single-value example: var certifiedValue : Text = ""; - // Certify the hash of the current value (max 32 bytes; update calls and init only) + // 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))); }; diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index 57f3fe10..e6470b1f 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -5,7 +5,7 @@ sidebar: order: 4 --- -Query responses on ICP are answered by a single replica without going through consensus. A malicious or faulty replica could return fabricated data. **Response certification** solves this: canisters commit a cryptographic hash to the subnet's certified state, and query responses include a certificate signed by the subnet's threshold BLS key. [HTTP gateways](../../concepts/edge-infrastructure.md#http-gateways) verify every HTTP response automatically, so users are protected without any extra client-side code: as long as the canister certifies its responses. The gateway does not verify Candid calls an app makes through an agent; see [Client-side certificate verification](#client-side-certificate-verification). +Query responses on ICP are answered by a single replica without going through consensus. A malicious or faulty replica could return fabricated data. **Response certification** solves this: canisters commit a cryptographic hash to the subnet's certified state, and query responses include a certificate signed by the subnet's threshold BLS key. On a verifying hostname, [HTTP gateways](../../concepts/edge-infrastructure.md#http-gateways) verify every HTTP response automatically, so users are protected without any extra client-side code: as long as the canister certifies its responses. The gateway does not verify Candid calls an app makes through an agent; see [Client-side certificate verification](#client-side-certificate-verification). This guide explains how certification works at the HTTP layer, what each frontend recipe does automatically, when you need custom certification, and how to verify certificates client-side. @@ -15,7 +15,7 @@ Both frontend recipes implement **HTTP certification v2**, a protocol on top of 1. **Certification setup (update call)**: when an asset is uploaded, the canister inserts its path, response headers, and body hash into a Merkle tree and commits the tree's root hash via `certified_data_set`. The subnet includes this root hash in its certified state each consensus round. -2. **HTTP query call**: when a browser requests an asset, the canister retrieves the subnet BLS certificate via `data_certificate()`, generates a Merkle proof (witness) for the requested path, and returns the response with `IC-Certificate` and `IC-CertificateExpression` headers containing the certificate and witness. +2. **HTTP query call**: when a browser requests an asset, the canister retrieves the subnet BLS certificate via `data_certificate()`, generates a Merkle proof (witness) for the requested path, and returns the response with an `IC-Certificate` header, which carries the certificate and the witness, and an `IC-CertificateExpression` header, which carries the CEL expression describing what was certified. 3. **Gateway verification**: the HTTP gateway verifies the BLS signature on the certificate, extracts the certified root hash, and confirms the witness proves the response body and headers are included under that root hash. If verification fails, the gateway returns an error. @@ -47,7 +47,7 @@ Through the standard ICP gateway, a canister that serves HTTP is reachable on tw | Domain | Certification | Notes | |--------|--------------|-------| | `.icp.net` | Verified | The gateway checks the proof on every response | -| `.raw.icp.net` | None | The canister still attaches the certificate; the gateway discards it | +| `.raw.icp.net` | None | The canister still attaches the certificate; the gateway forwards it without checking it | What you can do about the raw host depends on which canister you deployed. From fd17959df0149a05455fa0a632dd7b7359fc0c4d Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 18:25:23 +0200 Subject: [PATCH 4/7] docs: list the exact certified_data_set contexts and scope the cookie root key certified_data_set is allowed in init, upgrade hooks, updates, reply and reject callbacks and system tasks, not in cleanup callbacks or any query. The ic_env root key is only trustworthy on a verifying hostname. --- docs/guides/backends/certified-variables.md | 2 +- docs/guides/frontends/certification.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index c693f0eb..1994b41a 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -40,7 +40,7 @@ CLIENT: ## Key constraints - `certified_data_set` accepts **at most 32 bytes**. You cannot certify arbitrary data directly. Build a Merkle tree over your data and certify only the 32-byte root hash. The tree provides proofs for individual values. -- `certified_data_set` works in every replicated context (`init`, `post_upgrade`, update calls, reply and reject callbacks, timers, heartbeat) and **traps in a query call**. +- `certified_data_set` can be called from `canister_init`, `canister_post_upgrade`, `canister_pre_upgrade`, update methods, reply and reject callbacks, and system tasks (`canister_heartbeat`, `canister_global_timer`, `canister_on_low_wasm_memory`). It **traps anywhere else**, including query methods (whether called as a query or as an update), composite queries and cleanup callbacks. - `data_certificate()` returns `None` in update calls, including a query method invoked as an update call. `icp canister call` sends an update call unless you pass `--query`, so always test certified getters with `icp canister call --query`. - Certified data survives upgrades (install and reinstall start it empty). A Merkle tree kept on the heap does not: in Rust, rebuild the tree in `#[post_upgrade]` and call `certified_data_set` again. A Motoko `CertTree.Store` persists with the actor, so nothing needs re-setting. diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index e6470b1f..3302c975 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -273,7 +273,7 @@ export async function getVerifiedValue( Pass the root key of the network the canister runs on: -- **Browser:** `safeGetCanisterEnv()?.IC_ROOT_KEY` from the `ic_env` cookie (`@icp-sdk/core/agent/canister-env`), which the frontend canister sets on local networks and mainnet alike. It is the key of the network serving the page. +- **Browser:** `safeGetCanisterEnv()?.IC_ROOT_KEY` from the `ic_env` cookie (`@icp-sdk/core/agent/canister-env`), which the frontend canister sets on local networks and mainnet alike. It is the key of the network serving the page, and only as trustworthy as the page: on a verifying hostname the gateway verifies the cookie along with the page, but a page loaded from a `raw` hostname can carry a forged key. A client that verifies responses fetched from a `raw` hostname needs a root key obtained independently, such as the mainnet key built into `@icp-sdk/core`. - **Node scripts and tests:** the `root_key` field of `icp network status --json`, hex-decoded to bytes. - **Mainnet:** the agent's built-in default, `agent.rootKey` on an agent created without a `rootKey` option. From 281c663062528aa15adebb2b48e093245e3ad995 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 18:49:33 +0200 Subject: [PATCH 5/7] docs: accept the nullable certificate the Motoko getters return --- docs/guides/backends/certified-variables.md | 8 ++++++-- docs/guides/frontends/certification.md | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index 1994b41a..c0d869f4 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -279,8 +279,10 @@ export async function getVerifiedValue( rootKey: Uint8Array, canisterId: string, key: string, - response: { value: string | null; certificate: Uint8Array; witness: Uint8Array }, + // certificate is a blob (Rust) or ?blob (Motoko); null means the getter did not run as a query call + response: { value: string | null; certificate: Uint8Array | null; witness: Uint8Array }, ): Promise { + if (!response.certificate) throw new Error("no certificate: call the getter as a query"); // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), @@ -321,8 +323,10 @@ import { Principal } from "@icp-sdk/core/principal"; export async function verifySingleValue( rootKey: Uint8Array, canisterId: string, - response: { value: string; certificate: Uint8Array }, + // certificate is ?blob in the Motoko getter; null means it did not run as a query call + response: { value: string; certificate: Uint8Array | null }, ): Promise { + 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, diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index 3302c975..c61e8e47 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -240,8 +240,10 @@ export async function getVerifiedValue( rootKey: Uint8Array, canisterId: string, key: string, - response: { value: string | null; certificate: Uint8Array; witness: Uint8Array }, + // certificate is a blob (Rust) or ?blob (Motoko); null means the getter did not run as a query call + response: { value: string | null; certificate: Uint8Array | null; witness: Uint8Array }, ): Promise { + if (!response.certificate) throw new Error("no certificate: call the getter as a query"); // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), From 88932855a43796ccd7230c5aad11fc355a8837e7 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 25 Sep 2026 19:07:32 +0200 Subject: [PATCH 6/7] docs: accept the Motoko CertTree getter's blob value in the witness helper --- docs/guides/backends/certified-variables.md | 14 ++++++++++---- docs/guides/frontends/certification.md | 16 +++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index c0d869f4..9ce707da 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -279,10 +279,16 @@ export async function getVerifiedValue( rootKey: Uint8Array, canisterId: string, key: string, - // certificate is a blob (Rust) or ?blob (Motoko); null means the getter did not run as a query call - response: { value: string | null; certificate: Uint8Array | null; witness: Uint8Array }, + // 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 { 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; // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), @@ -297,11 +303,11 @@ export async function getVerifiedValue( switch (result.status) { case LookupPathStatus.Found: { const verified = new TextDecoder().decode(result.value); - if (response.value !== verified) throw new Error("value does not match witness"); + if (value !== verified) throw new Error("value does not match witness"); return verified; } case LookupPathStatus.Absent: - if (response.value !== null) throw new Error("witness proves the key is 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 diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index c61e8e47..c5d92175 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -240,10 +240,16 @@ export async function getVerifiedValue( rootKey: Uint8Array, canisterId: string, key: string, - // certificate is a blob (Rust) or ?blob (Motoko); null means the getter did not run as a query call - response: { value: string | null; certificate: Uint8Array | null; witness: Uint8Array }, + // 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 { 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; // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. const tree = await verifyCertification({ canisterId: Principal.fromText(canisterId), @@ -258,11 +264,11 @@ export async function getVerifiedValue( switch (result.status) { case LookupPathStatus.Found: { const verified = new TextDecoder().decode(result.value); - if (response.value !== verified) throw new Error("value does not match witness"); + if (value !== verified) throw new Error("value does not match witness"); return verified; } case LookupPathStatus.Absent: - if (response.value !== null) throw new Error("witness proves the key is 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 @@ -271,7 +277,7 @@ export async function getVerifiedValue( } ``` -`lookup_path` returns a status, and only `Absent` proves that a key does not exist. `Unknown` means the witness does not cover the key: treat it as a failure, never as "not found", or a replica can hide a real value behind a witness for another key. Candid `blob` fields arrive as `Uint8Array` in `@icp-sdk/bindgen` bindings, so the response can be passed as is. +`lookup_path` returns a status, and only `Absent` proves that a key does not exist. `Unknown` means the witness does not cover the key: treat it as a failure, never as "not found", or a replica can hide a real value behind a witness for another key. Candid `blob` fields arrive as `Uint8Array` in `@icp-sdk/bindgen` bindings, so a getter's response can be passed as returned: the helper accepts the Rust example's `opt text` value and the Motoko `CertTree` example's `?Blob` value (decoded as UTF-8), and a `?Blob` certificate. Pass the root key of the network the canister runs on: From 5c780ff3c8bbca999cb29a496b81abf44faf79fc Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 28 Sep 2026 10:28:18 +0200 Subject: [PATCH 7/7] docs(certification): accept bindgen's undefined for empty opt fields @icp-sdk/bindgen maps opt record fields to optional properties, so an absent key's value arrives as undefined and the helpers rejected a valid proof of absence. Also drop step numbers the frontend page never defines, say the HTTP gateway (not the boundary node) verifies HTTP responses, and note that agent.rootKey is nullable. --- docs/guides/backends/certified-variables.md | 18 ++++++++++-------- docs/guides/frontends/certification.md | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/guides/backends/certified-variables.md b/docs/guides/backends/certified-variables.md index 9ce707da..08321615 100644 --- a/docs/guides/backends/certified-variables.md +++ b/docs/guides/backends/certified-variables.md @@ -281,15 +281,17 @@ export async function getVerifiedValue( 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; + value?: string | Uint8Array | null; + certificate?: Uint8Array | null; witness: Uint8Array; }, ): Promise { 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; - // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. + 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, @@ -298,7 +300,7 @@ export async function getVerifiedValue( maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS, }); - // Step 6: the path must match how the canister inserted the key (here: UTF-8 bytes). + // 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: { @@ -329,8 +331,8 @@ import { Principal } from "@icp-sdk/core/principal"; export async function verifySingleValue( rootKey: Uint8Array, canisterId: string, - // certificate is ?blob in the Motoko getter; null means it did not run as a query call - response: { value: string; certificate: Uint8Array | null }, + // certificate is ?blob in the Motoko getter; empty means it did not run as a query call + response: { value: string; certificate?: Uint8Array | null }, ): Promise { if (!response.certificate) throw new Error("no certificate: call the getter as a query"); const principal = Principal.fromText(canisterId); @@ -399,7 +401,7 @@ icp canister call --query backend get '("key")' ## HTTP asset certification -For canisters that serve HTTP responses directly through the HTTP Gateway, responses must be certified so the boundary node can verify them. This is a separate protocol built on top of certified data, handled by the `ic-http-certification` crate. For frontend assets (HTML, CSS, JS), [host a static site](../frontends/static-site/overview.md) instead, which handles HTTP certification automatically. +For canisters that serve HTTP responses directly through the HTTP Gateway, responses must be certified so the HTTP gateway can verify them. This is a separate protocol built on top of certified data, handled by the `ic-http-certification` crate. For frontend assets (HTML, CSS, JS), [host a static site](../frontends/static-site/overview.md) instead, which handles HTTP certification automatically. See [Frontend certification](../../guides/frontends/certification.md) for how the frontend canisters certify responses, and what a custom HTTP canister has to do itself. diff --git a/docs/guides/frontends/certification.md b/docs/guides/frontends/certification.md index c5d92175..a8ce23a8 100644 --- a/docs/guides/frontends/certification.md +++ b/docs/guides/frontends/certification.md @@ -242,15 +242,17 @@ export async function getVerifiedValue( 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; + value?: string | Uint8Array | null; + certificate?: Uint8Array | null; witness: Uint8Array; }, ): Promise { 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; - // Steps 1-5; throws CertificateTimeError or CertificateVerificationError on failure. + 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, @@ -259,7 +261,7 @@ export async function getVerifiedValue( maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS, }); - // Step 6: the path must match how the canister inserted the key (here: UTF-8 bytes). + // 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: { @@ -277,13 +279,13 @@ export async function getVerifiedValue( } ``` -`lookup_path` returns a status, and only `Absent` proves that a key does not exist. `Unknown` means the witness does not cover the key: treat it as a failure, never as "not found", or a replica can hide a real value behind a witness for another key. Candid `blob` fields arrive as `Uint8Array` in `@icp-sdk/bindgen` bindings, so a getter's response can be passed as returned: the helper accepts the Rust example's `opt text` value and the Motoko `CertTree` example's `?Blob` value (decoded as UTF-8), and a `?Blob` certificate. +`lookup_path` returns a status, and only `Absent` proves that a key does not exist. `Unknown` means the witness does not cover the key: treat it as a failure, never as "not found", or a replica can hide a real value behind a witness for another key. Candid `blob` fields arrive as `Uint8Array` in `@icp-sdk/bindgen` bindings, and `opt` record fields arrive as optional properties that are `undefined` when empty, so a getter's response can be passed as returned: the helper accepts the Rust example's `opt text` value and the Motoko `CertTree` example's `?Blob` value (decoded as UTF-8), and a `?Blob` certificate. Pass the root key of the network the canister runs on: - **Browser:** `safeGetCanisterEnv()?.IC_ROOT_KEY` from the `ic_env` cookie (`@icp-sdk/core/agent/canister-env`), which the frontend canister sets on local networks and mainnet alike. It is the key of the network serving the page, and only as trustworthy as the page: on a verifying hostname the gateway verifies the cookie along with the page, but a page loaded from a `raw` hostname can carry a forged key. A client that verifies responses fetched from a `raw` hostname needs a root key obtained independently, such as the mainnet key built into `@icp-sdk/core`. - **Node scripts and tests:** the `root_key` field of `icp network status --json`, hex-decoded to bytes. -- **Mainnet:** the agent's built-in default, `agent.rootKey` on an agent created without a `rootKey` option. +- **Mainnet:** the agent's built-in default, `agent.rootKey` on an agent created without a `rootKey` option (typed `Uint8Array | null`, so check it before passing it on). > **Never call `fetchRootKey()` or set `shouldFetchRootKey: true` in shipped code.** They make the agent fetch the root key from the replica over an unauthenticated connection: a man-in-the-middle could supply a fake root key and make forged certificates appear valid. @@ -313,4 +315,4 @@ For a runnable example of the verification steps in a browser (a single certifie - [Security concepts](../../concepts/security.md): why query integrity matters - [HTTP Gateway specification](../../references/http-gateway-protocol-spec.md): how HTTP gateways verify responses - +