Skip to content

Storage and Encryption

Nabeel Wasif edited this page Sep 29, 2026 · 1 revision

Storage and Encryption

Three storage backends sit behind one interface, and nothing above the storage layer knows which one answered. Every byte written is sealed with AES-256-GCM under a per-document key.

Canonical source: docs/storage.md · code: lib/storage/.


1. The driver interface

lib/storage/drivers.ts

type StorageDriver = {
  name: StorageDriverName
  clientUpload: "vercel-blob" | "server-route"
  put:       (key, data: Uint8Array) => Promise<StoredObject>
  get:       (key) => Promise<Buffer>
  getStream: (key) => Promise<Readable>
  getRange:  (key, start, end) => Promise<Buffer>     // half-open [start, end)
  putStream: (key, body: Readable) => Promise<StoredObject>
  size:      (key) => Promise<number>
  delete:    (key) => Promise<void>                   // a missing object is a successful delete
  exists:    (key) => Promise<boolean>
}
  • put returns an opaque handle that is persisted in the database and never handed to the browser.
  • clientUpload tells the upload panel how the browser should deliver bytes: Vercel Blob answers "vercel-blob" (direct upload with a scoped token), S3 and the filesystem answer "server-route" (/api/upload/local).
  • getRange is half-open, whatever the backend's own convention. A backend that ignores a range and returns the whole object is still answered correctly, because the driver slices it. Only efficiency varies.
  • size reads the stored length without reading the object. Ingest refuses an oversized upload with it before reading a byte.

The three drivers

Driver Key shape Notes
Local filesystem local:<path> Under .anonify-storage/. .. segments are stripped and leading slashes trimmed, so a traversal-shaped key cannot escape. putStream writes beside the destination and renames into place, so a failed stream leaves nothing under the real name. Not usable on serverless platforms.
S3-compatible (RustFS, AWS S3, …) s3:<path> S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY required; S3_REGION (default us-east-1), S3_ENDPOINT, S3_FORCE_PATH_STYLE (inferred from a custom endpoint). Native Range GETs; putStream is a multipart upload with 5 MiB parts. pnpm smoke:storage exercises it against RustFS in CI.
Vercel Blob an absolute URL addRandomSuffix: true, so the URL is unique and is the key. getRange sends Range and expects 206. pnpm smoke:blob fails on anything but a 206, because the driver's own slicing would otherwise hide a store that ignores ranges. (Ranges were confirmed honoured on 2026-09-27.)

Each driver lazy-imports its own SDK, so a Blob deployment never bundles @aws-sdk/client-s3 and a RustFS install never bundles @vercel/blob.

Writes go to the configured driver; reads go to the key's driver

selectStorageDriver picks the driver for new objects: an explicit STORAGE_DRIVER (vercel-blob / s3 / local, validated against its own env) → else BLOB_READ_WRITE_TOKEN → else S3 config → else local, so a fresh clone works with nothing configured.

driverForKey picks the driver for reads from the stored key's prefix. That is what makes a backend change safe: a document written to disk stays readable after the deployment moves to S3. The one way a configuration change can strand a document is removing S3 configuration while s3: keys still exist.


2. The key layout

lib/storage/blob.ts is the only place keys are constructed. The layout is fixed so that purgeDocument can find everything a document owns by prefix:

Path What it is
documents/:id/upload/<filename> the plaintext browser upload, until ingest re-seals it (filename sanitised to [A-Za-z0-9._-], last 80 chars)
documents/:id/source.bin the sealed original (layer A)
documents/:id/normalized.json.bin the sealed normalized model (layer B)
documents/:id/redacted.<artifactId>.<ext>.bin a sealed export (layer D)
documents/:id/report.<artifactId>.json.bin its sealed export report
documents/:id/vault.<artifactId>.json.bin a sealed token vault, batch exports only
documents/:id/render/<name>.bin a sealed rendered page or preview

