08 — Security
Status: normative. Each requirement names where it is enforced.
The threat model: the server is honest but its author makes mistakes; the network is hostile; a third party may run a server that lies. The client must survive all three without a crash, a leak, or an action its user did not grant.
1. Transport
- TLS 1.3 only, no downgrade.
http://andws://are refused in release builds without exception; a debug build MAY accept loopback with an explicit environment opt-in. Enforced:eui-client::transport::check_url. - A manifest MAY declare SPKI pins for its origin; a client that supports pinning MUST honour them.
- No user-agent string and no client identifier is ever sent. The
Helloframe carries the protocol version, the viewport, and the granted capability bits — nothing else. Enforced:Driver::hello.
The reference client honours the EUI_ALLOW_INSECURE_LOOPBACK=1 variable in every build, loopback only, and says so on stderr in a release build. The loopback exception has one other legitimate user: a host that embeds the
client and the server in one process — a desktop artifact — where the
socket never leaves the machine and the host arms its own gate. The client
library exposes this as an explicit call; the eui binary has no flag for
it.
2. Provenance and integrity
- The manifest is signed with Ed25519; the client pins the publisher key on
first use and refuses a different key unless a rotation record signed by
the pinned key accompanies it. The pin, and any remembered grant, is kept
per origin and
app_id(01 §2.1): the signed record names no origin, so a copy of a manifest served from another host is another application, with no inherited trust and nothing granted. - Every asset is named by BLAKE3 and verified on arrival; a mismatch is discarded. A CDN or proxy cannot substitute content.
Enforced: eui-client::manifest — the signature is checked with the manifest's own key, the key pinned under the origin and app_id in the pin store (manifest::store_path), the grants remembered under the same pair, a changed key refused without a rotation the pinned key signed; lang/src/serve/eui/manifest.rs signs with a key generated on first use and kept in config/eui_publisher.pkcs8.
3. Capabilities
- Deny by default. A capability the manifest never requested, or the user never granted, has no code path in the client: the check is not at the call site, the call site is absent.
- Unknown capability bits in a frame are a decode error. Enforced:
eui-proto::Frame::decode.
4. Code from the network
- No native code, no JIT, no
eval. Exactly two kinds of executable content reach the client: a chunk that passed the verifier in07-bytecode.md§4, and — only where thescenecapability was granted — a WGSL module that passed the verifier in11-shaders.md§2. - The grant is on the module, not on the node kind. A
scenethat names no shader runs the client's own program and is drawn without it: what a person is asked to allow is somebody else's code, and a picture the client computed for itself is not that. Charging for it would have been a toll on nothing, and a toll on nothing teaches people to grant without reading. Enforced:eui-vm::Chunk::verify,eui-shader::verify. - Both are total by construction, and differently. A chunk's steps are bounded at run time by fuel; a shader's are bounded before it is compiled, because a fragment stage is already on the GPU when it misbehaves and there is nothing left to stop it with. That is why 11 §2.4 refuses every loop whose trip count cannot be read off constants, including one bounded by a uniform — a uniform is a number the server sends after verification.
- A module reaches nothing: no storage, no atomics, no barriers, no images, no subgroup or ray-query operations. The only thing bound to it is the client's own uniform block, at the one group and binding the client offers.
- A chunk's reachable surface is the ten methods of the host trait: local
state, node text, props and style by key, the viewer's palette, an event
queue, a request to go back, and one float of a scene's uniform block.
The tenth is the narrowest of them: it refuses a node that is not a scene,
an index outside the eight the block holds, and a value that is not a
finite number — a typed door can refuse what a general one cannot, which
is why it is not a
set_propof a list. Enforced by the type:eui-vm::Host.go_backcarries no data in either direction and reaches no state: it asks, and the tree that results is the server's (06 §1.3). - A run is fuel-metered and string-bounded; an abort has no further effect
and sends nothing. Enforced:
eui-vm::run,Driver::emit.
5. Memory safety and hostile input
#![forbid(unsafe_code)]on every crate but the GPU boundary.- The decode path cannot panic: bounds-checked reads, minimal varints,
rejected unknown discriminants, rejected trailing bytes, every limit
checked before allocation, no reservation larger than the bytes left in
the frame could back (a count under its ceiling can still be a lie),
iterative tree decoding. Enforced:
eui-proto, underclippy::indexing_slicing,panic,unwrap_used,expect_used,arithmetic_side_effectsas errors — denied at the crate root ofeui-proto,eui-treeandeui-vm, which run inside the application wherever there is no worker process; 64 rejection tests; 40 000 hostile buffers percargo test; fourcargo fuzztargets. - A batch that is well-formed but incoherent — undefined atom, duplicate
node (including an id used twice within one subtree), a node past the
depth limit, index past the end — is refused before anything is placed,
and the session is poisoned until the next
Mount. A refused subtree leaves no node behind, so the resync'sMountis never refused for ids the failed one left live. Enforced:eui-tree::Session::apply.
6. Quotas
Enforced by the client, before the memory they bound is allocated: nodes,
atoms and atom bytes, styles, colours, chunks and inline chunk bytes (one
total for a page and its islands), children per node, props and
handlers per node, ops per batch, chunk size, string length in a chunk,
fuel per run. A hostile server can be annoying; it cannot make the client
exhaust itself. Enforced: eui-proto::limits, eui-tree::Limits, eui-vm.
7. Input isolation
- An application never sees a keystroke outside a focused editable node or
a node that holds focus and a
key_downhandler. There is no global key capture. Enforced:Driver::key. - Clipboard reads require
clipboard.read; pointer position is reported only inside the window. A person's ownCtrl+Vinto a field is not an application read: the window inserts the text and the application sees it as typing, exactly what a browser does. Enforced: the driver has no clipboard access at all; the window reads it only on that key.
7.1 The filesystem
The client reads a file only when a person picked it in the platform's own dialog, and writes one only where a person just said. Everything else about this feature follows from those two sentences.
- A dialog opens on an activation and nothing else: a primary click, or
Enter/Spaceon a focused node, on a node carryingpickorsavewith a server handler for the answering event and the capability granted (spec 03 §3.2). A tree that merely arrives opens nothing; neither does a batch, awake, or a local chunk. There is no frame that opens a dialog, because a frame is not a person. Enforced:Driver::offer_files, called from the click and activate paths alone. fs.pickandfs.saveare separate capabilities, and neither is implied by the other: reading what someone chose and writing where they said are different powers. Enforced:Driver::offer_files.- A
Blobfor a node with no open save ends the session — that is a server attempting to write a file nobody offered it, and there is no benign reading of it. A file is created on the first chunk, not when the path is chosen, and an aborted transfer removes what it had written. Enforced:Driver::blob,Shell::pump_writes. - No path ever reaches a server: a
file_pickcarries a name and a size, afile_savea name (spec 06 §3). A dismissed dialog is reported to nobody. Enforced:Driver::picked,Driver::saving, both throughbasename. - A file's bytes never enter the worker in the direction that would matter: the window reads and writes files, because the worker cannot open one and must not be able to (§10). What crosses the pipe is a name, a size, and opaque bytes.
- The ceilings are the client's, not the tree's: a node may ask for less
than
10-budgets.md§5 allows and never for more, and the driver enforces what it framed rather than trusting the size a dialog reported. Enforced:Driver::upload_chunk,Driver::blob.
7.2 The camera, the radio and the reader
- A camera sheet opens on an activation and nothing else, by the rule of
§7.1 and the same code path:
pickwith bit 1 of its flags, a server handler forfile_pick, andcameragranted (spec 03 §3.2). A picture taken this way is a file and travels as one; nothing new carries it. - The same for a recording:
pickwith bit 2, a server handler forfile_pick, andmicrophonegranted. What comes back is a finished recording — a file — and nothing here carries a live one. camera,microphoneandfs.pickare three separate grants and none implies another. A node asking for the camera onfs.pickalone opens nothing; so does one asking for a folder oncameraalone.- A scan starts on an activation and nothing else (spec 03 §3.3), and reads one tag. A reader that delivers twice is answered once, because the scan is over after the first.
- Location is not asked for by activation — a page that shows where you
are would be unusable if every fix needed a click — so its limits are
elsewhere and there are four of them: the capability, the window having
the input, a fix actually existing, and a floor of one second
(spec 06 §1.2). Without the capability the tree is not read for
locateat all and an offered fix is discarded rather than stored. - A location leaves the client coarse: a thousandth of a degree, and an accuracy never better than 100 m. The rounding is the client's, before anything is sent, because it is the only side of this that a server cannot argue with. There is no capability that buys more.
- A session that ended stops all three: no scan is in flight, no node is asking to be placed, and the fix that was held is dropped (spec 01 §4).
7.3 Resuming a session
A session id is a bearer credential: whoever holds it can pick the session
up. It MUST come from a CSPRNG, MUST NOT be logged, and MUST NOT be handed
to a second socket while a first is still on it — two windows on one tree
is a session hijack whichever way it happens. The client believes the
server's resumed answer over its own memory (01 §4.1), so a server that
has forgotten a session cannot be made to act on a tree it no longer has.
8. Privacy
No user-agent, no font enumeration, no canvas readback, no device identifier, no third-party connection. The fingerprinting surface is the viewport frame, and that is a testable claim.
An application MAY supply a face (02 §5.1, 05 §3), and none of the above softens to let it:
- the face is an asset, named by its content and fetched from the
session's own origin like every other byte a server chose — enforced:
eui-client::assets::origin_forderives the origin from the session URL and nothing else can name a host. A face hosted by a third party is fetched by the server, once, and re-served from the application's origin; the viewer's address never reaches it; - the client still never asks the machine what fonts it has — enforced:
eui-text::TextEngine::newbuilds its database by hand rather than throughFontSystem::new_with_fonts. A session's faces are exactly the embedded ones plus the assets it was sent, so the same text shapes the same way on every machine; - the face is parsed in the confined worker, under the same seccomp and Landlock as the tree (§10). A font file is a table of offsets a server chose, and it meets the parser where a parser taken over reaches nothing;
- bytes that are not a face the client reads are discarded rather than
displayed, and the role falls back to
sans. No font a server sends or fails to send can stop text being drawn.
A scene (03 §1.2) does not widen it, and is built so that it cannot:
- its target carries no
COPY_SRC, so reading it back is a validation error rather than a rule somebody has to remember; - a press on it reports a position and never an object, a triangle or a depth — picking is readback under another name;
- the adapter's name, vendor, device and driver reach neither the tree nor an event payload, and a shader that fails to compile yields one word from a closed vocabulary, never the compiler's own diagnostic, which would name the driver and through it the machine;
- no timestamp query is enabled on a device that will run one.
One channel is not closed, and saying so is the point of this paragraph.
A server can already time a client: a wake arrives on the driver's clock
while a sound's time_update arrives on the audio thread's, and the
difference between them is a coarse measure — about a tenth of a second — of
how hard the window is working. That existed before scenes. What a shader adds
is the ability to modulate what is being timed. The client does not close
this; what it does is refuse to add a finer one.
level (03 §7) is the case that tests that refusal, and it is why the
event is shaped the way it is. A meter wants to be fast, and a fast meter
would be exactly the finer clock this paragraph refuses — so level is
sent on time_update's existing tick and carries the peak since the last
one rather than a fresh sample. Nothing about the timing surface changes:
the same four messages a second the client already sent, one of them now
carrying a second number.
What the number itself says is bounded by where it is measured. The peak
is taken on the source's own samples with the application's own volume
applied and the viewer's master gain deliberately excluded, so it is a
property of bytes the server sent, at a gain the server set — a value the
server could have computed for itself from the file it uploaded. Measured
one line later, after the master gain, it would instead be a readout of
the viewer's volume knob, and a zero would report that they had muted.
That is the whole difference between a meter and a sensor, and it is one
multiplication. Enforced: eui_audio::Mixer::take_peak, pinned by
peak_is_before_the_viewers_own_gain.
8.1 The address bar is a hole in §8, and this is what it costs
net.open (03 §3.5) is the one capability that hands something to the world
outside this client, and the thing it hands over cannot be sanitised: a
server may put a token in the address it offers. The browser then arrives at
that server carrying the person's cookies and their address, and the session
they were reading is linked to the identity they browse the web with.
No allowlist closes that — a server is always allowed to link to itself — and nor does the scheme check, which is there for a different attack. What the client does instead is make the link the person's act and keep it that way: no op opens an address, no event reports one, the host is shown before the opener is called, and the capability is refused by default. A person who never clicks is never correlated, and a person who clicks has done the same thing they do every day in a browser.
It is written here rather than discovered later because §8's claim is that
the fingerprinting surface is the viewport frame. With net.open granted,
that claim has an asterisk, and this paragraph is the asterisk.
8.2 A notification is the one thing a server may do while nobody looks
Everything else in this protocol happens inside a window somebody has open in front of them. A notification (02 §5.2) does not: it is raised by the machine, it outlives the batch that asked for it, and it reaches somebody who had gone to do something else. That is what makes it worth having and what makes it the loudest thing a server can reach for, so three things hold it down.
The capability is the whole of the gate, because nothing in the tree asks for a notification and there is therefore nothing else to refuse; a client not granted it drops the op and says so once, to its own error output and never to the server. The count is the second: four to a batch, refused past that, which is the only limit in 02 §6 that bounds attention rather than memory. And nothing comes back — not shown, not clicked, not dismissed, not "this machine has no notifier". A server that notifies learns exactly what a server that offers an address learns, which is nothing, and for the reason §8.1 gives at length: an answer is a probe for what this machine is and who is at it, with a clock beside it.
The text itself is a server's string on its way to a platform that parses
arguments, so it is cleaned before it is handed over — control characters
out of the title, the body and the tag, nothing passed through a shell, and
no notifier started with a string that could be read as a flag.
Enforced: eui-client/src/driver.rs::note and one_line, pinned by
a_notification_needs_the_capability_and_a_title.
9. Server side
Every client event is validated against the tree the server last sent: the
node exists, it carries a handler of that kind naming that atom, the payload
has the declared shape. A local handler's effect is advisory until the server
re-derives it. Enforced: lang/src/serve/eui/session.rs::validate.
9.1 Sound and moving pictures
Playing a sound needs no capability: it is output, like drawing, and a
window that can draw can already annoy. What it does need is a bound, and
the client imposes it — at most eight sources at once, decoded bytes
counted against the session's asset quota, and the viewer's own volume
above everything, unreadable by the application — including through the
level event, which is measured before that gain is applied precisely so
that it stays unreadable. Decoding runs in the
worker with every other decoder; the audio device belongs to the window
process. Recording is not output and is not this: the microphone is a
declared capability (01 §2.1) and is not implemented.
A moving picture is bounded the same way and decoded in the same place. Its formats are chosen for the same reason: GIF and animated WebP decode in pure Rust with no C library, no assembly and no patent licence, which is worth more here than the compression a real codec would buy.
10. Process isolation
Everything that reads bytes a server chose — the frame decoder, the tree, layout, text shaping, the picture decoders, the bytecode VM — runs in a worker process. The window process keeps what needs the platform: the display, the GPU driver, TLS, the pin store, the clipboard, the accessibility adapter. Two pipes carry a private request/reply protocol between them: the window forwards raw frames and inputs; the worker answers with outbound frames and, on request, a draw list and the atlas bitmaps behind it. The window never decodes a frame. A worker that dies — a panic, a runaway allocation, a sandbox kill — ends the session with a reason; the window stays standing.
On Linux the worker confines itself before it reads its first byte, with two mechanisms each sufficient on its own:
- Landlock handles every filesystem and TCP right the running kernel knows, with no rule granting any: a deny-all, best effort on older kernels.
- seccomp allows the system calls the worker loop needs — its pipes,
memory, clocks, signals, exit — and kills the process on any other.
A worker that reaches for
openatorsocketis not tolerated and asked again; it is gone.
The worker is also marked not dumpable: a kill leaves no core — the core
would be the session, every text on screen, written to disk — and no other
process of the user can attach a debugger to it. What initialises itself
lazily (the text engine's font loader starts a thread pool and asks for the
core count) is warmed before the door closes, and the confinement is
applied to every thread. The two decode threads that take pictures, sounds
and moving pictures off the paint (10, Assets) are among what is warmed:
after the door closes a thread cannot be created, so they are started by
the throwaway driver the worker builds first, and every decode runs on
them, confined like the rest
(a_picture_is_decoded_in_the_confined_worker_and_lands_later in
crates/eui-client/tests/worker.rs).
Warmed means running, not created. A thread that has been created but
has not yet started does its own first steps whenever the scheduler gets to
it: the runtime names it (prctl(PR_SET_NAME)), and its first allocation is
where the C library gives it a memory arena. Behind the filter either one is
a kill, of a worker that did nothing wrong, and a loaded machine makes it
likely: a parallel test run, or a window starting its GPU in the same
instant. So the worker MUST NOT close the door until every thread it started
has run code of its own and allocated: the decode pool returns only once
each of its threads has checked in, and the lock-down first has every thread
of the text engine's pool run a task that allocates.
An arena is not only a timing question. glibc, while no arena limit is set,
works one out the first time the process holds eight arenas, by counting the
processors: __get_nprocs, an openat of /sys/devices/system/cpu/online,
in whichever thread's allocation got there, whenever that is. A thread
that allocated nothing during the warm-up and does so later is killed for
it. The worker therefore runs under GLIBC_TUNABLES=glibc.malloc.arena_max=4:
with a limit set, glibc takes it and never counts. The window sets it on the
worker's environment after clearing it, so nothing inherited can remove it,
and a worker started without it runs itself again with it before doing
anything else. Four is an arena for the worker's loop and one for each
decode thread, and one over. The allowlist is not widened for either call:
neither is something the worker needs
(every_thread_has_started_before_the_sandbox_closes in
crates/eui-client/tests/worker.rs: 128 workers locking down at once,
none killed).
The scene capability moves one thing across this line, and it is the
largest thing on either side of it. A shader is verified in the worker —
that is where the parse meeting bytes a server chose belongs — but it must
then be compiled, and a shader compiler belongs to the graphics driver, which
is in the window because the GPU is. So a program the server wrote is
translated by the client's shader front end and then compiled by the vendor's,
inside the process that holds TLS and the pin store. No division of labour
removes this. What verification buys is that the input to that compiler is
small, dull, and already rejected if it is anything else; what it does not buy
is the compiler's own safety. A client that grants scene MUST wrap that
compilation in a validation scope, because the alternative — the host API's
default — is a panic in the window process, which is the one outcome this
section exists to prevent.
One window process may hold several applications, and that is a choice
about cost rather than about this boundary. A window process carries the
GPU instance, the adapter, the device and the compiled pipelines, which is
most of what a first pixel costs; a client MAY therefore open a second
application's window in a process that is already running rather than
starting another. What it MUST NOT do is share a worker: the confinement
above is per session, and two applications in one worker would be two
servers' bytes in one address space. What it accepts in exchange is shared
fate — a lost device or a panic in the event loop takes every window in
the process — so a client that does this MUST offer a way to launch one
alone. The reference client hands a launch to the running instance over a
0600 socket in the user's runtime directory, keyed to the exact build on
both ends, and --standalone opts out.
EUI_SANDBOX=0 runs the driver in the window process, for debugging; the
window prints which of the two it did and what the sandbox enforced. macOS
(sandbox_init) and Windows (AppContainer) are not done: there the worker
is still its own process — a crash is contained — but a compromised worker
is not confined, and the window says so.
Enforced: eui-client/src/sandbox.rs (Landlock, seccomp, dumpable),
eui-client/src/worker.rs (the boundary and the wire),
eui-client/src/instance.rs (the shared window process and its socket).
Tested:
eui-client/tests/worker.rs — the counter end to end through a confined
worker, a hostile frame, a dead worker, and self-tests that a file read, a
TCP connect and an exec are refused.
Rendered from spec/08-security.md in the repository. The
normative text is spec/; where it and a prose page
disagree the spec wins, and that is a bug worth reporting.
Edit this page