Skip to content

TEE Authorship

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:

  1. The TEE was admitted to the namespace as a TEE member (ReadOnlyTee or RelayTee — 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.
  2. The write comes from an #[app::tee] method that the node’s TEE scheduler fired, running as the TEE authority principal.
  3. 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.

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 (ReadOnlyTee or RelayTee) 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.

The policy is a governance op, set by a namespace-root admin on the root, and the last one wins:

Terminal window
curl -X PUT "$NODE/admin-api/groups/$NS/settings/tee-authoring-policy" \
-d '{ "allowedMrtd": ["<approved-td-measurement>"] }'

To turn it off, delete the policy:

Terminal window
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.

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.

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.

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.

#[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 unless env::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_bytes traps outside a TEE-triggered run. Use it instead of env::random_bytes, which on a member’s node returns whatever that member chooses.
  • TeeOnly<T> is PermissionedStorage<T, TeeAuthorityAcl> with the frozen writer set {TEE_AUTHORITY}. It also guards in-place edits through get_mut, so a member’s forged write fails on their own node, not only at their peers.
  • A TeeOnly cell stores nothing until the TEE’s first write. init runs 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 with try_get, which returns None. get returns 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 TeeOnly cell’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 a TeeOnly state 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 (Shared with exactly TEE_AUTHORITY as writer), members anchored to it, and, at a collection’s id there (which carries a mark of its own), the collection’s own Public entity, 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 (units s, 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_tee runs a closure as the scheduler would, for native tests. It runs as the device testing::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.

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:

  1. Is this node a TEE authority for the context? If not, skip. A skip counts as handled, so nothing is replayed on restart.

  2. 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.

  3. 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.

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.

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.

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.

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.

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:

  1. If the namespace has no key, it creates one.
  2. For each key it holds and each TEE authority without a copy, it publishes a GroupOp::TeeVaultKeyDelivered on 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.

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.