Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quoro threat model

Status: M0 draft for review.

This document is the threat model of the self-hosted Quoro product. It describes what is worth protecting, who can attack it, where the trust boundaries are, and which threats the design already answers. It is written against the contracts in ../contracts/ and the architecture in architecture.md; where the three disagree, an accepted decision wins over this document, and this document wins over nothing.

1. Scope

In scope: the self-hosted product from M0 to M6 as defined in ../product/roadmap.md. That is one installation of the quoro binary in the roles node, witness and edge (D-0012), the HTTP API and panel, the MCP server, the node-agent protocol, the engine on Cloud Hypervisor and KVM (D-0007, D-0008), ingress and TLS (D-0024), the grant and approval model (D-0030), the audit log (D-0031), webhooks and the AI-native loop (D-0032), the lease and fencing mechanism (D-0019), and the later replication, witness and backup paths (D-0017, D-0023).

Out of scope:

  • An optional managed offering may come later (D-0003); this document does not cover it.
  • Live migration, distributed storage, SDN, multi-tenancy with billing and writing our own VMM (D-0034). None of them exists in M0-M6.
  • Licensing and contributor agreements (D-0004). AGPL and the CLA are legal questions, not security controls, and are not discussed here.
  • The security of third-party software as a product. Where this document makes a claim about external software that is not established in docs/research/, the claim is labelled Hypothesis.

2. Method

The model is built in five passes:

  1. Assets. What has value to the operator or to an attacker, and where each asset lives.
  2. Actors. Who can act on the system, and how far each actor is trusted.
  3. Trust boundaries. Where data crosses from a less trusted to a more trusted region.
  4. STRIDE per component. For each component, threats are classed as Spoofing, Tampering, Repudiation, Information disclosure, Denial of service or Elevation of privilege, and each row names the mitigation and what remains.
  5. Mitigations and residuals. A map from decisions to the threats they answer, then the risks that stay open, then the functional checks that would show the mitigations work on the lab.

Every row in the STRIDE tables carries a reference: an accepted decision (D-xxxx), a research finding (F-xxxx) or a research file, a recommendation marked pending maintainer decision, an open question (Q-xxxx), a contract file under ../contracts/, or the label Hypothesis / “design choice of this document” when the statement rests on nothing else. A row without a reference would be an assertion, and assertions are not accepted here.

Recommendations are pending maintainer decision. Where this document relies on one it says so, for example “recommended, pending maintainer decision”. Open questions stay open: this document lists the options and never presents one as decided.

2.1 Assumptions

  • The host kernel and KVM are trusted. Quoro runs on a host whose kernel the operator controls (D-0009). A kernel compromise is outside this model: the engine cannot defend against the layer it runs on.
  • Cloud Hypervisor is the VM isolation boundary (D-0007, D-0008). One microVM per app, no Docker daemon on nodes (D-0008). Everything inside a guest is untrusted; the boundary is the hypervisor and the kernel beneath it.
  • An operator with root on the primary can do anything locally. They can read the database, rewrite it, stop the process and edit the binary. This is not a flaw to be fixed; it is the reason the audit log has an off-site copy out of reach of whoever operates the server (D-0031) and why the chain only claims tamper evidence, not tamper prevention.
  • The network between nodes is hostile. Nodes talk across the public internet, often from behind NAT (D-0010, D-0011). Every node connection is treated as if it were intercepted.
  • Agents are untrusted by default. An agent may be prompt-injected by anything it reads: app logs, a webhook payload, an issue title. It acts only inside a grant (D-0029, D-0030).
  • The operator’s browser is trusted until it is not. A session cookie stolen from a compromised workstation is treated as the human, because the server has nothing else to go on.

3. Assets

AssetWhere it livesWhy it matters
Control-plane state and the change logSQLite on the primary, replicated to replica nodes (D-0012, D-0013, ../contracts/db-schema.sql)It is the description of everything that runs. Changing it changes the infrastructure.
Audit logaudit_log table on the primary, chained (D-0031, ../contracts/audit-log-format.md)It is the only record of who did what. Destroying it destroys accountability.
Cluster signing key (Ed25519)Primary, used to sign desired stateWhoever holds it can make any node run any workload. It is the root of node trust.
Node credentialsHashed on the primary, root-only file on each nodeA credential authorizes a node-agent session and nothing else, but that session carries desired state and app secrets for that node.
Human passwords, sessions and API tokensArgon2id hashes and token hashes on the primary; cookie in the browser (D-0030, ../contracts/permission-model.md)A human identity carries a role bundle, which is most of the cluster.
Agent keys and access tokensAgent keys hashed on the primary; access tokens short-lived (D-0029, D-0030)A stolen access token impersonates the agent for up to five minutes, within whatever grants are active at each call; revoking the grant stops it on the next call (D-0030).
Grants and approvalsgrants, approvals tablesThey are the boundary between “an agent can ask” and “an agent can act”.
App secrets (environment values)app_env, encrypted at rest with the cluster key; decrypted per node for placed appsThey are database passwords and API keys of the operator’s customers.
TLS private keys and the ACME account keyNode holding the edge or node role, root-onlyA stolen key allows impersonation of the operator’s domains.
Cloudflare and other provider API tokensProvider configuration on the primary, encrypted at restThey control DNS, load balancing and tunnels for every domain in the installation (D-0025).
Webhook secretswebhooks, encrypted, write-only (D-0032)They sign deliveries and prove that a webhook came from Quoro.
App data on volumes and in backupsNode disks, chunk store, S3-compatible bucket (D-0023)It is the operator’s actual data, and its loss is not recoverable from the control plane.
App logs and metricsNode-local, queried on demand (D-0014)They describe app behaviour and often contain secrets in URLs, headers or stack traces.
Release signing keyRelease pipeline (D-0027)A stolen key signs a malicious binary that the curl | sh installer will run as root.
Availability of running appsNodes (D-0011)The product promise is that apps keep serving when the panel does not.
The witness and the lease arbiterWitness daemon or external arbiter (D-0017)Whoever controls the arbiter decides who is primary.

4. Actors

ActorTrust levelNotes
OwnerFully trusted humanEvery scope, including user:write (../contracts/README.md). Can still be phished.
OperatorTrusted humanEvery scope except user:write. Can enroll nodes (node:enroll) and decide approvals (approval:decide).
ViewerTrusted human, read-mostlyEvery :read scope plus app:logs. Cannot change anything, but can read app logs and audit records.
AgentUntrusted by defaultMay be prompt-injected; acts only within a grant (D-0029, D-0030). Records who, on behalf of whom and why.
External agent runner woken by a webhookUntrustedReceives event payloads and incident context; its only authority is the incident grant (D-0032).
NodeTrusted for its own workloads onlyMay lie about itself, may be compromised. Cannot write control-plane state or read other nodes’ secrets.
PrimaryTrusted while it is the primaryFull control of state. A compromised primary is game over for confidentiality and integrity (see T-NODE-14).
Replica nodeTrusted for its own workloads; holds a copy of the logSees control-plane state, including encrypted secrets. Cannot write (D-0012).
WitnessTrusted to vote, stores no dataIts only power is breaking ties (D-0017). A lying witness can stall an election but cannot read data.
EdgeTrusted, sees all ingress trafficTerminates TLS, routes by hostname, can read plaintext HTTP (D-0018, D-0024).
Providers (Cloudflare DNS/LB/Tunnel, S3-compatible storage)Trusted for availability, not for confidentiality of plaintextThey see traffic metadata, tunnel plaintext at the edge, and stored ciphertext (D-0025).
App workloads inside microVMsUntrusted codeArbitrary code supplied by the operator or by an image publisher. Must not reach the host or other apps.
Internet clientsUntrustedSend arbitrary requests to app domains and to the API.
Upstream image publishers and the catalogTrusted only through signatures and digestsThe catalog pins images with verifiable signatures (D-0026).
Contributors and the release pipelineTrusted through review and signingA merged change ships to every installation (D-0004, D-0027).
A network attacker on the WAN between nodesHostileCan drop, delay, replay and modify traffic (D-0011).

