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.
| Component | Sees | Must never see |
|---|---|---|
| User's wallet (operator) | everything; it is the user | — |
| Tenant CVM | the buyer key, prompts, request bodies | anything that can withdraw |
| Phala (host + KMS) | ciphertext, timing, SNI | plaintext prompts/key from the hypervisor (but see §7) |
| Railway (control plane) | tenant metadata, orchestration | buyer keys, prompt bodies, funds |
| Sellers | the prompts they serve | the buyer key, other tenants |
4. The custody model (on-chain, not cryptographic)
Two roles exist per buyer account:
| role | held by | can do |
|---|---|---|
| buyer key | the tenant CVM | sign SpendingAuths to spend; lockForChannel/settle path |
| operator | the user's wallet | withdraw, transferOperator, channel requestClose/withdraw |
deposit(buyer, amount)is permissionless — anyone may credit a buyer, and USDC is pulled frommsg.sender. The user funds from their own wallet. (C2)- Every function that moves money out calls
_requireOperator, i.e.msg.sender == getOperator(buyer). (C4) - The buyer key has no path to withdraw — it is not
_isOperator, sowithdrawrevertsNotAuthorized()for it. (A1, C4) - The operator is set once by
setOperator(buyer, operator, nonce, buyerSig), which requires an EIP-712 signature from the buyer key. The signed struct is only(operator, nonce); thebuyeris recovered from the signature, never supplied. (C1)
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
| # | Invariant | How it is enforced | Evidence |
|---|---|---|---|
| I1 | Anthosting never holds USDC | Funding 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) |
| I2 | The operator is the user's wallet, verified on-chain before any deposit | scripts/check-operator.mjs (3 exit codes, fails closed) gates fund-tenant.mjs, which refuses to emit a funding page otherwise | C1 (blocked exit 1 → allowed exit 0), C2 (operator == you FAIL ⇒ refuse) |
| I3 | The buyer key exists only inside the measured CVM | Derived 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) |
| I4 | The host sees only ciphertext | TLS 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) |
| I5 | The user can withdraw and force-close without Anthosting | requestClose → 15 min grace → withdraw, all operator-gated; transferOperator for recovery | C4 (full exit + transfer round-trip, both sent by the user) |
| I6 | Attestation is independently verifiable | verify-tls-domain.mjs / check-measurement.mjs: DCAP quote, domain binding, served cert == evidence leaf, compose/OS-image hashes vs approved | B2, B4, B5 |
| I7 | Host-side limits are defense-in-depth, never the custody guarantee | The 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) |
| I8 | No prompt or request-body logging | The 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:
app_idandcompose_hash(the exact tenant image, pinned by digest),- the MRTD of the approved OS image (
dstack-0.5.9,is_dev=false), - our own
report_databinding (buyer address + nonce, or the domain-binding digest), - the served TLS certificate matching the one in the attested evidence bundle.
Never RTMR3 / the event log — they change across legitimate restarts, so pinning them would reject honest tenants (G20).
Two consequences worth stating plainly:
app_idis stable across in-place updates (image swap, env change), so the buyer key — and therefore the user's funded address — survives an upgrade. A fresh deploy rotates it. (G37)- Because of that, the measurement check is what protects an in-place update. Key continuity cuts both ways: a tampered image pushed to our own app would keep the same buyer key. The user's defence is verifying
compose_hash/app_id/OS hash/MRTD before funding — plus the operator role, which lets them withdraw at any moment regardless.
7. What the TEE protects — and what it does not
Protects (against the host operator and a compromised host OS/hypervisor):
- the buyer key and the prompt/request contents — TDX memory encryption,
- the TLS session and private key — termination happens in-CVM,
- integrity of the measured image — via attestation.
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:
- the tenant's boot, identity and operator paths need no network at all — proved by running the image with
--network none(scripts/f1-posture-check.mjs), - no DNS credential enters the CVM (§7 above:
tls-alpn-01, so the ACME challenge never touches the zone), - the money-spending surface is token-gated (§8),
- there is no key material on disk to exfiltrate — the buyer key is derived per boot and injected into the daemon from memory.
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:
- The Antseed verifier SDK (0.1.0) supports
ANTSEED_VERIFIER_STRICT_TCB=true, which turns TCB status into a gate (UpToDaterequired). It is off by default because a genuine, untampered platform can reportOutOfDatewhile waiting on a vendor microcode rollout; failing closed on that would drop real sellers. The hosted buyer's default is a G4 decision to be measured against live TEE-seller supply, not flipped silently here. - Our own verifier records the DCAP TCB verdict (
UpToDatein every Phase B/F1 run) but does not gate on it either; if we ever do, it is the same measured decision, and it belongs in this document first. - TCB status is a maintenance property: hosts must keep firmware/microcode current, and a claim that “attestation passed” is a statement about code, not about a permanently patched CPU. The practical consequence for users is stated where they can act on it: if a seller's TCB is
OutOfDate, a strict-configured buyer will not use it. - DCAP-verification libraries themselves get advisories like any dependency. We use the maintained Rust
@phala/dcap-qvl(pinned 0.6.5, the current latest, B2) rather than a bespoke path, and keeping that pin fresh is part of routine dependency currency (the same rule as every pin inpackages/shared/src/versions.ts).
Does not protect — state these to users:
- The KMS is in the trust set for key confidentiality. dstack's
GetKeyderives the buyer key via the KMS (Phala). The hypervisor cannot read it, but the KMS provider can derive it. So key confidentiality trusts Phala's KMS; custody does not (a KMS that leaks the buyer key still only gets a spending key). - A compromised tenant image that the user did not verify. Attestation only helps if someone checks it.
- Spending by the buyer key — by design, bounded by the balance (§9).
- A malicious seller reading prompts. That is the seller's own TEE concern; Anthosting cannot fix it, and should not claim to.
- The user's own endpoint. If the user's machine is compromised, the tenant is irrelevant.
- Denial of service, rollback, or network cuts by the host. Availability is not protected.
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:
- absent ⇒ 503 (refuse to serve; never an open money-spending endpoint),
- wrong/absent credential ⇒ 401,
x-anthosting-key: <token>orAuthorization: Bearer <token>,- the token is stripped before proxying, so our credential never reaches a seller.
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:
- We never expose the daemon. It binds
127.0.0.1hardcoded, exposes no auth config of its own, and every request through it spends the buyer's USDC — so the edge in front of it is not optional hardening, it is the only thing standing between a public hostname and an open wallet. - Header stripping happens before the daemon sees the request, which is why it is not a patch to Antseed. The daemon copies the client's headers into the outbound peer request, deleting only
hostand its own internal marker (G116), so a tool configured by hand with a real provider key would hand that key to an arbitrary seller. Our edge deletesauthorization,cookie,x-api-keyandx-goog-api-key— a superset of upstream's ownTunnelGateway, which strips only the first two and only on the tunnel path. Sellers authenticate their own upstream with their own credentials and the buyer pays on-chain, so the daemon needs none of them. - If upstream ships host/auth/header hygiene, the edge shrinks to
/attest,/identity,/operator,/health. Until then the thin edge is the documented, deliberate exception to "keep the daemon native" (G4; upstream issue in §15 #10, findings indocs/notes/upstream-daemon-auth.md).
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
| Scenario | Outcome |
|---|---|
| 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 compromised | Memory encryption + in-CVM TLS mean plaintext prompts/keys are not exposed by the hypervisor; availability and timing are. |
| KMS provider malicious | Can derive the buyer key ⇒ spend-only, same bound as above. Cannot withdraw. |
| Tenant image tampered, user does not verify | Attacker 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 leaks | The 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 leaks | Spending up to the balance. |
User sends transferOperator to 0x0 | Exit is bricked permanently — the contract has no zero-address guard (G11). scripts/transfer-operator.mjs refuses it. |
| Seller closes the channel during grace | Normal, not an attack: the reserve is refunded automatically. channels.withdraw then reverts ChannelNotActive; the balance is already available (G40). |
10. Operational security
- Secrets live only in env vars (
.env, gitignored). No keys in code, docs, commits, or chat. - Pinned versions for anything touching keys/payments/protocol:
packages/shared/src/versions.tsrecords the tenant image digest, the ingress digest, and the OS image hash. - The tenant never touches the user's
~/.antseed. All Antseed work happens in a sandbox, and tests useBUYER_BINso they never invoke the host's real CLI. - No DNS credential enters a CVM.
tls-alpn-01means the operator creates three records once; the production plan has the control plane apply them via a webhook authenticated by HMAC and a payload-committing TDX quote (Option A,docs/notes/codebase.md§11.1). - Monitoring sees metadata, never content. The monitor (E4) reads one health URL and one on-chain balance per tenant. It is not given a tenant's request logs, its conversation store, or its inference surface, and it holds no credential for any of them —
/healthis public on purpose. Its alert webhook URL is treated as a bearer capability: never logged, never returned by the API. PHALA_API_KEYmust be rotated (it was shared in chat early on).- Operator access is excluded by construction — 2026-10-07, verified from the code. No SSH key or operator process is authorized inside a tenant CVM; the operator's capabilities are the on-chain contracts and the plaintext bearer tokens it generates. This is a real boundary, not a policy preference, and here is why it holds and where it ends.
infra/phala/tenant/agent.mjs:198derives the buyer identity by calling the guest agent's socket (/var/run/dstack.sock) with no per-caller authorization — the VM is the TCB — and the requested path isanthosting/tenant/${TENANT_ID}/buyer/v1, which is derived from the publicTENANT_IDand so is not a secret that gates anything. Root inside a CVM would therefore obtain the buyer key. What that adds over §2 is narrow and bounded: spend authority up to the on-chain credit limit, and the ability to bypassDAILY_BUDGET_USDC, which our agent enforces and the chain does not. It is not a withdrawal path (operator-gated, operator = the buyer's wallet) and it does not touch code governance (on-chain owner). Debug exceptions must be scoped to one machine, authorized per-CVM, and revoked when the session ends —phala ssh-keys grant/revokedo this,ssh-keys addalone does not (see G175).
11. Residual risks and open items
- 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. - 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.
- 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.
- Key export / rotation and account exit runbook (E5).
transferOperatorcovers wallet loss; migrating a buyer identity is unsolved. - Incident response playbook (F4).
- Trust in Intel TDX + dstack + Phala KMS is assumed, not eliminated. Documented, not hidden.
- In-VM diagnosis requires a dev OS image, and that has a security cost.
phala sshworks only onis_devimages; 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 newapp_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 attestedos_image_hash.public_logs: trueis 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. - Prompt confidentiality vs. sellers is out of scope for Anthosting — state it, don't imply otherwise.
- 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: - the machine keeps running and the buyer keeps spending — nothing about the CVM depends on the operator being present;
- but account access is gone, so the user can no longer stop/start/destroy it, read their invoices, or open the instance panel;
- and the funds cannot be moved by anyone.
setOperatoris 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. 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.mjsrefuses 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).