Skip to case study
← All case studies

Cheapwork

End-to-end encryption, device trust and reliable message delivery.

The first thing I want from an encrypted messenger is simple: the relay should not need the message plaintext to deliver it. The next thing is harder. I want the same statement to remain meaningful when two tabs are open, a device changes, a message arrives twice or the browser closes immediately after Send.

Cheapwork is an end-to-end encrypted messenger for direct conversations and small groups. The public application is called cwsecure. This account follows the implementation around its Rust protocol layer, Rust relay and browser client, with particular attention to the points where cryptographic state becomes application state.

I am less interested in a diagram that ends at “encrypt, send, decrypt” than in the few milliseconds around each of those verbs. Which state has already changed? What has reached durable storage? What can safely be repeated? A respected cryptographic library is necessary, but the application still has to preserve the conditions under which that library is useful.

In this essay
See the application and installation film
Cheapwork public preview showing a sample encrypted conversationCheapwork public preview of a small group conversationCheapwork public preview of account and application settings
Original Cheapwork installation film. Product previews use sample conversations.

Follow a message beyond the encryption call

Cheapwork is a CONVRO messaging product, presented publicly as cwsecure. It supports browser-based direct conversations, small groups, contact numbers and message timers. The user interface is deliberately familiar. Behind it, the difficult requirement is to keep cryptographic state consistent with delivery state across reloads, retries and device changes.

I think of encryption here as a state-management problem as much as a cryptographic operation. A ratchet advances when a message is encrypted or decrypted. If the browser advances a session but loses the corresponding message, or sends a message before its new state is durable, a routine network failure can become a protocol failure. The system has to coordinate storage, concurrency, identity and the network as one workflow.

I’ll follow a message through the Rust backend, the Rust protocol crate compiled to WebAssembly, and the TypeScript browser integration. That route makes the important decisions visible: when a key changes, when a write becomes durable, and which failures can be retried.

The backend uses Axum on Tokio, with Serde for typed request handling and rusqlite for persistence. Rust also implements the cryptographic protocol boundary in a separate crate compiled both natively and to WebAssembly. The browser application uses React, TypeScript and Vite, with IndexedDB and Web Crypto for local persistence.

I distinguish two uses of Rust here because they solve different problems. The backend authenticates requests, enforces room membership and moves opaque envelopes. The protocol crate owns account identity, peer trust, session establishment, message authentication and encrypted snapshots. Keeping the protocol logic in one crate makes it possible to exercise its invariants in native Rust tests while using the same implementation in the browser.

I value Rust’s ownership model for making state transitions and secret-buffer lifetimes explicit. I still need to check the protocol; the type system does not prove it correct. The design still needs careful validation at the JavaScript boundary, clear transaction ordering and adversarial tests. WebAssembly is an execution format, not a sandbox that can protect a user from a compromised application origin.

Two browsers display Meet at 10? while the relay carries ciphertext. Original drawing by Peter Liszka.
Message content is encrypted between the browsers. The relay still sees routing, timing and payload-size metadata.

I use the word relay deliberately. Its job includes routing encrypted envelopes, checking membership and maintaining delivery state. It is not a neutral pipe with no knowledge of the conversation. It handles participant relationships, timing and payload sizes even when it cannot derive the message text from the ciphertext it stores.

That distinction affects what I am willing to claim about the product. End-to-end encryption is a statement about where message content is protected and which endpoints can recover it. It is not a promise that a server cannot observe traffic patterns, interrupt delivery or serve a malicious application after an origin compromise. Those are different threats with different controls.

The drawing shows the narrow content boundary: the sender and recipient can display “Meet at 10?”, while the relay transports the encrypted representation. The readable sentence is still present at the endpoints. A recipient can copy it, photograph it or retain it elsewhere. No delivery protocol can make an already-read message cease to have existed.

From there, I follow the message in order. Before encryption, the sender must know which device it trusts. After encryption, the advanced session state and the outbound envelope must survive together. At the relay, a repeated request must be recognizable. On receipt, the browser must remember acceptance before it tells the relay that the envelope can leave the queue.

These are related requirements. A perfect cipher cannot repair an application that accepts a substituted device identity. A careful trust screen cannot repair a send path that loses its only copy of the new ratchet state. I find it useful to review the whole chain as a sequence of commitments, each of which creates obligations for the next step.

Before the first message, decide whose key this is

The protocol uses the vodozemac Rust library, pinned to version 0.11.1. Its Olm implementation provides asynchronous session establishment and a double ratchet. Device accounts carry Curve25519 and Ed25519 identity material. Signed, one-time prekeys allow a sender to establish a session while the recipient is offline.

