Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TEE SDK Design Document

Overview

A Rust macro-based SDK for building OP-TEE Trusted Applications (TAs) with ergonomic, type-safe APIs on both the TA (secure world) and CA (normal world) sides. Built on top of Apache Teaclave TrustZone SDK (optee_utee / optee_teec).

The core idea: the developer writes normal Rust functions, and the SDK generates all OP-TEE plumbing — parameter packing/unpacking, command dispatch, and the CA-side calling stubs.


Crate Structure

consortium-tee/                  # error types, TeeResult alias
consortium-tee-macros/           # proc-macro wrapper crate: #[tee_command], tee_service!
consortium-tee-macros-impl/      # proc-macro2 implementation crate for testable expansion logic

The proc-macro wrapper delegates to the implementation crate. The implementation crate depends on syn, quote, proc-macro2, and heck, and emits code that references optee_utee (TA side) and consortium_tee (for TeeResult on the CA side), but does not import those runtime crates itself.


Project Layout (User Code)

my-ta/               # Trusted Application
├── Cargo.toml       # [lib] (rlib) + [[bin]] (cdylib); features: ["ca"]
├── build.rs         # optee_utee_build: UUID, TA properties, user_ta_header
└── src/
    ├── lib.rs       # tee_service! + #[tee_command] definitions — CA-facing API
    └── main.rs      # #![no_main], use my_ta::*, include!(OUT_DIR/user_ta_header.rs)

my-ca/               # Client Application
├── Cargo.toml       # depends on my-ta (rlib) with features = ["ca"]
└── src/
    └── main.rs      # uses generated call_function_a(), call_function_b(), etc.

Cargo.toml for the TA crate

[features]
ca = []    # enabled by the CA when depending on this crate
           # must NOT be enabled in the TA binary build

[lib]
crate-type = ["rlib"]     # linked by CA

[[bin]]
name = "ta"
crate-type = ["cdylib"]   # loaded by TEE OS

The CA depends only on the rlib target, which compiles for the host architecture using optee_teec. The cdylib binary compiles for the secure world using optee_utee.

The ca feature flag gates all CA-side generated code. The TA binary build never enables it, so call_* stubs are not compiled into the secure-world binary.

# my-ca/Cargo.toml
[dependencies]
my-ta = { path = "../my-ta", features = ["ca"] }

Developer Experience

What the developer writes

#![allow(unused)]
fn main() {
// my-ta/src/lib.rs

use consortium_tee_macros::{tee_command, tee_service};

tee_service! {
    context TaContext
    commands { function_a, function_b }
}

#[tee_command(ctx)]
fn function_a(ctx: &mut TaContext, count: u32, data: &[u8]) -> Result<()> {
    // business logic
    Ok(())
}

#[tee_command(ctx)]
fn function_b(ctx: &mut TaContext, input: &[u8], output: &mut [u8]) -> Result<u32> {
    // writes return value into ValueOutput slot
    Ok(42)
}
}
#![allow(unused)]
fn main() {
// my-ta/src/main.rs
#![no_main]

use my_ta::*;

include!(concat!(env!("OUT_DIR"), "/user_ta_header.rs"));
}
// my-ca/src/main.rs
fn main() -> consortium_tee::TeeResult<()> {
    let mut ctx = optee_teec::Context::new()?;
    let uuid = optee_teec::Uuid::parse_str("12345678-1234-1234-1234-123456789abc")?;
    let mut session = ctx.open_session(uuid)?;

    // Generated by #[tee_command] — normal Rust function calls
    call_function_a(&mut session, 42u32, &my_data)?;
    let n: u32 = call_function_b(&mut session, &in_buf, &mut out_buf)?;
    Ok(())
}

What gets generated

#[tee_command] generates per function:

  • The original function — emitted unchanged
  • fn function_a_dispatched(...) — TA-side unpacking wrapper, always compiled, crate-private
  • #[cfg(feature = "ca")] fn call_function_a(...) — CA-side calling stub, compiled only with the ca feature