5. Trust boundaries

        internet clients
              |
              v
   +------------------------+         boundary 1: unauthenticated traffic
   |  edge / ingress        |         terminates TLS, routes by hostname
   |  (proxy or Tunnel)     |
   +-----------+------------+
               |  boundary 2: app traffic into a guest
               v
   +------------------------+
   |  microVM (guest)       |  <-- untrusted code, one per app
   +-----------+------------+
               |  boundary 3: virtio devices, console socket, TAP
               v
   +------------------------+         boundary 4: host kernel / VMM
   |  node agent + engine   |         root on the node
   +-----------+------------+
               |  boundary 5: dial-out WebSocket over TLS, certificate pinned
               v
   +------------------------+
   |  primary (API, panel,  |
   |  MCP, audit, state)    |
   +------------------------+

   browser / panel  --boundary 6-->  API (session cookie)
   agent / MCP client --boundary 6--> API / MCP (bearer access token)
   primary --boundary 7--> webhook receiver (HMAC-signed HTTP POST)
   primary --boundary 8--> providers (Cloudflare, S3-compatible storage)
   node <--boundary 9--> node (mesh, from M5a)
   release pipeline --boundary 10--> operator host (curl | sh)
#BoundaryWhat crosses itControl
1internet -> edgeArbitrary HTTP, TLS handshakesTLS termination, hostname routing, per-app isolation (D-0024)
2edge -> guestHTTP requests for one appRouting table derived from desired state, one microVM per app (D-0008)
3guest <-> hostvirtio-blk, virtio-net, virtio-console, serial console, TAPCloud Hypervisor device model, seccomp, one process per VM (docs/research/cloud-hypervisor.md)
4node agent <-> engineEngine trait calls, Cloud Hypervisor API socketLocal UNIX socket, node root only, ../contracts/engine-trait.md
5node -> primaryNode-agent session, desired state, events, leasesWebSocket over TLS with pinned certificate, Ed25519-signed desired state, ../contracts/node-agent-protocol.md
6human/agent -> APIBearer token or session cookie, Quoro-JustificationScope check, grant check, audit write in the same transaction (../contracts/permission-model.md)
7primary -> webhook receiverEvent payload and Quoro-SignatureHMAC-SHA256 over <t>.<raw body>, 5 minute window (D-0032, ../contracts/README.md)
8Quoro -> providersDNS records, LB pools, tunnel config, objects in a bucketProvider API tokens scoped per provider; separate credentials per purpose (D-0025)
9node <-> nodeReplication traffic, chunk shipping (from M5a)Mesh: kernel WireGuard over netlink, per-node keys and peers in desired state (D-0063), fencing tokens on every write (D-0019)
10release pipeline -> hostA binary executed as root by the installerSignature check plus reproducible build (D-0027)

Boundary 5 is the most important one in the product: it is the only path by which instructions reach a machine that runs workloads, and it crosses the public internet (D-0011). Boundaries 3 and 4 are where untrusted code meets the host. Boundary 6 is where an untrusted agent meets authority.

6. STRIDE analysis

The tables below use threat ids T-<AREA>-<nn>. The STRIDE column holds one letter: S spoofing, T tampering, R repudiation, I information disclosure, D denial of service, E elevation of privilege.

(a) HTTP API and panel

IdSThreatMitigationReferenceResidual risk
T-API-01SA stolen bearer token is replayed by an attackerTokens are stored only as hashes, carry an expiry, and a human token can be revoked; agent access tokens live 5 minutesD-0030, ../contracts/permission-model.mdTheft from a compromised workstation is indistinguishable from the human until revocation
T-API-02SA stolen panel session cookie is used from another machineSession cookie is an absolute 24 h session, stored hashed, bound to the user../contracts/permission-model.mdNo device binding; a copied cookie works until it expires
T-API-03TA write sent to a replica node silently forks stateOnly the primary writes; a write that reaches a replica that cannot forward answers not_primary (503) and never applies locallyD-0012, ../contracts/openapi.yamlA replica that wrongly believes it is primary would fork state; prevented by the promotion rules in (m)
T-API-04TA POST is retried by a client or a proxy and creates a duplicate resourceIdempotency-Key is required on every creating POST../contracts/README.mdKeys are scoped per identity and expire; a retry after expiry duplicates
T-API-05TCross-site request forgery against the panel’s cookie-authenticated endpointsDesign choice of this document: state-changing panel requests require a non-simple header and the cookie is SameSite=Lax, so a cross-origin form post cannot carry themdesign choice of this documentA same-site script injection still succeeds; see T-API-09
T-API-06RA human denies having made a changeEvery mutating call by any identity is audited with actor, channel panel or api, params and resultD-0031, ../contracts/audit-log-format.mdHuman reads are deliberately not recorded, so “who looked at this” is unanswerable
T-API-07IApp environment values leak through a read endpointapp_env.value is write-only: the API returns names only; app:read never returns a value../contracts/README.md scope app:read, ../contracts/openapi.yamlA value still leaks through app logs if the app prints it (T-LOG-01)
T-API-08DAn unauthenticated client floods the API or an event streamAuthentication before work, per-identity rate limit answering rate_limited../contracts/permission-model.md (rate limits are implementation defaults, not contract)No global admission control in M0; a single identity can still consume the process
T-API-09EA viewer escalates to owner through the panelHuman roles are fixed scope bundles; user:write is owner-only; no endpoint grants a role to oneself../contracts/README.md roles, D-0030A viewer who obtains an owner’s token is an owner (T-API-01)
T-API-10IA forbidden response leaks which resources existRefusals use a fixed reason set and do not echo resource contents../contracts/README.md forbidden reasonsnot_found versus forbidden still reveals existence of ids the caller guessed
T-API-11SUnclaimed first-run setup: between install and the first POST /api/v1/setup, anyone who reaches the listener can claim ownerThe installer prints a one-time setup token, required in the Quoro-Setup-Token header; it is single use and expires after one hour, the primary stores only its hash, and quoro setup-token refuses once an owner exists (D-0054)D-0054, architecture section 2, host layoutThe token is printed on the host console and in the install log, so whoever reads that output within the hour can claim owner

(b) MCP server and agents

The MCP server is not a separate security path. Each tool maps one-to-one onto a service operation and needs the same scope as the matching endpoint (D-0031, ../contracts/README.md).

