Skip to content

cryptify

GitHub · Rust · File Sharing Service

Cryptify is the file encryption and sharing service that PostGuard uses for delivering encrypted files. It allows encrypting any file with an identity attribute. Only people who can prove they have that attribute can decrypt and view the contents.

The PostGuard website and the JavaScript SDK use Cryptify as the default file storage and delivery backend.

Cryptify is a Rust service built on the Rocket framework. It handles file storage, chunked uploads, email notifications, and serves the API.

Configuration

Cryptify reads its configuration from a TOML file. Example configuration files are in conf/. Set the ROCKET_CONFIG environment variable to point to the configuration file.

Configuration parameters:

ParameterDescriptionExample
server_urlPublic URL of the servicehttp://localhost:8080/
addressBind address0.0.0.0
portListen port8000
data_dirDirectory for storing uploaded files/tmp/data
email_fromSender address for notification emailsnoreply@postguard.local
smtp_urlSMTP server hostnamemailcrab
smtp_portSMTP server port1025
smtp_tlsEnable TLS for SMTPfalse
smtp_usernameOptional SMTP usernameuser
smtp_passwordOptional SMTP passwordpw
allowed_originsRegex pattern for CORS allowed origins^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$
pkg_urlURL of the PostGuard PKG serverhttp://postguard-pkg:8087
chunk_sizeMaximum size in bytes of a single upload chunk. Defaults to 5000000 (5 MB)5000000
session_ttl_secsIdle TTL for an in-flight upload session, in seconds. The eviction deadline resets on each successful chunk PUT or /status call. Defaults to 3600 (60 minutes)3600
staging_modeWhen true, send_email skips SMTP entirely and logs the intended email metadata at info level. The upload finalize still returns Ok. Defaults to false. Intended for staging deploys where real email delivery is undesirablefalse
usage_dbPath to the SQLite database used for upload usage accounting/app/data/usage.db
metrics_scan_interval_secsInterval in seconds for the background task that samples data_dir size and file count for the storage gauges exposed at GET /metrics. Defaults to 60.60

The chunk_size setting caps the size of each PUT /fileupload/{uuid} body. Clients (such as @e4a/pg-js and the PostGuard website) use the same value for their upload chunks, so increasing it server-side without updating the client default will not produce larger chunks on its own.

Source: src/config.rs#L3-L38

Staging mode

Set staging_mode = true in the active config file (for example conf/config.toml) to skip SMTP for a whole deploy. With staging mode on, POST /fileupload/finalize/{uuid} still completes normally and returns Ok; the recipient email is replaced by a single info-level log line of the form:

[STAGING] Email NOT sent (staging_mode=true). Would have notified recipients=[...] from sender=... (attributes=[...]) lang=... expires=... confirm=... notify_recipients=... download_url=... uuid=...

The line carries the same recipients, sender, sender attributes, language, expiry, confirmation flag, notify-recipients flag, download URL, and UUID that a real notification would have used, so a staging deploy can be exercised end to end without delivering mail.

The smtp_url, smtp_port, smtp_username, smtp_password, and smtp_tls settings are ignored while staging_mode is on, so they can stay unset in a staging environment.

Source: src/email.rs#L191-L198, L294-L337

Upload limits

Cryptify enforces three independent limits on every upload. They are constants in src/store.rs, not config options.

LimitDefault tierAPI-key tierNotes
Per-chunk sizechunk_size from config (default 5 MB)same as defaultBigger chunks are rejected with 400 Bad Request.
Per-upload size5 GB100 GBTotal bytes for a single upload session.
Rolling window total5 GB per 14 days100 GB per 14 daysDefault tier accounts per sender email; API-key tier accounts per tenant id.

The default tier identifies the sender by the email attribute disclosed in the encrypted envelope's signature. The API-key tier accounts on the validated tenant id (api-key:<organizations.id>) so a single tenant cannot evade quota by varying sender attributes. See Authentication for the higher tier below.

The rolling window only counts finalized uploads.

