ocaml-tpm#
A native, pure-OCaml TPM 2.0 client. It marshals the TCG TPM 2.0 command
and response stream directly -- no FFI to tpm2-tss, so it links into a
statically-linked binary or a unikernel-style space-init where a C TSS
cannot.
Two packages:
tpm-- the pure codec and protocol library. No IO, no clock, no ambient randomness. Built onwirefor the byte layout anddigestiffor hashing/HMAC. Each command is a small module exposing acommandbuilder (bytes to send) and aresponsedecoder (bytes received).tpm-eio-- an Eio transport that carries those bytes to a TPM: a Unix/TCP socket (an swtpm emulator) or a kernel character device (/dev/tpmrm0,/dev/tpm0).
Design#
The split is deliberate: the tpm library is a pure
bytes -> bytes transformation, so the same code runs under Eio, under a
test harness with canned bytes, and under a fuzzer. Randomness (session
nonces) and the clock are inputs the caller supplies.
Every TPM structure is described once as a wire codec -- the field
definitions are the specification, used for both encoding and decoding.
Authorization (HMAC sessions, cpHash, KDFa, password and policy
sessions) and the object Name computation are implemented per TCG Part 1;
a wrong Name or HMAC is rejected by the TPM, so the live tests double as
correctness proofs.
Capabilities#
GetRandom, GetCapability, Startup/Shutdown, PCR_Read/PCR_Extend,
StartAuthSession, FlushContext, CreatePrimary/Create/Load/Unseal
(including sealing a secret to a PCR policy), the NV monotonic counter
(NV_DefineSpace/Increment/Read), Quote attestation, and Sign with a
loaded key (an unrestricted signing key signs a caller-computed digest, so the
private key can serve a signer backend without ever leaving the TPM).
Install#
opam install tpm # the pure codec and protocol library
opam install tpm-eio # the Eio transport
If opam cannot find the packages, they may not yet be released in the public
opam-repository. Add the overlay repository, then install them:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install tpm
$ opam install tpm-eio
Usage#
Seal a secret under an ECC storage primary and read it back. The body is a
pure function of an Eio network capability: each codec call pairs a command
builder (bytes to send) with a response decoder (bytes received), and the
transport carries the bytes between them.
let unseal_demo ~sw ~net =
let tpm = Tpm_eio.Transport.connect_unix ~sw net "/tmp/swtpm.sock" in
let run c = Tpm_eio.Transport.transceive tpm c in
let ok = function Ok v -> v | Error rc -> Fmt.failwith "%a" Tpm.Rc.pp rc in
let owner = Tpm.Session.pw () in
(* A storage primary in the owner hierarchy. *)
let parent =
ok
(Tpm.Objects.Create_primary.response ~auth:owner
(run
(Tpm.Objects.Create_primary.command ~hierarchy:Tpm.Objects.rh_owner
~public:
(Tpm.Public.Ecc_p256_storage
{ name_alg = Tpm.Alg.Sha256; auth_policy = "" })
~auth:owner ())))
in
(* Seal a secret to it, load the sealed object, unseal it. *)
let pa = Tpm.Session.pw () in
let blob =
ok
(Tpm.Objects.Create.response ~auth:pa
(run
(Tpm.Objects.Create.command ~parent:parent.handle
~parent_name:parent.name
~public:
(Tpm.Public.Keyedhash_seal
{
name_alg = Tpm.Alg.Sha256;
auth_policy = "";
user_auth = true;
})
~auth_value:"pw" ~data:"my secret" ~auth:pa ())))
in
let obj =
ok
(Tpm.Objects.Load.response ~auth:(Tpm.Session.pw ())
(run
(Tpm.Objects.Load.command ~parent:parent.handle
~parent_name:parent.name ~blob ~auth:(Tpm.Session.pw ()))))
in
let auth = Tpm.Session.pw ~auth:"pw" () in
let secret =
ok
(Tpm.Objects.Unseal.response ~auth
(run
(Tpm.Objects.Unseal.command ~object_:obj.handle
~object_name:obj.name ~auth)))
in
assert (secret = "my secret")
Drive it from an Eio main loop against a running swtpm:
let run_unseal_demo env =
Eio.Switch.run @@ fun sw -> unseal_demo ~sw ~net:(Eio.Stdenv.net env)
Testing#
Deterministic unit tests (dune test) cover the codecs with spec-anchored
vectors (FIPS/RFC digests, exact command bytes, response-code decoding). An
Alcobar fuzz target asserts the decoders never crash on hostile bytes and
checks codec round-trips.
Behaviour is verified end-to-end against swtpm: the eio/test/*_smoke.ml
executables drive a real emulator. Start one and point a smoke test at it:
swtpm socket --tpm2 --tpmstate dir=/tmp/mytpm \
--ctrl type=unixio,path=/tmp/mytpm/ctrl.sock \
--server type=unixio,path=/tmp/mytpm/data.sock \
--flags startup-clear,not-need-init &
dune exec ocaml-tpm/eio/test/policy_seal_smoke.exe -- /tmp/mytpm/data.sock
Security#
The library parses bytes a TPM -- or a host that can sit on the bus between the CPU and the TPM -- produces, so every response is treated as hostile input. Being I/O-free, it has no ambient attack surface: no sockets, no clock, no global randomness.
What the protocol layer enforces:
- Authorized responses are authenticated before they are trusted. The
response HMAC is checked in constant time (
Eqaf.equal) before any response parameter is decoded or decrypted,nonceTPMis rolled on every exchange (anti-replay), and anonceCallershorter than 16 octets is rejected so the authorization HMAC can never be made predictable. - Sealed secrets never cross the bus in clear. Parameter encryption
(AES-128-CFB) is keyed by
sessionValue = sessionKey || authValueper Part 1 sec 21.2, so an unsealed value is encrypted to the session even on a snooped bus. - Quotes are checked for structure, not just signature.
Quote.verifyconfirms the signed structure is a quote (magic,attestType) committing to the caller's challenge nonce before validating the ECDSA signature, so a replayed or structurally-confused attestation is rejected. - Framing is bounded and exact.
response_lengthcaps the advertisedresponseSizeat 64 KiB, so a hostile bus cannot announce a multi-gigabyte response and exhaust memory;unframerequiresresponseSizeto equal the bytes received, so a padded or truncated packet cannot smuggle trailing bytes through as parameters. Every decoder fails closed on a truncated or oversizedTPM2Brather than reading out of bounds (CVE-2023-1018 regression), and response-code decoding is total over all 32-bit codes (CVE-2023-22745 regression). Adversarial coverage lives in the per-module hostile tests and the Alcobar fuzz target.
Key hygiene: a session holds its key in a wipeable buffer (Tpm.Secret, an
abstract type so the bytes cannot be mutated except by an explicit wipe), and
Session.destroy zeros it once the session is finished -- so the long-lived
copy of the key, the realistic cold-boot or core-dump target, does not linger
resident. The transient copies a hash or concatenation makes internally are
short-lived and left to the collector; the salt and authorization values the
caller supplies are the caller's to clear. Elliptic-curve point validation
(rejecting off-curve salt and signing keys) is delegated to nox-crypto-ec.
License#
ISC.