IdSThreatMitigationReferenceResidual risk
T-MCP-01EPrompt injection in an app log, a webhook payload or an issue title makes the agent call a destructive toolDestructive scopes are refused with approval_required unless a matching approval grant exists; the grant is bound to the target and paramsD-0030, ../contracts/permission-model.mdInjection can still spend the agent’s non-destructive scopes, for example restart loops
T-MCP-02EAn agent is created or edited with an over-broad grantgrant:write and agent:write are human-only, so an agent can never widen itself; the wildcard app:* is not allowed in grants../contracts/README.md scopes and flags, D-0030A careless human can create a broad grant; the panel must show the selector and TTL plainly
T-MCP-03SAn agent key is stolen from the runner’s environmentThe agent key authorizes nothing by itself; it only mints access tokens; disabling the agent invalidates tokens immediately; secrets are shown once and stored hashedD-0029, ../contracts/permission-model.mdOnce minted, an access token is usable by whoever holds it for its lifetime
T-MCP-04SAn access token is replayed within its lifetime5 minute lifetime, opaque 32 random bytes, stored as SHA-256, minted per call sequence../contracts/permission-model.mdNo proof-of-possession; a token captured in transit or in a log is usable for up to 5 minutes
T-MCP-05EAn agent approves its own approval requestapproval:decide is human-only, so the attempt is refused with human_only and auditedD-0030, ../contracts/README.mdAn agent can still social-engineer the human approver (T-GRT-06)
T-MCP-06EA webhook-woken runner is used as a confused deputy to act outside the incidentThe incident grant carries only the webhook’s wake.grant_scopes, is limited to the incident subject, and is revoked when the incident resolvesD-0032, ../contracts/permission-model.mdThe subject selector is coarse: an incident on one app allows all listed scopes on that app
T-MCP-07RAn agent’s stated justification is a plausible lieThe justification is recorded, not trusted: it is evidence for a human reader, and the decision to act was the grant’s, not the text’sD-0031, header Quoro-JustificationNothing verifies the text; a model can write a convincing wrong reason
T-MCP-08IThe MCP tools expose more data than the equivalent endpointEach tool calls the same service operation and needs the same scope; there is no MCP-only queryD-0031, ../contracts/README.md MCP tools tableget_app_logs inherits the log risk (T-LOG-01)
T-MCP-09SThe MCP transport accepts a caller Quoro did not authenticateQuoro validates the bearer access token itself before dispatching a tool; rmcp documents no inbound authorization middleware, so no such middleware is relied on (Hypothesis)F-0041, docs/research/rust-api-and-mcp-tooling.mdIf rmcp later adds server-side auth, two layers could disagree; the pinned version (F-0041) limits the surprise
T-MCP-10IAn agent reads app logs and extracts secrets to another systemapp:logs is a scope and can be withheld; every agent call, including reads, is auditedD-0031, ../contracts/audit-log-format.mdAn agent with app:logs and network egress can exfiltrate what it reads; that is the grant owner’s choice
T-MCP-11DAn agent in a restart loop harms availability within its scopesGrants can carry max_uses; reads do not consume uses, mutating calls do../contracts/permission-model.md, D-0030A grant with no max_uses and a long TTL permits unbounded restarts

(c) Grants and approvals

IdSThreatMitigationReferenceResidual risk
T-GRT-01EPrivilege escalation by creating a grant for oneselfgrant:write is human-only; an agent holding it is refused with human_only; incident and approval grants are created by system, not by the agentD-0030, ../contracts/README.md scopes, ../contracts/permission-model.mdAny human with grant:write can issue anything to any agent
T-GRT-02EAn approval grant is reused or widened to another targetApproval grants carry one scope, selector = that target only, max_uses 1 and a 10 minute TTL; the call must match params_hashD-0030, ../contracts/permission-model.mdA second identical destructive call needs a second approval; that is intentional friction
T-GRT-03EA grant lives forever because its TTL was omittedexpires_at is mandatory on every grant; there is no non-expiring grantD-0030, ../contracts/permission-model.mdExpiry depends on the primary’s clock; a clock jump shortens or lengthens a TTL (T-GRT-07)
T-GRT-04EA revoked grant keeps workingRevocation is checked on every request in the authorization path, so the next call after revocation is refused with grant_revokedD-0030, ../contracts/permission-model.mdA call already executing when revocation lands completes; the audit record shows both
T-GRT-05EAn incident grant is widened beyond the incidentIncident grants may carry non-destructive scopes only, validated when the webhook is saved, and the selector is the incident subject onlyD-0032, ../contracts/permission-model.mdA webhook created with a broad grant_scopes list gives every matching incident that list
T-GRT-06SApproval fatigue: a human approves a harmful request after many plausible onesThe panel must show scope, target, params_hash and justification together; the decision is audited with the deciding userD-0030, ../contracts/audit-log-format.mdSocial engineering of the approver is not technically preventable
T-GRT-07TClock skew makes an expired grant usableExpiry is evaluated against the primary’s clock only; nodes do not evaluate grantsD-0030, ../contracts/permission-model.mdA misconfigured or manipulated primary clock extends every TTL; the audit timestamp is affected too
T-GRT-08ETime-of-check to time-of-use between approval and executionThe approval is bound to target and params_hash; a call with different parameters does not match the grant and is refusedD-0030, ../contracts/permission-model.mdA matching call can still be harmful if the human misread the parameters
T-GRT-09EAn agent collects several narrow grants and combines them into a broad capabilityEvery request needs one matching grant for its scope and selector; uses are counted per grant; the audit names the grant used../contracts/permission-model.md, D-0031Nothing prevents a human from issuing several grants to the same agent; combination is a human decision
T-GRT-10RA refusal happens but is not recorded, so the attempt is invisibleEvery refusal is audited with result refused and result_detail holding the forbidden reason../contracts/audit-log-format.md, D-0031A refusal caused by a crash before the audit write is an error at best, or missing

(d) Audit log

IdSThreatMitigationReferenceResidual risk
T-AUD-01TAn operator edits one audit record to hide an actionEach record carries hash over its canonical JSON and prev_hash of its predecessor; verification reports hash_mismatch at that seq and emits audit.chain_brokenD-0031, ../contracts/audit-log-format.mdThe edit is evident, not prevented; if nobody verifies, nobody notices
T-AUD-02TAn operator truncates the tail of the logSegments and head objects are copied off-site, so a truncated local tail disagrees with the last exported last_seq and head_hashD-0031, ../contracts/audit-log-format.mdUntil an off-site copy exists there is none; the earliest is M5a (section 8.1), so truncation is undetectable before that
T-AUD-03TAn operator with root rewrites the whole log and its hashesThe off-site copy is written once with an object-lock retention in compliance mode; credentials are limited to put and cannot delete or overwrite a locked objectD-0031, ../contracts/audit-log-format.mdObject-lock support is not portable across S3-compatible providers; the adapter probes it and must report “not locked” when the probe fails (F-0038)
T-AUD-04RAn agent claims a different actor made the changeEvery record carries actor_type, actor_id, agent_model, on_behalf_of, channel, request_id and grant_idD-0029, D-0031, ../contracts/audit-log-format.mdThe fields are filled by Quoro, not by the caller; a compromised primary can write anything (T-NODE-14)
T-AUD-05ISecrets appear in the recorded paramsA fixed redaction list (value, values, secret, password, token, key and every env value) replaces them with [redacted]../contracts/audit-log-format.mdA secret in a key not on the list, for example a URL with a query token, is recorded verbatim
T-AUD-06TAn action exists without its audit record, or the reverseThe record is appended in the same SQLite transaction as the change it describes, and change_seq links it to the change-log entryD-0031, D-0013, ../contracts/audit-log-format.mdA crash between the transaction commit and the response is still one consistent state
T-AUD-07DAudit write failure blocks all work, or work proceeds unauditedThe transaction couples them, so a failed audit write fails the action../contracts/audit-log-format.mdA full disk on the primary stops writes; that is availability, not integrity (T-AVL-07)
T-AUD-08TAn attacker floods the log to bury an entry or to exhaust storageRate limits per identity; agents are rate limited like humans../contracts/permission-model.mdNo archival policy in M0-M6; growth is unbounded by design (retention would need its own decision)
T-AUD-09TDeleting the off-site bucket credential’s target objectsThe audit-copy credential is put-only, separate from the backup credential, and locked objects cannot be overwritten../contracts/audit-log-format.md, D-0025Bucket policy is provider configuration; a misconfigured bucket may accept deletes from another credential
T-AUD-10RVerification itself is claimed but never runPOST /api/v1/audit/verify returns {ok, checked, first_seq, last_seq, head_hash, broken_at_seq?, reason?} and the verification run is audited../contracts/audit-log-format.mdNothing forces a schedule; an unverified chain is only as good as the next run