A bundle binds the algorithm, device identity, prekey identifier and prekey material. The receiver validates that binding before using the key. Duplicate identifiers and substituted keys are rejected. Consumed prekeys cannot establish a second session, and a full local prekey store is not silently evicted to make room for another batch. Silent eviction would leave the server advertising keys the browser can no longer use.

The selected Olm session configuration is version 1. Message encryption belongs to the Olm construction; it is not the same AES-GCM operation used for local snapshots and history. That distinction matters when explaining the system or reviewing changes. Treating every encrypted object as one generic AES payload would conceal different authentication rules and key lifecycles.

The ratchet limits what a later compromise of current session keys can reveal about earlier message keys, under the protocol’s assumptions and key-deletion behavior. This does not make saved message history disappear. A recipient can retain plaintext, take a screenshot or have an already-compromised device. Forward secrecy and disappearing-message interfaces solve different problems.

The one-time prekey is what makes the first exchange possible without both people being online at once. The sender can establish a session using material the recipient prepared earlier. That convenience creates a storage obligation: the advertised key must still be usable by the recipient, and consuming it must not leave a path for the same material to establish another independent session.

This is why the full-prekey-store behavior matters. Silently dropping older local keys to make room for a new batch would be an appealing cleanup shortcut. It could also leave the relay distributing public prekeys whose private counterparts no longer exist in the browser. The inspected implementation refuses that silent eviction rather than hiding the mismatch until a message fails.

An encrypted connection to the wrong device is still the wrong connection. The browser pins verified peer identities and blocks unexpected identity changes. It does not silently accept a replacement key because the server returned one. The protocol requires a trusted peer before an outbound session can be created.

The migration path also binds the newer device identity to an existing contact identity. An encrypted proof includes the room and participant context, and the controller validates the exact canonical identity before activation. A room only activates the newer protocol once the participants are ready, bound and verified. After activation it does not fall back to the older message path.

Replacing a device is a staged operation. The client persists the replacement material so it can recover if the browser stops between local preparation and the server response. Peers must review the identity change; associated sessions and queued messages are invalidated instead of being quietly transferred to a new identity. That creates some visible friction, but makes a security-relevant event understandable.

A trusted device keypinned to the contact
A different key arrivessame name, different identity
Pause and reviewdo not silently replace trust

Encrypting to the wrong device is still wrong.

An unexpected identity change blocks sending until it is reviewed. A server response does not by itself authorize replacing a previously trusted peer.

The migration proof is not just “this new key belongs to the contact.” It includes the room, sender and recipient context, and the controller compares the canonical identity exactly. Without those bindings, a valid statement from one relationship could be mistaken for evidence in another. Authentication needs to cover the meaning of a message as well as its bytes.

A device change deserves a visible interruption. If a contact’s new identity arrives from the server, automatically trusting it would make the server’s statement sufficient to choose the encryption endpoint. The existing pin instead creates a discrepancy the user must review. Until that review, sending can remain blocked.

There is a recovery consequence too. A device replacement can fail halfway through a browser-to-server exchange. Preparing replacement material durably allows the client to reason about what it was trying to install when it starts again. That does not justify carrying old sessions and queued messages across the identity change. Those objects belong to the trust relationship under which they were created.

I would describe the resulting friction honestly. Changing devices is more involved than opening another anonymous tab and immediately continuing as though nothing happened. That is a product trade-off. The interface needs to explain the interruption clearly, but hiding it would change the security property rather than merely simplify the presentation.

The send button crosses a storage boundary first

The browser uses a per-owner Web Lock to serialize access to its cryptographic state. If that locking capability is unavailable, this path fails closed. IndexedDB transactions also compare a stored revision before accepting a new state, so a stale tab cannot simply overwrite a newer session snapshot.

On send, the client encrypts for the intended recipients, then commits the advanced ratchet state, the exact ciphertext outbox entry and encrypted sent history in one local transaction. Only after that commit does it attempt the network request. A retry reuses that saved envelope. It does not encrypt the same logical message again and advance the ratchet a second time.

The receive path mirrors this ordering. Decryption validates the authenticated inner message context, including room, sender, message identity, generation and timing information. The browser commits its advanced state, replay tracking and encrypted history before acknowledging the server queue. An interrupted acknowledgement can produce a repeat delivery, but the replay state already exists.

Within the Rust crate, candidate account or session state is cloned for operations that can fail. Validation must succeed before the new state replaces the original. A tampered message therefore cannot consume a one-time prekey or advance a ratchet merely by reaching the decryption routine. This is a small implementation detail with a large effect on recovery behavior.

