With a DECTRIS detector the broker starts the writers - and so learned the collection's start message - only when the detector's own start message arrived, after /start had returned. A reader started right after /start found no collection (404) and had to wait for, and race with, the detector. - /start registers the collection in LiveCollection (Expect) as it is accepted, so GET /live/events is accepted from then on. The image pusher fills in the start message when it starts the writers (Begin) - at once on the FPGA path, when the detector's start message arrives on the DECTRIS path. - The event stream's first event is now `start`, carrying that start message (CBOR, base64) with run number, file prefix, image count and images per file; it replaces `collection`. Until it is known the stream only keeps alive. file and end follow as before. - However the measurement thread ends, it ends a collection the pusher did not end (EndIfOpen), so a cancel or a failure before the detector streamed reaches the reader as `end` with an error. The wait is event-driven: it ends with the start message or with the measurement. - GET /live/start.cbor is removed: the stream carries the start message, one route less. - rugnux takes the start message from the stream (BrokerFeed::WaitForStart), so it can be started right after /start; files reported before it attached the reader are replayed to it. Tests: LiveCollection_ExpectedAtStart; BrokerFeed_StartFromEventStream (an event stream whose start message comes late, then a file and the end). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
135 lines
9.2 KiB
Markdown
135 lines
9.2 KiB
Markdown
# Security
|
|
|
|
Jungfraujoch is a data-acquisition and analysis system for X-ray detectors, designed to run
|
|
**inside a controlled facility network**. This document describes what the software does and does
|
|
not protect against, the current known limitations, and the authentication work in progress.
|
|
|
|
## Threat model and scope
|
|
|
|
The security model targets a **semi-trusted internal facility network**. The concern is a peer on
|
|
that network reaching a Jungfraujoch service with **little or no effort** — a mistyped host/port, a
|
|
curious colleague, a mis-pointed script, a stray browser tab — not a determined attacker and not
|
|
passive wire capture (which is the responsibility of the network layer: 802.1x, VLANs, facility
|
|
infrastructure).
|
|
|
|
The asset that matters most is the **confidentiality of live analysis data**: the diffraction
|
|
images and derived metadata (unit cell, resolution, spot counts, sample name) that reveal *which
|
|
sample is being measured*. This matters for industrial and proprietary experiments. By contrast,
|
|
**acquisition control** (start / stop / configure) is treated as low risk — scientists operate their
|
|
own experiments and there is little to gain from restricting it.
|
|
|
|
Security is **best-effort**: measures that materially impede normal operation get turned off, so the
|
|
design favours a few high-value, low-friction controls over comprehensive lockdown.
|
|
|
|
> **Out of scope.** Jungfraujoch is **not** designed to be exposed to an untrusted network or the
|
|
> public internet. Do not do this.
|
|
|
|
## 1. Good practice — what is and is not protected
|
|
|
|
### What you can secure (and should)
|
|
|
|
These controls work and a deployment should apply them (see also `DEPLOYMENT.md`):
|
|
|
|
- **Network isolation.** Keep the broker and its data streams on a controlled segment. The
|
|
broker ↔ writer ↔ receiver traffic should run on a **dedicated back-end network**, with the
|
|
data-socket addresses pinned to that interface and the ports firewalled.
|
|
- **Reverse proxy for TLS.** The broker speaks plain HTTP. To get HTTPS, put a reverse proxy
|
|
(Apache / nginx) in front that terminates TLS and pin the broker to `localhost` behind it. The
|
|
desktop viewer supports `https://` endpoints — choose the scheme in the *Open HTTP Connection*
|
|
dialog.
|
|
- **Filesystem confinement of written data.** The writer creates NXmx HDF5 files on shared
|
|
storage. Confidentiality of that data **at rest** is enforced by the filesystem: run the writer
|
|
under a dedicated identity and use directory ownership / ACLs (and setgid) so that only the owning
|
|
experiment can read its files.
|
|
- **Firewall the ZeroMQ ports.** The image / preview / metadata / republish streams have no access
|
|
control of their own (see below), so restrict who can reach those ports at the network layer.
|
|
|
|
### What the software does NOT provide
|
|
|
|
The gaps are listed explicitly so a deployment does not assume protection that is not there:
|
|
|
|
- **No authentication or authorization in the broker.** The HTTP/REST API currently has **no login,
|
|
token, or access control**. Anyone who can reach the broker's host and port has full read access
|
|
(live images, unit cell, resolution, sample metadata) and full write access (start, cancel,
|
|
reconfigure). Confidentiality currently depends **entirely** on network/firewall isolation. This
|
|
is being addressed — see §3.
|
|
- **No transport encryption in the broker.** The broker serves plain HTTP; there is no built-in TLS.
|
|
Encryption must be provided by a reverse proxy.
|
|
- **No access control or encryption on the ZeroMQ streams.** The preview, metadata, image, and
|
|
republish streams are unauthenticated sockets. Any peer that can connect can subscribe to live
|
|
data. For the image `PUSH` stream specifically, an accidental extra consumer does not merely
|
|
eavesdrop — a `PULL` peer is load-balanced into the stream and will *divert* images away from the
|
|
real writer.
|
|
- **No per-user isolation.** The broker has no concept of users; it cannot separate one operator's
|
|
access from another's.
|
|
- **No application-level audit trail** of who accessed or changed what.
|
|
|
|
### Recommended deployment checklist
|
|
|
|
- [ ] Broker and back-end streams on an isolated network; **never** exposed to a general/untrusted network.
|
|
- [ ] ZeroMQ data-socket addresses pinned to the back-end interface; ports firewalled to known peers.
|
|
- [ ] TLS terminated by a reverse proxy; broker bound to `localhost` behind it.
|
|
- [ ] Writer run under a dedicated identity; data directories owned / ACL'd per experiment (setgid) so users read only their own data.
|
|
- [ ] ZeroMQ compatibility streams (preview / metadata / republish) enabled only if actually consumed, and only on the trusted back-end.
|
|
|
|
## 2. Known issues
|
|
|
|
| # | Issue | Impact | Mitigation today |
|
|
|---|-------|--------|------------------|
|
|
| 1 | Broker HTTP API has no authentication for control, and read access is protected only per dataset | Anyone who can reach it can start / stop / configure; a dataset started without `tokens` is readable by anyone | Per-dataset bearer tokens (§3) for the read endpoints; network / firewall isolation for the rest |
|
|
| 2 | Broker binds all interfaces, plain HTTP | Reachable from anywhere routable; no encryption | Expose only on the trusted segment; TLS via reverse proxy |
|
|
| 3 | ZeroMQ preview / metadata / image / republish streams are unauthenticated and unencrypted | Live-data exfiltration; a rogue `PULL` on the image stream diverts/steals images | Firewall the ports; run only on the back-end network |
|
|
| 4 | Web frontend has no login | The bundled UI takes a dataset token (key button) but nothing identifies its user | Serve and reach it only on the trusted network |
|
|
|
|
**Input robustness.** Services parse framed data from peers on the (trusted) data path. Hardening of
|
|
untrusted-frame handling (size caps, overflow guards) is ongoing; these paths are not intended to
|
|
face an untrusted network.
|
|
|
|
## 3. Authenticated read access — per-dataset bearer tokens
|
|
|
|
The confidential **read** endpoints are protected with a **best-effort, low-friction** scheme;
|
|
acquisition control stays open.
|
|
|
|
**How it works.** `/start` accepts an optional list of `tokens` - plain strings, any number, all
|
|
equivalent. A typical pair is one constant beamline secret and one secret minted for the
|
|
experiment, so both the beamline staff and the experiment's own users can open the data. While the
|
|
current dataset has tokens, the endpoints below answer `401` unless the request carries
|
|
`Authorization: Bearer <one of them>`; the 401 says nothing about the dataset. Every accepted
|
|
`/start` replaces the previous tokens, so a run started without them is open, and the next run's
|
|
users cannot read this one. No endpoint returns the tokens; the broker only compares strings
|
|
(constant-time) and keeps them in memory. Whoever runs `/start` hands the token to the viewers.
|
|
An accepted `/start` also clears what the previous run left readable - its statistics, plots and
|
|
buffered images - in the same step that installs the new tokens, so the previous run is never
|
|
served under the new tokens, and the new run's name never under the old ones. A refused `/start`
|
|
(wrong state, invalid settings) changes nothing.
|
|
|
|
| Endpoint | With tokens set |
|
|
|----------|-----------------|
|
|
| `/statistics/data_collection` (dataset name, unit cell, ...) | 401 without a token |
|
|
| `/result/scan` (dataset name, cell of a grid scan / rotation) | 401 without a token |
|
|
| `/image_buffer/start.cbor`, `/image_buffer/image.cbor`, `/image_buffer/image.jpeg`, `/image_buffer/image.tiff` (the images and the start message) | 401 without a token |
|
|
| `/preview/plot`, `/preview/plot.bin` (per-image plots, unit cell) | 401 without a token |
|
|
| `/live/events` (the start message and the data files of the collection being written) | 401 without a token |
|
|
| `/statistics` (the aggregate the web UI polls) | 200, but the `measurement` block is omitted |
|
|
| everything else (`/status`, `/config/*`, `/start`, `/cancel`, masks, pedestal, ...) | open |
|
|
|
|
**Clients.**
|
|
|
|
- *jfjoch_viewer* - the token field of **File ▸ Open HTTP** (password echo), the
|
|
`JUNGFRAUJOCH_HTTP_TOKEN` environment variable, or D-Bus (`LoadFile(url, image, sum, token)` /
|
|
`SetHttpToken(token)`); the dialog overrides both. A 401 clears the display and puts a note on
|
|
the status bar - no dialog, since a changed dataset is the normal reason.
|
|
- *Web frontend* - the key button in the top bar; the token lives in the tab's `sessionStorage`
|
|
and is sent with the protected calls only. The start form has a field for the tokens of a new run.
|
|
- *Python client* - `Configuration(host=..., access_token="<token>")`.
|
|
- *rugnux* following a collection from the broker - the `JUNGFRAUJOCH_HTTP_TOKEN` environment
|
|
variable, sent only in the `Authorization` header and never written to its logs or reports.
|
|
- *Anything else* - `curl -H "Authorization: Bearer <token>" ...`.
|
|
|
|
**What it does not do.** The broker still speaks plain HTTP, so the token crosses the network in
|
|
clear unless a TLS reverse proxy fronts the broker (§1); on a facility network this raises the bar
|
|
from "type the IP" to "capture packets", which is the aim. It does not authenticate users or
|
|
control, does not touch the ZeroMQ streams (issue #3), and `/status`'s free-text `message` may
|
|
still quote a path. Datasets of different users within one session are separated by their tokens
|
|
alone; there is no long-lived login.
|