(e) Node-agent protocol

IdSThreatMitigationReferenceResidual risk
T-NODE-01SA rogue machine enrolls as a nodeEnrollment needs a one-time join token with a 1 h default expiry, created under node:enroll, which is human-only; the token is stored as a hash and marked usedD-0010, ../contracts/README.md scope flags, ../contracts/node-agent-protocol.mdA leaked unused token can be redeemed once within the hour
T-NODE-02SA man-in-the-middle intercepts the node sessionThe join command carries the SHA-256 fingerprint of the primary’s TLS certificate and the node pins it, so a self-signed certificate works and a substituted one does not../contracts/node-agent-protocol.md, boundary 5Pinning survives only if the fingerprint was transmitted out of band, which is the operator’s job
T-NODE-03SAn attacker impersonates an existing nodeEach session needs the node credential, 32 random bytes, stored hashed on the primary and root-only on the node../contracts/node-agent-protocol.mdA stolen node credential plus the pinned host allows impersonation; only one session per node exists at a time
T-NODE-04TA forged desired_state makes a node run attacker workloadsEvery desired_state is signed with the cluster Ed25519 key over the canonical JSON of its body; the node verifies before persisting../contracts/node-agent-protocol.md, boundary 5The node trusts the cluster public key it received at enrollment; a wrong key at enrollment poisons everything after it
T-NODE-05TReplay of an old, validly signed desired staterev increases monotonically; the node rejects an older rev and acts on the newest verified stored state../contracts/node-agent-protocol.md, D-0011Two states with the same rev cannot exist unless the primary is compromised
T-NODE-06TReplay of an old log segment to roll state backEach entry carries prev_hash/hash; the node verifies on receipt and on hello, and a divergence quarantines its copy instead of applying itD-0013, D-0022, ../contracts/node-agent-protocol.mdA node that never reconnects keeps a stale copy; that is expected, not a compromise
T-NODE-07IA compromised node reads secrets of apps that are not placed on itenv values are decrypted per node and sent only for apps in that node’s desired_state../contracts/node-agent-protocol.mdA node can read everything for apps that are placed on it; that is unavoidable
T-NODE-08IApp secrets persist in plaintext on the nodeThe node stores env encrypted with a node-local key; the session carries them only over the pinned TLS connection../contracts/node-agent-protocol.mdRoot on the node can read the node-local key; see assumption 2.1
T-NODE-09TA compromised node lies about its status, health or eventsNothing can stop a node lying about itself../contracts/node-agent-protocol.mdA lying node can trigger false app.down, false incidents and false agent wakes (T-WHK-05); control-plane state is not writable by a node
T-NODE-10TA compromised node injects events to open incidents and wake agentsThe primary assigns seq and dedups by id, records source, and the incident grant is limited to the subject with non-destructive scopesD-0032, ../contracts/node-agent-protocol.md, ../contracts/permission-model.mdNoise and cost from false wakes; an incident on a node cannot grant destructive power
T-NODE-11DA node is flooded with frames or oversized messagesOne JSON message per text frame, 1 MiB maximum, heartbeat every 5 s and disconnect after 15 s without a frame../contracts/node-agent-protocol.mdA slow-loris style session can hold the single session slot until it times out
T-NODE-12EA node sends a message type or version the primary does not expectUnknown fields are ignored, an unknown type is answered with a non-fatal error, and a major version mismatch is a fatal unsupported_version../contracts/node-agent-protocol.mdA downgrade attack has nothing to gain: the protocol has one major version
T-NODE-13TA removed node keeps receiving desired stateDELETE /api/v1/nodes/{node_id} ends the session with a fatal node_removed and the node stops dialing../contracts/node-agent-protocol.mdIts VMs are left as they are; removal never destroys data remotely, by design
T-NODE-14EA compromised primary takes over the clusterNothing in the protocol stops it: it holds the cluster signing key, the state and the audit logD-0012, D-0031Full control. Signed desired state does not help, because the primary is the signer. This is why the audit copy must be out of reach of whoever operates the server (D-0031) and why a promoted replica must obtain the key through the custody path decided with manual promotion in M5b (D-0067)

(f) Leases and fencing

IdSThreatMitigationReferenceResidual risk
T-LEASE-01TSplit brain: two nodes run the same stateful app and divergeA stateful VM starts only while its node holds the app’s lease in state held with the same fencing_token as the VM spec; every write to shared storage carries the token and receivers reject a lower oneD-0019, ../contracts/node-agent-protocol.md, docs/research/r2-conditional-writes.mdFencing depends on every receiver checking the token; a new shared resource added later must implement it
T-LEASE-02TAn old lease holder keeps writing after losing the leaseThe lease is expiring mode with a monotonic duration, and the node self-fences at valid_until; a failed forced stop never reports fenced (D-0074)D-0019, ../contracts/node-agent-protocol.mdA paused process can act between valid_until and the actual stop; the fencing token is what protects the data, not the timer
T-LEASE-03DThe arbiter is unreachable and nodes treat it as lease loss, stopping apps needlessly“Arbiter unreachable” maps to uncertain: keep running, start nothing new stateful, and fence only at valid_until; local-mode leases never expire because the control plane is goneD-0047, D-0011, docs/research/r2-conditional-writes.mdA long arbiter outage (a 59 minute R2 outage is recorded) still fences every expiring lease eventually
T-LEASE-04TClock drift between nodes makes a lease look valid after it expiredDurations are computed on the node’s monotonic clock, with margin = max(1000 ms, duration_ms / 10); the authority only regrants after duration_ms + 2 * margin../contracts/node-agent-protocol.md; the drift bound itself is HypothesisThe margin is a guess until measured on the lab; a large enough drift or a suspended VM breaks the arithmetic
T-LEASE-05TR2 answers 404 for a read while a conditional write is in flight, so a node believes it holds a lease it lostAmbiguous outcomes are resolved with If-None-Match: * retries, and the fencing token, not the read result, decides what a shared resource acceptsdocs/research/r2-conditional-writes.md, D-0019The negative-read window is a documented trap; the retry rule must be implemented, not assumed
T-LEASE-06TA Durable Object restarts mid-write and comes back with pre-write stateAn in-flight storage-touching request is terminated with an error, so “no definitive answer” means “lease not held”; the token still protects the datadocs/research/durable-objects.md, D-0017, Q-0012 (open)Alarms are at-least-once, so expiry transitions must be idempotent; that is code-level care, not a protocol guarantee
T-LEASE-07DDenial of service on the arbiter stops stateful appsAccepted: an arbiter outage fences expiring leases, which is availability loss by design rather than corruptionD-0019, docs/research/r2-conditional-writes.mdA provider-wide outage becomes an app outage; the local lease mode limits the blast radius to warm/hot apps
T-LEASE-08TA node with a valid credential claims a lease it never hadLease authority is the primary in M0-M5a and the primary backed by a quorum from M5b; a node cannot grant itself a leaseD-0017, D-0019, ../contracts/node-agent-protocol.mdA compromised primary can grant a lease to any node (T-NODE-14)
T-LEASE-09TA local lease never expires, so a lost node comes back and starts its stateful app beside the new holderA local lease ends only by lease_revoke or when an operator declares the node lost, which increments the fencing token so the old holder’s token is stale; a cold restore elsewhere needs the manual fence acknowledgement first (D-0047)D-0047, ../contracts/node-agent-protocol.mdThe manual “declared lost” step is the weak link: forget it and two copies can start
T-LEASE-10DFencing takes longer than the margin and the node is still stopping when another startsThe node must finish fencing before valid_until + margin, and the authority waits duration_ms + 2 * margin before regranting../contracts/node-agent-protocol.mdA guest that ignores SIGTERM delays a force stop; the formula assumes the force stop works

