Repository navigation
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/.
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>
}-
putreturns an opaque handle that is persisted in the database and never handed to the browser. -
clientUploadtells 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). -
getRangeis 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. -
sizereads the stored length without reading the object. Ingest refuses an oversized upload with it before reading a byte.
| 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.
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.
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.)
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"]
- 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 (Settingrowai.login.openai). -
Changing
ENCRYPTION_KEYmakes everything already stored permanently unreadable. There is no recovery, by design.pnpm setupoffers to reuse the existing key for this reason.
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).
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.
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.
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.
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.
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.
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog