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 fn invoke_command(ctx: &mut ContextStruct, cmd: u32, params: &mut optee_utee::ParametersAny<'_>) -> Result<()>— TA dispatch entry point, called from the TA’s#[ta_invoke_command]hook; dispatches viaCommand::from(cmd). It takesParametersAny(theoptee_utee0.10 typed API) rather than a fixed tuple because each command has its own slot layout.
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
// Every slot is type-checked (unused ones must be `None`) before the call; a mismatch
// is `BadParameters`. Memrefs are copied out of shared memory, never borrowed.
fn function_a_dispatched(ctx: &mut TaContext, params: &mut optee_utee::ParametersAny<'_>) -> Result<()> {
let (__p_0, __p_1, __p_2, __p_3) = params;
let __h_0 = __p_0.as_value_input()?;
let count = <u32 as TeeParam<NoCodec>>::from_tee_param(Primitive { a: __h_0.get_a(), b: __h_0.get_b() })?;
let __buf_1 = __p_1.as_memref_input()?.read_to_vec();
let data: &[u8] = &__buf_1;
__p_2.as_none()?;
__p_3.as_none()?;
function_a(ctx, count, data)?;
Ok(())
}
// 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::ParametersAny<'_>) -> Result<()> {
let (__p_0, __p_1, __p_2, __p_3) = params;
let __out = __p_0.as_value_output()?; // taken before the call
__p_1.as_none()?;
__p_2.as_none()?;
__p_3.as_none()?;
let __ret_val = function_b(ctx)?;
__out.set_a(__ret_val);
__out.set_b(0);
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,
}
#[cfg(feature = "ta")]
pub fn invoke_command(
ctx: &mut TaContext,
cmd: u32,
params: &mut optee_utee::ParametersAny<'_>,
) -> 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 | pN.as_value_input()?.get_a() != 0 | ParamValue::new(x as u32, 0, ValueInput) |
u32 | VALUE_INPUT | pN.as_value_input()?.get_a() | ParamValue::new(x, 0, ValueInput) |
&mut u32 | VALUE_INOUT | pN.as_value_inout()?: get_a(), then set_a() after the call | ParamValue::new(*x, 0, ValueInout) |
&[u8] | MEMREF_INPUT | pN.as_memref_input()?.read_to_vec() | ParamTmpRef::new_input(x) |
&mut [u8] | MEMREF_INOUT | pN.as_memref_inout()?: read_to_vec(), then write_at(0, ..) | ParamTmpRef::new_inout(x) |
T | MEMREF_INPUT | pN.as_memref_input()?: reject buffer_len() > MAX_SIZE, read_to_vec(), decode | ParamTmpRef::new_input(&encoded) |
&mut T | MEMREF_INOUT | as T, framed by a 4-byte length (consortium_tee::inout); set_output back | new_inout over a capacity(MAX_SIZE) frame |
Return value (output slot)
The output slot is always allocated as the last slot. A non-() return type triggers this; () / bare -> Result<()> does not.
The TA takes the output accessor before the call, so a wrong output slot type is rejected without running the handler.
| Rust return type | GP TEE slot | TA flushes via | CA reads back via |
|---|---|---|---|
u32 | VALUE_OUTPUT | pN.as_value_output()?: set_a(v); set_b(0) | _operation.parameters().N.a() |
u64 | VALUE_OUTPUT | set_a(v as u32); set_b((v >> 32) as u32) | (b as u64) << 32 | (a as u64) |
bool | VALUE_OUTPUT | set_a(v as u32); set_b(0) | a != 0 |
Vec<u8> / T | MEMREF_OUTPUT | pN.as_memref_output()?.set_output(bytes)? | ParamTmpRef::new_output(&mut buf); decode buf[..updated_size()] |
set_output reports the exact length written, which the CA decodes; an encoding larger than the CA’s MAX_SIZE buffer fails with ShortBuffer instead of overrunning it.
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