(g) Engine and guest isolation

IdSThreatMitigationReferenceResidual risk
T-VM-01EA guest escapes to the host through a virtio device or the VMMOne Cloud Hypervisor process per VM with a small device model (virtio-blk, virtio-net, virtio-console), a memory-safe VMM language, seccomp filters enabled by default and an optional Landlock sandboxD-0007, D-0008, docs/research/cloud-hypervisor.mdA hypervisor or kernel bug is a host compromise; the device model is minimal but not empty. Sandboxing beyond seccomp/Landlock is Hypothesis
T-VM-02EA guest attacks the host kernel through KVMThe kernel is trusted (assumption 2.1) and the guest runs unprivilegedD-0009Kernel vulnerabilities are out of scope; patch cadence is the operator’s
T-VM-03DOne app starves another (noisy neighbour)Each microVM gets its own vCPU and memory allocation from resources, and virtio I/O can be throttled with token buckets../contracts/engine-trait.md, docs/research/cloud-hypervisor.mdCPU shares are not a hard cap on every host; a sustained load still affects neighbours
T-VM-04TOne app reaches another app’s networkEach app has its own TAP on the node bridge and a private address; isolation between projects is enforced with nftables rules generated with the networkD-0008, ../contracts/engine-trait.md, design choice of this document for the nftables default-denyA misgenerated rule set can open a path; rules must be derived from desired state and tested
T-VM-05TA malicious image compromises the node during preparationImages are referenced with a digest and verified before conversion; the OCI-to-ext4 pipeline resolves layer paths inside the rootfs with openat2 and RESOLVE_IN_ROOT (D-0061); the catalog pins images with verifiable signatures — mitigation pending: the catalog format and signature scheme are decided at M4 planning (D-0070)D-0026, D-0061, ../contracts/engine-trait.mdConversion runs as root on the node; a parser bug in the image path is host code, which RESOLVE_IN_ROOT narrows but does not remove
T-VM-06IA volume snapshot leaks guest secretsA Cloud Hypervisor snapshot contains full guest RAM and no disk contents, so RAM must be treated as secret-bearing; the chunk engine encrypts chunksdocs/research/cloud-hypervisor.md (F-0035 finding), D-0023Snapshot contents include whatever the app had in memory, including keys
T-VM-07EThe guest’s init is subverted to escape the microVMThe minimal init is PID 1 inside the guest and trusts nothing from the guest application; the app runs as PID 2 under the configured user../contracts/engine-trait.md, D-0008The init is Quoro code inside a trusted boundary; a bug there is a guest-internal bug, not a host escape
T-VM-08DA guest exhausts host disk with its rootfs or logsPer-VM disk size from the VM spec, and node.disk_low is emitted when free disk falls below the node’s threshold../contracts/README.md event types, ../contracts/node-agent-protocol.mdA full disk affects every app on that node before anyone reacts
T-VM-09EDocker on the node gives a container a path to the hostThere is no Docker daemon on nodes; apps are microVMsD-0008, D-0034Builds in M4 run inside a builder microVM, not on the host (Hypothesis for the isolation of that builder until specified)

(h) Ingress and TLS

IdSThreatMitigationReferenceResidual risk
T-ING-01SSomeone attaches a hostname they do not control and obtains a certificate for itA domain belongs to one app and is attached by a holder of app:update; ACME still requires control of the name to issueD-0024, ../contracts/README.md scopesAn operator can attach a hostname that ACME later validates through a DNS record they do control; the certificate then covers a name they own, which is not an attack
T-ING-02IThe ACME account key or a domain private key is stolen from the nodeKeys are root-only on the node that terminates TLSD-0024, assumption 2.1Root on that node reads them; that node is trusted
T-ING-03SA stolen Cloudflare Tunnel token lets an attacker join the tunnelThe token is stored encrypted on the primary; a tunnel token authorizes a connector, not the APID-0024, D-0025A rogue connector can receive and answer traffic for the tunnel’s published hostnames
T-ING-04THealth checks are spoofed so traffic goes to a dead nodeOver a tunnel, an HTTPS monitor with Host = the app hostname reaches the app through the connector; the HTTP_STATUS route only proves connector liveness and must not be used as app healthF-0046, docs/research/cloudflare-lb-and-tunnel.md, D-0024Replicas of one tunnel UUID count as one endpoint, so a monitor can pass while another replica’s local app is down
T-ING-05IPlaintext leaves the app and crosses an untrusted networkTLS terminates at the edge or at Cloudflare; after that the hop to the guest is on the node bridgeD-0024Design choice of this document: the node-local hop is treated as trusted; if it is not, per-app TLS inside the guest is the operator’s option
T-ING-06THost header confusion routes a request to the wrong appRouting is by hostname from the verified route list in desired state, never by a client-supplied path or header beyond the hostname../contracts/node-agent-protocol.md route shape, D-0024A shared hostname between apps cannot happen because a domain belongs to one app
T-ING-07DA single edge is a traffic single point of failureDocumented; mitigations are Cloudflare as the traffic edge with the small host as witness only, or two edges with DNS failoverD-0018A self-hosted single edge loses all app traffic when it dies, while the apps keep running
T-ING-08TAn expired or wrong certificate serves trafficcert_status is modelled (pending, issued, renewing, failed) and certificate.expiring warns before expiry../contracts/README.md, D-0024Renewal failure is visible but the app is unreachable until it is fixed
T-ING-09SDomain takeover after an app is deletedDomains belong to an app and are removed with it; certificate material stays with the node until it is cleaned../contracts/README.md relationships, D-0024A stale DNS record pointing at an old IP is the operator’s to remove

(i) Providers and secrets

