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:
- Prefer crates with a security track record and an audit, such as the RustCrypto, dalek, zkcrypto and libcrux families. Use them unmodified.
- Pin exact versions in
Cargo.toml(=1.2.3), commitCargo.lock, and review its diff when you update. Check the lock against the RustSec advisory database withcargo audit. - Don't write your own big-number, field or curve arithmetic. That code is where constant-time and correctness bugs live, and it needs expert review that a stock crate has already had. If you must, say so in your package's README, as the published kernels do.
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:
#![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:
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 from | Verdict |
|---|---|
| A constant, a loop counter, or a length the caller already knows | Fine |
| A byte or word loaded from secret data, or computed from one | A leak |
| The result of a call | Check 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:
- Static data is public. The kernel's bytes ship in your package. Never compile a key or seed into a kernel.
- Statics persist. A
static mutkeeps its value between calls, until the next upgrade. Don't keep secrets, or anything else, in one. - Scratch memory isn't erased. Memory from the allocator is reclaimed by the garbage collector after the call, not cleared. Because the allocator never frees during a call, a
Vecthat grows leaves a copy of its old contents behind each time. Reserve capacity up front for buffers that hold secrets. - Copies exist outside the kernel. The secret arrived as a Motoko value and stays in the Motoko heap, and the result is copied into a new value.
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#
- Run every exported function against the published test vectors for its algorithm: Wycheproof for signatures, encryption and key agreement, NIST's ACVP and CAVP vectors, and the vectors in the RFC that defines it. Include the vectors that must be rejected.
- Compare the kernel with its Motoko body on random and edge-case inputs, through the Motoko function, as the tutorial does. The same input must give the same result, and an input that traps in one must trap in the other.
- Test hostile inputs: empty, too short, too long, all
0xff, values one past the valid range. A kernel must trap or return a defined result, never write past its output.
Make it checkable#
- Commit
Cargo.lock, and build only withmoxzi kernel build, so anyone gets the same bytes. - Declare
sourceandcommitin[kernel], naming a commit rather than a branch, so users can runmoxzi kernel verifyand see what they accept. - Keep any patched dependency inside the crate as a path dependency, where reviewers see it. moxzi refuses path dependencies outside the crate, and ignores
RUSTFLAGS; select a dependency's configuration with[package.metadata.moxzi-kernel] cfginCargo.toml. - When a release changes the kernel, say so in your changelog. Every user's pin changes, and they'll review the new version before their next build.
Checklist#
| Check | How |
|---|---|
| The kernel wraps a stock, pinned crate | Cargo.toml with exact versions, Cargo.lock committed, cargo audit clean |
| Secret-handling functions cost the same for every secret | Instruction counts for varied secrets are identical |
No branch, select or address depends on a secret | wasm-tools print of the built kernel, each site traced |
No secret in static data or in a static | Read the source |
| Results match the Motoko body and the test vectors | A differential test through the Motoko function |
| Anyone can rebuild it | moxzi kernel verify from a fresh clone at the declared commit |