moxzi
Docs / Native kernels / Security for kernel authors

Security for kernel authorsalpha

What a kernel can do to the canisters that link it, and how to write one that doesn't leak secrets.

A kernel runs inside every canister that links it, with full access to that canister's memory. This page covers what that means for you as an author, how to check that code handling secrets runs in constant time, and how to make your kernel checkable by the people who link it. It assumes you've read Writing a kernel.

What your kernel can do#

Before a user links your kernel for the first time, moxzi shows them this:

A native kernel is compiled code that runs inside your canister and shares its memory. It can read and write anything in the canister's heap -- every user's data, keys, and the state that survives upgrades -- and return what it read. The linker keeps the system API away from it (it cannot send messages, spend cycles or set timers) and the glue checks what it returns, but nothing checks what it does to memory. The sha256 pin guarantees which bytes run, not that they are correct or benign. Accept a kernel only if you trust its author or have reviewed its source at the commit it names, and take a canister snapshot before deploying a new one.

Your users can only decide whether to trust your kernel from what you publish. Keep each kernel small and single-purpose, publish its source with the commit it was built from, and make it build reproducibly (see Make it checkable).

Use stock crates#

Wrap a published, widely used crate rather than writing the algorithm yourself:

Constant time#

Code that handles a secret, such as a private key, a seed, an HMAC key or a password, must take the same time whatever the secret's value. Otherwise an observer who can measure the time learns about the secret.

On the Internet Computer, a message's cost is its instruction count, and others can observe things that depend on it. Constant time there means the same number of instructions for every value of every secret.

The same kernel also runs off-chain: under moxzid, an actor platform or a browser, the wasm is compiled to machine code. There, two paths with the same instruction count can still take different times, through the branch predictor and the cache. So also require that no branch, no select and no memory address in the compiled wasm depends on a secret.

Rust source that looks constant-time isn't enough. The compiler can turn masked arithmetic back into branches, and library code written as if flag { a } else { b } compiles to one. Check the compiled kernel, and check it again after every rebuild that changes a dependency or the toolchain.

The example below has two functions that compare a secret with a guess. equal_fast uses ==; equal_ct uses the subtle crate:

compare/rust/src/lib.rs
#![no_std]
use moxzi_kernel::bytes;
use subtle::ConstantTimeEq;

/// `(Blob, Blob) -> Bool`: do `a` and `b` hold the same bytes? Stops at the first difference.
#[no_mangle]
pub unsafe extern "C" fn equal_fast(a: *const u8, a_len: usize, b: *const u8, b_len: usize) -> u32 {
    (bytes(a, a_len) == bytes(b, b_len)) as u32
}

/// The same answer, in a time that depends only on the lengths.
#[no_mangle]
pub unsafe extern "C" fn equal_ct(a: *const u8, a_len: usize, b: *const u8, b_len: usize) -> u32 {
    bytes(a, a_len).ct_eq(bytes(b, b_len)).unwrap_u8() as u32
}

Measure instruction counts#

Call each secret-handling function with secrets that differ, and require identical counts. This application compares a 32-byte secret with guesses that first differ at different bytes, and returns the instructions each comparison took. It's built with the kernel opted in, as in the tutorial:

app/main.mo
import Array "mo:core/Array";
import Blob "mo:core/Blob";
import IC "mo:core/InternetComputer";
import Nat8 "mo:core/Nat8";
import Compare "mo:compare/Compare";

actor {
  // Compare a 32-byte secret with a guess that differs from it only at byte `at`, and return
  // the instructions each function took.
  public func cost(at : Nat) : async { fast : Nat64; ct : Nat64 } {
    let secret = Array.tabulate<Nat8>(32, func i = Nat8.fromNat(i));
    let guess = Blob.fromArray(Array.tabulate<Nat8>(32, func i = if (i == at) 255 else Nat8.fromNat(i)));
    let s = Blob.fromArray(secret);
    var same = true;
    let fast = IC.countInstructions(func() { same := Compare.equalFast(s, guess) });
    let ct = IC.countInstructions(func() { same := Compare.equalCt(s, guess) });
    { fast; ct }
  };
};
npm install @dfinity/pic@0.23.0
node timing.mjs
first differing byte   equal_fast   equal_ct
                   0          117       1485
                   1          145       1485
                  15          537       1485
                  31          985       1485

