I tried interacting with TPM 2.0 by sending raw commands through Windows TBS (TPM Base Services).
The implementation is written in Rust.
This article walks through the entire process, from creating a TBS context to sending a TPM command and parsing the response.
Overall Flow
The following is a simplified example of the complete TPM2_GetRandom flow:
marshal the command → submit it → parse the response → retrieve the random bytes
TPM2_GetRandom is one of the simpler TPM commands to implement because it doesn't require a session and has only a small number of parameters.
const TPM2B_SIZE: usize = 2; // u16
fn main() {
let handle = create_context().expect("failed to create TBS context");
let bytes_requested = 32_u16;
let command = marshal_get_random_command(bytes_requested);
let max_response_param_size = TPM2B_SIZE + bytes_requested as usize;
let response =
submit(handle, &command, max_response_param_size).expect("failed to submit command");
let mut cursor = response.as_slice();
let random_bytes = parse_response(&mut cursor).expect("failed to parse response");
println!("random bytes: {random_bytes:?}");
close_context(handle).expect("failed to close TBS context");
}
Note that TPM2_GetRandom doesn't necessarily return exactly the number of random bytes requested.
If the requested size exceeds the amount of random data the TPM can generate in a single operation, the TPM returns only the amount it can generate.
Creating a TBS Context
The first step is to create a TBS context.
The windows-sys crate is required.
Cargo.toml
[dependencies]
windows-sys = { version = "0.61", features = [
"Win32_System_TpmBaseServices",
] }
A TBS context can be created using Tbsi_Context_Create.
use windows_sys::Win32::System::TpmBaseServices::{
TBS_CONTEXT_PARAMS, TBS_CONTEXT_PARAMS2, TBS_CONTEXT_PARAMS2_0, TBS_CONTEXT_PARAMS2_0_0,
TBS_SUCCESS, TPM_VERSION_20, Tbsi_Context_Create,
};
type TbsContext = *mut std::ffi::c_void;
fn create_context() -> Result<TbsContext, u32> {
let mut handle = std::ptr::null_mut();
let ctx_params2_0_0 = TBS_CONTEXT_PARAMS2_0_0 {
_bitfield: 4, // includeTpm20 = 1 (bit 2)
};
let ctx_params2 = TBS_CONTEXT_PARAMS2 {
version: TPM_VERSION_20, // Specify TPM 2.0
Anonymous: TBS_CONTEXT_PARAMS2_0 {
Anonymous: ctx_params2_0_0,
},
};
let status = unsafe {
Tbsi_Context_Create(
&ctx_params2 as *const TBS_CONTEXT_PARAMS2 as *const TBS_CONTEXT_PARAMS,
&mut handle,
)
};
if status != TBS_SUCCESS {
return Err(status);
}
Ok(handle)
}
Marshaling the Command
Integer values in TPM commands and responses are encoded in big-endian byte order, so they have to be converted into byte sequences using to_be_bytes().
The command header consists of tag, commandSize, and commandCode, and its size is always 10 bytes.
TPM_ST_SESSIONS (0x8002) is specified as the tag when a session is used, while TPM_ST_NO_SESSIONS (0x8001) is used when there is no session.
Sessions are optional for TPM2_GetRandom, so this example doesn't use one.
commandSize specifies the total size of the command, including the header.
TPM2_GetRandom has only one command parameter (UINT16), so the parameter size is 2 bytes.
type CommandSize = u32;
const HEADER_SIZE: usize = 10;
const TPM_ST_NO_SESSIONS: u16 = 0x8001;
const TPM2_GET_RANDOM: u32 = 0x0000_017B;
fn marshal_get_random_command(bytes_requested: u16) -> Vec<u8> {
let mut command = Vec::new();
let parameters_size = size_of::<u16>();
let command_size = (HEADER_SIZE + parameters_size) as CommandSize;
// TPM header (tag → commandSize → commandCode)
command.extend_from_slice(&TPM_ST_NO_SESSIONS.to_be_bytes());
command.extend_from_slice(&command_size.to_be_bytes());
command.extend_from_slice(&TPM2_GET_RANDOM.to_be_bytes());
// Command parameters
command.extend_from_slice(&bytes_requested.to_be_bytes());
command
}
Submitting the Command
The command is submitted using Tbsip_Submit_Command.
Currently, only TBS_COMMAND_LOCALITY_ZERO is supported for Locality, so it's specified directly.
Priority controls the execution priority of the command.
Since this example submits only a single command, TBS_COMMAND_PRIORITY_NORMAL is used.
use windows_sys::Win32::System::TpmBaseServices::{
TBS_COMMAND_LOCALITY_ZERO, TBS_COMMAND_PRIORITY_NORMAL, TBS_SUCCESS,
Tbsip_Submit_Command,
};
fn submit(
handle: TbsContext,
command: &[u8],
max_response_param_size: usize,
) -> Result<Vec<u8>, u32> {
let mut response = vec![0_u8; HEADER_SIZE + max_response_param_size];
let mut response_len = response.len() as u32; // Actual response size is written here
let status = unsafe {
Tbsip_Submit_Command(
handle,
TBS_COMMAND_LOCALITY_ZERO, // Fixed
TBS_COMMAND_PRIORITY_NORMAL, // Normal priority
command.as_ptr(),
command.len() as u32,
response.as_mut_ptr(),
&mut response_len,
)
};
if status != TBS_SUCCESS {
return Err(status);
}
response.truncate(response_len as usize);
Ok(response)
}
The only response parameter returned by TPM2_GetRandom is TPM2B_DIGEST (randomBytes).
Types in the TPM2B_* family consist of a size field (UINT16) representing the length of the data, followed by a buffer (BYTE) containing the actual data.
Therefore, max_response_param_size is calculated as:
2 bytes (size) + bytesRequested bytes (buffer)
Parsing the Response
Integer values in TPM responses are encoded in big-endian byte order, so the byte sequences have to be converted back into integers using from_be_bytes().
The TPM response header is always 10 bytes long.
On success, tag contains the same session tag used when the command was submitted, and responseCode is 0 (TPM_RC_SUCCESS).
On error, tag is TPM_ST_NO_SESSIONS, while responseCode contains a Format-Zero or Format-One error code.
In that case, no response parameters are returned.
const TPM_RC_SUCCESS: u32 = 0x0000_0000;
fn parse_response(response: &mut &[u8]) -> Result<Vec<u8>, u32> {
// Response header (tag → responseSize → responseCode)
let tag = read_u16(response);
let response_size = read_u32(response);
let response_code = read_u32(response);
println!(
"tag: {tag:#06x}, responseSize: {response_size}, responseCode: {response_code:#010x}"
);
if response_code != TPM_RC_SUCCESS {
return Err(response_code);
}
// Response parameters
let random_bytes = read_tpm2b(response);
assert!(response.is_empty(), "unexpected trailing data");
Ok(random_bytes)
}
fn read_u16(input: &mut &[u8]) -> u16 {
let (value, remaining) = input
.split_at_checked(size_of::<u16>())
.expect("insufficient data for u16");
*input = remaining;
u16::from_be_bytes(value.try_into().unwrap())
}
fn read_u32(input: &mut &[u8]) -> u32 {
let (value, remaining) = input
.split_at_checked(size_of::<u32>())
.expect("insufficient data for u32");
*input = remaining;
u32::from_be_bytes(value.try_into().unwrap())
}
fn read_tpm2b(input: &mut &[u8]) -> Vec<u8> {
let size = read_u16(input) as usize;
let (value, remaining) = input
.split_at_checked(size)
.expect("insufficient data for TPM2B");
*input = remaining;
value.to_vec()
}
This example doesn't use responseSize, but in a real implementation it should be used to verify that the size specified in the response header matches the actual size of the received response.
This is especially important when handling variable-length responses.
Closing the TBS Context
Once the TBS context is no longer needed, it can be closed using Tbsip_Context_Close.
use windows_sys::Win32::System::TpmBaseServices::Tbsip_Context_Close;
fn close_context(handle: TbsContext) -> Result<(), u32> {
let status = unsafe { Tbsip_Context_Close(handle) };
if status != TBS_SUCCESS {
return Err(status);
}
Ok(())
}
Final Thoughts
I wanted to perform low-level TPM operations on Windows by working directly with raw commands, so this was my first attempt at implementing a binary protocol.
At first, even implementing the full flow for a single command—from sending the command to parsing its response—took quite a bit of time. A large part of the work involved defining the required types while referring to the TPM specifications.
This article uses a command that doesn't require a session.
When HMAC or policy sessions are involved, both the command and response structures become more complex, which makes the implementation quite a bit more challenging.
If you're implementing raw TPM commands for the first time, I'd recommend starting with a command that doesn't require a session and has only a small number of parameters.