I made Chatwoot store only ciphertext for contact PII and message text #15866
sashyo
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Short version: in this fork, a contact's name and phone, and the body of every message, are sealed before they reach Postgres. The database holds
ms1:ciphertext, the encryption key is never assembled anywhere (not in the server, not on disk, not in one admin's hands), and a field opens back to plaintext only for an agent a quorum granted a reading role. Turn it off and Chatwoot behaves exactly like upstream.There is a separate Ideas post proposing the small extension point that would let this ship as a plugin instead of a fork: https://github.com/orgs/chatwoot/discussions/15868 . This post is the why and the does-it-work.
The problem
A support tool holds a lot of what a breach is actually about: who your customers are (names, phone numbers, emails) and everything they told your team in a conversation. In Chatwoot today that all sits in Postgres as readable text. TLS and disk encryption help in transit and at the volume level, but a stolen database, a leaked backup, or anyone with a psql connection reads every contact and every message. Chatwoot has no field-level encryption to change that.
What it does
The key is held by nobody. It lives as threshold shares across the Tide network and is never reassembled, not even to decrypt. The server asks the network to open a field on behalf of a named agent, and the network only agrees if a quorum-granted role says that agent may read.
contactsormessagestable, or a leaked backup, reveals nothing.How it works
It is a small ActiveRecord concern plus a sidecar client, no changes to how Chatwoot runs:
before_saveon the model, so the configured fields are sealed before they hit Postgres.Contactsealsnameandphone_number(email and identifier stay clear, they are the lookup keys Chatwoot dedupes and routes on);Messagesealscontent.after_find, asCurrent.user, the agent Chatwoot already sets per request. Gated by that agent's role, so an ungranted agent just gets ciphertext.Current.user; the concern mints a short-lived signed token for that agent and hands it to the sidecar.The crypto runs in a small language-agnostic sidecar; the Ruby side just calls it over HTTP. The same sidecar backs integrations in other stacks, which is the point: this is not tied to Node or to any one framework.
Does it actually work
Yes, end to end:
contacts.name,contacts.phone_number, andmessages.contentarems1:...ciphertext.ms1:...ciphertext.The honest tradeoff
This trusts the app server to say which agent is calling, and gates the read on a quorum-granted role. That is the right trade for a team tool where the server is already trusted to run your support desk, and what you gain is that the database and backups are worthless to a thief and that access is governed and revocable rather than implicit in whoever holds the disk. If your threat model is that the server operator themselves must never read, that is a different and stronger tool.
Disclaimer: this is the lightweight form
What I have shown is the lightweight, self-hostable form: minidauth, running "tideless", where the app vouches for the agent and a quorum-granted role gates the read. It is a proof of concept, not production hardened, and per-field round trips are the demo shape rather than the envelope-encryption shape a real deployment would use.
The enterprise-grade version is TideCloak, Tide's identity and access product, where who is calling is proven per request with threshold cryptography inside the network rather than asserted by the app server. minidauth is the small on-ramp to that model, meant to show the shape and let you try it without adopting the full stack. Treat this fork as a demonstration, and TideCloak as the path if you want to run field-level, quorum-governed sealing for real.
Try it / the code
MINIDAUTH_SEAL_URLis set; every path is upstream-identical when it is unset.Feedback welcome, especially on the extension-point proposal in the Ideas post. If that lands, this stops being a fork and becomes a plugin.
All reactions