dsh-plugin-morlin-e2e
Verifieddsh-plugin-morlin-e2e · v0.8.8 · MIT · Web UI
DeepSeek Harness plugin: end-to-end encryption (HPKE) and automatic key exchange for the Morlin API — in-process sealing (ONE endpoint) or a local encrypting relay
Install
dsh plugin add dsh-plugin-morlin-e2e Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
dsh-plugin-morlin-e2e
End-to-end encryption and automatic key exchange for the Morlin API in
DeepSeek Harness (dsh).
What it does
Two ways to encrypt, both with automatic key exchange over the signed directory — and both keep the same Morlin endpoint.
intercept: 'seal' (recommended for the harness). The provider keeps its real Base URL
(https://router.morlin.ai/v1) and the plugin seals every request to the Morlin origin in-process, at the
undici dispatcher seam of the agent process — no relay hop in the request path, nothing leaves the process
unencrypted while the plugin is on. Fail-closed everywhere: a body that cannot be sealed (unsupported type, not
a JSON object, over the 32 MiB limit), a POST outside encryptPaths, an unsealed 2xx answer
(502 e2e_downgrade) and a stream without the sealed finale (in-band e2e_truncated) are refused — never
sent or passed in the clear.
The local encrypting relay. Mounting the plugin also starts a small relay on 127.0.0.1:8788 for tools
that have no plugin system — point them at the relay instead of at router.morlin.ai:
harness (plain OpenAI Chat Completions)
└─> http://127.0.0.1:8788/v1 ── relay ──> https://router.morlin.ai/v1
│ 1. GET /v1/e2e/keys (public key, cached, refreshed on 410)
│ 2. seal the request body with HPKE (RFC 9180, P-521 + SHA-512 + AES-256-GCM)
│ 3. open the sealed answer / each sealed stream chunk
└─ hands the harness an ordinary OpenAI answer
The TLS proxy, the gateway and their logs only ever see ciphertext. The model id stays in clear (the gateway
routes and bills on it); everything else — messages, tools, parameters, the answer — is sealed. The protocol is
specified in docs/encryption-e2e.md of the platform repository.
Why a relay and not a new model adapter: the harness' OpenAI-compatible routes already handle streaming,
usage, tool calls and retries; the seal interceptor leaves all of that untouched (it sits one layer below
fetch), and the same binary serves tools that have no plugin system.
In the harness UI
The plugin is a full plugin of the app, not just a background process:
- Plugins page — title, description, icon and version; a Configuration block (status, relay address, upstream, key id, number of sealed requests, how to set the provider) between the description and the components; the on/off switch and the Running status.
- Chat composer — a small status chip next to the model selector: green E2E on while the relay answers, grey E2E off when it does not. Click it for a small popover (successful requests, errors, a turn-off button; the full statistics are in Settings); switching off asks for the word
disabletyped by hand and for how long (red E2E paused while it is off; it switches back on by itself when the period ends, earlier when you turn it on here or reload the plugin). The popover also shows whether the server requires E2E for your key (see When the server requires encryption below) and, while encryption is paused on a required key, warns that model calls will fail. The plugin itself is switched on and off in the Plugins page / Settings. The chip asks the relay's/healthzon127.0.0.1:8788(setlocalStorage["morlin-e2e-port"]if you configured another port).
Install
# from a checkout (until the package is on npm)
dsh plugin --profile morlin add ./clients/dsh-plugin-morlin-e2e
# later, from npm
dsh plugin --profile morlin add dsh-plugin-morlin-e2e
dsh --profile morlin --dump-config # shows the "# == dsh-plugin-morlin-e2e" layer
dsh --profile morlin
dsh plugin needs pnpm on PATH. The package ships a bundle layer (cordis.patch.yml) that mounts the
plugin with its defaults.
Provider setup
Settings → custom provider (or a llm-pi-ai row in your profile's cordis.patch.yml). The API row is the same in
both encrypted modes — only the Base URL differs (see the table in "With and without encryption"):
| Field | Value |
|---|---|
| Display name | morlin-ai |
| API key | your mk_live_… key (sent with every request; the plugin stores nothing) |
| Base URL | seal mode: https://router.morlin.ai/v1/ (the real one) · relay mode: http://127.0.0.1:8788/v1 |
| API protocol | OpenAI Chat Completions |
| Models | llm-fast-large (type LLM) — ids from "Models & prices" |
- name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
morlin-ai:
displayName: morlin-ai
api: openai-completions
# seal mode (recommended): the real Base URL — the plugin seals at the undici seam, no relay hop
baseURL: https://router.morlin.ai/v1/
# relay mode (tools that cannot load the plugin): http://127.0.0.1:8788/v1
apiKeyEnv: MORLIN_API_KEY
models:
- id: llm-fast-large
name: GLM-5.3-Flash
and the plugin row that picks the mode:
- id: morlin-e2e
name: dsh-plugin-morlin-e2e
config:
upstream: https://router.morlin.ai
intercept: seal # 'off': only the relay encrypts (then point the provider at it)
(Patches replace a row's whole config: restate every provider you use in that row.)
- Chat composer, reasoning level — the model selector is replaced by one with a reasoning-level slider (adapted from hytime/dsh-thinking-effort v0.3.7, MIT, see
THIRD_PARTY_NOTICES.md). The levels are not typed in by hand: the plugin asks the router (GET /v1/model/info,model_info.reasoning_levelsof each model, declared once in the fleet catalog) and writes them intoreasoningEffortsof the models you declared on a Morlin-routed provider (Base URL on the relay, or on the Morlin origin itself in seal mode).offis sent asreasoning_effort: "none"(omitting the parameter would mean the model's default, which is thinking). Only those providers, only models you already declared, onlyreasoningEffortsare touched; models the router has but you have not declared are listed in the log. Refreshed everythinking.refreshSeconds;thinking.sync: falseleavesreasoningEffortsto you.
With and without encryption
Encryption is optional, and the choice is the route's Base URL (plus intercept):
| Mode | Base URL | Needs the plugin |
|---|---|---|
End-to-end encrypted, ONE endpoint (intercept: 'seal') |
https://router.morlin.ai/v1 (unchanged) |
yes, mounted and running |
| End-to-end encrypted via the relay | http://127.0.0.1:8788/v1 (the relay) |
yes, mounted and running |
| Plain TLS, no E2E | https://router.morlin.ai/v1 |
no |
The relay never falls back to plaintext by itself: if it is down or the key directory does not verify, requests fail
(Connection error in the harness means the relay is not running). To go plain, point the Base URL at the router, or
switch encryption off for a while from the composer chip (confirmed by typing disable).
When the server requires encryption (e2e_required)
A Morlin key can be flagged so that the server itself refuses unsealed requests for it: the billing row
api_keys.e2e_required makes the gateway answer 403 e2e_required to any plain POST /v1/chat/completions
before it is proxied. The plugin asks the gateway's policy endpoint once per key (cached, default 15 min):
GET /llm/v1/e2e/policy answers {"e2e_required":true|false} without consuming the key's RPM or creating
billing events. On a gateway that predates the endpoint (404) the plugin falls back to the old free probe — a
chat request for a model nothing serves (that fallback is scheduled for removal after two releases).
/healthz shows the answer as enforcement (and, while the requirement is known, forcedByServer), the
popover as the Server requires E2E row.
With encryption switched off from the chip a chat POST goes out plain only on a fresh policy answer that
proved the key accepts plain requests; otherwise it is sealed anyway — the exact path the switch-on takes —
and only refused when it cannot be sealed either. If the server still refuses the plain request (a stale
answer inside the cache window), the plugin retries the request once, sealed: the gateway refuses before
proxying, so the retry cannot double-bill, and there is never a retry after a response has started streaming.
While the server requires encryption for the key the popover locks the off switch (forcedByServer) —
clicking it would only meet the relay's 409 e2e_required — and /control refuses with that same 409.
A failed check (gateway unreachable, 401/402/429) counts as unknown and is treated like a
requirement: the request is sealed rather than sent on a guess. The only errors that reach the model call are
ones the encrypted path itself produces (no key, no keys directory, sealing impossible) — each with a message
saying what to do.
Configuration
Row morlin-e2e in the profile patch (all optional):
| Field | Default | Meaning |
|---|---|---|
intercept |
off |
seal: requests to the Morlin origin are sealed in-process — the provider keeps its real Base URL (ONE endpoint), no relay hop in the request path. off: only the relay encrypts |
upstream |
https://router.morlin.ai |
Morlin API origin. https only (plain http is refused except on loopback) |
host |
127.0.0.1 |
Bind address. Keep loopback |
port |
8788 |
0 picks a free port (read it from the log) |
encryptPaths |
[/v1/chat/completions] |
POST endpoints that are sealed; any other POST is refused (403 e2e_required), body-less GETs (model list) pass as ordinary TLS (a warning is logged once) |
apiKeyEnv |
— | Env var with a fallback API key for tools that send none |
keyTtlSeconds |
300 |
How long a fetched public key is trusted |
allowedOrigins |
[http://127.0.0.1:3080] |
Extra exact origins allowed to read /healthz and call /control. The harness web page's origin is discovered from the webserver service automatically (and re-checked at every page render) — add entries here only for special setups (a page behind a proxy, a fixed port that differs from the running one) |
thinking.sync |
true |
Take each model's thinking levels from the router (see the composer slider above) |
thinking.refreshSeconds |
600 |
How often they are re-read |
Standalone relay (Cline, Continue, Open WebUI, scripts)
npx morlin-e2e-relay --port 8788 # then use http://127.0.0.1:8788/v1 as the OpenAI base URL
Security properties
- Signed key directory (protocol v2):
GET /v1/e2e/keysis only a hint until its signatures verify against the OFFLINE root keys built into the plugin (src/trust.ts): root → DSK certificate → key statement. A swapped key (a TLS proxy in the middle) fails and requests are NOT sent. There is nopinnedKeysany more — pinning broke on every rotation. - Anti-rollback: the highest accepted key number is remembered (
~/.morlin-e2e/state.json); an older, validly signed key is refused. - No downgrade: a gateway that does not offer v2 is an error; a 2xx answer (or stream) that is not sealed is refused
with
502. There is no silent fall back to v1. - The end of a stream is authenticated: every part is typed (
ddata,ffinale,eerror) and the type is bound into the AEAD. A stream cut short, or an unencrypted[DONE]before the sealed finale, ends in an in-bande2e_truncatederror instead of a silently shortened answer. - Errors after decryption are sealed (
t="e"), in a stream and in a whole answer; they reach the model client as ordinary in-band errors. - Length hiding: the request is Padmé-padded and stream chunks are padded to 256-byte buckets, so ciphertext sizes do not reveal token lengths (spaces only, JSON stays valid).
- Fresh context per request (new ephemeral key, nonce and timestamp): nothing to store, replays are rejected by the
gateway. The clock is corrected from the
Dateheader of the key endpoint (only the request timestamp is affected). - Key exchange over TLS only; a redirect is refused. A
410 e2e_key_refresh(key rotated out) is refetched and retried once, invisibly. - Loopback only: the relay answers
127.0.0.1/localhost; requests with anotherHostare refused (DNS rebinding). - No secrets in logs: only paths, model ids, key ids, status and timing are logged — never bodies or API keys.
- Limits: this is software protection on top of TLS; it does not defend against someone with root on the model host. The model id is not encrypted. Other local processes can use the relay (with their own API key) — it is bound to loopback.
Troubleshooting
| Symptom | Cause |
|---|---|
| every sealed request fails on 0.6.0 | 0.6.0 shipped with empty root keys (deprecated): upgrade to 0.6.1 or newer |
502 … end-to-end encryption is not available |
the gateway has E2E off (GET /v1/e2e/keys is not 200) |
502 e2e_downgrade |
something answered without sealing — do not ignore; check the upstream |
400 e2e_invalid from the gateway |
clock off by more than ±5 min, or a modified body |
409 e2e_replay |
the same sealed request was sent twice (do not retry sealed bodies by hand) |
Connection error / harness cannot connect |
the plugin is not loaded or the port differs from the route's Base URL (curl http://127.0.0.1:8788/healthz) |
Development
npm install && npm run build
npm test # unit + relay tests
MORLIN_E2E_PYTHON=/path/to/python3.12 npm test # + interop with the real Python server implementation
MORLIN_LIVE=1 [MORLIN_API_KEY=mk_live_…] npm test # + live key fetch / encrypted chat against router.morlin.ai
The interop suite runs the relay against a mock gateway built on services/encrypt-gateway (the same
open_request / ResponseKeys.seal the production hook uses), so a protocol drift fails the build.
Publishing: add the dsh-plugin topic to the repository; engines.dsh pins the supported harness range.
No plaintext while the plugin is on
- The relay never forwards a request body it did not seal:
POSTto any endpoint outsideencryptPathsanswers403 e2e_required; only body-lessGETs (the model list) pass. egress: strict(default) installs a guard on the whole process: every direct outbound HTTP(S) request except loopback andallowHostsis answered with a403whose message ("Blocked: this request to would be sent without end-to-end encryption ...") the harness prints in the chat. So a provider switched back tohttps://router.morlin.ai/..., or any other provider, cannot send a prompt in the clear while the plugin is on. Hosts the harness itself needs (updates, MCP servers) go toallowHosts(exact name or*.suffix).egress: offdisables the guard.
Honest limits of the guard (read this)
The guard covers the two things real HTTP clients use: the process-wide fetch (undici) dispatcher and the
http/https modules (http.request / http.get / https.request / https.get — what axios, got and ws
call). It is a guard for the HTTP clients of this process, not a sandbox:
- raw sockets (
net.connect,tls.connect) andfetch-like libraries that speak HTTP over them directly are not covered; - child processes (a spawned curl, an MCP server binary) have their own network permissions;
- native modules can open sockets the guard never sees.
For hard guarantees pair the plugin with a network filter at the OS level (a per-app firewall profile, a
sandbox with no direct egress): the relay itself only ever needs router.morlin.ai:443.
The on/off switch is protected
POST /control answers only when all three hold (v0.5):
- Exact origin — the request comes from
dsh-app://app(the desktop app) or an origin configured inallowedOrigins(the harness web page's origin is discovered from the webserver service automatically; the default list hashttp://127.0.0.1:3080). Another loopback port that was not allowed is not enough any more. - Per-run token — the header
X-Morlin-Controlmust carry the token the relay generated at startup. It reaches the page as the boot global__MORLIN_E2E_CONTROL__(published by the plugin's host half through the harness' index injection); it is never served over the relay's own HTTP. A foreign page — another local dev server, an XSSed page — can produce neither. After a plugin reload the token changes: reload the page so the chip picks up the new one (until then/controlanswers 403 to everyone). - The typed word for switching off, as before.
Every change is logged (who, when, how long). An off period is always bounded — 15 minutes to 8 hours, default
1 hour — the relay switches encryption back on by itself; it never stays off by accident. /healthz is
readable only by the same allowlisted origins, so a foreign page cannot even learn the relay's status.
Remote mode (CLI)
morlin-e2e-relay refuses a non-loopback --host unless --allow-remote is passed: on 0.0.0.0 the relay
would be an open proxy for the whole network. Remote mode is deliberately restricted: the fallback
MORLIN_API_KEY is ignored (every caller brings its own Authorization), /control does not exist (404) and
/healthz answers only {status: "ok"}. --port is validated (1–65535).
Why there is no client-side key refresh any more
Versions before 0.5 offered a scheduled re-fetch of the gateway's public key. It gave no security benefit —
the key is refetched whenever the gateway says the cached one is stale (410 e2e_key_refresh), and the cache
expires after 5 minutes anyway — while the schedule invited a false sense of freshness. Key freshness is the
gateway's job (rotation + grace period); the client just follows. ~/.morlin-e2e/settings.json is no longer
read and can be deleted.