When a request would push the sender over the per-upload or the rolling-window limit, the server responds with 413 Payload Too Large and a JSON body:

json
{
  "error": "Sender has exceeded the 14-day rolling limit of 5000000000 bytes",
  "limit": "rolling_window",
  "used_bytes": 4800000000,
  "limit_bytes": 5000000000,
  "resets_at": "2026-05-09T12:34:56Z"
}

limit is either "per_upload" or "rolling_window". resets_at is an RFC 3339 timestamp for when the oldest counted upload expires from the rolling window. It is null for per_upload rejections, since the per-upload limit does not reset.

GET /usage returns the current state for the authenticated sender, including used_bytes, limit_bytes, per_upload_limit_bytes, window_days, and resets_at. When the request includes a validated Authorization: Bearer PG-…, the response describes the per-tenant bucket (api-key:<tenant>); otherwise it describes the per-email bucket.

Source: src/store.rs#L11-L15

Authentication for the higher tier

Callers unlock the API-key tier by sending Authorization: Bearer PG-… on every upload request (init, each chunk PUT, and finalize). The key is a PostGuard for Business API key issued through the postguard-business portal.

Cryptify itself does not own the key allowlist. On init it forwards the bearer to PKG's GET /v2/api-key/validate, which authenticates against the shared business_api_keys table and returns the tenant id (organizations.id). Cryptify uses that id for tier selection and as the rolling-window accounting key. Validation runs only at init — once the upload session is established, the tier and accounting key are fixed for its lifetime.

Validation outcomeTier appliedBehaviour on cap exceeded
No Authorization header / non-PG bearerDefault413 Payload Too Large
PKG returns 2xx with tenant idAPI-key413 Payload Too Large (at 100 GB)
PKG returns 401/403 (unknown or expired key)Default413 Payload Too Large
PKG unreachable for the full retry budgetDefault + warning503 Service Unavailable when the upload exceeds the default 5 GB cap; 413 otherwise behaviour matches default

The PKG retry budget at init is 30 seconds with exponential backoff (250 ms → 5 s ceiling). Authoritative responses (2xx with body, 401, 403) short-circuit the retry loop. Connection errors and 5xx are retried until the budget is exhausted.

The 503 response distinguishes "we couldn't tell whether you should have gotten the higher tier" from the regular 413 ("you're over your tier's cap"). Smaller uploads degrade silently to the default tier with a warning logged on the server, so transient PKG outages don't block uploads that would have fit anyway.

The legacy X-Api-Key header is no longer recognised; older clients that still send it are treated as default tier.

Source: src/main.rs

API

Cryptify exposes a file upload/download API. An OpenAPI 3.0 specification is available in api-description.yaml in the repository root. The main endpoints:

  • POST /fileupload/init: Initialize a multipart file upload. The JSON body takes recipient, mailContent, mailLang, confirm, and the optional notifyRecipients.
  • PUT /fileupload/{uuid}: Upload a file chunk (use Content-Range header for chunked uploads).
  • POST /fileupload/finalize/{uuid}: Finalize the upload (sends the recipient notification email if notifyRecipients was true on init).
  • GET /fileupload/{uuid}/status: Read rolling-token state to resume an in-flight upload across a page refresh or tab crash. Authenticated via X-Recovery-Token.
  • GET /filedownload/{uuid}: Download a file. Supports resumable downloads via the HTTP Range header (see Range support on /filedownload below).
  • GET /metrics: Prometheus text-format metrics for monitoring (see Metrics below). Unauthenticated; intended for scraping over a restricted network only.

POST /fileupload/init request body

FieldTypeRequiredDescription
recipientstring (email)yesRecipient email address.
mailContentstringyesBody text included in the recipient and confirmation emails.
mailLangstringyesEmail language. EN or NL.
confirmbooleanyesSend a confirmation email to the sender.
notifyRecipientsbooleannoEmail each recipient with a download link. Defaults to true when omitted, for backward compatibility. Set to false to upload silently when the encrypted payload reaches the recipient through another channel.

