This document formalizes what Section 13 of the technical architecture already narrates in prose. It is not a rewrite of that section - it restates the same design as a structured STRIDE analysis (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) so a reader, an auditor, or the security-tooling remediation plan can check specific threats against specific mitigations, one surface at a time.
1. Scope and method
In scope, one STRIDE pass per surface:- Client-side key handling (wallet path and the secret-key advanced mode).
- The client-side session layer (the
DemolishPhasestate machine and IndexedDB session store). - API transaction construction (the pure transaction-builder module).
- The two backend signing keys: the mediator co-sign key, and the fee-bump sponsor key.
- Allowance revocation (Tranche 2, epic #159).
- Soroban token conversion via Soroswap (Tranche 2, epic #159).
- DeFi exit adapters - Blend, Aquarius, Soroswap router (Tranche 2, epic #151).
- Read-only external data sources (RPC providers, the Horizon-compatible enumeration endpoint, the Soroswap routing API, OctoPos). These carry availability risk, not custody risk - they cannot move funds - and are already addressed as a trust-minimization concern in Section 14, not repeated here.
- A third-party penetration test or formal audit. Tracked separately, deferred by design (epic #166), outside this document’s scope.
- Infrastructure-level threats (cloud provider compromise, CI/CD supply-chain attacks against the deploy pipeline). Covered by Section 15’s deployment model, not re-analyzed here.
docs/architecture.md or traced to the specific source
file that implements it - this document verifies existing controls, it does not propose new ones.
2. Surface 1: client-side key handling
Two paths: the wallet path (stellar-wallets-kit, primary) and the secret-key advanced mode
(apps/web/lib/stellar/signer.ts), for keys not held in any wallet.
Wallet-path signing carries a narrower version of the same threats: the key never enters the application at
all, so Information disclosure and Tampering against in-memory key material do not apply - the residual
threats are wallet-extension compromise and CSP bypass, both outside this surface’s boundary.
3. Surface 2: the client-side session layer
apps/web/store/demolish.ts (the DemolishPhase state machine) and apps/web/lib/session/store.ts (the
IndexedDB-backed session store, via idb), plus verify() (apps/web/lib/stellar/verify.ts) as the trust
anchor gating every signature this layer eventually authorizes.
4. Surface 3: API transaction construction
The API’s transaction-builder module (apps/api) is a pure module by design invariant - state in, unsigned
envelopes out, no network side effects (CLAUDE.md, “Hard invariants”) - which is what makes it possible for
verify() to treat its output as untrusted input rather than a peer.
5. Surface 4a: backend signing key — mediator co-sign
apps/api/src/mediator/mediator.controller.ts and mediator-validation.ts. The mediator is the one signing
key the API holds today; it co-signs only the forwarding payment of the exchange-mediator flow
(Section 11).
Known residual risk: mediator forward TOCTOU
forwardExceedsMergedBalance (apps/api/src/mediator/mediator-validation.ts) documents, in its own source
comment, a genuine time-of-check/time-of-use gap: the bound is checked against a balance read taken at
co-sign time, but the merge’s actual delivery is decided at submit time, which the caller controls. An
adversary who controls the merged account can drain it after co-signing (draining does not consume the
co-signed transaction’s sequence number, since it is a separate operation from a separate account), then
submit - so op0 (the merge) delivers close to nothing while op1 (the forward) still pays out the amount
that was valid at co-sign time, sourced from the mediator’s own balance rather than the merge.
This check is explicitly defense-in-depth, not a complete guarantee, against that active-adversary case. The
primary control is operational, not code-level: the shared mediator is funded to its base reserve only and
holds no spendable surplus, so even a successful exploitation of this gap has nothing to forward beyond
dust. The check in code stops the passive cases - a client bug, rounding dust, or a naive over-forward -
and raises the bar for the active case. This residual risk is carried forward into the summary table in
Section 10 as an accepted risk with an operational (not code) compensating control, which is the shape
#171’s remediation plan expects findings to already be in.
6. Surface 4b: backend signing key — fee-bump sponsor
Status: implemented (#164, epic #159). This surface is specified in Section 8.1 of the architecture doc and built inapps/api/src/fee-bump/ (fee-bump.controller.ts,
fee-bump-validation.ts) and apps/api/src/lib/stellar/fee-account.ts. Every row below has been
re-verified against the running code, with test coverage listed in Section 11; two mitigations this
table originally described - the account/IP dimension of rate limiting and the fee account’s
spend cap with alerting - turned out to be operational controls this PR does not implement in
code, and are carried instead as residual risks in Section 10, the same way the mediator’s own
funding discipline is.
7. Surface 5: allowance revocation
Status: implemented (#162/#163, epic #159). Entirely independent of the account-close flow -apps/web/lib/stellar/verify-revoke-allowance.ts is its own standalone trust anchor, not a branch
of assertCloseIntent, gating the one-operation approve(owner, spender, 0, expiration_ledger)
transaction that apps/api/src/lib/stellar/revoke-allowance.ts builds to zero a live SEP-41
allowance discovered by apps/api/src/lib/stellar/allowances.ts.
Even a wrong or hostile
{token, spender} pair from the discovery endpoint (an out-of-scope, read-only
source per Section 1) bounds its own damage structurally: approve(owner, spender, 0, _) can only ever
zero out one specific allowance - it has no path to move the account’s actual balance, unlike a
diverted destination or amount would.
8. Surface 6: Soroban token conversion via Soroswap
Status: implemented (#161, epic #159).apps/web/lib/stellar/verify.ts’s invoke_host_function
branch for a contract in expected.conversionContracts (the bundled registry’s Soroswap aggregator
and router, never the API), fed by apps/api/src/lib/soroswap/conversion-quotes.ts on the build side.
Documented residual, not a gap: unlike the DeFi-exit branch (Section 9), this branch’s own source
comment states plainly that the authorization tree’s sub-invocations - the actual DEX route the
aggregator picks - are not pinned client-side at all: “the browser has no way to enumerate them
[the pools a route passes through]… the API pins them.” This is a deliberate, already-documented
trust boundary (
docs/architecture.md §10.1), not an oversight comparable to #208 - the floor above
(minAmountOut) is what bounds the damage a route the client cannot enumerate could otherwise do.
Carried forward as a residual risk in Section 10.
9. Surface 7: DeFi exit adapters (Blend, Aquarius, Soroswap router)
Status: implemented (epic #151).apps/web/lib/stellar/verify.ts’s DeFi-exit branch, fed by
apps/web/lib/stellar/exit-expectations.ts (which builds exitContracts/heldTokenContracts/
positionTokenContracts/exitFunctions from the account read the user reviewed, never from the
transaction under verification), backed on the API side by the versioned wasmHash contract registry
(apps/api/src/lib/contract-registry/index.ts) and the exit adapters themselves
(apps/api/src/lib/defi-exits/{blend,aquarius,soroswap}.ts).
Documented residual, not a gap: the exact amount an exit moves, and the protocol-level meaning of
the call beyond function/recipient shape, are not checked client-side - the browser cannot re-simulate
a DeFi protocol’s internal accounting. Issue #208’s own proposal explicitly scoped this out (“keep the
amount unconstrained - the pool decides it”). Carried forward as a residual risk in Section 10.
10. Known residual risks (summary)
None of these risks are new in kind: the first two are already narrated in
docs/architecture.md, and the
rest follow the same “operational discipline” or “documented, bounded trust” pattern the earlier rows
establish. Restating them here is the point of formalizing the threat model - they are now indexed by surface
and category instead of embedded in prose, and this is the table #171’s tooling remediation plan cross-checks
its own findings against.
11. Coverage cross-reference
What already holds under automated test, as of this document, not just what is designed:12. Maintenance
This document goes stale the same waydocs/architecture.md’s trust boundary does: a new close operation,
a new signing key, or a new adapter needs a STRIDE entry added here in the same pull request that introduces
it, not as a follow-up. In particular:
- A new operation shape added to the builder and to
verify()’s allowlist (CLAUDE.md, “Consequence for any new close operation”) gets a row in Section 4’s Elevation of privilege threat. - A new server-side signing key gets its own subsection under Section 5/6’s pattern before it ships, the same way this document covers the fee-bump sponsor ahead of its implementation.
- A new DeFi protocol adapter that introduces its own invariants (
docs/architecture.md§9.9) is covered by those invariants directly; it only needs an entry here if it changes what the API signs or holds.
apps/web/hooks/useCloseExecution.ts requests sponsorship and
re-verifies the result the same way it re-verifies the mediator’s co-sign (Section 5) - and Section 11’s
coverage row now lists the client-side test files this added.
Sections 7-9 (allowance revocation, Soroban token conversion, DeFi exit adapters) were added after this rule
was already being violated: epics #151 and #159 shipped their PRs (#212-#220) without the STRIDE entries this
section calls for, so this document briefly did not cover three real, already-signing surfaces. Section 9 in
particular was written only once issue #208 - a real gap in the DeFi-exit surface it now describes - was found
and fixed, rather than ahead of the surface’s implementation the way this section asks for. Recorded here
plainly rather than silently backfilled, since a maintenance section that hides its own past lapses is worse
than one that names them.