Security Model — Anthosting v1

Status: v1, written after Phase C. Every claim below is tied to on-chain or attested evidence in evidence/; where something is not proven, it says so explicitly.


1. What Anthosting is

Anthosting hosts Antseed buyer instances. A user points their tool at https://<tenant>.anthosting.dev/v1/... and Anthosting runs the buyer proxy for them, so they do not have to run the AI VPN, the CLI, or the buyer daemon themselves.

The buyer is the party that pays: it holds a USDC balance in AntseedDeposits on Base and signs per-request spending authorizations to sellers over AntseedChannels.

That makes Anthosting's central problem: we run software that holds a key which spends the user's money. The model below exists to make that safe by construction, not by promise.

2. The one-sentence version

Anthosting can spend the user's balance, and it cannot take custody of it.

Those are different guarantees, enforced by different mechanisms, and it is worth being precise about which is which — see §5.

3. Components and trust boundaries

 user's wallet ────────────────┐
 (operator)                    │  on-chain: Base mainnet
                               ▼
                        AntseedDeposits / AntseedChannels     ◀── the custody guarantee lives HERE
                               ▲
                               │ SpendingAuth (buyer key signature)
                               │
   ┌───────────────────────────┴───────────────────────────┐
   │  TENANT CVM — Intel TDX confidential VM (Phala)        │
   │                                                        │
   │   dstack-ingress ──TLS 1.3 terminates IN HERE──▶ /v1/*  │
   │        │                                               │
   │        └─▶ tenant agent ─▶ Antseed buyer daemon        │
   │              (holds the buyer key, sees prompts)       │
   └────────────────────────────────────────────────────────┘
                               ▲
   ┌───────────────────────────┴───────────────────────────┐
   │  CONTROL PLANE — Railway (Phase E: built, deployed)    │
   │  metadata · orchestration · billing · DNS records      │
   │  NEVER: buyer keys, prompt contents, user funds        │
   │                                                       │
   │  Holds per-tenant bearer tokens in plaintext — which   │
   │  is NOT custody (no withdrawal path) but IS spend      │
   │  authority up to the on-chain credit limit. See        │
   │  docs/legal/terms.md §3.2.                             │
   └────────────────────────────────────────────────────────┘

The two sentences in that box are deliberately adjacent. The custody claim is true on-chain; the credential exposure is real; separating them would misrepresent the service. The full statement, with the code pointers, is docs/legal/terms.md §3.2 and docs/legal/privacy.md §2.3.

ComponentSeesMust never see
User's wallet (operator)everything; it is the user—
Tenant CVMthe buyer key, prompts, request bodiesanything that can withdraw
Phala (host + KMS)ciphertext, timing, SNIplaintext prompts/key from the hypervisor (but see §7)
Railway (control plane)tenant metadata, orchestrationbuyer keys, prompt bodies, funds
Sellersthe prompts they servethe buyer key, other tenants

4. The custody model (on-chain, not cryptographic)

Two roles exist per buyer account:

roleheld bycan do
buyer keythe tenant CVMsign SpendingAuths to spend; lockForChannel/settle path
operatorthe user's walletwithdraw, transferOperator, channel requestClose/withdraw

Consequence: the guarantee that Anthosting cannot take the user's money rests on public Solidity and the user's own wallet, not on our TEE, our honesty, or our infrastructure. A total compromise of Anthosting — host, image, control plane, and KMS — still cannot withdraw a cent.

5. Invariants

#InvariantHow it is enforcedEvidence
I1Anthosting never holds USDCFunding is deposit(buyer, amount) sent by the user's wallet; withdrawal sends to the operator. No Anthosting address appears in any money path.C2 (approve+deposit txs, sender/target/Transfer verified from receipts), C4 (withdraw → user's wallet)
I2The operator is the user's wallet, verified on-chain before any depositscripts/check-operator.mjs (3 exit codes, fails closed) gates fund-tenant.mjs, which refuses to emit a funding page otherwiseC1 (blocked exit 1 → allowed exit 0), C2 (operator == you FAIL ⇒ refuse)
I3The buyer key exists only inside the measured CVMDerived in-TEE via dstack GetKey from (app_id, key path), injected as ANTSEED_IDENTITY_HEX; never written to disk. A different app derives a different key.B3 (stable across restart), B4 (tampered compose ⇒ different key), C3 (stable across in-place image update)
I4The host sees only ciphertextTLS 1.3 terminates inside the CVM (dstack-ingress, key generated and used only there); no DNS credential in the TEE (tls-alpn-01)B5 (prove-no-plaintext.mjs: SNI visible, request marker never in cleartext)
I5The user can withdraw and force-close without AnthostingrequestClose → 15 min grace → withdraw, all operator-gated; transferOperator for recoveryC4 (full exit + transfer round-trip, both sent by the user)
I6Attestation is independently verifiableverify-tls-domain.mjs / check-measurement.mjs: DCAP quote, domain binding, served cert == evidence leaf, compose/OS-image hashes vs approvedB2, B4, B5
I7Host-side limits are defense-in-depth, never the custody guaranteeThe only hard cap is the on-chain credit limit (BASE/MAX_CREDIT_LIMIT = 500 USDC on mainnet). E4 adds a host-side detector (uptime + spend-rate alerts, opt-in auto-pause on a critical anomaly) — a control that acts after the fact, never a limit, and never a custody one. See §9, §10 and docs/monitoring.md.C2 (limits read live from the deployed contract), E4 (drill: alert fires, auto-pause acts, undeliverable recorded as undelivered)
I8No prompt or request-body loggingThe tenant agent logs lifecycle lines and errors only; the buyer proxy's only console call is a state-file write failure. Bodies are parsed in memory for token counts, not logged.§8, grep of buyer-proxy.ts / telemetry.ts

6. Identity and attestation anchors

A tenant's identity is pinned to:

Never RTMR3 / the event log — they change across legitimate restarts, so pinning them would reject honest tenants (G20).

Two consequences worth stating plainly:

7. What the TEE protects — and what it does not

Protects (against the host operator and a compromised host OS/hypervisor):

7.1 What the guest itself is made of (F1, 2026-10-03)

The tenant runtime is a distroless image (ghcr.io/…@sha256:1e367d2b…, uid 65532, reproducible — two clean builds produce the same digest; F1's 28edc8be… was the first revision, G1a rebuilt with @antseed/cli 0.1.168). Inside the enclave there is no shell to type into, no package manager, no coreutils; the root filesystem is read_only with cap_drop: ALL and no-new-privileges, and every runtime write lands in a 64 MB tmpfs that dies with the container. The router plugin the buyer daemon needs is pinned in a lockfile and baked into the image rather than installed at boot, which means it is inside the image digest, inside compose_hash, and therefore inside the approved measurement — code running in the TEE that no measurement covers is exactly what the measurement is for. Evidence and the checks that assert each of these: evidence/F/F1/.

The gap, stated plainly: there is no egress allowlist. A running tenant may open outbound connections to anything it can reach. dstack exposes no compose-level egress control we could find, in its CLI options or its compose fields, so this is a platform property and not something a compose flag fixes. What is true today, and is not the same thing:

So the exposure is what a compromised process inside the enclave could reach over the network, not a stolen key. Closing it properly needs an egress policy from the platform; until then it belongs on this list rather than in a bullet somewhere else.

7.2 Trusted-computing-base hygiene (G1a, 2026-10-03)

Attestation says what code and configuration are running; it does not say the platform underneath it is patched. The TCB — Intel TDX firmware/microcode, the dstack guest OS, the KMS — sits below the measurement, and a DCAP quote carries a TCB status (UpToDate, OutOfDate, …) that is a signal about that layer, separate from MRTD/compose_hash:

Does not protect — state these to users:

8. The inference surface, and why it is gated

/v1/* is reverse-proxied to the loopback buyer daemon, streaming both ways. The buyer daemon has no authentication of its own — it is built to sit on loopback — and every request through it spends the buyer's USDC.

So the surface requires PROXY_AUTH_TOKEN:

8.1 The tenant request path and where trust boundaries sit (G4, 2026-10-03)

Every inference request crosses four hops, and only the second one is ours:

client ──HTTPS──▶ dstack-ingress :443   TLS terminates inside the TEE
       ──HTTP───▶ tenant agent :8080     PROXY_AUTH_TOKEN gate · header hygiene · body byte count · budget
       ──HTTP───▶ Antseed buyer daemon    127.0.0.1:8377, loopback-only, UNMODIFIED upstream code
       ──P2P────▶ seller

Three consequences worth stating separately, because each is a different kind of claim:

Two knobs on that edge are deliberately off unless configured, so the default path adds no behaviour the operator did not ask for: the body cap (MAX_BODY_BYTES, default 0 = disabled — upstream's gateway caps nothing, and multimodal requests are legitimate) and the spend budget (DAILY_BUDGET_USDC, default 0 = off; I7's host-side detector still exists alongside it). The require-verifier toggle is likewise off by default, because the measured TEE-seller supply is thin (2 of 53 peers advertising a verifier, 2026-10-03) — so /health publishes the live number and lets the owner choose strict privacy against availability with the trade-off in front of them.

Dashboard and panel data is metadata only. The CVM resource panel (memory from cgroup and /proc/meminfo, CPU load, tmpfs usage via statfs) and the spend panel (/_antseed/buyer-usage) read numbers about the machine. I8 is intact: no panel, log, monitor or alert ever carries prompt bodies, and none of them are given a request transcript.

Same reasoning for the operator endpoint, which is a signing oracle and is therefore double-gated by a bearer token and a pinned OPERATOR_ADDRESS (G33).

Residual risk: a bearer token is a bearer capability. Whoever holds PROXY_AUTH_TOKEN can spend the tenant's balance up to the cap. Treat it as a secret, scope it per tenant, and rotate it.

9. Worst cases, with bounds

ScenarioOutcome
Anthosting is fully compromised (host + image + control plane + KMS)Attacker can spend the buyer's balance to colluding sellers — bounded by the on-chain credit limit (500 USDC today). Cannot withdraw. The user withdraws with their own wallet and rotates the operator if needed.
Tenant CVM compromised (buyer key exfiltrated)Same bound: spend-only, up to the balance. No withdrawal path exists for that key.
Host hypervisor compromisedMemory encryption + in-CVM TLS mean plaintext prompts/keys are not exposed by the hypervisor; availability and timing are.
KMS provider maliciousCan derive the buyer key ⇒ spend-only, same bound as above. Cannot withdraw.
Tenant image tampered, user does not verifyAttacker gets a spending key for that app. Bounded by the balance. Prevented by the measurement check (I6) + I2's gate before funding.
OPERATOR_AUTH_TOKEN leaksThe pinned OPERATOR_ADDRESS still forces the signature to name the user's wallet, so it cannot be redirected (G33). Worst case: an attacker burns the one-time setOperator slot with the correct address — harmless, but it front-runs the user's UX.
PROXY_AUTH_TOKEN leaksSpending up to the balance.
User sends transferOperator to 0x0Exit is bricked permanently — the contract has no zero-address guard (G11). scripts/transfer-operator.mjs refuses it.
Seller closes the channel during graceNormal, not an attack: the reserve is refunded automatically. channels.withdraw then reverts ChannelNotActive; the balance is already available (G40).

10. Operational security

11. Residual risks and open items

  1. No in-tenant spend limiter. I7 still stands: the only hard cap is the on-chain credit limit. E4 added a host-side detector — an uptime and spend-rate monitor with opt-in auto-pause on a critical anomaly — but a detector is not a limit: it notices a rate after the fact, it can be wrong in both directions, and if the control plane is down nothing is watching. A per-tenant daily budget enforced inside the tenant would be genuine defense-in-depth; it is not in v1. docs/monitoring.md §2 is the honest list of what the detector cannot do.
  2. Control plane not built (Phase E). When it exists it must never receive buyer keys, and its DNS token must be scoped to the tenant's records only.
  3. User verification UX. Attestation is only as good as the check. A one-command verifier the user actually runs (and a UI that refuses to fund an unverified tenant) is the missing piece.
  4. Key export / rotation and account exit runbook (E5). transferOperator covers wallet loss; migrating a buyer identity is unsolved.
  5. Incident response playbook (F4).
  6. Trust in Intel TDX + dstack + Phala KMS is assumed, not eliminated. Documented, not hidden.
  7. In-VM diagnosis requires a dev OS image, and that has a security cost. phala ssh works only on is_dev images; a prod-image tenant is unreachable in-VM (verified: kex_exchange_identification: Connection closed by remote host, evidence/G/G2/commit/ssh-requires-dev-image.md). The alternatives are a dev-image tenant built for debugging — a new app_id, so a new buyer address, KMS app and buyer signature — or re-deploying the tenant under test onto a dev image, which weakens the TCB of the machine whose isolation is the product and changes its attested os_image_hash. public_logs: true is the third option and is refused: it makes tenant logs world-readable for every tenant, permanently, which is a larger and less reversible concession than either. The consequence to accept now: a prod-image tenant with a defect is not debuggable in place, and that limit should be designed around rather than discovered during an incident.
  8. Prompt confidentiality vs. sellers is out of scope for Anthosting — state it, don't imply otherwise.
  9. A lost wallet is unrecoverable today — this is a documented limitation, not a bug queue item. Access to an account, and every operator-only path (withdraw, transferOperator, requestClose), requires the user's own wallet. If that wallet is lost or inaccessible:
  10. the machine keeps running and the buyer keeps spending — nothing about the CVM depends on the operator being present;
  11. but account access is gone, so the user can no longer stop/start/destroy it, read their invoices, or open the instance panel;
  12. and the funds cannot be moved by anyone. setOperator is one-time and irreversible by the buyer key, so the operator is whatever address was set at onboarding; there is no second signer and no recovery contact, because we hold no email and no key for the user.
  13. transferOperator(0x0) is the sharpest form of this: the contract has no zero-address guard (G11), so that call bricks withdrawal permanently — scripts/transfer-operator.mjs refuses it.

Future options are listed for the record and none is committed: a passkey-backed backup signer on a smart account; making the operator a Safe/multisig (user devices plus optional social guardians); social recovery (guardians approve a rotation); a pre-provisioned recovery address; and smart-account recovery modules in the EIP-4337/EIP-7702 era. Any of them must reckon with three facts: setOperator is one-time, the transferOperator(0x0) brick is irreversible, and the repo's standing rule (G43, and the E3 verifier work) is that a recovery flow recovers the signer rather than trusting a claimed address — a "recovery" that accepts an address without proving control of it is not recovery, it is a second custody surface.

What this means for the product: with wallet-primary sign-in (G8.1) the wallet is the account. The legal drafts say so (docs/legal/terms.md, docs/legal/privacy.md §2), and the account panel must not imply a support path that does not exist.

12. What is proven, and how to check it

# the money path, from receipts alone
node scripts/verify-setoperator-tx.mjs --tx <tx> --buyer <buyer> --operator <wallet>   # C1
node scripts/verify-deposit-tx.mjs    --tx <tx> --buyer <buyer> --depositor <wallet>  # C2
node scripts/verify-settlement-tx.mjs --tx <tx> --buyer <buyer> --min-settled 0.003   # C3
node scripts/verify-withdraw-tx.mjs   --tx <tx> --buyer <buyer> --operator <wallet>   # C4

# the guard that must pass before anyone funds a buyer
node scripts/check-operator.mjs --buyer <buyer> --operator <wallet>   # 0 allowed / 1 blocked / 2 unknown

# the TEE + TLS story
node packages/verifier/scripts/verify-tls-domain.mjs <tenant-host> --compose-hash <approved> --os-image-hash <approved>   # B5
node packages/verifier/scripts/check-measurement.mjs https://<tenant-host>/attest --compose-hash <approved> --os-image-hash <approved>
node packages/verifier/scripts/prove-no-plaintext.mjs <tenant-host>   # I4

Evidence index: evidence/A/ (buyer lifecycle) · evidence/B/ (TEE, tamper gate, attested TLS) · evidence/C/C1..C4 (custody: onboarding, funding, live paid request, exit).

Threat detail: docs/notes/gotchas.md G1–G44, in particular G11 (zero-address brick), G29 (no DNS credential in the TEE), G33 (the signing oracle), G37 (key continuity across upgrades), G40 (seller-closed channel), G43 (never move an operator-gated capability to a holder that can't act).