The notifyRecipients field was added in cryptify 0.9 (see encryption4all/cryptify#135). Direct API callers that omit it keep the original notify-on-finalize behaviour. SDK callers (@e4a/pg-js 1.2.0+, E4A.PostGuard 0.3.0+) send false explicitly so the silent-by-default semantics hold regardless of the cryptify version on the other end.

Source: api-description.yaml#L33-L72

POST /fileupload/init response body

The init response is JSON and includes the upload UUID and a recovery token:

FieldTypeDescription
uuidstring (uuid)Upload identifier used in subsequent chunk PUTs and finalize.
recovery_tokenstring (hex, 32 bytes)Bearer credential for GET /fileupload/{uuid}/status. Clients should store it alongside the UUID (for example in IndexedDB) and present it in an X-Recovery-Token header to rehydrate after a page refresh, tab crash, or navigate-away-and-back.

The cryptifytoken response header carries the initial rolling token for the first chunk PUT.

Source: api-description.yaml#L82-L103

GET /fileupload/{uuid}/status

Returns the rolling-token state of an in-flight upload so a client that lost track of the session can resume. The response body has uploaded (bytes committed so far), cryptify_token (the value to send as cryptifytoken on the next chunk PUT), and once at least one chunk has been committed, prev_token and prev_offset for the idempotent-retry path described below.

Authentication uses the X-Recovery-Token header issued at init. The token is compared in constant time:

  • Missing or empty header: 401.
  • A token that does not match the stored value: 404 with the same upload_session_not_found body as a real unknown UUID. The two cases are deliberately collapsed so callers cannot probe for live UUIDs by varying the token.

A successful call also resets the session's idle eviction deadline, so the next chunk PUT will not 404 because the rehydrate window aged out.

If two clients hold the same UUID and recovery token (two open tabs, say), there is no server-side lease. The first chunk PUT to land wins and the second sees a 4xx as soon as it tries to advance past the now-stale state.

Source: api-description.yaml#L258-L319

Missing-upload-session 404 body

PUT /fileupload/{uuid}, POST /fileupload/finalize/{uuid}, and GET /fileupload/{uuid}/status return a JSON body on 404. The status code is unchanged, so older clients see the same wire behaviour:

json
{
  "error": "upload_session_not_found",
  "uuid": "…",
  "reason": "expired_or_unknown"
}

reason is one of:

  • expired_or_unknown: the session was evicted after its idle TTL or never existed. Clients cannot tell these apart, by design.
  • invalid_uuid: the path UUID is malformed.
  • file_missing: the in-memory session exists but the on-disk file is gone (server-state inconsistency).

Clients should not retry — start a new upload via /fileupload/init.

Source: api-description.yaml#L444-L472

Idempotent chunk retry

A chunk PUT whose response was lost in flight can be safely retried. The client re-issues the request with the previous cryptifytoken (the value sent on the failed attempt), the same Content-Range, and the same body bytes. The server matches the request against the cached (prev_token, offset, length, sha256) of the most recently committed chunk and replays the previously returned cryptifytoken without rewriting the file or double-counting against quotas.

If the request looks like a retry but the body bytes differ, the server responds 400. Clients must not retry the same offset with different bytes. Retries are only honoured for the most recently committed chunk; a client that has fallen behind by more than one chunk must start a new upload.

The prev_token and prev_offset fields on GET /fileupload/{uuid}/status exist to feed exactly this path after a refresh.

Source: api-description.yaml#L109-L124

Range support on /filedownload

GET /filedownload/{uuid} honours inbound Range headers so browsers and other clients can resume an interrupted download instead of restarting from byte zero. Every response sets Accept-Ranges: bytes.

RequestResponse
No Range header200 OK with Content-Length and the full body.
Range: bytes=N-M, bytes=N-, or bytes=-N206 Partial Content with Content-Range: bytes start-end/total and the requested slice. Open-ended and suffix forms are clamped to the file size.
Unsatisfiable or malformed range416 Range Not Satisfiable with Content-Range: bytes */total.
Multi-range (bytes=0-9,20-29)Rejected as malformed; the server returns 416. multipart/byteranges is intentionally unsupported.

The CORS preflight allowlist on the service includes Range alongside Authorization, Content-Type, Content-Range, CryptifyToken, and X-Recovery-Token, so browser fetch calls can send the header.

Source: src/main.rs#L959-L1083

Metrics

GET /metrics returns counters and gauges in the Prometheus text exposition format so a Prometheus instance can scrape Cryptify for dashboards and alerting. The endpoint is unauthenticated. It is intended to be reachable only from the internal monitoring network; deployments should restrict it with a firewall rule or a reverse-proxy allow-list in front of Cryptify.

Five series are exposed:

MetricTypeDescription
cryptify_uploads_total{channel}counterFinalized uploads since process start, labelled by client channel.
cryptify_upload_bytes_total{channel}counterBytes uploaded across finalized uploads, labelled by client channel.
cryptify_storage_bytesgaugeCurrent bytes of uploads held under data_dir. Sampled every metrics_scan_interval_secs.
cryptify_active_filesgaugeCurrent file count under data_dir. Sampled on the same interval.
cryptify_expired_files_totalcounterUploads purged before being finalized (idle-TTL eviction).

The two storage series are produced by a background task that walks data_dir once every metrics_scan_interval_secs (default 60). The counters are updated inline on finalize and on eviction, so they do not depend on the scan cadence.

At process startup Cryptify pre-seeds cryptify_uploads_total and cryptify_upload_bytes_total at zero for six known channel values: website, staging-website, outlook, thunderbird, api, and unknown. Without the pre-seed, Prometheus only creates a series the first time a request from a given channel lands, which means PromQL increase() over a window can read zero for a channel whose first observed sample is already non-zero. Pre-seeding also keeps the channel always visible on Grafana dashboards before any traffic arrives.

Source: src/metrics.rs#L28-L182

Channel detection

The channel label on cryptify_uploads_total and cryptify_upload_bytes_total is derived per request from the headers in this priority order:

  1. X-Cryptify-Source if present.
  2. Otherwise api when the request carries Authorization: Bearer … or X-Api-Key.
  3. Otherwise staging-website or website from the Origin header.
  4. Otherwise outlook or thunderbird from the User-Agent substring.
  5. Otherwise unknown.

Whichever rule fires, the resulting value is lowercased, restricted to [a-z0-9_-], and truncated to 32 characters before it reaches Prometheus, so clients cannot inject label syntax or explode cardinality with arbitrary inputs. An empty or all-dash result falls back to unknown.

The first-party clients now set the header explicitly: the PostGuard website sends X-Cryptify-Source: website (encryption4all/postguard-website#228), the Outlook add-in sends outlook (encryption4all/postguard-outlook-addon#96), and the Thunderbird add-in sends thunderbird (encryption4all/postguard-tb-addon#121). A third party integrating its own Cryptify client should set X-Cryptify-Source to a stable identifier for that client to be classified correctly on the dashboards. Without it, the channel falls through to Origin or User-Agent heuristics and may land on unknown.

Source: src/metrics.rs#L184-L249

Development

bash
# Development setup
docker-compose -f docker-compose.dev.yml up

# Production-like setup
docker-compose up

Manual Setup

Requires Rust.

Building and running

bash
# Development (with auto-reload)
env ROCKET_ENV=development ROCKET_CONFIG=conf/config.dev.toml cargo watch -x run

# Production build
env ROCKET_ENV=production cargo build --release

# Run the built binary
env ROCKET_CONFIG=conf/config.toml ./target/release/cryptify

Releasing

This repository uses Release-plz for automated versioning. Merging a release PR triggers a multi-architecture Docker image build.

CI/CD

WorkflowTriggerWhat it does
ci.ymlPush to mainRelease-plz PR/release, multi-arch Docker build