TEE Authorship
Why a TEE author
Section titled “Why a TEE author”A TEE fleet node is admitted by attestation as a
TEE member: a ReadOnlyTee replica, or a RelayTee relay when the namespace’s
admission mode is relay. On its own a replica only replicates, and a relay
only replicates and carries members’ writes under their warrants. Some apps need the
opposite: a participant nobody in the group controls that can draw a
random number no player can bias, keep a secret no player can read, and record
a verdict every player can check came from the enclave’s code. A card game’s
deal is the motivating case.
TEE authorship lets such a node write, but only three things together make a write count:
- The TEE was admitted to the namespace as a TEE member (
ReadOnlyTeeorRelayTee— TEE authorship is the same for both), under an admission policy that pins its image (MRTD and RTMR1–3), and the namespace’s TEE authoring policy names the MRTD in its attestation evidence, which every node verifies for itself. - The write comes from an
#[app::tee]method that the node’s TEE scheduler fired, running as the TEE authority principal. - The data it writes lives in
TeeOnly<T>storage, whose only writer is the TEE authority. Every peer checks that writer set at merge.
The third is the security boundary. Peers never re-run app code, so they cannot tell which method produced a delta. What they check is who signed it, and that check needs no trust in the author’s node.
The TEE authority
Section titled “The TEE authority”AccountId::TEE_AUTHORITY is a reserved account: the domain hash of
calimero.tee-authority.v1. No real account is a hash under that domain, and
no key signs as it. A node treats a signing key as speaking for it only after
it checks that the key’s account is a TEE authority:
- a direct TEE row (
ReadOnlyTeeorRelayTee) at the namespace root, which only attestation admission mints; - still a member of the context’s group;
- verified attestation evidence for the account whose MRTD is in the root’s
latest
TeeAuthoringPolicySet; - and the signing key is the one key that evidence’s quote binds.
The same four facts are checked in two ways, depending on who is asking:
| Where | Against | What it decides |
|---|---|---|
Local execute (internal_execute) and the TEE scheduler |
This node’s current governance state, through calimero_governance_store::tee_authority_key. The policy and evidence are read from the projection’s fold at this node’s own heads (FoldedTeeAuthority), or from the op log when the fold does not yet hold them all |
Whether a TEE-triggered run may go ahead. If so, the principal becomes (TEE_AUTHORITY, this node's key) and the read-only discard is lifted, for this run only. |
| Receive path | The delta’s causal cut, through ScopeProjections::writer_account_at_cut |
The signer resolver maps the TEE’s key to TEE_AUTHORITY, so its TeeOnly writes match the writer set at merge. |
The receive path asks at the cut because peers apply governance at different speeds. The role, the authoring policy, the verified evidence, the key’s binding and the membership where the delta writes all come from the fold of the delta’s cited ancestry, and the evidence’s age is judged against the delta’s own clock, so a policy change or a TEE removal the delta did not cite cannot change how its writes merge. Two peers holding that ancestry decide the write the same way, however far past the cut each has applied. To make that possible, the authoring policy and the evidence fold into the unified projection, and evidence is verified when its op is decoded, so the fold only ever holds what a quote proved.
The read-only gate in front of the merge (rejects_state_writes_from) lets
through any delta signed by an attested TEE key, whatever the policy says.
Asking the live policy there would put a live read in front of the at-cut
decision, and two peers on either side of a policy change would disagree about
the whole delta. The attested merod only signs a TEE-triggered run it judged
authorised, and the merge decides the rest.
Every other write a TEE node attempts in its own name, such as an ordinary
JSON-RPC call on it, is still discarded locally, on a relay as on a replica,
and the caller gets ReadOnlyWriteRefused rather than a success. That holds in
a subgroup context the TEE reaches by inheriting its root role as much as in
the root’s own contexts. A
relay’s delegated writes are not its own: they are the author’s, admitted by
the warrant gate,
and a replica asked to relay is refused with a 403 before anything runs.
Granting the authority
Section titled “Granting the authority”The policy is a governance op, set by a namespace-root admin on the root, and the last one wins:
curl -X PUT "$NODE/admin-api/groups/$NS/settings/tee-authoring-policy" \ -d '{ "allowedMrtd": ["<approved-td-measurement>"] }'To turn it off, delete the policy:
curl -X DELETE "$NODE/admin-api/groups/$NS/settings/tee-authoring-policy"That publishes the same op as a PUT with an empty list, which also turns it
off. Admitted TEEs stay members; they lose only the authority. There is no
default: a namespace with no policy has no TEE authority, whatever TEEs it
admitted.
Attestation evidence
Section titled “Attestation evidence”An admission op is not enough to make a TEE an authority. Any member may sign one, and it carries only a hash of the quote and the signer’s word for the measurements, so no other node can check them. A member could forge an admission for an account it controls, claiming exactly the MRTD the policy names.
So authority needs a second op, TeeAuthorityEvidence, which carries the proof:
| Field | What it is |
|---|---|
quote |
The raw TDX quote. |
collateral |
The Intel-signed DCAP collateral the quote was appraised against. |
attested_at |
The moment the collateral is judged at. |
attested_key |
The key the quote binds: SHA-256 of it is in report data bytes 32..64. |
The node that admits the TEE publishes it right after the admission. Every node
verifies it offline when it applies the op: the quote’s signature chain
against the collateral at attested_at, the key binding, and the measurements
and TCB status against the admission policy. The check needs no network and no
clock, so a node that joins years later and replays the log reaches the same
verdict. An op that fails is never applied.
The reader verifies again when it looks the evidence up, and it ignores
evidence whose attested_key does not speak for the account it names. Copying
one TEE’s evidence and relabelling it for another account does not work, and
neither does evidence whose quote binds a different key.
Only the TEE holds its quote, so only the TEE can put right evidence that never landed: a publish that failed right after admission, or an admission by a build that published none. A TEE therefore watches for it. While TEE authorship is on in a namespace it was admitted to, and no verified evidence for it is on the log, it announces itself again with a fresh quote, and an admitter that hears an already-admitted TEE with no evidence publishes it. The first re-announce waits a minute, since right after an admission the admitter’s own publish is usually still on its way, and the wait doubles after each one up to half an hour. It stops once the evidence is recorded. Whether the policy names the TEE’s MRTD plays no part: the evidence is owed either way.
How long evidence lasts
Section titled “How long evidence lasts”Evidence records the platform’s TCB status at attested_at. A platform that
later falls out of date would otherwise keep its authority for as long as its
first evidence exists, so evidence lapses:
| Rule | Value |
|---|---|
| Evidence confers authority for | 7 days after attested_at |
| A TEE is appraised again once its latest evidence is | 1 day old |
| Evidence dated ahead of the reader’s clock by more than | 5 minutes is ignored |
The refresh is the same exchange as the retry above: the TEE’s evidence is owed once it is a day old, so it announces itself with a fresh quote, and an admitter publishes new evidence for an already-admitted TEE whose evidence is missing or a day old. A TEE whose platform has fallen out of date fails that appraisal, so nothing replaces its old evidence and its authority ends when that evidence lapses. The most recently appraised evidence counts, whatever order it was logged in.
Age is never judged when a node applies the op, so every node keeps the same op
log. A peer merging a TEE’s delta judges it against the delta’s own clock, which
the delta signature covers, so every peer reaches the same verdict about that
delta however late it reads it. Where a node decides for itself — whether to
run a trigger, whether it is owed a refresh — it uses its own clock.
attested_at is the admitter’s clock, which is why a little skew is allowed.
Evidence dated further ahead is ignored instead of being allowed to outlive its
window, and it cannot shadow the evidence that is current.
Joining from a snapshot
Section titled “Joining from a snapshot”A node that joins a context from a snapshot writes the leaves straight to disk. A snapshot leaf is state, not an op, so there is no causal cut to ask whether its signer was a writer then. The snapshot path therefore checks only that each leaf’s signature verifies, and the writer check resumes on the entity’s next write.
TeeOnly state gets the writer check anyway. A leaf whose writer set holds the
TEE authority, whether the cell itself or an entry under it, is stored only when
its signer resolves to the TEE authority by the same rule the merge path uses.
Otherwise a member serving the snapshot could sign a value with its own key and
label it with the TEE-only writer set, and the joiner would store and read it. A
refused leaf is dropped like a leaf with a bad signature, and repair sync, which
checks writers, fetches the honest copy.
Writing an app
Section titled “Writing an app”#[app::state(emits = for<'a> Event<'a>)]pub struct TeeDice { results: TeeOnly<UnorderedMap<String, LwwRegister<u32>>>,}
#[app::logic]impl TeeDice { #[app::init] pub fn init() -> TeeDice { TeeDice { results: TeeOnly::new_tee_only() } }
// Any member: asks for a roll. Writes nothing the game trusts. pub fn roll(&mut self, roll_id: String, sides: u32) -> app::Result<()> { app::emit!((Event::RollRequested { roll_id: &roll_id, sides }, "tee:resolve_roll")); Ok(()) }
// Only the elected TEE authority runs this. #[app::tee] pub fn resolve_roll(&mut self, roll_id: String, sides: u32) -> app::Result<()> { let mut bytes = [0u8; 4]; env::tee_random_bytes(&mut bytes); // ...insert into self.results Ok(()) }}#[app::tee]inserts a guard that panics unlessenv::tee_origin()is true. That flag is set by the node only for a TEE-triggered run on a TEE authority, so a JSON-RPC call or a member’s handler run cannot enter the body.env::tee_random_bytestraps outside a TEE-triggered run. Use it instead ofenv::random_bytes, which on a member’s node returns whatever that member chooses.TeeOnly<T>isPermissionedStorage<T, TeeAuthorityAcl>with the frozen writer set{TEE_AUTHORITY}. It also guards in-place edits throughget_mut, so a member’s forged write fails on their own node, not only at their peers.- A
TeeOnlycell stores nothing until the TEE’s first write.initruns as the context creator, who is not a writer. A cell created there would be signed by the creator, and every peer would drop it, so its initial state would never leave the creator’s node. Instead the cell is created by the TEE authority’s first write, which peers accept. Until that write reaches a node, read it withtry_get, which returnsNone.getreturns an error until then. Two TEE authorities may both make that first write before either sees the other’s; when the value is a collection, its id is derived from the cell’s, so both name the same collection and every entry either wrote survives the merge. - Nobody can take a
TeeOnlycell’s id first. Until the TEE’s first write the cell’s id, its value entry and every id under a collection it holds are empty, and a member could create an entity at any of them: naming itself as writer, or with another storage type. The TEE’s write there would then be refused for good, because a stored writer set is what a write is checked against and a storage type never changes. So aTeeOnlystate field lives at a TEE-only id (tee_only_id): its first bytes mark it, and every id derived beneath it carries the same mark, except the rotation log beside the value, which the node writes. Merge, on the delta path and in state sync, refuses anything at a TEE-only id but the TEE’s own cell (Sharedwith exactlyTEE_AUTHORITYas writer), members anchored to it, and, at a collection’s id there (which carries a mark of its own), the collection’s ownPublicentity, without which no entry of the collection would land on a peer. A context created before this keeps the ids its root state names, so its cells stay open until it is created again with this SDK. #[app::tee(every = "30s")]makes the method a timer (unitss,m,h,d). The period is recorded in the ABI and the node fires it once per period; see Timers. A timer method takes no arguments.TestHost::call_as_teeruns a closure as the scheduler would, for native tests. It runs as the devicetesting::TEE_DEVICE_KEY, the one TEE authority key the mock reports.
The tee-dice
example and its tee-dice-roll.yml merobox scenario exercise all of it. The
tee-cards
example adds a timer and hidden values.
Firing: the tee: handler
Section titled “Firing: the tee: handler”A TEE trigger is an event handler whose name starts with tee:. Handlers only
run on receivers, never on the delta’s author. For a tee: handler each
receiver asks two questions, in events.rs and tee_firing.rs:
-
Is this node a TEE authority for the context? If not, skip. A skip counts as handled, so nothing is replayed on restart.
-
Where does it rank? Every TEE authority ranks all of them by
H(delta_id ‖ account), lowest first. The ranking depends on the triggering delta, not on anything a TEE produces, so no TEE can grind an outcome by choosing whether to fire. -
The first-ranked TEE calls
ContextClient::execute_tee_trigger, which runs the method after the authority check. Every other TEE waits its turn, as below.
Failover
Section titled “Failover”The authority ranked k fires k turns after the delta arrives
(TEE_FAILOVER_GRACE, 15 seconds a turn), and only if no firing has reached it
by then. So when the elected TEE is down, the next one takes the trigger over
a turn later, and a game stalls for seconds rather than until its TEE returns.
Each firing is named by a TeeTriggerId, derived from its cause
(TeeTriggerCause): H(delta_id ‖ method) for an event trigger. The delta
the run produces is signed under SignatureDomain::Tee, which commits to that cause
(see The TEE envelope). A node that accepts such a delta
records a node-local marker for the firing, and so does the TEE that fired;
every TEE checks the marker before it fires. A waiting TEE keeps the delta’s
events in the DB, so a restart before its turn replays them and waits again,
and a replay after the firing finds the marker and stands down.
A delta a node is catching up on — one whose clock is more than a turn behind this node’s — costs every rank one extra turn, the first-ranked included. That node was down or partitioned, and a fallback may have fired meanwhile; its delta is still on the way, and firing before it lands would double the trigger.
Without a consensus round this is at-least-once, not exactly-once. See Limits of this prototype.
Timers
Section titled “Timers”An #[app::tee(every = "..")] method is fired by the node’s TEE scheduler
(tee_scheduler.rs) with no member asking. It is how a game times out a turn.
Every 30 seconds the scheduler reads the timer methods (Method.tee_every_secs
in the ABI) of every context this node is a TEE authority for. Periods are
counted from the Unix epoch, so every authority agrees which tick is current.
The tick names the firing (TeeTriggerCause::id, over the context, the method,
the tick and the period) and seeds the ranking, and from there a tick fires exactly like an
event trigger, failover included. Only the current tick is offered: a node that
was down does not replay the ticks it missed.
The TEE envelope
Section titled “The TEE envelope”A delta a TEE’s run produces is signed by the TEE’s attested key under its own
signature domain, SignatureDomain::Tee. It covers the fields a self-authored
delta signs (SignatureDomain::Delta) plus the cause of the firing:
(SignatureDomain::Tee, context_id, delta_id, author_id, trigger: Event { cause: delta_id, method } | Timer { method, tick, every_secs }, governance_position, hlc)The cause travels in the clear beside the signature, as tee_trigger on
BroadcastMessage::StateDelta and DeltaResponse, so a receiver checks it
before it decrypts anything. Every receive path, gossip, buffered replay,
parent fetch and both catch-up pulls, then applies three rules, in
check_tee_envelope:
- A write from a TEE must carry a trigger. An attested TEE key that is not also a writing member’s writes only from a run its scheduler fired, and every such run is signed this way, so an untriggered delta from it is refused.
- A trigger must come from an attested TEE. The envelope is what the fired marker is read from, so a member signing its own delta this way would stand every waiting TEE down. It is refused.
- A timer’s tick must have begun by the delta’s clock. A TEE fires only a tick that has begun, and a delta’s clock is never behind its author’s wall clock, so a delta dated before its tick is a TEE marking the tick fired early, and it is refused. The period is part of the trigger’s id, so a TEE that claims a shorter one to pass this names a trigger no scheduler fires.
An event trigger needs no such check: its id hashes a delta id, which no one can know before that delta exists, so it cannot be marked early.
Once the store takes the delta, the receiver records the marker from the signed
cause. It skips the marker for a tick that has not begun by its own clock
(allowing the 5 seconds of drift the HLC allows a peer): a delta may be dated
ahead of its tick by a clock running fast, and marking it would stand every TEE
down when the tick comes. It keeps the cause beside the delta (scope calimero-teedelt) so it can
serve the delta to a peer catching up. The persisted delta row is unchanged:
it is plain borsh, and a new field would leave every row on disk unreadable.
A delta cannot be both delegated and TEE-triggered: a TEE writes for itself.
A run that writes nothing produces no delta to carry the envelope. The TEE then
signs a statement under its own domain, SignatureDomain::TeeFired, over the context,
its key and the cause, and gossips it on the context topic as
BroadcastMessage::TeeFired. A receiver checks it like the envelope: the
signature, that the key is an attested TEE’s for the context, and for a timer
that the tick has begun by its own clock. It then records the marker. The
statement is not persisted, so a TEE that misses it fires on its own turn.
The envelope changes the gossip and catch-up wire, so every merod in a
network must be upgraded together. An older node drops a newer node’s
StateDelta as undecodable, and an older node’s delta from a TEE carries no
trigger, so a newer node refuses it.
Hidden values
Section titled “Hidden values”A TEE that no member controls can also keep something from them. Four host functions, and two storage types built on them, cover a card game:
| What it does | |
|---|---|
env::seal_to(key, bytes) |
Seals bytes to an Ed25519 key: an ephemeral key agreement, then AES-256-GCM. Any run may seal. |
env::open_sealed(envelope) |
Opens an envelope with the run’s executor key, or in a TEE-triggered run with a namespace TEE key the TEE holds. |
env::tee_authority_keys() |
What a TEE-triggered run seals a value only the TEE may read to: the namespace TEE key (see below). |
env::account_device_keys(account) |
What a TEE-triggered run seals a value only one member may read to: the signing key of every live device of that account in the namespace. |
Sealed<T> |
A T sealed to one or more keys (to_key, to_keys, to_account, to_tee); open returns it to a reader it was made for. |
TeeSecret<T> |
A TeeOnly cell holding a Sealed<T> that set seals to the TEE on each write, and reveal opens inside a TEE run. |
A hand is dealt by sealing each card to every device of the player’s account
(Sealed::to_account) and writing it into TeeOnly state: every member
replicates it, and only the player’s own devices open it, in a run the player
makes. The deck is a TeeSecret, which members store and cannot read.
The node decides which runs may open. It gives the runtime the executor’s key, except for a run on a TEE node that the TEE scheduler did not fire, so an ordinary JSON-RPC call there cannot read what is sealed to the TEE, and for a delegated run, whose principal is not the node the key belongs to. The key stays on the host; a guest only ever sees what an envelope opens to.
Sealing proves confidentiality, never authorship. Anyone who knows a key can seal to it, so a dealt card is genuine because it sits where only the TEE authority can write, not because it opened.
The namespace TEE key
Section titled “The namespace TEE key”What only the TEE may read is sealed to one key per namespace, not to each TEE authority’s own key. Sealed to each TEE, the deck would hold no copy for a TEE admitted after it was shuffled, and that TEE could not deal, even with every other TEE down.
A TEE authority’s node checks a namespace (calimero_context::tee_vault) as
soon as it applies an op that can change which TEEs are its authorities: an
admission, a removal, the authoring policy, or evidence. It also checks every
namespace every ten seconds, which catches evidence that lapses with no op at
all and any event it missed. Each check does two things:
- If the namespace has no key, it creates one.
- For each key it holds and each TEE authority without a copy, it publishes a
GroupOp::TeeVaultKeyDeliveredon the root: the key’s private half sealed to that TEE’s attested key.
Only a TEE member (ReadOnlyTee or RelayTee) may publish one; apply refuses an admin as well as
a member, because a key an admin handed the TEEs is one it could read
everything with. A TEE opens only the copies addressed to it, and checks that
each opens to the key it names.
A TEE-triggered run is given every key its TEE holds, and seals to the lowest. Two TEEs that each create a key before seeing the other’s hand both on, so each ends up holding both and opens whichever a value was sealed to. Until the namespace has a key, a run seals to every TEE authority’s own key, as before; the next write seals to the namespace key.
A TEE that stops being an authority rotates the key. A TEE may stop being
a TEE authority in four ways: it is removed, it leaves, the authoring policy no
longer names its MRTD, or its evidence lapses. Whichever it is, it still holds
every key it was handed. Each such key is retired (retired_tee_vault_keys):
no run seals to it again, and once the namespace has no other key a TEE
authority creates a new one and hands it to the TEE authorities that remain.
They keep the retired keys, so a value sealed before still opens for them, and
the next TEE run that writes it seals it to the new key.
Retiring follows the authorities as they are now, so it is not permanent. A TEE that becomes an authority again, because it refreshed its evidence or the policy names its image again, makes its keys live again: it held them all along, and it is trusted again. It is handed the new key as well. A TEE that misses its evidence refresh for the full seven days therefore costs one rotation, not a lost key.
Replacing a TEE image works the same way as before this rotation existed: name the new MRTD in the policy alongside the old one, let the new TEEs receive the key, then drop the old MRTD. Dropping the old one first leaves no authority that holds the key, so the new TEEs create another and cannot open what was sealed before.
Limits of this prototype
Section titled “Limits of this prototype”| Limit | Consequence | Planned fix |
|---|---|---|
| Firing is at-least-once. A TEE that is up but cut off from the others for longer than a turn is doubled by the next-ranked one. | Two firings of one trigger both write. Their TeeOnly writes converge like any concurrent writes, but a randomised value may change once. An app whose writes are keyed by the trigger (a roll id, a hand slot) keeps the first. |
Ask the ranked authorities to agree on one firing before any writes, instead of only waiting for one. |
A firing that writes nothing is announced by gossip only (TeeFired), and not persisted. |
A TEE that misses the statement, or is offline when it is sent, runs the trigger too on its own turn. That is harmless for a timer that only acts when there is something to do, which is what a timer method should be. | None needed: a run that writes nothing has nothing to converge. |
| A timer only fires the current tick. | A tick missed while every TEE was down is not replayed; the next one runs. | Declare catch-up per timer if an app needs it. |
An event trigger’s marker names a real delta, but a receiver does not check that the TEE saw that delta’s tee: handler. |
A compromised TEE could sign a delta for a real cause it was not elected for, with any writes, and the other TEEs stand down. It cannot mark a cause before it exists, nor a timer tick before it comes. | None at this layer: a compromised TEE can already write anything a TEE may write. |
| A TEE’s delta written before the TEE envelope existed carries no trigger. | A node on this release refuses it on every receive path, catch-up included, so it cannot fetch such a delta from a peer. It stays pending, and the next sync round reconciles state without it, as for any delta a peer cannot serve. | None: the rule is what stops a TEE writing outside a firing. |
| The namespace TEE key reaches a new TEE authority only from a TEE that holds it, while that TEE is up. A holder hands it over as soon as it applies the evidence that makes the new TEE an authority, so a TEE that vouches for another and holds the key delivers it straight away. | A TEE admitted by an admin while every holder is down cannot open what was sealed before, the same as without the key. After one delivery it can hand the key on itself. | None at this layer: an admin never holds the key, so only a TEE can hand it over. |
| Rotation hides what is written after a TEE stops being an authority, not what was sealed before. | That TEE keeps the retired key, and the ciphertext sealed to it stays in the history every member holds. Any member can hand it that ciphertext, so it can learn each value as it stood when it stopped being trusted. Sealing the same value again to the new key would not change that: the old ciphertext is still there. | None at this layer. An app that must hide such a value from a former TEE changes the value after a rotation: a card game would reshuffle the rest of the deck. |
| A value sealed before the namespace has a key is sealed to each TEE authority’s own key. | A TEE authority creates the key as soon as it applies the authoring policy, so this covers only a run that fires before that key’s delivery reaches the TEE running it. A TEE admitted later cannot open such a value until a TEE run writes it again. | None planned: the window is the time two ops take to propagate. |
| A card is sealed to the devices the player’s account binds when it is dealt. | A device bound later sees that the player holds those cards, not which. The cards dealt after it is bound open there. | Re-seal a hand to a newly bound device, from a TEE run the binding triggers. |
A context created before TEE-only ids keeps the TeeOnly ids its root state names. |
In such a context anyone may still create an entity at a cell’s id before the TEE does, and block that cell on every peer that holds it. It cannot forge a value. | None: create the context again with this SDK. |
Delta apply runs in the app’s WASM, while state sync checks entities in merod. |
In an app built with an SDK from before TEE-only ids, an entity a member creates at a TEE-only id is accepted from a delta but refused in state sync. That app has no cell there, so nothing breaks, but that one entity does not converge. | None needed once the app is rebuilt. |
| The live check reads the fold only when it holds the whole namespace: backfilled, not cut short by the backfill cap, and holding every governance head this node has. | Until then, and on a node that just authored a governance op itself, it scans the op log and verifies each quote as before. The sync protocols that hold no projection (hash comparison, level sync, entity pushes) read the op log once per session instead of once per leaf. | Keep the fold current for a node’s own ops, so it is never missing one. |