Consider the failure if this ordering were reversed. The client encrypts and sends, the relay accepts the envelope, and then the tab closes before the advanced session state is saved. The outside world has observed a message from a state transition that the browser no longer remembers. Retrying from the old local snapshot is not equivalent to resuming the completed transition.

Persisting first moves the recoverable object into local storage before the network is involved. The transaction contains the new cryptographic state, the exact encrypted envelope to transmit and the encrypted sent-history entry. If the local commit fails, transmission does not proceed as though the commit had succeeded. If the network later fails, the saved outbox provides the material for another attempt.

Encrypt onceadvance the session state
Commit togetherstate + outbox + sent history
Then transmitsend the saved envelope

The durable copy comes before the network.

One local transaction stores the advanced state, exact ciphertext and encrypted sent history. On receive, the corresponding rule is to commit state and replay information before acknowledging.

The word exact is doing real work here. A retry takes the existing envelope out of the outbox. It does not call encryption again for the same user intention. Calling encryption again would create another cryptographic transition and potentially another payload, while the earlier payload may already be at the relay. The retry path must resume a delivery, not invent a second send.

Two tabs make local state coordination just as important as network coordination. Both tabs could load the same snapshot and attempt to advance it. The per-owner Web Lock serializes that work. The IndexedDB revision check adds a guard at persistence time so a stale writer cannot simply replace a more recent snapshot.

Failing closed when locking is unavailable is a deliberate limitation of the inspected path. It avoids quietly offering weaker concurrency behavior in a browser that cannot provide the required primitive. A compatibility fallback would need its own coordination design; omitting the lock is not an equivalent implementation.

On receive, the irreversible-looking moment is the acknowledgement. Once the relay removes its queue entry, it is no longer responsible for offering that copy again. The browser therefore commits the advanced receive state, replay information and encrypted history before acknowledging. A lost acknowledgement can cause another delivery attempt, but the receiver already has the state needed to recognize what happened.

I also pay attention to failures inside decryption. The Rust layer works with candidate state and publishes the changed state only after validation succeeds. A tampered envelope should not be able to consume a one-time key or advance a session merely because some early part of parsing succeeded. Rejection must leave the legitimate conversation able to continue.

Authenticated context ties the payload to its room, sender, message identifier, generation and timing information. A valid ciphertext in the wrong application context is not a valid message for the current operation. This is where protocol validation and application routing meet, and it is why “decryption returned bytes” is not the end of the receive path.

A lost response must not become a second message

The failure case I keep coming back to is a lost response after a successful write. The server may have committed the message immediately before the connection disappeared. Cheapwork keeps the saved ciphertext queued when the outcome is ambiguous, then retries that same request identity.

The relay checks room membership, recipient devices and room generation again inside an immediate SQLite transaction. A group send must contain the expected envelope for every other participant. Queue entries, message metadata and deduplication state are committed together, preventing a partial fan-out from being treated as a complete send.

Deduplication records bind the sender and message identifier to a digest of the submitted payload. Reusing an identifier with different content is rejected. Acknowledging a delivered envelope removes the queue entry without removing the deduplication record before its original expiry. Otherwise, a retry after acknowledgement could accidentally recreate a message that had already been consumed.

Room generations provide another boundary. A shred operation changes the generation, and an old queued send cannot revive content in the new room state. Local history and pending messages are reconciled with that lifecycle. These controls make retries safe within the defined message lifecycle; they are not a claim of universal exactly-once delivery.

Imagine the relay accepts a send and commits its transaction, but the response never reaches the sender. The browser still has the saved ciphertext in its outbox. It cannot infer rejection from silence, so it retries that same request identity and payload. The relay then needs enough memory of the first acceptance to recognize the repeat.

Binding the identifier to a payload digest prevents an important ambiguity. If an identifier were merely a switch saying “already seen,” a client could accidentally or deliberately reuse it for different content and receive a misleading success. The digest makes that mismatch an error. Idempotency applies to the same operation, not to anything later submitted under the same name.

The deduplication record must also outlive consumption of the delivery queue entry. Suppose the recipient has already acknowledged the envelope before the sender retries its lost response. If acknowledgement erased all evidence of acceptance, that retry could recreate the message. Keeping deduplication state until the original expiry prevents removal from the queue from becoming permission to enqueue it again.

Envelope #42saved ciphertext and metadata
Response disappearsretry the exact saved request
Already acceptedsame ID + same payload digest