tee_service! generates centrally:

  • pub enum Command { FunctionA = 0, FunctionB = 1, Unknown = N }#[repr(u64)], #[default] on Unknown
  • pub(crate) fn invoke_command(ctx: &mut ContextStruct, cmd: u32, params: &mut optee_utee::Parameters) -> Result<()> — TA dispatch entry point; dispatches via Command::from(cmd) to match the #[ta_invoke_command] hook signature

Macro Design

#[tee_command]

An attribute macro applied to individual functions in lib.rs. It generates two companion functions alongside the original:

  1. fn function_a_dispatched(ctx, params) — always compiled; called by invoke_command
  2. #[cfg(feature = "ca")] fn call_function_a(...) — compiled only when the ca feature is enabled; dead in the TA binary
#![allow(unused)]
fn main() {
// Input
#[tee_command(ctx)]
fn function_a(ctx: &mut TaContext, count: u32, data: &[u8]) -> Result<()> { ... }

// Generated (1): TA-side dispatch wrapper — always present, crate-private
fn function_a_dispatched(ctx: &mut TaContext, params: &mut optee_utee::Parameters) -> Result<()> {
    // SAFETY: slot 0 is ValueInput — type match guaranteed by #[tee_command]
    let count: u32 = unsafe { params.0.as_value()?.a() };
    // SAFETY: slot 1 is MemrefInput — type match guaranteed by #[tee_command]
    let mut __p_1 = unsafe { params.1.as_memref()? };
    let data: &[u8] = __p_1.buffer();
    function_a(ctx, count, data)
}

// Generated (2): CA-side calling stub — only with `ca` feature
#[cfg(feature = "ca")]
fn call_function_a(session: &mut optee_teec::Session, count: u32, data: &[u8]) -> consortium_tee::TeeResult<()> {
    let _p0 = ParamValue::new(count, 0, ParamType::ValueInput);
    let _p1 = ParamTmpRef::new_input(data);
    let mut _operation = optee_teec::Operation::new(0, _p0, _p1, ParamNone, ParamNone);
    session.invoke_command(Command::FunctionA as u32, &mut _operation)
}
}

When the function has a non-() return type, an extra output slot is allocated and flushed after the call:

#![allow(unused)]
fn main() {
// Input
#[tee_command(ctx)]
fn function_b(ctx: &mut TaContext) -> Result<u32> { Ok(42) }

// TA-side: flushes return value into ValueOutput slot
fn function_b_dispatched(ctx: &mut TaContext, params: &mut optee_utee::Parameters) -> Result<()> {
    let __ret_val = function_b(ctx)?;
    // SAFETY: slot 0 is ValueOutput — type match guaranteed by #[tee_command]
    let mut __p_out = unsafe { params.0.as_value()? };
    __p_out.set_a(__ret_val);
    Ok(())
}

// CA-side: allocates ValueOutput, reads back after invoke
#[cfg(feature = "ca")]
fn call_function_b(session: &mut optee_teec::Session) -> consortium_tee::TeeResult<u32> {
    let _p0 = ParamValue::new(0, 0, ParamType::ValueOutput);
    let mut _operation = optee_teec::Operation::new(0, _p0, ParamNone, ParamNone, ParamNone);
    session.invoke_command(Command::FunctionB as u32, &mut _operation)?;
    Ok(_operation.param_0().as_value().a())
}
}

tee_service!

A function-like macro in lib.rs. Syntax:

#![allow(unused)]
fn main() {
tee_service! {
    context TaContext        // optional — omit for stateless TAs
    commands { function_a, function_b }
}
}

Generates:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u64)]
pub enum Command {
    FunctionA = 0,
    FunctionB = 1,
    #[default]
    Unknown = 2,
}

#[invoke_command]
pub(crate) fn invoke_command(
    ctx: &mut TaContext,
    cmd: u32,
    params: &mut optee_utee::Parameters,
) -> Result<()> {
    match Command::from(cmd) {
        Command::FunctionA => function_a_dispatched(ctx, params),
        Command::FunctionB => function_b_dispatched(ctx, params),
        Command::Unknown => Err(Error::UnknownCommand),
    }
}
}