The row is deleted after everything under documents/:id/ is gone, because the row is the only thing that knows where the bytes are. The cleanup sweep and the explicit "Delete now" share one purgeDocument, so there is one list of what a document owns. (An early delete route left the normalized model, which holds the document's text, behind in storage.)


3. Envelope encryption

lib/storage/encryption.ts, chunked.ts, sealed.ts

flowchart LR
    MK["ENCRYPTION_KEY<br/>32 bytes, hex or base64<br/>checked at boot"] -- wraps --> DK["per-document data key<br/>random 256-bit"]
    DK -- "stored wrapped" --> ROW["Document.encryptionKey<br/>Document.encryptionFormat"]
    DK --> A["source.bin"]
    DK --> B["normalized.json.bin"]
    DK --> D["exports · reports · vaults"]
Loading
  • Every document gets its own random data key. It is never derived from an IP, a MAC, browser behaviour or network activity.
  • The data key is wrapped by the master key and stored on the row. It is unwrapped server-side only.
  • A few things outlive any document and are sealed directly with the master key: a global rule's pattern (OwnerRule.sealedPattern) and a ChatGPT sign-in token (Setting row ai.login.openai).
  • Changing ENCRYPTION_KEY makes everything already stored permanently unreadable. There is no recovery, by design. pnpm setup offers to reuse the existing key for this reason.

assertMasterKey: fail on the way up

instrumentation.ts validates the key at boot and refuses to start if it does not decode to 32 bytes. The key is otherwise touched only deep in the pipeline, so a malformed one used to surface as a workflow step exhausting its retries, several layers from the configuration line that caused it. That once happened in CI, when YAML turned a 64-zero key into the integer 0 (#27).


4. Two envelopes, chosen by the record

Each document records which envelope all of its objects use, in Document.encryptionFormat. Reads dispatch on that record, never on the bytes. A v0 object begins with a random IV, and a random IV can begin with the v1 magic like anything else can.

Recorded Envelope Used for
null / v0 one AES-256-GCM pass: iv(12) ‖ tag(16) ‖ ciphertext documents ingested before 1.6.0
v1 chunked AES-256-GCM every document ingested since, including every child of an older container

A v0 document keeps writing v0 for the rest of its life and ages out through retention. Nothing is backfilled.

The chunked envelope (v1)

header   16 bytes, cleartext, bound into every chunk's AAD
  magic        4   "ANFY"
  version      1   0x01
  chunkShift   1   log2(chunkSize); 20 = 1 MiB
  reserved     2   zero
  noncePrefix  8   random, per object

chunk i
  ciphertext   chunkSize bytes (the final chunk is shorter; an empty object
               is one empty final chunk)
  tag          16
Property How it is achieved
No nonce reuse nonce = noncePrefix(8) ‖ counter_be32(i); the counter fails closed instead of wrapping; objects are write-once
No truncation the AAD includes a final-chunk flag, so no prefix of an object is a valid object
No reordering the chunk counter is in the nonce
No splicing between objects the AAD includes the object's logical key (documents/:id/…), supplied by the reader from the document it is reading, so a report cannot be swapped in for a source even though both share a data key (security fix in 1.6.0)
No unauthenticated plaintext no byte of a chunk is released before its tag verifies; a tampered chunk fails the stream at that chunk, a truncated object fails at the end
Random access sealed size is exactly 16 + n·(chunk + 16) minus the final chunk's shortfall, so a plaintext range maps to a computable run of chunks and one ranged read
Config-safe the chunk size is read from the header when opening; configuration only chooses what new objects use

ANONIFY_ENCRYPTION_CHUNK_SIZE (default 1MB; a power of two from 64KB to 16MB) sets the chunk size for new objects.


5. Integrity

SHA-256 in three places, all hashing, never encryption:

Where What it proves
Document.checksum the sealed source is what was uploaded; re-verified before extraction (corrupt-source otherwise)
ExportArtifact.checksum / reportChecksum / vaultChecksum what is downloaded is the artifact that passed verification
identity and tokens salted identity derivation; HMAC for download tokens and Hush approvals

Downloads are streamed through a ChecksumVerifier that hashes as the bytes pass and holds the last piece back until the hash matches. A mismatch breaks the download off short: the recipient never holds a complete file that failed its check. See Streaming IO §8.


6. What never reaches the database

The database holds metadata about bytes that live elsewhere. Extraction text is never in a column in the clear. Redaction.text is the one exception, because a suggestion the reviewer has not decided on yet still has to be shown, and it dies with the document. Patterns of rules that outlive documents are sealed. See Data Model.


7. Moving from MinIO to RustFS

The local stack used MinIO until its server repository was archived (April 2026) and its client repository followed (July 2026). Since 1.3.0, Compose runs RustFS, pinned (1.0.0-rc.6 server, v0.1.35 rc client). It reuses the existing anonify_minio-data volume, which RustFS imports in place, and creates the bucket with --ignore-existing.

The import is one-way, so back up the volume first. Installations that used storage-server encryption, tiering or custom bucket features must copy through the S3 API instead. Step by step: Upgrading and docs/migrate-object-storage-to-rustfs.md.

Anonify encrypts before it calls PutObject, so the storage server only ever sees opaque bytes. It never needs SSE, versioning or bucket policies.


See also

Clone this wiki locally