Retry the delivery. Do not encrypt it again.

The relay binds an accepted message identifier to a payload digest. Deduplication survives removal of the delivery queue entry until the original expiry.

For a group, the transaction has another responsibility: the recipient set must be complete for the current room. The relay validates the expected envelopes and commits fan-out with the corresponding metadata and deduplication state. A partial set must not be recorded as a completed group send merely because one recipient’s queue insertion succeeded.

Membership and generation are checked inside the transaction, not only before entering it. Otherwise a change between the preliminary read and the write could let an envelope cross a lifecycle boundary it no longer belongs to. The database is therefore participating in the meaning of the protocol, even though it never needs the message plaintext.

A generation is particularly useful for explaining a shred operation. A pending message belongs to the room state in which it was prepared. After the generation changes, replaying that old outbox entry must not restore content into the new state. The client and relay reconcile against the lifecycle boundary instead of treating every queued item as timeless work.

I would still avoid promising universal exactly-once delivery. There are local commits, network attempts, recipient acknowledgements, expiry rules and possible infrastructure failures. The narrower claim is useful enough: an accepted request has a stable identity, exact retries are recognized within the defined lifecycle, and stale content is checked against the current room generation.

That narrower description is also easier to test. Instead of asking whether the entire internet delivered a message exactly once, I can ask what happens after a commit with a lost response, after an acknowledgement with a repeated send, or after a generation change with an old outbox entry. Each has an observable state transition and a specific expected result.

Encrypted history is still history

The browser stores a non-extractable AES-GCM key in IndexedDB and uses it to protect local material. State snapshots have authenticated context that includes the owner and revision; the Rust snapshot layer also binds device and algorithm information. Random nonces and authenticated metadata prevent a ciphertext from being casually moved into a different context.

Encrypted local history makes a reload useful without treating the relay as a permanent plaintext archive. Expiry and read timers remove local history records according to the application lifecycle. A changed generation invalidates older material. The implementation keeps bounded history and queue sizes rather than allowing indefinite growth in the browser.

A non-extractable CryptoKey prevents normal key export, but code executing within the same trusted origin can still ask the browser to use it. It does not defeat malicious application JavaScript. Likewise, a revision check detects conflicting updates against the current database state, but cannot independently detect an attacker rolling back the entire local database. These are important boundaries for a browser-based security model.

It is tempting to combine local encryption, forward secrecy and disappearing messages into one reassuring sentence. I would keep them separate. The ratchet concerns the relationship between session state and message keys. Local encryption concerns stored material on a device. Expiry concerns how the application removes retained records. None of these automatically supplies the properties of the other two.

A useful reload requires some state to remain available. That is why the client stores encrypted history and session material rather than asking the relay to act as a plaintext archive. It also means the browser becomes part of the trusted storage environment. Retaining useful local history and claiming that no readable history can ever exist on an endpoint would be inconsistent promises.

Authenticated metadata makes a stored blob belong to a particular context. A snapshot for one owner or revision should not be accepted simply because its encryption tag verifies under some available key. The outer persistence context and the protocol snapshot’s device and algorithm bindings reduce the scope for moving material into a place where it has a different meaning.

Revision checks solve a concurrency problem against the database state the application currently sees. They do not provide an independent clock outside that database. If an attacker can roll back the entire relevant local store, a revision held inside that same store cannot by itself prove that rollback occurred. I would not describe this implementation as having a rollback defense it does not have.

Authentication uses a browser-derived proof over TLS, with an Argon2id verifier on the server. This is not a password-authenticated key exchange and should not be marketed as one. Browser-side key-vault protection and server-side credential verification have separate roles.

The HTTP layer uses a secure, HttpOnly session cookie with a strict same-site policy, validates request origin and fetch context, and applies room membership checks to protected operations. A browser or installation marker is not treated as a substitute for authorization.

The relay cannot derive message plaintext simply from the recipient ciphertext envelopes. It does, however, handle participant relationships, routing, timing and encrypted payload lengths. End-to-end encryption does not imply metadata anonymity. Availability also depends on the relay, and compromise of the served browser application remains a different threat from passive access to stored envelopes.

The upstream cryptographic library has its own public security history. That is useful context, but it does not amount to an independent audit of Cheapwork’s integration, identity workflow or complete deployment.

This is also why the application origin matters so much. Non-extractable keys restrict key export through the normal browser API. They do not prevent authorized same-origin code from invoking cryptographic operations with those keys. Serving malicious JavaScript to an unlocked application changes the endpoint threat model, even if the relay’s stored ciphertext remains perfectly encrypted.