Without a context clause the context parameter is omitted from invoke_command and all _dispatched wrappers.


Parameter Type Mapping

Slot assignment is sequential by argument order, skipping the context. Both the TA unpacking and the CA packing are generated from the same signature, so slot order is always consistent.

Input parameters

Rust typeGP TEE slotTA unpacks viaCA packs via
boolVALUE_INPUTunsafe { params.N.as_value()?.a() } != 0ParamValue::new(x as u32, 0, ValueInput)
u32VALUE_INPUTunsafe { params.N.as_value()?.a() }ParamValue::new(x, 0, ValueInput)
&[u8]MEMREF_INPUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer()ParamTmpRef::new_input(x)
&mut [u8]MEMREF_INOUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer() (mut)ParamTmpRef::new_inout(x)

Return value (output slot)

The output slot is always allocated as the last slot. A non-() return type triggers this; () / bare -> Result<()> does not.

Rust return typeGP TEE slotTA flushes viaCA reads back via
u32VALUE_OUTPUTlet mut p = unsafe { params.N.as_value()? }; p.set_a(v)_operation.param_N().as_value().a()
u64VALUE_OUTPUTp.set_a((v >> 32) as u32); p.set_b(v as u32)(a as u64) << 32 | (b as u64)
boolVALUE_OUTPUTlet mut p = unsafe { params.N.as_value()? }; p.set_a(v as u32)_operation.param_N().as_value().a() != 0
Vec<u8>MEMREF_OUTPUTlet mut p = unsafe { params.N.as_memref()? }; p.buffer()[..].copy_from_slice(...)ParamTmpRef::new_output(&mut buf); read buffer()[..size()] back

Slot Assignment ABI

Slots are assigned left-to-right from the function signature (context excluded). Both the TA dispatch wrapper and the CA calling stub are generated from the same signature, so slot order is always consistent — the developer never coordinates slot indices manually.

OP-TEE supports up to 4 parameter slots per invocation. The macro emits a compile_error! if a function exceeds this limit.


Context Detection

With #[tee_command(ctx)], the first parameter is treated as the TA context if and only if it has type &mut SomePath where SomePath is not a slice ([u8]). Explicit selectors such as ctx = name or ctx = 0 are accepted for compatibility and emit one warning; if the selected context is not the first parameter, the warning asks the user to move it first. Prefer bare ctx. A context parameter:

  • Is forwarded directly to the inner function on the TA side
  • Is omitted from the CA-side call_* signature (it is the TA’s internal state, not the caller’s concern)
  • Does not consume a TEE parameter slot

TA Build (build.rs)

The TA’s build.rs uses optee_utee_build (existing Teaclave tooling) to handle UUID registration and generate user_ta_header.rs:

// my-ta/build.rs
use optee_utee_build::{Error, RustEdition, TaConfig};

fn main() -> Result<(), Error> {
    let config = TaConfig::new_default_with_cargo_env("12345678-1234-1234-1234-123456789abc")?;
    optee_utee_build::build(RustEdition::Before2024, config)
}

The UUID lives here and nowhere else. It is not part of tee_service!.


Error Handling

consortium-tee owns all error types. The CA-side generated stubs return consortium_tee::TeeResult<T>, which is Result<T, consortium_tee::TeeError>. The TA-side generated stubs return Result<()> (the optee_utee::Result in scope).


Advanced Parameter Types

See tee-advanced-type-system.md for full documentation on:

  • Additional primitive types: i32, u64, (u32, u32) value pairs
  • Output value parameters: &mut u32, &mut i32, &mut u64
  • Serialized types: T: TeeParam with pluggable codecs via #[tee_command(codec = MyCodec)]

Future Work

  • Lifecycle hookstriggers { on_create, on_open_session, on_close_session, on_destroy } block inside tee_service!, mapping to GlobalPlatform entry points with compile-time signature checking