Docs menu

Encryption and retention

Encryption at rest

dotwire encrypts message content with AES-256-GCM before it publishes the message to JetStream. The ciphertext is what both the NATS file store and Postgres hold, so neither store has plaintext content at rest. dotwire decrypts only at delivery: at the fanout edge for live messages, and on the read for history, SSE replay, and DSAR export.

Each envelope is nonce (12 bytes) ‖ ciphertext ‖ tag (16 bytes). The key id is stored next to the envelope, not inside it.

Configure the key

Dotwire__Encryption__ActiveKeyId=k1
Dotwire__Encryption__Keys__k1=<base64-32-byte-key>

Generate a key with openssl rand -base64 32. Each value under Dotwire:Encryption:Keys must decode to exactly 32 bytes. dotwire refuses to start unless ActiveKeyId names one of the configured keys. There is no auto-generated fallback key and no setting that turns encryption off.

Rotate the key

  1. Add a new key under a new id, for example Dotwire__Encryption__Keys__k2.
  2. Set Dotwire__Encryption__ActiveKeyId=k2 and restart.
  3. Keep k1 configured. New messages use k2, and k1 still decrypts older messages.

There is no re-encryption step.

Back up the key

If you lose an encryption key, every message encrypted under it is permanently unreadable. Keep the key in a secret store, not on the data volume, and back it up like the archive it protects.

Threat model

At-rest encryption protects against stolen disks, stolen backups, and direct SQL or pg_dump access. It doesn't protect against a malicious server: dotwire must read content to fan it out. End-to-end encryption is out of scope.

Metadata stays in plaintext: sender ids, room ids, sequence numbers, and timestamps. Queries need it. The control for metadata is retention.

Two key systems

dotwire uses two unrelated sets of keys. Don't confuse them.

Host JWT signing keysdotwire encryption keys
AlgorithmRS256 (asymmetric)AES-256-GCM (symmetric)
Who holds the secretThe HOST app (private key)dotwire (service key)
dotwire holdsPublic key only (JWKS or inline PEM)The key itself
PurposeProve who a user isEncrypt content at rest
RotationNew kid via JWKS or configNew key id; old keys retained to decrypt old messages

Losing the first means impersonation: someone else can mint tokens for your users. Losing the second means unreadable history. JWT keys are covered in Auth.

Retention

Each store has its own retention window, because they serve different purposes.

  • Postgres is the durable archive. History is kept indefinitely by default. When you set a window, dotwire drops whole Timescale chunks once every row in them is older than the window, so there is no per-row delete and no vacuum bloat.
  • JetStream only needs to cover the Postgres writer's lag and the reconnect gap-fill window. The ROOMS stream keeps messages for Nats:RoomsMaxAgeHours, 48 hours by default.

Retention applies to metadata as well as content, because whole rows are dropped.

Set the Postgres window

You can set it in either of two places. Pick one and manage it only there.

  • At startup: set Dotwire:Retention:MessagesDays. The node that runs migrations applies it on every start. Leaving it unset keeps whatever value was last set through the API; it doesn't clear it.
  • At runtime: call the admin API or a host SDK. 0 means indefinite, and the maximum is 36,500 days. Each change writes a retention.set audit event.

Node.js (@dotwire/host)

await host.setMessageRetentionDays(90);
const days = await host.getMessageRetentionDays();

.NET (Dotwire.Host)

await client.SetMessageRetentionDaysAsync(365); // 0 = indefinite
var days = await client.GetMessageRetentionDaysAsync();

Redaction

Redaction is the erasure mechanism for GDPR Art. 17. It's physical, not a soft-delete flag:

  • dotwire records a tombstone and deletes the message row from Postgres. The tombstone keeps a message that the Postgres writer hasn't inserted yet from reappearing.
  • It deletes the message from the JetStream stream by sequence, with secure erase.
  • It sends MessageRetracted to live hub subscribers and a retracted event to SSE streams, so clients can drop the message.
  • It writes a message.redacted audit event with ids only.

Redaction is idempotent. Retrying after success returns 200 with both removedFromHistory and removedFromStream set to false.

Node.js (@dotwire/host)

const result = await host.redactMessage(roomId, 7);

.NET (Dotwire.Host)

var result = await client.RedactMessageAsync(roomId, seq);
// result.RemovedFromHistory, result.RemovedFromStream

DSAR export

The DSAR export is the access mechanism for GDPR Art. 15. For one user id, it returns:

  • the user's role and room ids,
  • every message the user sent, decrypted,
  • the audit entries where the user is the actor or the subject.

A message that can't be decrypted is exported with content: null and undecryptable: true. The response is streamed, so a large history isn't buffered in memory. Each export writes a dsar.exported audit event.

Node.js (@dotwire/host)

const exported = await host.exportUser('dave');
const stream = await host.exportUserStream('dave');

.NET (Dotwire.Host)

var export = await client.ExportUserAsync(userId);          // buffered UserExport
await client.ExportUserAsync(userId, someStream);            // streamed straight through

Related