IdSThreatMitigationReferenceResidual risk
T-PRV-01EA Cloudflare API token with account-wide scope is used to change unrelated DNSProviders sit behind interfaces with per-provider credentials; the token should be scoped to the zone and to the products usedD-0025, design choice of this document for the least-privilege defaultA user who pastes a global token gets global authority; Quoro cannot narrow it after the fact
T-PRV-02IBackup credentials can also delete the audit copyBackup and audit-copy credentials are separate: the audit-copy credential is its own put-only object_store provider entry (D-0051)D-0051, ../contracts/audit-log-format.md, D-0025The separation is a deployment property; both live on the same primary
T-PRV-03TAn S3-compatible provider silently ignores conditional writes, giving two nodes the same leaseThe provider adapter probes the capability at setup and refuses to enable the arbiter if the probe failsF-0038, docs/research/r2-conditional-writes.md, D-0017Community MinIO and Backblaze B2 status is Hypothesis; the probe is the only defence
T-PRV-04IStored backups are readable by the providerChunk-engine chunks are encrypted client-side; SQLite and template backups go through that same chunk engine from consistent snapshots, so nothing is stored unencrypted (D-0069)D-0023, D-0069, F-0040Litestream with SSE-C may be added later as an optional backend (D-0069); such a backend would rely on the provider for encryption, with a Quoro-held key sent per request
T-PRV-05DA provider rate limit (R2 allows 1 write per second per key) stalls the off-site copySegment keys are unique, so the per-key limit does not bind the audit copy; retries back offF-0037, ../contracts/audit-log-format.mdA provider-wide outage pauses the copy; the local chain continues
T-PRV-06ISecrets in the change log are readable to a replica nodeReplicated secret columns are encrypted with the cluster key, and a replica holds ciphertextD-0013, ../contracts/node-agent-protocol.mdHow a promoted replica obtains the cluster key is decided with manual promotion in M5b (D-0067); custody determines who can read history
T-PRV-07TA provider changes a DNS record outside QuoroDesired state is the source of truth and reconciliation rewrites driftD-0011, D-0025Drift between reconciliations is unobserved unless a route check reports it
T-PRV-08IProvider secrets read back through the API or the audit logProvider secrets are write-only, encrypted with the data-encryption key in secret_enc, never returned by a read call, redacted from audit parameters, and reachable only under the human-only provider:manage scopeD-0051, D-0050A compromised primary holding the DEK can decrypt them

(j) Webhooks

IdSThreatMitigationReferenceResidual risk
T-WHK-01SA forged webhook delivery wakes an agent or triggers a receiver actionQuoro-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the webhook secret>; receivers reject a signature older than 5 minutesD-0032, ../contracts/README.md request headersThe receiver must implement verification; Quoro cannot enforce it on a third party
T-WHK-02SA captured delivery is replayed inside the 5 minute windowThe timestamp window bounds it; deliveries also carry Quoro-Event-Id and Quoro-Delivery-Id for the receiver to dedupe../contracts/README.mdA receiver that does not dedupe can be replayed within the window
T-WHK-03IThe webhook secret leaksThe secret is write-only, shown once, and stored encrypted../contracts/README.md entity fields, D-0031A secret in a delivery log on the receiver is the receiver’s problem
T-WHK-04EServer-side request forgery: a webhook URL points at an internal addressURLs must be https, except http://localhost for testing; design choice of this document: private and link-local ranges are refused unless the operator opts in../contracts/README.md entity fields, design choice of this documentDNS rebinding against an allowed host is not covered by a URL check
T-WHK-05TA compromised node manufactures events that wake agents and spend moneyEvents are recorded with source, incidents are subject-scoped, and incident grants are non-destructiveD-0032, ../contracts/permission-model.md, T-NODE-09False incidents produce false work; cost, not compromise
T-WHK-06DRetry storms from a failing receiver amplify loadExponential backoff from 10 s doubling to 1 h, at most 10 attempts, delivery records carry status, response_code, error../contracts/README.md webhook delivery fieldsA receiver that returns 500 quickly still costs 10 attempts per event
T-WHK-07EOne event wakes several runners that act on the same incident concurrentlyEach gets its own incident grant with the same selector; the audit shows each callD-0030, D-0032Two agents can interleave restarts; the grants do not serialize

(k) Telemetry

IdSThreatMitigationReferenceResidual risk
T-LOG-01IApp logs contain secrets and are read by a viewer or an agentLogs stay on the node and are read on demand under app:logs; every agent read is auditedD-0014, D-0031, ../contracts/README.md scope flagsapp:logs is a broad read of everything the app printed, including tokens
T-LOG-02IA caller reads logs of an app outside their selectorList results are filtered to matching resources, never refused wholesale../contracts/permission-model.mdA selector with {"all": true} reads every log in the installation
T-LOG-03TLog content injects markup or terminal escapes into the panelDesign choice of this document: log lines are rendered as text, never as HTML, and control characters are escaped in the terminal viewdesign choice of this documentA terminal emulator still interprets some sequences; the M4 terminal is scoped and audited
T-LOG-04DA telemetry query is used to reach a node the caller cannot otherwise reachThe primary forwards the query over the existing node session and answers node_unreachable (504) when it cannot../contracts/README.md error codesThe query path is the same session the control plane already has
T-LOG-05DLog growth fills the node diskRetention is local and the node emits node.disk_lowD-0014, ../contracts/README.md event typesDetection is after the fact; a fast log flood can fill a disk between checks
T-LOG-06EThe M4 web terminal becomes a root shell with no trailTerminal sessions require a scope, are recorded in the audit log, and run inside the app’s guest../product/roadmap.md M4 scope, D-0031The terminal gives whatever the guest gives; an agent with terminal access can act inside the app unlogged beyond the session record

(l) Supply chain and releases

IdSThreatMitigationReferenceResidual risk
T-SUP-01TA tampered download is piped into a root shellBinaries are signed and builds reproducible, so the installer can verify the signature before running the binaryD-0027The verification step is code in the installer; if it is skipped, the pipe still runs
T-SUP-02TA build host is compromised and produces a malicious, correctly signed binaryReproducible builds let anyone rebuild from source and compare; a mismatch is detectableD-0027Detection needs someone to rebuild; the signature alone would not catch it
T-SUP-03SThe release signing key is stolenIt is a release-pipeline secret, never on the operator’s host; releases are signed, so key custody is the controlD-0027A stolen key mints valid signatures; rotation and revocation are not defined in M0
T-SUP-04TAn upstream catalog image is replaced with a different oneThe catalog pins images by digest with verifiable signatures — mitigation pending: the catalog format and signature scheme are decided at M4 planning, before the first template ships (D-0070)D-0026, D-0070A publisher that signs a malicious image passes every check; the signature proves origin, not intent
T-SUP-05TA dependency upgrade breaks a security invariant (for example MCP auth)rmcp is pinned to a minor version and majors are budgeted; the API spec is frozen and diffed in CIF-0041, F-0042, D-0048, D-0036A pinned old version accumulates unpatched issues; the upgrade budget is a process, not a guarantee
T-SUP-06EA contributor merges a backdoorReview before merge, one maintainer merges, and contracts are standalone pull requestsD-0004, D-0036Review is human; a subtle change can pass
T-SUP-07TThe panel client is generated from a hand-edited spec that diverged from the serverThe hand-written ../contracts/openapi.yaml is frozen and CI fails on a semantic diff against the utoipa output; orval generates the panel client from itD-0048, F-0042CI compares documents, not behaviour; a handler can still differ from its annotation

(m) Availability

