A Private Messenger With No Server Anywhere: What One P2P Graph Database Can Carry
Every peer-to-peer database has a chat demo. It is the hello world of the genre: two browsers, a shared key, messages appearing on both screens, and the promise that this is what a world without servers looks like. Then
Every peer-to-peer database has a chat demo. It is the hello world of the genre: two browsers, a shared key, messages appearing on both screens, and the promise that this is what a world without servers looks like. Then you try to add identity, groups, a way to throw someone out, messages that expire โ and the demo quietly acquires a server.
I wanted to know what happens if you refuse. dMessenger is a full private messenger โ identity with a recovery phrase and passkeys, one-to-one conversations and groups with a roster, replies and reactions, message lifetimes, attachments and voice notes, invites by link or QR, an installable app that opens with no network โ built on one GenosDB graph and nothing underneath. No relays holding messages. No server anywhere in the data path. And a test suite that proves every one of those claims in real browsers, over real WebRTC, before I was allowed to write them down.
Try it: estebanrfp.github.io/dMessenger โ open it in two windows, or on two devices. Source: github.com/estebanrfp/dMessenger, MIT.
Where do the messages go when there is no server?
Into the graph โ the same one every peer holds. A GenosDB database is named, and everyone who opens that name replicates it, peer to peer, with every operation signed by its author and verified by whoever receives it. There is exactly one database instance in dMessenger, for everything. That was the first design decision and the most important one, so it is worth saying why.
The obvious alternative โ a database per conversation โ works, and I measured it working: three instances in one page, three graphs, no bleed. But a second instance in the same page re-initialises the Security Manager and drops the active signer, which you can watch happen as an address that turns null mid-session. One graph, and everything separated inside it, along three axes that never touch each other:
| axis | mechanism | what it separates |
|---|---|---|
| kind |
value.t + one db.map query per view |
conversations, messages, identities, keys, vouches |
| ownership |
value.owner, id ${owner}:โฆ
|
who may write, link or delete a node โ checked by every peer |
| confidentiality | an encrypted record + acls.grant
|
who may read, enforced by an envelope rather than by topology |
The queries are the routing. The conversation list is a subscription to { t: 'conv', members: { $in: [me] } }; the open thread is a subscription to { t: 'msg', conv: id }, sorted by the engine. No store, no router, no array to keep in step with the screen. If you have read about how peer-to-peer databases differ, this is the part that only works when the database already syncs and already sorts.
End-to-end encryption without a key server
A private conversation is two nodes with different jobs. A public one, reactive, says who takes part and where the key lives โ metadata every peer sees anyway. An encrypted record (db.sm.put) holds the key itself, and it reaches each member through acls.grant: one envelope per reader, wrapped for that reader's published key. Read control is cryptographic. The room replicates every byte of it to everyone, and only the members can open it โ which is a test, not a claim: the suite adds a third peer, proves it is replicating the ciphertext, and then proves it cannot read it.
Each message is an ordinary public node whose body is ciphertext under that key. Public on purpose: it keeps realtime, ordering and pagination working, which an encrypted query cannot offer, while the content stays sealed. A reaction is a node too, ${reactor}:react:${messageId}, which does three jobs at once โ reacting again replaces the earlier one, removing it is a remove only its owner can sign, and nobody can react in your name, because the id carries the owner the gate enforces. Forged one in the suite; it landed in the forger's copy and nowhere else.
Groups add the part that key servers usually do for you: removal. Take someone off the roster and two incompatible things must happen โ they stop reading what comes next, without destroying what came before for everyone who stayed. So the key record holds an array of keys, one per epoch, and every message carries the epoch it was sealed under. revoke takes the envelope and rotates the record; a fresh key is appended for whoever still holds one.
Here it is live, in Bob's window, thirty seconds after he was removed:
What he read while he was in is still on his screen โ no rotation takes that back, and any design that claims otherwise is lying. What came after is ciphertext for a key he cannot reach. Nobody asked his client to cooperate.
I should be precise about what this is not. Envelope encryption with a rotated key gives forward-only access control, not the per-message forward secrecy or post-compromise security of a double ratchet. It is honest, it is cheap โ 19 ยตs to seal a kilobyte โ and it is what the primitives give you without a server; the security audit is where the engine's own guarantees are written down.
Is a Nostr relay a server?
GenosDB introduces peers over Nostr relays and then moves the data over WebRTC. The question is fair, and the only honest answer is an experiment you can kill. The performance suite starts a relay it controls, lets two peers meet through it, kills it with SIGKILL, checks the port is closed โ and sends twenty more messages. Twenty of twenty arrive, p50 45 ms. A public relay could not have been killed, and with a list of several, killing one would prove nothing. During 150 messages neither peer makes a single HTTP request; the bytes on the busiest ICE candidate pair say where the data went.
So: a relay is the phone book. The post goes by hand. That is also why the app scales the way the cellular mesh does rather than the way a chat server does โ the room is a community, not a public network of strangers, and I say so in the README.
Roles nobody can grant themselves
A new identity is a guest. It can write โ the base role writes, links and deletes on purpose, and ownership already scopes deleting to your own nodes, so the residual risk is spam, never takeover. What it cannot do is become anything else. Roles are set by a superadmin's signed decision, or by governance rules the engine evaluates every four seconds, last match wins:
{ if: { role: 'guest' }, offsetTimestamp: 8000, then: { assignRole: 'user' } },
{ if: { role: { $in: ['user', 'manager'] } }, then: { assignRole: 'user' } },
{ if: { role: { $in: ['user', 'manager'] }, vouches: { $gte: 2 } }, then: { assignRole: 'manager' } },
The first rule keys on time the engine itself observed, the one metric a modified client cannot forge. The third keys on vouches, which live on a node its subject may rewrite โ self-service by design, which the guide is explicit about โ so the tier it grants is deliberately weak: manager may publish, never delete. And publish is what creating a group costs. That is the whole ladder made real: a user cannot create a group; two signed vouches later, a manager can.
The demo ships the engine's canonical demo identities, so you can watch it without trusting me. Open two windows, press ๐ก๏ธ Superadmin in one โ its window runs the engine โ and ๐ฉโ๐ฆฐ Alice in the other:
| when | |
|---|---|
Alice arrives as guest
|
0 s |
| the two windows see each other, over public relays | 4 s |
rule 1 makes her user, signed in the Superadmin's window |
18 s |
| Superadmin and Bob vouch; she publishes her count | 18 s |
rule 3 makes her manager; New group lights up; she creates one; Bob sees it |
22โ23 s |
Alice's own window says waiting for a superadmin window the whole time. She never ran the engine. Her role changed anyway, because another peer signed the decision and every peer accepted it. If you know role-based access control from the server side, this is the same vocabulary with the enforcement moved to every receiver.
What broke, and what it taught me
The engine did not break once. My constitution did, twice, and both times the test suite caught it before a user would have.
The manager that kept losing his job. The first group test promoted Alice to manager with a superadmin's signed decision. It passed โ then failed a minute later with the button saying you are user. The governance rules govern the manager tier, so the floor rule overwrote the manual assignment on the next cycle. Documented behaviour: a rule decides over a term. Either keep standing rules to the tiers below the ones you assign by hand, or earn the tier the way the rule describes. The suite now earns it.
The guest who couldn't take it back. A guest retracting a reaction had its remove refused by every other peer โ remove costs delete, which I had put above the floor. Since ownership already limits deleting to your own nodes, moving delete down was safe, and the ladder became honest: the base role can speak and take back what it said.
Deleting is not disappearing. Message lifetimes were the hardest feature, for a reason that has nothing to do with UI. In this engine a tombstone lives only in the operation window; a peer that held the message, went away, and comes back after the tombstone rolled out will bring the node back through its full state. So expiry cannot be "delete on expiry". It is a property of the receiver, enforced at three points that do not trust each other: no honest peer paints an expired message; a db.use middleware drops one at the door on every sync path โ including a laggard's full state, which arrives as one operation carrying every node and gets filtered node by node; and the author removes its own as housekeeping. The suite shrinks the window to three operations, lets a returning device bring the node back, sends a sentinel to prove it is connected, and asserts nothing expired exists. Live, a 20-second message left both screens 20.5 s after it was sent, and both graphs held zero nodes.
The numbers
Measured by the second suite, pnpm perf, against the application โ DOM included โ in real Chromium over real WebRTC:
| what | result |
|---|---|
| cold start, engine from the CDN, until the identity door is on screen | 640 ms |
| warm start | 226 ms |
| identity: key pair, mnemonic, volatile session | 97 ms |
| peer discovery, relay introduction + ICE | ~4 s |
| key exchange: conversation opened, envelope granted and delivered | 125 ms |
| delivery latency, 50 messages one by one | p50 95 ยท p95 117 ms |
| burst of 100, until all are on the other peer | 242 ms total, 2 ms per message |
| HTTP requests by either peer during 150 messages | 0 |
| relay killed after the introduction | 20/20 delivered, p50 45 ms |
| catch-up after a partition, 30 messages missed | 420 ms from page load |
| AES-GCM seal / open, 1 KB | 19 ยตs / 19 ยตs |
Latency is read the honest way: the sender stamps before sealing, the receiver stamps inside the same db.map callback the UI paints from, on one machine whose clock both share. That one measurement also fixed a bug โ puts take 0 ms, so bursts shared a millisecond and were ordered by random id. Stamps are strictly increasing per session now.
From seven lines to a messenger
The engine's own realtime chat in seven lines is where this started, and if you have seen GunDB's chat examples, you have seen the same hello world. dMessenger is what that demo turns into when identity, ownership, key distribution, governance and lifetimes are the database's job rather than an afterthought bolted on with a server. It is about 3,100 lines of vanilla JavaScript, three runtime dependencies (identicons, QR generation, QR scanning), and the engine from a pinned CDN URL. Nothing is bundled underneath it.
It is also the third app in a series that keeps asking the same question โ a code editor with no server came before it โ and the first where the answer had to include cryptography that other people's messages depend on.
What it is not
- Not interoperable with anything. Identities are GenosDB addresses; no federation, no bridge.
- Not a double ratchet. Forward-only access control, as above.
- Not a public network. A room replicates to everyone in it; the scope of a database is a community.
- No push notifications. With no server, nothing wakes a closed page โ the one thing a serverless design cannot offer.
- Attachments are capped at 512 KB, because the room replicates every byte to every peer. Larger files belong on an ephemeral channel, not in the graph.
Try it, clone it
git clone https://github.com/estebanrfp/dMessenger && cd dMessenger
pnpm install && pnpm exec playwright install chromium
pnpm test # 19 specs, real browsers, real WebRTC, a fresh room per test
pnpm perf # the numbers above, on your machine
pnpm dev # http://localhost:5605
Or skip all that: open the demo in two windows, press two buttons, and watch a role change that no client decided.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.