The practical consequence is that frontend delivery, session protection and dependency handling belong in the security discussion. WebAssembly does not make the surrounding browser application untrusted by default. It gives the protocol code a useful execution and reuse boundary; the application still decides which inputs to pass and where the resulting plaintext goes.

What the implementation and the tests actually establish

SQLite and password verification are blocking workloads. Running them directly on Tokio’s async worker threads would let a slow database operation or an expensive password hash delay unrelated network requests. The backend moves that work into spawn_blocking and bounds admission with a semaphore. When capacity is exhausted, it returns a temporary failure instead of creating an unbounded backlog.

The database uses WAL mode and a busy timeout. Immediate transactions are used where a read determines a subsequent write, including one-time prekey claims and message fan-out. A claimed prekey cannot be resurrected by uploading the same bundle again. This is where the database concurrency model becomes part of the cryptographic design.

SQLite keeps the service operationally compact, but still has a single-writer constraint. WAL does not remove that constraint. The selected NORMAL synchronous mode also has a durability trade-off under sudden power loss that differs from an ordinary process crash. Those properties should inform capacity planning and recovery tests before a more distributed architecture is considered.

The semaphore is important because moving work off the async runtime is only half the problem. An unlimited stream of blocking jobs can still exhaust capacity elsewhere. Bounded admission turns overload into an explicit temporary failure instead of allowing a growing queue to consume memory and make every request progressively slower.

Immediate SQLite transactions matter where a read determines a write. A one-time prekey claim and a group fan-out both depend on facts that must remain valid until the write commits. Choosing the transaction boundary is therefore part of implementing the protocol correctly, rather than a database optimization that can be postponed until after the security work.

I would keep ordinary process failure and sudden power loss as separate recovery scenarios. WAL improves concurrency between readers and a writer, but it does not turn SQLite into a multi-writer service or erase the selected synchronization policy. The NORMAL setting has a durability trade-off under power loss. Capacity and recovery claims should be based on those actual properties.

The protocol test suite was run locally against the inspected crate on 28 October 2026: all 12 native tests passed. The tests cover asynchronous handshakes, both communication directions, out-of-order messages, skipped-message replay, tampering, context binding, identity replacement, prekey substitution and reuse, snapshot authentication, malformed inputs and prekey exhaustion.

The repository also contains a browser release exercise for concurrent first messages, a lost response after a server commit, group attachments, encrypted history after reload, message burn retries, generation changes and device replacement. Those scenarios were inspected, but that browser integration suite was not rerun as part of preparing this portfolio. Native tests do not replace a browser or deployment test.

For source confidence, the running release binary was compared with the binary in the inspected production build workspace and their SHA-256 digests matched. This establishes artifact identity; it is not a claim that an independent reproducible build has been completed.

The tampering tests are especially useful because they inspect what remains possible after rejection. Rejecting a malformed message is necessary; preserving the legitimate state so a valid message still works is a stronger question. The same applies to skipped-message replay and snapshot restoration. A protocol test should examine the next operation, not only the error returned by the current one.

The native tests and the browser exercise answer different questions. Native tests can isolate the Rust invariants and adversarial inputs. The browser path adds Web Locks, IndexedDB, page lifecycle, network behavior and the JavaScript-to-WASM boundary. Passing the former is valuable evidence, but it cannot be substituted for having run the latter.

If I were changing the send path, my review would start with its commit ordering and the exact payload reused by retries. For a device workflow, I would inspect the trust transition and invalidation of old sessions. For a relay change, I would follow one accepted message through acknowledgement and a later repeated request. These are the seams where a small convenience change can invalidate an assumption elsewhere.

The central lesson I take from Cheapwork is that security and reliability share a lot of state. The new ratchet snapshot, the outbox envelope, the replay record and the room generation are not incidental implementation details around the cryptography. They are how the application keeps its promises while users close tabs, networks fail and devices change.

Sources and verification scope

This essay describes source, release artifacts and public product material reviewed on 28 October 2026. The test results above refer to that review, not a new test run accompanying this article. Failure sequences are engineering walkthroughs, not claims about customer incidents.

Rust · Axum · Tokio · WebAssembly · TypeScript · SQLite

Return to the beginning
Read the Reesponder case study

Reesponder

Business knowledge, confirmed API actions and a durable route to human support.

Privacy on this site

You can read about Convro and explore the case studies without an account. The service selector works locally in your browser.

Email links open your email app. You choose whether to send a message. Product videos load from the respective product websites when played. External websites have their own privacy information.

For company-level privacy enquiries, contact team@convro.eu.