IdSThreatMitigationReferenceResidual risk
T-AVL-01DThe control plane dies and apps stopNodes store desired state locally and reconcile without the control plane, and they keep self-restarting appsD-0011, D-0010Changes are blocked until a primary exists again
T-AVL-02DThe primary is lost and nobody can change anythingReplica nodes hold a copy and can be promoted manuallyD-0012, D-0016Manual promotion is an operator action; until it happens the cluster is read-only
T-AVL-03DTwo nodes both believe they are primary after a network cutAutomatic election is not allowed below three votes, and Quoro says so instead of pretendingD-0016, D-0017The operator may still force a promotion by hand and create a split; that is a human error the product warns about
T-AVL-04DThe witness sits in the same failure domain as a node and votes with itThe requirement is stated in the product and the panel; the witness must be outside the nodes’ failure domainD-0017Nothing enforces placement; a home PC next to a node is accepted if the operator says so
T-AVL-05DA single edge loses all app trafficTwo edges with DNS failover, or Cloudflare as the traffic edge with the host as witness onlyD-0018Both alternatives add a provider dependency
T-AVL-06DA provider outage removes the arbiter and stops stateful appsThe outage fences expiring leases by design; local-mode leases keep none/cold apps runningD-0047, D-0019, docs/research/r2-conditional-writes.mdwarm and hot apps lose availability during a long outage
T-AVL-07DThe primary’s disk fills with change log and audit recordsnode.disk_low exists for nodes; the primary is a node tooD-0013, D-0031, ../contracts/README.md event typesNo archival policy in M0-M6; growth is bounded only by the operator
T-AVL-08DA crash-heavy app consumes restart budget and hides a real faultRestart backoff from 1 s doubling to 5 min, reset after 10 minutes healthy; app.down after the failure threshold../contracts/node-agent-protocol.md, ../contracts/README.md event typesA crash loop is reported, not fixed; it still burns CPU on the node
T-AVL-09DUpgrading the binary stops appsThe node agent restart does not restart VMs, because VMMs are separate processes and are rediscoveredD-0011, ../contracts/engine-trait.mdA VMM crash during the upgrade window is not covered by the agent restart

7. Mitigation map

The map is the reverse view: which threats each accepted decision answers. It is not exhaustive for decisions with a single narrow threat.

DecisionThreats it mitigates
D-0011 (autonomous nodes, dial-out)T-AVL-01, T-NODE-05, T-API-03 (no inbound path to a node to attack), T-AVL-09
D-0013 (numbered hash-chained log)T-NODE-06, T-PRV-06, T-AUD-06, T-AVL-02 (a replica can verify what it holds), T-AUD-01 (the same chain discipline)
D-0017 (three votes, witness)T-AVL-03, T-AVL-04, T-LEASE-08, T-PRV-03 (the arbiter must be probed before it votes)
D-0019 (mandatory lease-based fencing)T-LEASE-01, T-LEASE-02, T-LEASE-07, T-LEASE-10, T-LEASE-05 (tokens do the work, not reads), T-VM-06 (shared storage writes carry tokens)
D-0026 (curated catalog, pinned signed images)T-VM-05, T-SUP-04
D-0027 (signed binaries, reproducible builds)T-SUP-01, T-SUP-02, T-SUP-03
D-0029 (agents as separate identities)T-MCP-03, T-AUD-04, T-MCP-07, T-API-06
D-0030 (scoped, time-limited grants, JIT approval)T-MCP-01, T-MCP-02, T-MCP-04, T-MCP-05, T-MCP-11, T-GRT-01 to T-GRT-09, T-API-09
D-0031 (append-only audit, same path for MCP)T-AUD-01 to T-AUD-10, T-MCP-07, T-MCP-08, T-MCP-10, T-API-06, T-NODE-10, T-GRT-10, T-SUP-07 (the frozen spec and its CI diff support it, D-0048)
D-0032 (detect, notify, wake, act)T-WHK-01 to T-WHK-07, T-MCP-06, T-NODE-10, T-GRT-05
D-0012 (one primary, replicas)T-API-03, T-AVL-02, T-NODE-14 (it names the trust concentration instead of hiding it)
D-0014 (telemetry stays on nodes)T-LOG-01 (blast radius), T-LOG-02, T-PRV-04 (logs are not copied to a provider)
D-0018 (edge as third voter)T-AVL-05, T-AVL-03
D-0024 (ingress with ACME or Tunnel)T-ING-01 to T-ING-09 except availability-only rows
D-0025 (provider interfaces)T-PRV-01, T-PRV-02, T-PRV-07, T-PRV-03 (a probe per provider is possible only with an interface)
D-0007/D-0008 (own engine, microVM per app, no Docker)T-VM-01 to T-VM-09
D-0010 (independent nodes)T-AVL-03, T-API-03
D-0047 (lease modes, fencing tokens on every shared write)T-LEASE-02, T-LEASE-03, T-LEASE-05, T-LEASE-09, T-AVL-06, T-VM-06
D-0048 (frozen spec, CI diff, pinned rmcp)T-SUP-05, T-SUP-07
D-0050 (cluster keys, DEK storage and rotation)T-PRV-06, T-PRV-08
D-0051 (human-only providers resource, write-only secrets)T-PRV-01, T-PRV-02, T-PRV-08
D-0053 (off-site copy in M5a, object-lock probe)T-AUD-06, T-PRV-05
D-0054 (setup token, listener and pin model)T-API-11
D-0059 (embedded reverse proxy behind the Ingress interface)T-ING-01 to T-ING-09 (the proxy and its certificate handling are Quoro code; Caddy remains a fallback backend)
D-0061 (own init, own OCI-to-ext4 pipeline with RESOLVE_IN_ROOT)T-VM-05 (layer paths cannot escape the rootfs), T-VM-07 (PID 1 is Quoro’s own init)
D-0063 (kernel WireGuard configured over netlink)Boundary 9, node <-> node: replication traffic and chunks cross it under per-node keys instead of trusting the network
D-0069 (own chunk engine for SQLite and template backups)T-PRV-04, T-AUD-06 (consistent snapshots make the copy worth restoring)

8. Residual risks and open items

8.1 A root operator on the primary before the off-site audit copy exists

D-0031 asks for an off-site copy with object lock. The copy ships in M5a with the S3-compatible path, gated by an object-lock probe per provider (D-0053). Until the first deployment of that copy, an operator with root on the primary can rewrite or truncate the log and nothing outside the machine contradicts them. The hash chain still catches a careless edit, because verification reports the exact seq. It does not catch a careful rewrite of the tail or of the whole file.

This is the single largest gap in the model. It is a scheduling fact, not a design flaw, and it should be stated as such in the M3 demo: “the log is tamper-evident, not tamper-proof, until the off-site copy exists”.

8.2 Single primary until promotion exists

Until M5b (manual promotion, D-0067) and M6 (automatic election), one primary is a single point of write failure and a single point of trust. Losing it means no changes until an operator acts; compromising it means full control (T-NODE-14). Replica nodes reduce the data-loss risk, not the authority risk.

8.3 Cluster key custody and how a promoted replica obtains it

Replicated secret columns are encrypted with the cluster key (D-0013, ../contracts/node-agent-protocol.md). A promoted replica must obtain that key or it cannot read the state it now owns. The algorithms, the storage and the rotation of the cluster keys are decided (D-0050; architecture section 6, “Cluster keys”); custody on promotion is decided together with manual promotion in M5b (D-0067). That is why promotion is not offered before M5b: a replica promoted earlier would own state it cannot decrypt.

8.4 Object lock portability

The off-site audit copy depends on object-lock support in compliance mode, which is not portable across S3-compatible providers (F-0038). The contract requires the adapter to probe and to refuse to report “locked” when the probe fails (../contracts/audit-log-format.md). A provider that passes the probe but degrades later is not covered. Object-lock support per provider is Hypothesis: it is not in docs/research/.

8.5 Database backups and encryption

Litestream 0.5.x has no client-side encryption (F-0040), so a SQLite backup taken through it would rely on provider-side SSE-C or SSE-KMS. D-0069 settles the default instead: SQLite and catalog-template backups use Quoro’s own chunk engine with client-side encryption, from consistent snapshots taken with the SQLite backup API or VACUUM INTO. Litestream is not used. A Litestream backend with SSE-C may be added later as an optional path for a lower RPO; it would then rely on the provider for encryption, with a Quoro-held key sent per request, and pinning the older 0.3.x line with age stays a Hypothesis on its maintenance state.