equal_fast costs more the longer the guess matches, so an attacker who can measure it learns the secret byte by byte. equal_ct costs the same every time. It's also slower: constant-time code does the worst case's work on every call.

Choose the secrets you measure with care: all zeros, all ones, the smallest and largest valid values, and random ones. For a signature, also vary the message and nonce.

Check the compiled wasm#

To read the kernel's code, decode it from the kernel file and print it with wasm-tools:

sed -n '/^wasm = """/,/^"""/p' compare/kernel/compare.kernel.toml | sed '1d;$d' | base64 -d > compare.wasm
wasm-tools print compare.wasm > compare.wat

List every function, and every instruction that chooses between paths or values:

grep -n -E '^  \(func |br_if|br_table|select|^ +if' compare.wat
15:  (func $__wasm_call_ctors (;0;) (type 0))
16:  (func $subtle::black_box::<u8> (;1;) (type 1) (param i32) (result i32)
27:  (func $equal_ct (;2;) (type 2) (param i64 i64 i64 i64) (result i32)
36:      br_if 0 (;@1;)
40:      br_if 0 (;@1;)
45:      select
73:        br_if 0 (;@2;)
81:  (func $equal_fast (;3;) (type 2) (param i64 i64 i64 i64) (result i32)
89:      br_if 0 (;@1;)
95:      select
99:      select
107:  (func $memcmp (;4;) (type 3) (param i64 i64 i64) (result i32)
114:      br_if 0 (;@1;)
124:          br_if 1 (;@2;)
138:          br_if 2 (;@1;)

For each br_if, br_table, if and select in a function that sees a secret, find where its condition comes from:

Condition comes fromVerdict
A constant, a loop counter, or a length the caller already knowsFine
A byte or word loaded from secret data, or computed from oneA leak
The result of a callCheck the called function the same way

equal_ct's branches test the lengths and the loop counter, and its select tests whether b is empty. All of these are public, and so are the two selects in equal_fast. But equal_fast also calls memcmp, which the compiler supplied for ==:

sed -n '/^  (func \$memcmp/,/^  )/p' compare.wat
(func $memcmp (;4;) (type 3) (param i64 i64 i64) (result i32)
  (local i32 i32 i32)
  i32.const 0
  local.set 3
  block ;; label = @1
    local.get 2
    i64.eqz
    br_if 0 (;@1;)
    block ;; label = @2
      loop ;; label = @3
        local.get 0
        i32.load8_u
        local.tee 4
        local.get 1
        i32.load8_u
        local.tee 5
        i32.ne
        br_if 1 (;@2;)
        local.get 0
        i64.const 1
        i64.add
        local.set 0
        local.get 1
        i64.const 1
        i64.add
        local.set 1
        local.get 2
        i64.const -1
        i64.add
        local.tee 2
        i64.eqz
        br_if 2 (;@1;)
        br 0 (;@3;)
      end
    end
    local.get 4
    local.get 5
    i32.sub
    local.set 3
  end
  local.get 3
)

Inside the loop, br_if 1 leaves as soon as two loaded bytes differ (i32.ne on two i32.load8_u values). That's the leak the instruction counts showed. The other two br_ifs test the length, which is public.

Also look at loads and stores whose address is computed from a secret, such as a table indexed by a secret byte: on machine code they leak through the cache. Constant-time code reads every table entry and selects one with masks.

Check every function your secret-handling exports call, not only the exports. The names in the listing come from the kernel's name section. They show which crate each function belongs to, and they change when the compiler inlines differently.

Keep secrets out of memory#

A kernel can't erase secrets reliably:

You can clear your own buffers before returning, for example with the zeroize crate, but don't promise your users that a secret is erased. On the Internet Computer, node providers can read canister memory in any case.

Test against vectors and the Motoko body#

Make it checkable#

Checklist#

CheckHow
The kernel wraps a stock, pinned crateCargo.toml with exact versions, Cargo.lock committed, cargo audit clean
Secret-handling functions cost the same for every secretInstruction counts for varied secrets are identical
No branch, select or address depends on a secretwasm-tools print of the built kernel, each site traced
No secret in static data or in a staticRead the source
Results match the Motoko body and the test vectorsA differential test through the Motoko function
Anyone can rebuild itmoxzi kernel verify from a fresh clone at the declared commit
On this pageWhat your kernel can doUse stock cratesConstant timeMeasure instruction countsCheck the compiled wasmKeep secrets out of memoryTest against vectors and the Motoko bodyMake it checkableChecklist