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 thecafeature
tee_service! generates centrally:
pub enum Command { FunctionA = 0, FunctionB = 1, Unknown = N }—#[repr(u64)],#[default]on Unknownpub(crate) fn invoke_command(ctx: &mut ContextStruct, cmd: u32, params: &mut optee_utee::Parameters) -> Result<()>— TA dispatch entry point; dispatches viaCommand::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:
fn function_a_dispatched(ctx, params)— always compiled; called byinvoke_command#[cfg(feature = "ca")] fn call_function_a(...)— compiled only when thecafeature 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 type | GP TEE slot | TA unpacks via | CA packs via |
|---|---|---|---|
bool | VALUE_INPUT | unsafe { params.N.as_value()?.a() } != 0 | ParamValue::new(x as u32, 0, ValueInput) |
u32 | VALUE_INPUT | unsafe { params.N.as_value()?.a() } | ParamValue::new(x, 0, ValueInput) |
&[u8] | MEMREF_INPUT | let mut p = unsafe { params.N.as_memref()? }; p.buffer() | ParamTmpRef::new_input(x) |
&mut [u8] | MEMREF_INOUT | let 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 type | GP TEE slot | TA flushes via | CA reads back via |
|---|---|---|---|
u32 | VALUE_OUTPUT | let mut p = unsafe { params.N.as_value()? }; p.set_a(v) | _operation.param_N().as_value().a() |
u64 | VALUE_OUTPUT | p.set_a((v >> 32) as u32); p.set_b(v as u32) | (a as u64) << 32 | (b as u64) |
bool | VALUE_OUTPUT | let mut p = unsafe { params.N.as_value()? }; p.set_a(v as u32) | _operation.param_N().as_value().a() != 0 |
Vec<u8> | MEMREF_OUTPUT | let 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: TeeParamwith pluggable codecs via#[tee_command(codec = MyCodec)]
Future Work
- Lifecycle hooks —
triggers { on_create, on_open_session, on_close_session, on_destroy }block insidetee_service!, mapping to GlobalPlatform entry points with compile-time signature checking