8.6 Hypotheses carried by this document

HypothesisWhere it mattersWhat would settle it
Monotonic clock drift between machines is bounded well below max(1000 ms, duration_ms / 10)T-LEASE-04, lease timingA lab measurement of drift under load and after suspend
Cloud Hypervisor’s device model plus seccomp and Landlock make guest escape a hypervisor-bug problem, not a configuration problemT-VM-01A lab escape test and a review of the per-VM sandbox flags
rmcp does not validate inbound tokens for a server, so Quoro mustT-MCP-09Reading the pinned rmcp version’s server code
A provider passes the object-lock probe and keeps honouring itT-AUD-03, 8.4A per-provider test that writes, locks and tries to delete
The M4 builder microVM is isolated from the host the same way an app microVM isT-VM-09The M4 build brief and a test that the builder cannot reach the node filesystem
Cloudflare LB failover over a tunnel is app-level only with the Host monitorT-ING-04F-0046 already narrows it; a lab test would confirm the replica caveat
No Docker on nodes means no container escape pathT-VM-09Trivially true by construction; re-check if a container engine is ever added

8.7 Items this document deliberately does not decide

  • Retention of audit records and of app logs. Both are “never deleted” in M0-M6; a policy needs its own decision.
  • Key rotation for the release signing key and node credentials. The signing-key and data-encryption-key rotation of the cluster keys is defined (D-0050; architecture section 6, “Cluster keys”); the release key’s rotation is not defined.
  • Federation or multi-installation trust. Out of scope (D-0034).
  • The threat model of the managed offering.

9. Security testing on the lab

Functional checks only. The lab is nested virtualization and is not valid for performance work (../product/roadmap.md). Each item below is a check a person can run over SSH on the lab hosts, and each names the threat it exercises.

M0 — contracts

CheckThreat
Take the desired_state message from the protocol contract, change one field, keep the old signature, and confirm the verification step rejects itT-NODE-04
Send a desired_state with rev lower than the stored one and confirm it is rejectedT-NODE-05
Revoke a local lease and confirm the node runs the self-fence path and emits node.fencedT-LEASE-01
Expire a test-only short expiring lease and confirm the node fences before valid_until + marginT-LEASE-10, T-LEASE-04
Make a forced stop fail during a fence and confirm the node emits node.fence_failed, not node.fenced, and starts no stateful VMT-LEASE-01

M1 — installer and engine

CheckThreat
Modify one byte of the published binary and confirm the installer refuses to run itT-SUP-01
Rebuild from source and compare the artifact hash with the published oneT-SUP-02
From inside a guest, attempt to read a host file path and to reach another VM’s address; both must failT-VM-01, T-VM-04
Fill a guest disk and confirm node.disk_low fires for the nodeT-VM-08

M2 — node agent and ingress

CheckThreat
Present a revoked or unknown node credential to the session endpoint and confirm the connection is refusedT-NODE-03
Reuse a join token twice and confirm the second enrollment failsT-NODE-01
Connect with a mismatched TLS certificate and confirm pinning rejects the sessionT-NODE-02
Change a hostname in the panel to a domain the operator does not control and confirm ACME fails rather than issuingT-ING-01
Delete an app and confirm its route stops resolving to the old guestT-ING-06

M3 — agents, grants, audit

CheckThreat
An agent with a read-only grant attempts a restart: refused, reason scope_missing, refusal auditedT-GRT-01, T-GRT-10
An agent attempts a delete without approval: approval_required, no deletion, approval request pendingT-MCP-01, T-GRT-02
Approve, then call with changed parameters: refused because params_hash does not matchT-GRT-08
Reuse the approval grant: the second call is refused with grant_exhaustedT-GRT-02
An agent attempts approval:decide or grant:write: refused with human_only and auditedT-MCP-05, T-GRT-01
Revoke a grant and confirm the next call is refused with grant_revokedT-GRT-04
Edit a copy of the audit log and confirm audit.verify reports the exact seq and audit.chain_broken firesT-AUD-01, T-AUD-10
Send a webhook delivery with a stale timestamp and with a wrong HMAC; the receiver rejects bothT-WHK-01, T-WHK-02
Create a webhook with http:// to a private address and confirm it is refusedT-WHK-04
Read an app’s env values through the API and confirm only names come backT-API-07
Inspect an audit record for a call that carried a secret in params and confirm [redacted]T-AUD-05

M4 — builds, terminal, console

CheckThreat
Run the web terminal and confirm the session appears in the audit log with the acting identityT-LOG-06
Print a line with terminal escape sequences from an app and confirm the panel renders it as textT-LOG-03
Run a build and confirm the builder cannot read the node’s credential fileT-VM-09

M5a — replicas, edge, backups

CheckThreat
Run the object-lock probe against the configured bucket and confirm the panel reports locked or not-locked truthfullyT-AUD-03, T-PRV-03
Attempt to delete a locked audit segment with the audit-copy credential: refusedT-AUD-09, T-PRV-02
Read a backup chunk from the bucket and confirm it is ciphertextT-PRV-04
Restore a primary snapshot taken through the SQLite backup API and confirm the off-site copy is ciphertext, encrypted client-side with the cluster keyT-PRV-04

M5b — witness, leases, fencing

CheckThreat
Cut lab-1 off from lab-2 and lab-3: lab-1 self-fences its stateful apps and they stopT-LEASE-02
While lab-1 holds the lease, ask to start the warm copy on lab-2: refusedT-LEASE-01, T-LEASE-08
Reconnect lab-1: it starts nothing stateful, its copy becomes the warm copy, and the quarantined snapshot is in the audit logT-NODE-06, T-LEASE-09
Make the arbiter unreachable, if an external arbiter is used, and confirm the node reports uncertain and does not fence before valid_untilT-LEASE-03, T-LEASE-07
Present a stale fencing token to the chunk store and confirm the write is rejectedT-LEASE-01, T-LEASE-05
Let several expiring leases on one node expire at the same time and confirm every fence runs concurrently and finishes within its own bound, t_send + D (D-0075)T-LEASE-01, T-LEASE-02
Crash a node in the middle of a fence and confirm that, after restart, its stateful apps stay stopped and no lease is granted elsewhere until an operator acknowledges the fence (D-0075)T-LEASE-02, T-LEASE-09
Ask for manual promotion while the old primary may still hold expiring leases: refused until an operator acknowledges the fence (D-0067)T-AVL-02, T-AVL-03

M6 — election and hot

CheckThreat
Cut the network into a minority partition and confirm no node in it accepts writes or claims to be primaryT-AVL-03
Power off the primary and confirm a new primary takes writes with no human actionT-AVL-02
After the partition heals, confirm writes succeeded in the majority and the audit chain still verifiesT-AUD-01, T-AVL-03

10. References

  • Decisions and findings: the decision ledger, mirrored as ADRs in ../adr/.
  • Contracts: ../contracts/README.md, ../contracts/openapi.yaml, ../contracts/events.schema.json, ../contracts/node-agent-protocol.md, ../contracts/audit-log-format.md, ../contracts/permission-model.md, ../contracts/db-schema.sql, ../contracts/engine-trait.md.
  • Architecture and product: architecture.md, ../product/prd.md, ../product/roadmap.md, ../glossary.md.
  • Research: docs/research/cloud-hypervisor.md, docs/research/r2-conditional-writes.md, docs/research/durable-objects.md, docs/research/cloudflare-lb-and-tunnel.md, docs/research/rust-api-and-mcp-tooling.md, docs/research/vps-nested-virt.md.