Enterprise Edition: security and privacy model
Status: describes the system as deployed on 2026-10-13 (Seed branch feat/enterprise-edition, PR #1219; hosting repo enterprise/ tooling). Companion to the design. Every claim here is about what the code does today; the last section lists what is not yet true.
The promise, stated precisely
An Enterprise Edition (EE) instance is one organization's Seed, on one machine, reachable only through one hostname, usable only by people the organization invited. The privacy claim is:
Content stays on the box. Documents, comments, files and the index never leave the machine except in a backup the operator takes, or in an email the organization's own services send.
Nobody reads without a member session. Every page, API call and file by CID is refused at the edge unless the request carries a session that the instance itself issued to a member.
Nobody joins without an invitation. Accounts exist only in the on-box vault, which refuses to create one without a single-use code an operator minted.
Keys stay with people. Member keys live in the vault as ciphertext the server cannot read. The organization's root key is held by the daemon on the box and exported once to whoever installs the instance.
Everything below is the mechanism behind those four sentences, and then the places where the mechanism is weaker than the sentence.
Trust boundaries
flowchart LR
subgraph people [People]
M[Member browser]
A[Operator laptop / Actions]
end
subgraph box ["The instance (one droplet)"]
C[Caddy :443]
W[seed-web]
V[seed-vault]
D[seed-daemon]
S[(volume: blobs, SQLite, config, owner key)]
C --> W
C --> V
W --> D
V --> D
D --> S
W --> S
V --> S
end
subgraph seed [Seed-operated]
H[host.seed.hyper.media email relay]
R[Docker Hub images, GitHub raw deploy engine]
end
M -- HTTPS --> C
A -- SSH as root --> box
V -. email via relay .-> H
W -. email via relay .-> H
box -. image pulls, engine download, ACME .-> RThere are four parties with different powers:
Party | What they can do | Why |
|---|---|---|
A member | read everything on the instance; write where they hold a capability; create their own space | every member session passes the edge gate; there is no per-document access control inside the box in phase 1 |
The operator (root on the host) | read and modify everything, including every member's content, the daemon keystore and the owner key file | the daemon's API has no authentication of its own, blobs and databases are stored in the clear, and root reads the volume |
Seed (whoever can run the hosting repo's workflows, or owns the relay) | become the operator of any instance; read every email the instance sends | the workflows hold root SSH keys for every instance; the relay forwards the email body |
Everyone else | learn that the hostname exists and that it is a Seed site; store signed blobs on it; nothing more | TLS certificate transparency, the two open endpoints described below |
That third row is the most important thing on this page: in the current setup "the organization's private Seed" is private from the public and from other organizations, not from the Seed team. That is the normal hosted-service trust relationship, and it should be stated to customers as such until the hardening below changes it.
Network: what goes in and out
Inbound. The cloud firewall and the host firewall allow 22 (SSH), 80 (only to redirect to HTTPS and answer ACME), 443 TCP and UDP. Nothing else is reachable. The daemon's libp2p port is not published by Docker and the listener is bound to 127.0.0.1 inside its container. The daemon's gRPC and HTTP ports exist only on the Docker network; Caddy forwards only /ipfs/* to them, and only after the session check.
No peer-to-peer. The daemon runs with -p2p.isolated: a connection gater denies every inbound and outbound libp2p connection, no bootstrap peers are dialed, the peer-exchange tick does not run, relays, AutoNAT, hole punching and the NAT service are off, the site-domain poller and the on-demand /hm/api/config fetches are off, and -lndhub.disabled skips the startup wallet check that would otherwise send every local account id to Seed's lightning server. Discovery and the sync scheduler are disabled on top. Verified with a two-host test (an isolated node neither dials nor accepts) and by the NodeIsolated and LndhubDisabled log lines at boot.
Outbound, by design. These leave the box and should be understood as the privacy cost of operating it:
Flow | Who sees what | Can it be removed? |
|---|---|---|
ACME to Let's Encrypt on first start and renewal | Let's Encrypt learns the hostname; the certificate is published in Certificate Transparency logs, so the existence of | yes, with a private CA or a VPN-only listener (Caddy |
Docker Hub image pulls and the deploy-engine download from GitHub at install and on manual updates | Docker Hub and GitHub see the instance IP; images are pulled over TLS by tag | yes, with a mirror; integrity is tag-based today, not digest-pinned |
Email through the Seed relay ( | the relay sees recipient addresses, subjects and bodies (verification codes, notification text that quotes comments) | yes, by giving the instance its own SMTP; the relay exists so customers do not need one |
DNS and NTP from the host | ordinary | ordinary |
There is no telemetry: SENTRY_DSN is empty, the daemon has no update check, and the web app's statistics are off.
Identity: how a person becomes a member
An operator mints an invitation (invite <email>): a 32-byte random code stored in the vault, bound to that email, single-use. The link is https://<host>/vault?invite=<code>.
The person creates an account in the vault: email, the 4-digit code the vault emails (through the relay), then a passkey or a password. The vault stores only ciphertext of their key material; the account's public key is generated in the browser. The invitation is consumed in the same database transaction that creates the user, so a code cannot be reused.
The person signs in to the site. The web app makes a throwaway session key in the browser, the vault signs a capability delegating the account to that key, and the browser uses it from then on. The vault never leaves its origin for this; WEB_IDENTITY_ORIGIN is the instance itself.
/hm/api/auth: the browser proves possession of the session key to the daemon, which mints a 30-day bearer token. Then, and only in this profile, the web app resolves the session key back to its account (through the capability) and asks the vault, over the Docker network with the admin secret, whether that account belongs to a vault user. Only then does it set the HM-Session cookie: base64url({account, exp}) + "." + HMAC-SHA256(secret), HttpOnly, Secure, SameSite=Lax, same 30-day expiry.
Every later request is checked by Caddy with forward_auth against /hm/api/session, which verifies the HMAC and expiry with no network call and answers 204, 302 (to the sign-in page, for HTML) or 401.
Why the extra cookie exists: the daemon's own token is minted for any key that has a single blob in the database, and the sign-in flow has to publish such blobs before any cookie can exist, so "has a daemon token" does not mean "is a member". Membership is the vault's user table.
The vault learns one thing it did not know on hyper.media: which public account ids belong to which email, because the browser reports them (each claim signed by the account key, so nobody can register a key they do not hold). That mapping is the whole membership list and is readable by the operator. It is a deliberate step away from the hosted vault's zero-knowledge posture, limited to this profile.
Authorization inside the box
Phase 1 has exactly two classes: member and not a member. A member can read every document, comment and file the instance holds, including other members' personal spaces, because the daemon runs in public-only mode over content that is all public within the box, and the gate is binary. Writing is governed by the protocol's capabilities: the organization's space accepts writes from its owner key and from accounts holding a WRITER (path-scoped) or AGENT capability the owner signed. A member who was not granted anything can still create spaces of their own on the instance (/hm/create-site), and those are readable by all members.
What this means: EE today is a private intranet, not a system of compartments. Per-team confidentiality inside one instance needs phase 2 (private documents re-enabled behind a flag, a READER role, the two read-predicate gaps on the Privacy page fixed).
The unauthenticated surface
Everything the edge lets through without a session, with the reason and the risk:
Path | Why it is open | Risk | Note |
|---|---|---|---|
| the sign-in UI itself | none beyond the page shell | |
| the vault UI and API: invitation acceptance, sign-in, delegation | login attempts against known emails; account creation is blocked without a code | the vault's own rate limiting on login and code entry is the control here; it must be verified (see hardening) |
| sign-in needs the daemon peer id | reveals the registered space id, the peer id, the server signer id, the hostname, | low; it identifies the instance as a Seed site, which TLS already does |
| operations | reveals the exact commit | low; useful to attackers only with a known vulnerability |
| the sign-in handshake and the gate itself | token minting for any key that already has a blob; no cookie without membership | by design |
| the sign-in callback must store the delegation capability and profile blobs before a session exists | anyone can store signed blobs and raw files on the instance without an account: a disk-filling denial of service, or junk content that members then see as a stray account | the daemon verifies signatures, so blobs are at least self-authenticating; there is no size or rate limit today. This is the first thing to harden. |
| the callback reads the account it just created | profile lookup by account id for ids that exist on the box | low; ids are 32-byte keys, not guessable |
| re-registering a space to the site | needs the registration secret | keep the secret out of logs |
| creating the first space before anything exists | the page requires sign-in client-side | low |
| installation automation | need the vault admin secret (constant-time compare, 404 when unset) | the secret lives in |
| separate services on subdomains | agents has its own signed-envelope auth; notify's inbox API is signed | they are not behind the member gate |
Secrets and keys on disk
All under /opt/seed on the block-storage volume, owned by root, readable by root and by the containers that mount them. The volume is not encrypted by the installer; use DigitalOcean's encrypted volumes or LUKS if the hosting contract needs encryption at rest.
Secret | Where | Protects |
|---|---|---|
|
| re-registration; the vault admin API and the membership check; every member session (an attacker with it can mint any session); sending email as the instance |
the organization's owner key | the daemon's file keystore under | root of the space: everything published under it |
the web server's own signer key | the daemon keystore | site services; not a member identity |
vault database ( | user rows: email, ciphertext of key material, passkey public keys, the HMAC secret for vault sessions, invitations, the uid-to-email mapping | member key material only as ciphertext; the membership list in the clear |
daemon database and blob store |
| all content, in the clear |
Caddy certificates and ACME account key |
| the TLS identity of the host |
Backups (seed-deploy backup) contain all of the above except owner-key/, which is deliberately left out so an archive is not also the root key. A backup archive is therefore as sensitive as the instance; it is unencrypted until the operator encrypts it.
The mnemonic of the owner key is never written anywhere: the bootstrap derives the seed in memory, checks that the daemon derived the same account, writes the .hmkey.json, and forgets it. The key file supports a password; the current tooling writes it without one, so the file is the key.
Who holds power over the instance
The vault admin secret (operator, and the web app at runtime) can mint invitations and answer membership. It cannot read member keys.
The session secret (web app at runtime, config on disk) can forge any member session. Rotate it to log everyone out.
The owner key file is the organization's root. Whoever imports it into a vault owns the space; the installer gets it exactly once. The copy in the daemon keystore stays on the box and is used only by the bootstrap and the site-url update; a root operator can use it.
Root SSH sees all of the above. Today the hosting repo's ENTERPRISE_SSH_PRIVATE_KEY is authorized on every instance, so the hosting repo's operators are root everywhere. The ENTERPRISE_OPERATORS allowlist decides who may run the mutating workflows; the read-only ones (list, status, invite, backup) are not gated beyond repo access.
The DigitalOcean and Cloudflare tokens in the hosting repo can create and destroy instances and change DNS for the base domain. They are currently personal tokens with full scope.
What is private, in one table
Fact | From the public | From other members | From the operator | From Seed |
|---|---|---|---|---|
That the instance exists at | no (CT logs, DNS) | n/a | no | no |
Who the members are (emails, account ids) | yes | no (profiles are visible) | no | no (relay sees emails; workflows are root) |
Documents, comments, files | yes | no (phase 1 has no compartments) | no | no (root) |
Member key material | yes | yes | yes (ciphertext) | yes (ciphertext) |
The organization's root key | yes | yes | no (keystore + export) | no (root) |
Email contents the instance sends | yes | n/a | no | no (relay) |
Traffic metadata (who connects when) | from the network path, as any HTTPS site | yes | no | no (host access) |
Hardening, in priority order
Close the pre-session write path. POST /api/PublishBlobs and POST /ipfs/* are open to the world. Options, in increasing strictness: per-IP rate and size limits in Caddy (needs the rate-limit plugin or an upstream check), a short-lived "sign-in in progress" token issued by /hm/sign-in that the callback must present, or moving the capability publication to the server side of /hm/api/auth so the browser never needs the open endpoint. The last is the right fix.
Narrow Seed's power. Scoped DigitalOcean token (droplet, volume, firewall, ssh key, tag only); a Seed-organization account instead of a personal one; move the SSH private key out of the workflows into a per-instance key the customer can revoke; restrict SSH on the cloud firewall to known addresses; required reviewers on the mutating workflows when the repository plan allows it.
Encrypt the volume and the backups. DigitalOcean encrypted volumes or LUKS; age/gpg the backup archive before it leaves the box; keep the owner key export off the volume once imported.
Verify the vault's brute-force controls: lockout or rate limits on code entry (10,000 possible 4-digit codes) and on password sign-in, and that invitation lookup is constant-time. If absent, add them before offering password sign-in.
Audit log. Sign-ins, invitations, capability grants, admin API calls, backups and exports, written append-only and exportable to the customer's SIEM.
Daemon API authentication inside the Docker network, or at least a separate network per container pair, so a compromised vault or web container is not automatically a full read-write daemon client.
Pin images by digest and verify the deploy engine's hash; today both are fetched by tag or branch name over TLS.
Compartments (phase 2): private documents behind a server flag, the READER role, and the two read-predicate gaps fixed, so an instance can hold more than one circle of trust.
Headers: HSTS with preload, a CSP for the web app and the vault, and Referrer-Policy: no-referrer, set in Caddy.
Private CA option for organizations that do not want the hostname in Certificate Transparency.
Email relay opt-out: when the customer supplies SMTP, nothing about their users reaches Seed.
Retention and deletion: a documented procedure to wipe an instance (volume destruction is what delete does; confirm that DigitalOcean destroys the underlying blocks) and to delete a member (today: revoke their capabilities and their vault account; their already-published blobs remain, by protocol design).
What is not yet true
Stating the gaps plainly, because the first section is only as strong as this one:
"Nobody reads without a member session" is enforced at the edge and in the app, but a member reads everything; there is no finer boundary yet.
"Content stays on the box" is true for documents, but email text leaves through the relay and the hostname leaves through TLS.
"Keys stay with people" holds for members; the organization's root key also lives in the daemon keystore on the box.
Rate limiting at the edge is not configured (stock Caddy has no rate-limit module).
Nothing audits what operators do.
The deployment pipeline (hosting repo, Docker Hub, GitHub raw) is trusted on reputation, not on signatures.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime