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
- Add a new key under a new id, for example
Dotwire__Encryption__Keys__k2. - Set
Dotwire__Encryption__ActiveKeyId=k2and restart. - Keep
k1configured. New messages usek2, andk1still 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 keys | dotwire encryption keys | |
|---|---|---|
| Algorithm | RS256 (asymmetric) | AES-256-GCM (symmetric) |
| Who holds the secret | The HOST app (private key) | dotwire (service key) |
| dotwire holds | Public key only (JWKS or inline PEM) | The key itself |
| Purpose | Prove who a user is | Encrypt content at rest |
| Rotation | New kid via JWKS or config | New 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
ROOMSstream keeps messages forNats: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.
0means indefinite, and the maximum is 36,500 days. Each change writes aretention.setaudit 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
MessageRetractedto live hub subscribers and aretractedevent to SSE streams, so clients can drop the message. - It writes a
message.redactedaudit 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
- Compliance: how these controls map to frameworks and what stays your responsibility.
- HTTP API: admin
- Configuration reference