mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
# Description of Changes
**PR2 of the encrypt-at-rest initiative — PR1 was #7155** Makes the P1
crypto operable and compliance-credible: admins can see the feature's
state, flip the kill switch over an API instead of raw SQL, encrypt the
pre-existing plaintext backlog, rotate the master key, and every
security-relevant event lands in the audit trail. No frontend — that's
PR3.
**What was changed**
- **Audit events** — new `STORAGE_ENCRYPTION` audit type, emitted
through a small listener interface so the crypto classes stay plain
objects: `encrypt`, `decrypt` (per-read events honour
`storage.encryption.auditReads`, default **on** — HIPAA reviewers expect
read audit; busy installs can disable), `decrypt.denied` (always),
`key.created/disabled/enabled`, `master.rotated`, `migration.completed`,
plus a `plaintextExport` marker whenever a plaintext copy of
encrypted-at-rest content is served (with `inline` flag to distinguish
in-app view from saved download).
- **Admin API** `/api/v1/admin/storage-encryption` (`hasRole('ADMIN')`):
- `GET /status` — write/decrypt state, **master-key fingerprint**
(SHA-256 prefix for backup verification, never key material), encrypted
vs plaintext file counts, full key list with status history.
- `POST /keys/{id}/disable` / `enable` — the kill switch, now with
active cache invalidation so revocation is immediate on the handling
node (cross-node converges within the 60s cache TTL). Enable is
restricted to DISABLED keys so two ACTIVE keys can't exist per scope.
- `POST /migrate` + `GET /migrate/status` — encrypt-existing job.
- `POST /master/rotate` — key material is never accepted over HTTP; keys
come from config/env.
- **Deliberately no delete endpoint** — key material can be disabled but
never destroyed through the API.
- **Encrypt-existing migration job** — new writes are encrypted from the
moment the flag is on; this converts the backlog. Crash-safe per file:
store the encrypted copy under a NEW storage key → compare-and-swap the
DB row → only then delete the old blob. A CAS miss (user replaced the
file mid-run) discards the job's copy — the user's file always wins.
Worst crash outcome is an orphaned blob, never a lost file; re-runs are
idempotent (`encryption_key_id IS NULL` selection, cursor-paged so
failures can't wedge the loop). Handles all three blobs per row
(main/history/audit-log), runs on a throttled virtual thread,
single-flight guarded.
- **Master-key rotation** — cheap by design thanks to the P1 hierarchy:
rotate re-wraps the handful of KEK rows, zero file I/O. New config
`stirling.security.fileEncryptionKeyPrevious` (+env) gives `unwrap` a
fallback during rotation, and
`stirling.security.fileEncryptionKeyVersion` marks which master wrapped
each row. Runbook: set new key primary + old as previous + bump version
→ restart (startup self-check passes via fallback, warns about pending
rows) → `POST /master/rotate` → remove the previous key.
- **Shared state bean** — `StorageEncryptionState` is built once and
shared by the storage decorator and the admin API, so kill-switch cache
invalidation hits the same caches the decorator reads.
**Reviewer notes**
- The revoked→403 mapping promised for PR2 already landed in #7155 after
manual testing; this PR adds the matching `decrypt.denied` audit event.
- 19 new tests: audit emission (encrypt/decrypt/denied, legacy plaintext
emits nothing), kill-switch immediacy (no TTL wait), rotation
(previous-key fallback, re-wrap + cleanup, idempotent second call),
migration (backlog encrypted byte-identical, CAS-miss discards own copy,
per-file failure counting, concurrent-start rejection, write-disabled
rejection), admin controller status/conflict/not-found paths.
- Full proprietary suite: 2246/2247 green (the one failure is the
pre-existing Windows-symlink FolderIdentitiesTest, unrelated).
---
[ENCRYPTION_AT_REST_TEST_REPORT.html](https://github.com/user-attachments/files/30664158/ENCRYPTION_AT_REST_TEST_REPORT.html)
---------
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
176 lines
8.6 KiB
Markdown
176 lines
8.6 KiB
Markdown
# Storage Encryption at Rest
|
|
|
|
Encrypts files stored by Stirling (My Files, workflow files) so the bytes on disk, in the database,
|
|
or in S3 are unreadable without the master key. Requires a Pro or Enterprise licence to enable.
|
|
|
|
> **Back up the master key.** Losing it makes every encrypted stored file permanently
|
|
> unrecoverable. There is no recovery path by design — that is what makes the encryption
|
|
> meaningful.
|
|
|
|
> **Audit trail requires Enterprise.** Encryption itself works on Pro, but the audit events
|
|
> below (encrypt, decrypt, revocation, plaintext export, migration) are only recorded on an
|
|
> Enterprise licence — the audit subsystem is Enterprise-gated platform-wide. On Pro the files
|
|
> are encrypted exactly the same way, but there is no access trail, which matters if you are
|
|
> enabling this to satisfy an audit-logging requirement (HIPAA, CMMC). A warning is logged at
|
|
> startup when encryption is enabled without an Enterprise licence.
|
|
|
|
## How it works
|
|
|
|
Envelope encryption, three levels:
|
|
|
|
| Level | What it is | Where it lives |
|
|
|---|---|---|
|
|
| Master key | Wraps the scope keys | Config property, env var, or `configs/file-encryption.key` |
|
|
| Scope key (KEK) | One per team; wraps each file's data key | `file_encryption_keys` table, master-key-wrapped |
|
|
| Data key (DEK) | One per stored blob; encrypts the bytes | Inside the blob's own header, scope-key-wrapped |
|
|
|
|
Each blob is self-describing: an `SPDFEAR1` header carries the format version, the scope key's id,
|
|
the plaintext length, and the wrapped data key, followed by AES-256-GCM streaming ciphertext
|
|
(1 MiB segments). The header is bound as associated data to both the key wrap and the payload, so a
|
|
header cannot be transplanted between blobs.
|
|
|
|
Consequences of that design worth knowing:
|
|
|
|
- Blobs without the magic prefix are treated as plaintext and passed through, so enabling the
|
|
feature needs no migration and old files keep working.
|
|
- Because the key id is pinned per blob, moving a user between teams never breaks their existing
|
|
files.
|
|
- Plaintext sizes are what get recorded in the database, so quotas and `Content-Length` are
|
|
unaffected (ciphertext on disk is ~96 bytes + 16 bytes/MiB larger).
|
|
- Presigned S3 download URLs are suppressed once encrypted content can exist — a presigned GET
|
|
would hand raw ciphertext to the browser — so those downloads stream through the application.
|
|
|
|
## Enabling it
|
|
|
|
```yaml
|
|
storage:
|
|
encryption:
|
|
enabled: true
|
|
```
|
|
|
|
The master key is resolved in this order:
|
|
|
|
1. `stirling.security.fileEncryptionKey` property
|
|
2. `STIRLING_FILE_ENCRYPTION_KEY` environment variable
|
|
3. an auto-generated `configs/file-encryption.key` (owner-only permissions)
|
|
|
|
Generate a key with:
|
|
|
|
```bash
|
|
openssl rand -base64 32
|
|
```
|
|
|
|
It must decode to exactly 32 bytes; anything else fails at startup rather than silently
|
|
downgrading the cipher. The startup log prints a fingerprint (a SHA-256 prefix, never the key) so
|
|
you can verify a backup matches the live key.
|
|
|
|
**Cluster mode** (`cluster.enabled=true`) requires the key to be set explicitly and identically on
|
|
every node; the auto-generated file is refused, because a node-local key would make files written
|
|
elsewhere unreadable.
|
|
|
|
### Turning it off
|
|
|
|
Disabling only stops encrypting *new* writes. Existing encrypted files stay readable as long as the
|
|
key material is present — the decrypt path is always active and is never licence-gated, so a lapsed
|
|
licence cannot lock you out of your own data.
|
|
|
|
## Encrypting files that already exist
|
|
|
|
Enabling the flag does not touch the existing plaintext backlog. To convert it:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/migrate
|
|
curl http://localhost:8080/api/v1/admin/storage-encryption/migrate/status
|
|
```
|
|
|
|
The job is throttled, resumable, and safe to re-run: for each file it writes the encrypted copy
|
|
under a new storage key, swaps the database row only if nothing else changed it, and deletes the old
|
|
blob last. If a user replaces a file mid-migration their copy wins and the job skips it. Progress is
|
|
in-memory, so a restart mid-run loses the counters and `migrate/status` reports `IDLE` again — just
|
|
start it again; already-encrypted files are skipped. There is currently no way to cancel a run, and
|
|
on a cluster the guard is per-node, so trigger the migration on one node only.
|
|
|
|
## Revoking access (kill switch)
|
|
|
|
Disabling a scope key makes every file already stored under it fail closed with `403` until it is
|
|
re-enabled:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/keys/{keyId}/disable
|
|
curl -X POST http://localhost:8080/api/v1/admin/storage-encryption/keys/{keyId}/enable
|
|
```
|
|
|
|
This revokes access to existing content; it does **not** stop the scope from storing new files. The
|
|
next upload finds no active key for the scope and mints one, so the team keeps working while its
|
|
history stays sealed. To stop new writes as well, turn encryption off (or take the scope's access
|
|
away at the application level) — the kill switch is aimed at stored bytes.
|
|
|
|
Because of that, re-enabling is status-aware: the key returns to `ACTIVE` if its scope has no other
|
|
active key, and to `RETIRED` if one was minted while it was revoked. Both statuses decrypt existing
|
|
content; only `ACTIVE` wraps new writes, so a scope never ends up with two keys competing for new
|
|
uploads. The `enable` response reports which status was applied.
|
|
|
|
This is reversible: the key material stays in the database and nothing is destroyed. No API path
|
|
deletes key material. On a cluster, other nodes pick the change up within their 60-second key-cache
|
|
window.
|
|
|
|
## Rotating the master key
|
|
|
|
Rotation only re-wraps the small `file_encryption_keys` table — file contents are never rewritten.
|
|
|
|
1. Set the new key as `stirling.security.fileEncryptionKey`.
|
|
2. Keep the outgoing key in `stirling.security.fileEncryptionKeyPrevious`.
|
|
3. Bump `stirling.security.fileEncryptionKeyVersion`.
|
|
4. Restart. Startup warns about rows still wrapped by the previous key. On a cluster, wait until
|
|
**every** node carries both keys — a node still holding only the outgoing key cannot read a
|
|
re-wrapped row, so rotating mid-deploy makes the lagging nodes fail on those scopes until they
|
|
catch up.
|
|
5. `POST /api/v1/admin/storage-encryption/master/rotate`.
|
|
6. Confirm the response's `rewrapped` count and that `/status` shows every key row at the new
|
|
`masterKeyVersion`.
|
|
7. Remove `fileEncryptionKeyPrevious` and restart.
|
|
|
|
**Do not skip step 6.** Until a row is re-wrapped it is still readable only with the outgoing key, so
|
|
removing that key while rows remain behind would seal the files under them. Startup verifies every
|
|
key row against the configured keys and refuses to start if any cannot be unwrapped, naming the count
|
|
and the first affected scope — so this shows up as a failed deploy, recoverable by putting the old key
|
|
back, rather than as unreadable files discovered later. Keep the outgoing key archived until a
|
|
restart has succeeded without it.
|
|
|
|
Key material is never accepted over HTTP; the endpoint only performs the re-wrap step.
|
|
|
|
## Auditing
|
|
|
|
**Requires an Enterprise licence** (see the note at the top): on Pro these events are silently
|
|
dropped by the audit subsystem, and a warning is logged at startup.
|
|
|
|
Encrypt, decrypt, denied-decrypt, key lifecycle, rotation, and migration events are written to the
|
|
audit trail, along with a `plaintextExport` marker whenever a plaintext copy of encrypted content is
|
|
served. Per-read decrypt events can be noisy on busy instances and can be turned off with
|
|
`storage.encryption.auditReads: false`; denials and key lifecycle events are always recorded.
|
|
|
|
Two semantics worth knowing when reading the trail:
|
|
|
|
- A `decrypt` event means a decryption was *authorised and opened*, not that bytes were read to
|
|
completion — a load that is discarded still records one, and a re-read of the same open resource
|
|
(e.g. an HTTP range request) does not record a second.
|
|
- `plaintextExport` is currently emitted for stored-file and share-link downloads. Workflow-file
|
|
downloads are not yet marked.
|
|
|
|
## Status and backup verification
|
|
|
|
```bash
|
|
curl http://localhost:8080/api/v1/admin/storage-encryption/status
|
|
```
|
|
|
|
Reports whether writes are encrypted, the master-key fingerprint, encrypted vs plaintext file
|
|
counts, and every key row with its status history. All endpoints under
|
|
`/api/v1/admin/storage-encryption` require an admin account.
|
|
|
|
## What this protects against
|
|
|
|
Stolen disks, database dumps, exposed object-storage buckets, decommissioned media, and platform
|
|
users who are not authorised for a file. It is not a defence against an attacker who already has
|
|
root on a running instance — at that point the key is in memory. No storage-level encryption product
|
|
claims otherwise.
|