How to protect multi-step workflows, fintech funnels, and Server-Driven UI from step-skipping, state tampering, and replay attacks — with zero runtime dependencies.
Imagine a standard e-commerce checkout flow: Shopping Cart → Shipping Address → Promo Code → Payment Authorization.
You use HTTPS. You validate JWT tokens on every request. Your backend endpoints are covered with unit tests. You feel safe.
Then someone opens your app on a rooted device, hooks into memory with Frida, or simply manipulates an internal deep link. Instead of going from Cart to Shipping (where a $50 express delivery fee is calculated), the client dispatches a transition straight from Cart to Payment. Or worse, in a Server-Driven UI (SDUI) flow, an attacker replays a cached JSON payload from five minutes ago where a KYC verification flag was temporarily set to true.
HTTPS only encrypts the pipe. JWTs only verify who is holding the token. Neither guarantees that your client-side state machine actually followed the legal sequence of steps to reach its current screen.
To fix this at the architectural level without littering business logic with endless if-else checks, I built TrustChain — a pure functional, cryptographically verifiable state machine ledger for Kotlin Multiplatform (KMP).
The Mental Model: Think "Git Commits" for Your App State
You don’t need a PhD in cryptography or heavy blockchain consensus algorithms to secure a user journey. You just need strict lineage.
Think about how Git works: every commit contains a hash of its own content plus the exact hash of the parent commit. If you try to alter history or insert a commit out of nowhere, the hashes don't match, and Git rejects the tree immediately.
TrustChain brings this exact mechanism to your application’s navigation and state stack:
- Every screen or workflow step has a unique
hashrepresenting its current state, and apreviousHashpointing to the exact step that preceded it. - When the user moves forward, the incoming step’s
previousHashmust match thehashof the currently active step on top of the stack. - Partial state updates (like applying a promo code or updating live order status over WebSockets) arrive as signed deltas (
DeltaBlock). They verify the current hash, check the server’s asymmetric digital signature, mutate the state, and verify the new hash.
If an attacker tries to skip a screen, inject a fake discount, or replay an old state, the hash chain breaks. The state machine fails fast before a single pixel is rendered or a network call is fired.
| Security Concern | Standard REST + HTTPS | JWT Tokens | TrustChain |
|---|---|---|---|
| Data In-Transit Encryption | Yes (TLS) | Yes (TLS) | Yes (TLS) |
| Prevent Step Skipping (Deep Links) | No (Complex backend checks) | No | Yes (Parent Hash Check) |
| Detect Tampered State Deltas | No | Partially | Yes (Asymmetric Signatures) |
| Replay Attack Prevention | Manual nonces required | No | Yes (Strict Hash Lineage) |
| Zero Runtime Dependencies | N/A | Often heavy | Yes (Pure KMP) |
Designing for Zero Bloat: The Core Primitives
One of my main goals with TrustChain was keeping the core completely free of third-party runtime dependencies so it drops cleanly into Android, iOS, Desktop, Web, or backend KMP targets.
1. VerifiableBlock
Any screen, workflow step, or state snapshot in your domain simply implements a three-property interface:
interface VerifiableBlock {
val id: String
val hash: String
val previousHash: String? // null only for the root (genesis) step
}
2. DeltaBlock (Signed Partial Updates)
Why re-fetch an entire screen when a user applies a 10% coupon or an order changes status? Instead of sending full state trees, the server sends a lightweight DeltaBlock:
@Serializable
data class DeltaBlock<P>(
@SerialName("patches") val patches: P,
@SerialName("previous_hash") val previousHash: String,
@SerialName("new_hash") val newHash: String,
@SerialName("signature") val signature: String = ""
)
Notice how DeltaBlock locks the mutation from both sides:
- It can only be applied if the client is currently sitting on
previousHash. - Its
newHashis signed by the server's private key (RSA,Ed25519, orECDSA). - Once the client applies
patchesvia aBlockPatcher, the resulting block must producenewHash.
3. Pure Functional Reduction
TrustChain is completely immutable. There are no hidden side effects, mutable singletons, or uncaught exceptions flying across coroutines. You send a ChainEvent (Push, Pop, Replace, or ApplyDeltas), and reduce() returns a standard Kotlin Result:
sealed interface ChainEvent<out B : VerifiableBlock, out P> {
data class Push<B : VerifiableBlock>(val block: B) : ChainEvent<B, Nothing>
data object Pop : ChainEvent<Nothing, Nothing>
data class Replace<B : VerifiableBlock>(val block: B) : ChainEvent<B, Nothing>
data class ApplyDeltas<P>(val blocks: List<DeltaBlock<P>>) : ChainEvent<Nothing, P>
}
If a batch of 5 deltas is applied and the 4th delta has a broken hash link or invalid signature, atomicity is preserved — the entire reduction fails cleanly with a typed ChainError, leaving your original TrustChain untouched.
Real-World Walkthrough: The Unbreakable Checkout
Let’s look at how this works in practice using a multi-step checkout flow.
First, we define our domain step:
@Serializable
data class CheckoutStep(
override val id: String,
val title: String,
val state: Map<String, String> = emptyMap(),
override val hash: String,
override val previousHash: String? = null
) : VerifiableBlock
Next, we initialize TrustChain on the client. Notice how cryptography and state patching are injected via simple functional interfaces (SignatureVerifier and BlockPatcher). You aren't locked into any specific crypto library:
var clientChain = TrustChain<CheckoutStep, Map<String, String>>(
strictMode = true,
verifier = SignatureVerifier { payload, signatureBase64 ->
// Plug in your platform's Ed25519 / RSA / ECDSA verifier
CryptoEngine.verify(serverPublicKey, payload, signatureBase64)
},
patcher = { step, patches, newHash ->
step.copy(
state = step.state + patches,
hash = newHash
)
}
)
The Legitimate User Journey
As the user progresses through the checkout, every step returned by the server references the exact hash of the previous state:
// 1. User opens the Shopping Cart (Genesis block, previousHash = null)
val cartStep = server.startCheckout(sessionId)
clientChain = clientChain.reduce(ChainEvent.Push(cartStep)).getOrThrow()
// Active: Shopping Cart | Hash: h_c2c1a28598b8ad69
// 2. User confirms Shipping Address (Chained to Cart's hash)
val shippingStep = server.submitShipping(sessionId, "Baker St 221B")
clientChain = clientChain.reduce(ChainEvent.Push(shippingStep)).getOrThrow()
// Active: Shipping & Address | Hash: h_50bda3ba68cced0d | Prev: h_c2c1a28598b8ad69
// 3. User applies a 10% Promo Code (Signed DeltaBlock mutation)
val promoDelta = server.applyPromoCode(sessionId, "10%")
clientChain = clientChain.reduce(ChainEvent.ApplyDeltas(listOf(promoDelta))).getOrThrow()
// Active: Shipping & Address (Patched) | New Total: $1890 | New Hash: h_6471c5f0c53a644b
// 4. User proceeds to Payment (Chained to the patched Shipping hash)
val paymentStep = server.proceedToPayment(sessionId)
clientChain = clientChain.reduce(ChainEvent.Push(paymentStep)).getOrThrow()
// Active: Payment Authorization | Hash: h_6a6fd155e392f7ef
Simulating the Attack: Skipping Shipping
Now, let’s see what happens when an attacker loads the Cart (h_c2c1a28598b8ad69) and tries to jump straight to the paymentStep (bypassing shipping calculation):
var attackedChain = TrustChain<CheckoutStep, Map<String, String>>(strictMode = true)
attackedChain = attackedChain.reduce(ChainEvent.Push(cartStep)).getOrThrow()
// Attacker tries to force-push the Payment step directly onto the Cart
val attackResult = attackedChain.reduce(ChainEvent.Push(paymentStep))
attackResult.fold(
onSuccess = {
println("VULNERABILITY: Attack succeeded!")
},
onFailure = { error ->
when (error) {
is ChainError.HashMismatch -> {
println("[!] BLOCKED BY TRUSTCHAIN: State machine integrity preserved.")
println(" Expected parent: ${error.expected}, Actual: ${error.actual}")
}
is ChainError.SignatureVerificationFailed -> {
println("[!] BLOCKED: Invalid server signature for block ${error.blockHash}")
}
else -> println("[!] Blocked: ${error.message}")
}
}
)
Console Output:
------------------------------------------------------
Simulating Attack: Attacker tries to skip shipping
------------------------------------------------------
Attacker has Cart loaded.
Attacker attempts to directly apply Payment step (bypassing Shipping)...
[!] BLOCKED BY TRUSTCHAIN: State machine integrity preserved.
Expected parent: h_c2c1a28598b8ad69, Actual: h_6471c5f0c53a644b
Because paymentStep expects its parent to be the patched shipping step (h_6471c5f0c53a644b), pushing it onto the cart (h_c2c1a28598b8ad69) triggers an immediate ChainError.HashMismatch. The state transition is rejected atomically.
Built for Developer Ergonomics
Security tools often fail because they make local development painful. Nobody wants to spin up a signing server just to preview a Compose Multiplatform screen or write a quick UI test.
That’s why TrustChain includes a strictMode flag:
-
strictMode = true(Default / Production): Enforces parent hash lineage onPushandReplace, verifies digital signatures viaSignatureVerifier, and ensuresBlockPatcherupdates block hashes accurately. -
strictMode = false(Previews / UI Prototyping): Bypasses cryptographic and lineage checks while keeping the exact sameChainEventreducer API, allowing you to mock and jump between screens freely during development.
Where Does TrustChain Shine?
- Server-Driven UI (SDUI): Guarantee that screens and dynamic patches pushed by the backend are rendered in the exact sequence intended by the server.
- E-Commerce & Checkouts: Make it impossible to bypass delivery calculation, tax steps, or terms acceptance before payment.
- Fintech & Banking: Enforce strict step ordering for wire transfers, credit limit increases, and multi-factor challenges.
- KYC & Onboarding Funnels: Maintain a verifiable, tamper-proof progression through document capture and liveness checks.
Try It in Your Project
TrustChain is open-source under the Apache 2.0 License and published directly to Maven Central.
Check out the full source code, architecture docs, and the runnable CLI attack simulation on GitHub.
Top comments (0)