The security lens for the whole spec.
AUTH.md,APP_ISOLATION.md,STORAGE.md,MALMO_NETWORK.md, andUSERS_AND_GROUPS.mdeach defend against an attacker; this doc writes down who that attacker is, what we protect, and — most importantly — what we deliberately don't defend against. It is a checking framework and a place to point when arguing edge cases.
Environment profiles. This doc's posture is the
applianceone: the user owns the hardware, and the box is closed-by-default. The hosted profile (cloud VM) differs on two load-bearing points — malmo-operated infra is inside the trust boundary (an honest convenience tier, not operator-blind), and the exposure posture inverts to public-by-default / auth-gated. SeeENVIRONMENT.md# Threat model (hosted) and # Public-by-default, auth-gated.
Every "how we defend X" lives in the doc that owns X. This document is an index of what we defend and what we don't; the other docs are the how. If reading this makes us want to change a defense, that's the threat model doing its job — but the change lands in the owning doc, and an entry goes in DECISIONS.md. The threat model documents; it does not decide.
Scope: v1 only — closed-by-default, LAN-first. The deferred mesh / remote-access surface (MALMO_NETWORK.md # Deferred) changes the internet-facing boundary materially; it is out of scope here and gets its own pass when it ships (see # When this model changes). Everything below assumes the v1 posture: no app or service publicly exposed, cloud is DNS + certs only.
malmo is a household appliance, and almost every security decision follows from one assumption:
Members of a household broadly trust each other. malmo is not hardening one member against another at kernel grade. The real adversaries are (1) the network — LAN and internet, (2) a compromised app, and (3) physical possession of a drive that left the building.
This single paragraph justifies a large fraction of the spec, and naming it stops reviewers from relitigating decisions against the wrong adversary:
- No cross-app SSO and no member-vs-member kernel sandbox because members aren't the threat (
AUTH.md# No SSO;APP_ISOLATION.md# Multi-user runtime). - Admin/root can read every user's files in v1 — accepted, because the admin is a trusted household member, not an adversary; per-user admin-resistant encryption (fscrypt) is a roadmap upgrade, not a v1 gap (
STORAGE.md# Future: per-user encryption). - Demotion doesn't kill a live
sudosession, the login screen lists who lives here, temporary passwords travel verbally — all fine under household trust (USERS_AND_GROUPS.md# Known gaps,AUTH.md# Login screen UX). - Catalog apps are filtered by curation, not by sandbox-proof isolation — we trust the review process to keep host-rooting apps out, and isolate to bound the blast radius of an honest app that gets compromised, not to contain a deliberately malicious author (
APP_ISOLATION.md# Trust tiers).
Where the trust model does not extend: the network (treated as hostile), app runtime (assume any app can be compromised), and a drive that physically leaves (assume it falls into hostile hands).
| Asset | Where it lives | Primary threat |
|---|---|---|
| User content (Photos, Documents, …) | /home/<user>/ (STORAGE.md) |
Cross-user read; drive theft; ransomware-via-app |
| PAM credentials | /etc/shadow (AUTH.md) |
Credential theft, offline cracking |
| App state + per-user managed-service DBs | /var/lib/malmo/instances/<id>/ (APP_ISOLATION.md) |
Cross-app / cross-user access |
| Brain SQLite (accounts, sessions, audit log) | /var/lib/malmo/brain/state.db |
Tamper (esp. audit log), session theft |
| LUKS keys / TPM seal | OS drive + TPM (STORAGE.md) |
Whole-box theft, Secure-Boot subversion |
| Box network position | nftables, closed-by-default (MALMO_NETWORK.md) |
Remote exploitation, lateral movement |
| Privacy metadata (who-runs-what, audit trail, cloud DNS queries) | brain SQLite + cloud (LOGGING.md, MALMO_NETWORK.md) |
Disclosure / correlation |
| Principal | Capability assumption |
|---|---|
| Anonymous LAN device | On the same L2 network; can reach any LAN-exposed port; can attempt mDNS spoofing, ARP tricks. Present and untrusted. |
| Authenticated member | Valid malmo password; unprivileged Linux user; owns their own data + per-user app instances. Trusted (household). |
| Admin | Member + sudo + host mutation via the dashboard/host-agent. Trusted; a compromised admin is high-impact by design. |
| App container | Runs code we didn't write. Assume it can be compromised. Confined to its declared permissions. |
| Catalog app author | Submits manifests. Trusted via curation, not via sandbox. A deliberately malicious author is out of scope (curation's job). |
| malmo cloud | DNS resolver + ACME helper. Sees box-ids and query metadata; never sees traffic or data. Honest-but-curious. |
| Possessor of a removed drive | Has a drive that left the box. Hostile. Defended by LUKS. |
| Possessor of the whole box | Physical theft of the running/poweroff box incl. TPM. Out of scope beyond at-rest encryption. |
| Internet attacker | Off-LAN, no mesh credential. In v1 has no reachable surface (closed-by-default). |
The analysis is organized by boundary, because that's where threats live. Each table: the threat, the doc that owns the mitigation, and the residual risk we knowingly carry.
The box presents no surface to the public internet in v1. SSH/SMB are firewalled to RFC1918 + the mesh interface; store apps don't bind host ports; the cloud never carries traffic.
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Remote exploitation of an exposed service | Closed-by-default; SSH/SMB scoped to RFC1918+mesh via nftables (AUTH.md # Device access, BUILD.md # SSH); no public-exposure toggle (MALMO_NETWORK.md # closed by default) |
BYO-domain + explicit advanced exposure is the user's consciously-accepted risk |
| Apps reaching the internet uninvited | internet: false → internal: true bridge, kernel-level (no NAT route) (APP_ISOLATION.md # internet) |
— (host ports are admission-rejected for both doors; nothing binds to the host) |
| Cloud-mediated LAN access concerns | Cloud resolves names + sets ACME TXT only; no proxy/tunnel (MALMO_NETWORK.md) |
Cloud learns box-id exists and who queries it (metadata, not content) |
The richest boundary. The right question is not "can an app be compromised" (assume yes) but "what's the blast radius when one is?"
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Container escape to host root | cap_drop: ALL; privileged, docker socket, SYS_ADMIN admission-rejected for both doors; Docker default seccomp/AppArmor (APP_ISOLATION.md # Forbidden for both doors, # Capabilities) |
No userns remap, no custom seccomp in v1 (Docker defaults deemed sufficient) |
| App lies about declared permissions | Enforced at kernel/Docker layer, not metadata — violations silently fail + log (APP_ISOLATION.md # Failure mode) |
— |
| Compromised app reads beyond its scope | Per-app bridge (no inter-app traffic); bind-mounts limited to the declared folders at their elected source (one user's home, or the household-shared tree the owner can already reach); other homes not on its filesystem (APP_ISOLATION.md # Filesystem) |
Blast radius = the app's declared permissions + its mounted folders + its own managed DB. An app can read everything its grants allow — that's the grant, not a leak |
| Brain↔Docker control-plane abuse | Brain talks to Docker via socket-proxy, not raw socket (CONTROL_PLANE.md) |
— |
| Privileged Door-2 app | Rejected — admission is door-symmetric; privileged/socket/cap_add/host-ports/host-namespaces are refused for custom compose exactly as for store apps, because a container escape on a multi-user box hits every member, not just the (admin-only) installer (APP_ISOLATION.md # Trust tiers, DECISIONS.md 2026-06-02) |
An admin who needs such a container runs it over SSH (AUTH.md # SSH is rescue) — deliberate, not one-paste; that residual is the box owner's own root access |
Blast-radius summary: one compromised store app reaches its own data, its user's declared folders, and the internet (if granted) — not host root, other users' homes, or other apps. That containment is the security claim; preventing the compromise itself is curation's job, not the sandbox's.
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Member performs admin action | Role checked server-side in the brain every request; routes grouped by role at router level; UI hiding is defense-in-depth only (AUTH.md # Roles) |
— |
| Stolen elevated session | 5-minute elevation window; enrollment-class ops (add/eject drive) require a fresh password every time (USERS_AND_GROUPS.md # Elevation, AUTH.md) |
A forgotten open tab is bounded to 5 min of elevation |
| Demoted admin retains power | gpasswd -d flips group membership (USERS_AND_GROUPS.md) |
Live sudo/SSH session keeps capability until logout — accepted under household trust |
| Compromised admin SSH | SSH off-by-account-by-default; admin must opt in (AUTH.md # Device access) |
A compromised admin shell is root — accepted; marginal, since the admin can already mutate the host via host-agent |
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Cloud compromise injects bad data | Cloud is DNS + ACME-helper only; per-box keypair auth; enrollment opt-in (MALMO_NETWORK.md) |
Compromised cloud could mis-resolve a box-id; cannot decrypt traffic or reach data |
| Cloud sees user activity | No traffic ever traverses cloud servers (MALMO_NETWORK.md # What cloud actually does) |
Cloud sees box-ids and which devices query them — disclosed; privacy-doc surface |
| Privacy-strict user wants zero cloud | Enrollment is opt-in; box never contacts cloud if declined; BYO-domain alternative (MALMO_NETWORK.md) |
— |
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Drive theft / RMA / removal | LUKS on every drive; separate keyslots; one recovery passphrase (STORAGE.md # Encryption posture) |
Defended — a drive that leaves is unreadable |
| Whole-box theft | — | Out of scope. TPM is in the box; PCR-7 auto-unlock means a thief with the box boots into decrypted storage. Future PIN-on-boot "high-security mode" is the planned answer (STORAGE.md) |
| Attacker subverts Secure Boot | TPM seal against PCR 7 (STORAGE.md) |
PCR-7-only is weaker than PCR 7+11; trade made for kernel-update-survivable unattended boot. Stricter sealing is a non-destructive upgrade (NEXT.md) |
| Doubly-lost (no backup + lost passphrase) | — | Out of scope. Data gone. Honest, same shape as losing both copies of any single-copy secret |
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| One user reads another's files via FS | /home/<user>/ 0750, owned by user; per-user container runs as user UID, sees only own folders (STORAGE.md # Permissions, APP_ISOLATION.md) |
— |
| Cross-user managed-DB access | Per-(user,app) Postgres on that instance's private network — impossible by construction (APP_ISOLATION.md # Managed services placement) |
— |
| Admin/root reads another user's data | — | Out of scope in v1. LUKS-only encrypts the disk, not users from each other; once unlocked, root reads all. Per-user fscrypt is roadmap. v1 features touching user data are built as if fscrypt were on (per-user-keyed backup, no admin cross-user indexing) so the upgrade is data-only (STORAGE.md # Future) |
| Login screen reveals household members | User-list login (AUTH.md # Login screen UX) |
Accepted under household trust; privacy-conscious users flip to blank-form login |
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Session cookie leaks to app origins | Cookie host-scoped, Domain unset — never broadened to .local (AUTH.md # Sessions). Load-bearing for origin isolation |
— |
| Credential stuffing / brute force | Per-username exponential backoff + per-IP token bucket (AUTH.md # Rate limiting) |
Brain's own login endpoint throttling is the floor; finer lockout tuning in NEXT.md |
| CSRF | SameSite=Lax; no state-changing GETs (AUTH.md # CSRF) |
Future third-party API needs explicit CSRF tokens — out of scope until then |
| Offline hash cracking | yescrypt/argon2id via PAM; brain stores no password hash (AUTH.md # Identity primitive) |
Requires the drive to already be decrypted (B5) |
| Recovery-code abuse | argon2id hash, show-once, single-use, no physical-access reset (AUTH.md # The recovery code) |
Phone-photo of code lands in user's cloud backup — accepted convenience trade; lost-code + forgotten-password = unrecoverable |
| Weak / no second factor | Password-only in v1; HIBP non-blocking warning (AUTH.md) |
No 2FA/TOTP in v1 — designed to coexist later (TOTP is origin-independent) |
The most-trusted internal boundary: host-agent runs as root and trusts the brain completely.
| Threat | Mitigation (owner) | Residual |
|---|---|---|
| Unauthorized process drives host-agent | UNIX socket access gated by the malmo group, kernel-enforced; exactly one member (brain's runtime UID), CI-asserted (USERS_AND_GROUPS.md # Group reference, AUTH.md # Test invariant) |
The CI invariant is the entire authz model here — if group membership is wrong, the boundary is broken |
| Brain compromise | — | A compromised brain = host compromise, by design. The brain is the trusted control plane; isolating it from host-agent would defeat its purpose. Defense is keeping the brain small + the malmo-group invariant tight |
These are not defended against in v1, by deliberate choice:
- Whole-box physical theft beyond at-rest encryption (TPM is in the box). Future PIN mode.
- Member-vs-member at admin/root level — no fscrypt in v1; root reads all. Roadmap.
- A deliberately malicious catalog author — curation filters this; the sandbox bounds blast radius but is not claimed to contain a determined hostile author who slips through review.
- Nation-state / targeted physical-evil-maid / hardware implants.
- Side-channel & timing attacks (cache, power, EM).
- Supply-chain compromise of Debian, Docker, or upstream app images. Partially reduced by catalog digest-pinning (
APP_STORE.md) and signed release manifests (RELEASE_MANIFEST.md), but not modeled as a defended adversary. - Denial of service. No hard cgroup enforcement in v1 (
APP_ISOLATION.md# Resource limits); a runaway app can starve the box. Home-scale, low-priority; OOM hints later. - The doubly-lost data scenario (no backup + lost secret).
The honest list, gathered from across the spec so it lives in one place:
- A compromised app reads everything its granted permissions allow (the grant is the boundary).
- Admin/root reads any user's data until fscrypt ships.
- Compromised admin SSH session = root; demotion doesn't kill a live session.
- Whole-box theft defeats at-rest encryption (PCR-7 auto-unlock).
- PCR-7-only sealing is weaker against Secure-Boot subversion than PCR 7+11.
- Cloud DNS sees box-ids + query metadata.
- Recovery-code photo can land in a user's cloud backup.
- Lost recovery code + forgotten password = unrecoverable account; lost secret + no backup = lost data.
- No 2FA in v1.
- No DoS protection (no hard resource caps) in v1.
- A compromised brain compromises the host.
Each is defensible under the household trust model and the v1 scope; each has a named future upgrade where one exists.
The per-boundary tables above are the deliverable. STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) was walked across each boundary as a behind-the-scenes checklist to avoid missing a category — not rendered as a cell-by-cell matrix, which reads worse as a spec doc. Tampering of the audit log specifically is addressed by append-only SQLite triggers (LOGGING.md # Tamper-evidence); Repudiation by the audit trail itself (LOGGING.md); the hash-chain integrity guarantee is deferred (NEXT.md). A light privacy pass (LINDDUN-flavored) on the metadata assets is covered by the closed-by-default posture, telemetry-off-by-default (TELEMETRY.md), and local-analytics-never-leave-the-box (LOCAL_ANALYTICS.md).
This is a living document, revisited when a trust boundary moves. The known future trigger: remote access via the mesh (MALMO_NETWORK.md # Deferred). When it ships, B1 changes shape — .malmo.network names become reachable off-LAN, scoped pairing introduces a new principal (a paired-but-non-household device, e.g. "grandma sees Photos"), and the closed-by-default claim narrows to "closed except to identity-paired devices." That warrants a dedicated boundary pass and DECISIONS.md entries; it is explicitly not modeled here.
- This doc is a lens, not a source of mitigations. Every defense is owned by another doc; this one indexes and points.
- Household trust model is the root assumption. Members trust each other; adversaries are the network, a compromised app, and a removed drive.
- Assume any app can be compromised. The sandbox bounds blast radius; curation prevents the compromise. Neither claims to contain a deliberately malicious author.
- Scope is v1 closed-by-default. The deferred mesh surface is explicitly out of scope and gets its own pass.
- Named non-goals are first-class: whole-box theft, member-vs-member-at-root, malicious author past curation, nation-state, side-channels, supply-chain-as-adversary, DoS, doubly-lost.
- Boundary-narrative + tables is the format; STRIDE is a checklist, not a matrix.
Tracked centrally in NEXT.md. Resolutions land back here (or in DECISIONS.md if they flip a position).