Skip to content

dsh-plugin-morlin-e2e

Verified

dsh-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 disable typed 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 /healthz on 127.0.0.1:8788 (set localStorage["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_levels of each model, declared once in the fleet catalog) and writes them into reasoningEfforts of the models you declared on a Morlin-routed provider (Base URL on the relay, or on the Morlin origin itself in seal mode). off is sent as reasoning_effort: "none" (omitting the parameter would mean the model's default, which is thinking). Only those providers, only models you already declared, only reasoningEfforts are touched; models the router has but you have not declared are listed in the log. Refreshed every thinking.refreshSeconds; thinking.sync: false leaves reasoningEfforts to 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/keys is 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 no pinnedKeys any 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 (d data, f finale, e error) and the type is bound into the AEAD. A stream cut short, or an unencrypted [DONE] before the sealed finale, ends in an in-band e2e_truncated error 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 Date header 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 another Host are 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: POST to any endpoint outside encryptPaths answers 403 e2e_required; only body-less GETs (the model list) pass.
  • egress: strict (default) installs a guard on the whole process: every direct outbound HTTP(S) request except loopback and allowHosts is answered with a 403 whose message ("Blocked: this request to would be sent without end-to-end encryption ...") the harness prints in the chat. So a provider switched back to https://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 to allowHosts (exact name or *.suffix). egress: off disables 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) and fetch-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):

  1. Exact origin — the request comes from dsh-app://app (the desktop app) or an origin configured in allowedOrigins (the harness web page's origin is discovered from the webserver service automatically; the default list has http://127.0.0.1:3080). Another loopback port that was not allowed is not enough any more.
  2. Per-run token — the header X-Morlin-Control must 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 /control answers 403 to everyone).
  3. 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.