GESH is a small, self-hostable, zero-knowledge synchronization relay for application state. Clients encrypt their own data and resolve their own domain conflicts; GESH authenticates sync roots, stores opaque immutable event blobs, and provides an incremental event feed for other devices.
GESH is infrastructure, not a database-as-a-service. The server must never require plaintext application data.
GESH is under active development. The Rust service is the beginning of the v2 security foundation and is not yet considered production-ready.
client domain state
|
v
serialize -> encrypt -> immutable sync event
|
v
GESH
/ \
SQLite index blob store
The current reference server uses:
- Rust + Axum for the HTTP service
- Tokio for async I/O
- SQLite via SQLx for durable event metadata and cursors
- the local filesystem for opaque encrypted blobs
- self-provisioned roots, with the authority and every device credential held in SQLite
The storage layer is intentionally simple today. S3-compatible blob storage is planned without changing the rule that GESH never receives plaintext application data.
GESH assumes clients are responsible for encryption, key management, serialization, conflict resolution, and validation of decrypted application data.
The server is responsible for safely handling hostile network input and protecting stored ciphertext. The Rust implementation therefore starts with several non-negotiable properties:
- protocol identifiers are restricted before they can become filesystem path components
- secrets are compared through fixed-length SHA-256 digests using constant-time comparison
- each device holds its own credential, so one device can be revoked without re-pairing the rest
- a root is created by the app that will own it, so no secret is ever chosen by a person or written to a file
- the authority that enrolls and revokes is a separate credential from the one the owning app syncs with
- event IDs are immutable; uploading the same
(app, root, device, event)twice returns409 Conflict - ciphertext is staged under a temporary name and published by atomic rename, so a partially written blob is never readable
- an event exists only once its metadata row commits; an upload interrupted before that point can be retried under the same event ID
- requests are authenticated before their body is read
- enrollment, handle lookup, and the credential check are rate limited per client, and repeated failures earn a doubling lockout
- SQLite replaces rewrite-the-whole-JSON metadata indexes
- incremental listing uses a server cursor and bounded page size
- request bodies have a configurable hard size limit
- the server binds to localhost by default; public exposure should happen behind a properly configured TLS reverse proxy
- application errors do not expose internal filesystem or database details
This is only a foundation. See SECURITY.md for the threat model and work still required before a production declaration.
Install a current stable Rust toolchain and run the service:
cargo runBy default GESH listens on 127.0.0.1:3000. There is nothing to configure and
no secret to write down — an app creates its own root the first time it runs.
Deployments from before self-provisioning used a hand-written
data/secrets.jsonregistry mappingappId→rootId→ secret. Those secrets still authenticate, so an existing install keeps working, but the file is no longer read if it is absent and nothing new should be added to it.
No Rust toolchain needed. compose.yml builds the image and runs it as an
unprivileged uid behind whatever TLS reverse proxy you already have:
docker compose up -d --build
curl -fsS http://127.0.0.1:3000/healthDeployment-specific settings go in a .env beside it — at minimum
GESH_PUBLIC_URL, so enrollment responses carry a pairing_uri, and
GESH_DATA_DIR pointing somewhere outside the checkout, because the default
./data is inside it:
GESH_PUBLIC_URL=https://sync.example.com
GESH_DATA_DIR=/srv/gesh/data
GESH_PROVISIONING_SECRET=$(openssl rand -base64 33)That directory must be writable by the uid the image runs as:
install -d -m 750 -o 10001 -g 10001 /srv/gesh/dataOnly the host side of the published port is loopback; inside the container the
process binds 0.0.0.0, since a container-loopback bind would be unreachable
even from the host. Set GESH_BIND to change what the host exposes.
The person using the app should never see any of this. No file to edit, no secret to copy, no identifier to type.
The app calls this once, on first launch, and stores what comes back:
curl -X POST $GESH/v1/roots \
-H 'Content-Type: application/json' \
-d '{"appId":"fattern","deviceId":"desktop"}'
# {
# "app_id": "fattern",
# "root_id": "root_7c5e1bb3-fca2-4e24-8c15-0fbb72e4f121",
# "handle": null,
# "device_id": "desktop",
# "root_token": "<the authority: enrolls and revokes>",
# "device_token": "<this device's own sync credential>"
# }That app is now the source of truth for the root. It is the only thing that can add a device or take one away, and it is where the content key lives — GESH never sees that key and cannot help anyone recover it.
Two tokens, because the app plays two parts. root_token is the authority.
device_token is what it relays its own events with, exactly like any other
device. Keeping them apart costs the app nothing and means the credential in
daily use cannot revoke anybody.
handle is optional in the request. A root is reachable by pairing code alone,
so only set a name if people are meant to type one.
# the first device mints a one-time code, valid for ten minutes by default
curl -X POST $GESH/v1/admin/fattern/$ROOT_ID/enrollments \
-H "Authorization: Bearer $ROOT_TOKEN"
# {"code":"79T54-26AJX","pairing_uri":"gesh://pair?s=...&c=79T54-26AJX",...}
# the new device redeems what it scanned — no handle, no root id
curl -X POST $GESH/v1/enroll \
-H 'Content-Type: application/json' \
-d '{"code":"79t5426ajx","deviceId":"phone"}'
# {"app_id":"fattern","root_id":"root_7c5e...","device_id":"phone","token":"..."}pairing_uri is present once GESH_PUBLIC_URL is set, and is the string to put
in the QR code. It carries where to go and what to say, and nothing that
identifies the root — the code already does that.
The app appends the content key as a fragment, which is the second half of pairing and the half GESH must never learn:
gesh://pair?s=https%3A%2F%2Fsync.example.com&c=79T54-26AJX#k=<content key>
A URI fragment is never transmitted to a server, so one QR code can carry both halves while the relay only ever receives one of them.
Codes are single-use, expire, are stored only as a hash, and are bound to the
root that minted them. Typed codes are normalized, so case and the grouping dash
do not matter — the alphabet drops 0/O and 1/I so a code can be read
aloud when there is no camera. Re-enrolling an existing deviceId replaces its
credential, which is how a reinstalled phone recovers without becoming a second
device.
Redemption is throttled and a wrong code costs a growing wait — see
Rate limiting. A 429 means honour the Retry-After header;
minting a fresh code will not help, because the lockout is on the client.
Revoking one device leaves every other credential untouched, and takes the device's claim on retained data with it:
curl $GESH/v1/admin/fattern/$ROOT_ID/devices -H "Authorization: Bearer $ROOT_TOKEN"
curl -X DELETE $GESH/v1/admin/fattern/$ROOT_ID/devices/phone \
-H "Authorization: Bearer $ROOT_TOKEN"The root's own credential is not in that list and cannot be named in a revocation, so a root can never be left with no authority over itself.
New to GESH? docs/integrating.md walks an application through provisioning, syncing, and pairing in order. What follows is the reference.
Two conventions apply everywhere. Request bodies are camelCase and response
bodies are snake_case — you send appId and read back app_id. And every
identifier in a path (appId, rootId, deviceId, eventId) must be 1–128
characters of ASCII letters, digits, -, or _; a handle is narrower still,
3–64 characters of lowercase letters, digits, or -. Anything else is 400
before it reaches storage.
Sync endpoints accept either credential:
Authorization: Bearer <root_token | device_token>Admin endpoints under /v1/admin require the root token and answer 403 to a
device credential. Three routes are unauthenticated, because a device has to be
able to arrive holding nothing: POST /v1/roots creates a root, and POST /v1/enroll and POST /v1/roots/{handle}/enroll trade a pairing code for a
credential. GET /v1/roots/{handle} resolves a typed name and reveals only
whether it exists.
On the sync plane a device credential speaks only for itself: the {deviceId}
in the path must be the device the token was issued to, or the request is 401.
The root token is exempt, since it is the authority on the root. On the admin
plane the rule does not apply, because there a {deviceId} names the subject of
the operation rather than its author.
POST /v1/roots
Content-Type: application/json
{ "appId": "fattern", "deviceId": "desktop", "handle": "madsen-home" }handle is optional. Returns 201 with the root and its two credentials, which
are shown exactly once. Open by default; set GESH_PROVISIONING_SECRET to
require Authorization: Bearer <secret> here, and rate limited either way.
PUT /v1/admin/{appId}/{rootId}/handle
Content-Type: application/json
Authorization: Bearer <root_token>
{ "handle": "madsen-home" }Sets or replaces the name a person can type to find this root, for a root
provisioned without one. Returns 204 No Content, or 409 Conflict if another
root already holds the handle. A root reachable by pairing code alone does not
need one.
POST /v1/admin/{appId}/{rootId}/enrollments
Authorization: Bearer <root_token>{
"code": "79T54-26AJX",
"expires_at_ms": 1786270600000,
"pairing_uri": "gesh://pair?s=https%3A%2F%2Fsync.example.com&c=79T54-26AJX"
}Returns 201. pairing_uri is null unless GESH_PUBLIC_URL is set. GESH
returns a string, not an image — the application renders the QR code itself,
after appending the content key as a #k= fragment. See
Pairing devices.
GET /v1/admin/{appId}/{rootId}/devices
Authorization: Bearer <root_token>[
{
"device_id": "phone",
"enrolled_at_ms": 1786270000000,
"last_seen_ms": 1786270450000,
"ack_cursor": 42
}
]Ordered oldest enrolment first. last_seen_ms and ack_cursor are null for a
device that has enrolled but never synced. The root's own credential is not in
this list and cannot be named in a revocation, so a root can never be left with
no authority over itself.
DELETE /v1/admin/{appId}/{rootId}/devices/{deviceId}
Authorization: Bearer <root_token>Returns 204 No Content, or 404 Not Found for a device this root has not
enrolled. Every other credential on the root is untouched.
POST /v1/enroll
POST /v1/roots/{handle}/enroll
Content-Type: application/json
{ "code": "79t5426ajx", "deviceId": "phone" }Unauthenticated, because a device arriving to be paired holds nothing yet.
{
"app_id": "fattern",
"root_id": "root_7c5e1bb3-fca2-4e24-8c15-0fbb72e4f121",
"device_id": "phone",
"token": "<this device's own sync credential>"
}Returns 201. The code identifies its own root, so the scanned route needs no
handle; the named route additionally requires the code to belong to that handle
and answers 401 when it does not. A wrong or expired code is 401, and
repeated failures earn a growing lockout — see Rate limiting.
GET /v1/roots/{handle}Unauthenticated. Returns {"app_id": "…", "root_id": "…"}, or 404 if no root
holds the name. It reveals that a handle exists and nothing further.
PUT /v1/sync/{appId}/{rootId}/{deviceId}/{eventId}
Content-Type: application/octet-stream
Authorization: Bearer <root_token | device_token>The request body is opaque ciphertext. A new event returns 201 Created. Reusing an existing event ID returns 409 Conflict; events cannot be overwritten.
GET /v1/sync/{appId}/{rootId}?after=0&limit=100&deviceId={optionalDeviceId}
Authorization: Bearer <root_token | device_token>after is an opaque server cursor from a previous response. limit must be between 1 and 500.
Example response:
{
"events": [
{
"cursor": 42,
"app_id": "fattern",
"root_id": "root_example",
"device_id": "desktop_a",
"event_id": "event_123",
"created_at_ms": 1786270000000,
"size": 4281
}
],
"next_cursor": 42
}GET /v1/sync/{appId}/{rootId}/{deviceId}/{eventId}
Authorization: Bearer <root_token | device_token>Returns application/octet-stream or 404 Not Found.
PUT /v1/sync/{appId}/{rootId}/{deviceId}
Content-Type: application/json
Authorization: Bearer <root_token | device_token>
{ "ackCursor": 42 }{ "device_id": "phone", "ack_cursor": 42, "last_seen_ms": 1786270450000 }The returned ack_cursor is the device's position after the report, which is
the higher of what it already had and what it just sent.
Reports that this device has consumed the feed up to and including that cursor, and registers it as an active peer. Acknowledgements only move forward, so a retried or out-of-order report cannot rewind a device's progress.
Once every active peer has acknowledged past an event, the relay has finished its errand and the ciphertext is erased. A device is never required to acknowledge its own uploads.
GET /healthUnauthenticated. Returns {"ok": true} whenever the process is running. It is a
liveness check and nothing more — it does not test the database or blob store.
Failures GESH raises itself return {"error": "<message>"}. The messages are
deliberately generic; the detail stays in the server log.
| Status | Means |
|---|---|
400 Bad Request |
Malformed identifier or handle, after below zero, limit outside 1–500, ackCursor below zero, or an upload that is not application/octet-stream |
401 Unauthorized |
Missing, unknown, or revoked token; a device token addressing another device on the sync plane; a wrong or expired pairing code |
403 Forbidden |
A valid device token on an /v1/admin route |
404 Not Found |
Unknown event, device, root, or handle |
409 Conflict |
An event ID already used on this root, or a handle already taken |
429 Too Many Requests |
Throttled or locked out; honour the Retry-After header |
500 Internal Server Error |
Storage failure |
A 401 on the sync plane is worth distinguishing from a 403: the first means
the credential is not accepted, the second means it is accepted but is not the
authority. Only the second is worth retrying with a different token.
Three more statuses come from the framework layer, before any GESH handler runs, and carry a plain-text body rather than the JSON shape above — parse defensively:
| Status | Means |
|---|---|
413 Payload Too Large |
Body above GESH_UPLOAD_LIMIT_BYTES |
415 Unsupported Media Type |
A JSON endpoint called without Content-Type: application/json |
422 Unprocessable Entity |
Well-formed JSON missing a required field, or with a field of the wrong type |
GESH is a relay, not a record. Data is held only as long as it takes to hand it to the other devices on a root:
- an event is erased once every active peer has acknowledged it
- an event nobody collects is erased when it reaches
GESH_EVENT_TTL_SECONDS - a device that has been silent for
GESH_DEVICE_TTL_SECONDSstops counting as a peer, so a retired device cannot pin data forever - an erased event leaves a tombstone for
GESH_TOMBSTONE_TTL_SECONDS, which keeps its identifier reserved so already relayed ciphertext cannot be replayed back onto the root
Reclamation runs on a background sweep every GESH_SWEEP_INTERVAL_SECONDS, so
data ages out of a root even while no client is talking to it. Set the tombstone
window longer than the event window; once a tombstone is purged, its identifier
becomes reusable again.
Almost every credential here is 244 bits of CSPRNG output and not worth guessing. Two things are: the enrollment code, which is short because a person reads it aloud, and the bearer-token check, which anyone who can reach the port may hammer. Both are throttled, before the request body is read:
- root creation is capped at
GESH_ROOTS_PER_MINUTEper client, which is what stops an open server being turned into free storage - handle lookups are capped at
GESH_HANDLE_LOOKUPS_PER_MINUTEper client - redemption attempts are capped at
GESH_ENROLL_ATTEMPTS_PER_MINUTEper client, and separately at the same rate per root, so guessing a code does not scale with the number of addresses an attacker holds - after
GESH_FAILURES_BEFORE_BACKOFFconsecutive bad codes or bad tokens, a client waits one second, then two, then four, up toGESH_MAX_BACKOFF_SECONDS
A throttled request gets 429 Too Many Requests with a Retry-After header. A
successful redemption or a working credential clears that client's failures, so
a legitimate device that fumbles a code is not punished afterwards.
Clients are identified by connection address — a whole /64 for IPv6, since a
single host is normally handed one. Behind a reverse proxy every request
arrives from the proxy, so all clients would share one bucket. Set
GESH_TRUSTED_FORWARDED_HEADER (usually x-forwarded-for) to key on the real
client instead. It is unset by default on purpose: anyone can send that header,
and honouring it unconditionally would let one host claim a fresh identity on
every request. Only the last entry is used, since that is the one the proxy
appended, so set it only when your proxy is the sole route to the process.
Limiter state is in-process and bounded. A restart forgets it, which makes this an abuse control rather than a durable lockout — the durable defence is that codes are high-entropy, single-use, and expire in minutes.
| Variable | Default | Purpose |
|---|---|---|
GESH_LISTEN_ADDR |
127.0.0.1:3000 |
Socket address to bind |
GESH_BLOB_BASE_DIR |
data/blobs |
Encrypted blob storage |
GESH_DATABASE_URL |
sqlite://data/gesh.db |
SQLite metadata database |
GESH_SECRET_REGISTRY_PATH |
data/secrets.json |
Legacy root-secret registry, optional |
GESH_PROVISIONING_SECRET |
unset | Required to create a root, when set |
GESH_PUBLIC_URL |
unset | Address embedded in the pairing URI |
GESH_UPLOAD_LIMIT_BYTES |
33554432 |
Maximum event body size |
GESH_EVENT_TTL_SECONDS |
604800 |
Age at which an uncollected event is erased |
GESH_TOMBSTONE_TTL_SECONDS |
2592000 |
How long an erased event's ID stays reserved |
GESH_DEVICE_TTL_SECONDS |
2592000 |
Silence after which a device stops counting as a peer |
GESH_SWEEP_INTERVAL_SECONDS |
60 |
Delay between reclamation passes |
GESH_ENROLLMENT_CODE_TTL_SECONDS |
600 |
How long a pairing code stays redeemable |
GESH_ENROLL_ATTEMPTS_PER_MINUTE |
10 |
Redemption attempts per client, and per root |
GESH_ROOTS_PER_MINUTE |
5 |
Roots one client may create per minute |
GESH_HANDLE_LOOKUPS_PER_MINUTE |
60 |
Handle lookups per client |
GESH_FAILURES_BEFORE_BACKOFF |
5 |
Consecutive failures before lockouts begin |
GESH_MAX_BACKOFF_SECONDS |
300 |
Ceiling on the doubling lockout |
GESH_TRUSTED_FORWARDED_HEADER |
unset | Proxy header naming the real client address |
RUST_LOG |
gesh_server=info |
Structured log filter |
The next security milestones are root-secret rotation, ciphertext integrity metadata, per-root storage quotas and throttle state that survives a restart, protocol conformance tests, storage reconciliation, backup/restore documentation, and a reusable client SDK.
GESH should remain boring server-side. If the relay ever needs to understand invoices, notes, files, tasks, or whatever exciting new object a client invents, the protocol has gone in the wrong direction.