Serve static files from an S3 bucket
Threat model: the server only ever sees ciphertext. The browser seals each file with a key derived from ECDH P-256 against the recipient's public key (HKDF-SHA256, then AES-256-GCM in 4 MiB frames), so the service stores an opaque blob and never sees the plaintext, the original filename, or a key that could open it. What the server does see is the ciphertext's size and when it arrived.
The signed upload token is a bearer capability: it proves only that its holder may upload up to a byte limit before an expiry. It is not single use — anyone holding the link can upload repeatedly until it expires, and there is no revocation short of rotating Transfer:SigningKey (which invalidates every outstanding link). Keep TTLs short and treat the link as a secret.
Because the token lives in the URL fragment and is sent in an X-Upload-Token header, it is not written to server access logs or leaked through Referer.
| Setting | Env var | Purpose |
|---|---|---|
Transfer:SigningKey |
TRANSFER_SIGNING_KEY |
HMAC key (>= 32 chars) used to sign upload tokens. |
Transfer:AdminToken |
TRANSFER_ADMIN_TOKEN |
Bearer token (>= 16 chars) for X-Admin-Token on admin endpoints. |
Transfer:BucketName |
TRANSFER_BUCKET_NAME |
A private R2/S3 bucket for uploads. Must differ from R2:BucketName — the static bucket is publicly readable and must never receive customer uploads. |
Transfer:KeyPrefix |
TRANSFER_KEY_PREFIX |
Object key prefix, default transfers. |
Transfer:MaxBytes |
TRANSFER_MAX_BYTES |
Upper bound for any ticket's upload size, default 256 MiB. |
Transfer:DefaultTtlSeconds / Transfer:MaxTtlSeconds |
TRANSFER_DEFAULT_TTL_SECONDS / TRANSFER_MAX_TTL_SECONDS |
Link lifetime, default 48h / max 14d. |
Transfer:PublicBaseUrl |
TRANSFER_PUBLIC_BASE_URL |
Base URL used to build the returned url; falls back to the incoming request's scheme/host. |
The feature is disabled (all /transfer/* and /transfer-admin/* routes return 404) until SigningKey, AdminToken and BucketName are all set. A warning is logged at startup naming whichever of the three is missing.
A value set in the Transfer section of appsettings.json takes precedence over the matching TRANSFER_* environment variable, so that section deliberately ships only the three (blank) secrets — a non-blank default there would shadow the environment variable and silently ignore it.
/transfer/ is the only prefix the public Ingress (transfer.coflnet.com) routes to this pod — it carries just GetLink and Upload, the two endpoints a customer's browser calls. The admin API (CreateLink, ListUploads, DownloadUpload) lives under a separate /transfer-admin/ prefix on TransferAdminController, which the Ingress does not route to, so it stays cluster-internal even though both controllers run in the same pod. /objects/ (the public static-object API) is likewise never exposed by that Ingress rule.
Reach the admin API by port-forwarding into the cluster instead:
kubectl port-forward svc/static-s3 8000:8000 -n s3
then target http://localhost:8000/transfer-admin/... as shown below.
Two options, strongest first:
- In the browser (recommended): open
/transfer-admin/keys.html(or use the admin console below), generate a pair, save the private key, and pass the public key aspublicKeywhen creating the link. The private key never touches the server. - Server-generated: omit
publicKeyand the server generates a pair, returns the private key once in the create response, and keeps no copy. Convenient for scripting, but the private key travels over the wire to you.
curl -X POST http://localhost:8000/transfer-admin/links \
-H "X-Admin-Token: $TRANSFER_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ttlSeconds": 86400, "maxBytes": 104857600, "label": "contract for Acme"}'
Response (201):
{
"id": "AbCdEfGh12Q",
"token": "v1.eyJ...b64url...",
"url": "https://static.example.com/transfer/#v1.eyJ...b64url...",
"decryptPath": "/transfer-admin/decrypt.html",
"expiresAt": "2026-09-07T12:00:00+00:00",
"maxBytes": 104857600,
"label": "contract for Acme",
"publicKey": "BJ3f...b64url...",
"privateKeyJwk": "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"...\",\"x\":\"...\",\"y\":\"...\",\"ext\":true,\"key_ops\":[\"deriveBits\",\"deriveKey\"]}",
"note": "The private key is shown only once here and is never logged or stored by the server - save it now."
}privateKeyJwk is only returned when the server generated the key pair (i.e. no publicKey was supplied in the request) and is never logged or persisted. Save it immediately — it is required to decrypt the upload later.
Send the customer the url. Opening it loads the transfer web UI, which reads the token from the URL fragment, encrypts the selected file in the browser using the ticket's publicKey, and POSTs the ciphertext to /transfer/upload with header X-Upload-Token: <token>. The server stores the ciphertext as an opaque blob — it never sees plaintext, a filename, or the label.
curl http://localhost:8000/transfer-admin/uploads?id=AbCdEfGh12Q \
-H "X-Admin-Token: $TRANSFER_ADMIN_TOKEN"
curl http://localhost:8000/transfer-admin/uploads/transfers/AbCdEfGh12Q/20260906T120000Z-Ab12Cd.bin \
-H "X-Admin-Token: $TRANSFER_ADMIN_TOKEN" -o ciphertext.bin
Open decryptPath (/transfer-admin/decrypt.html, reachable only through the port-forward above), paste in the private key JWK, and load the downloaded ciphertext file — it is decrypted entirely in the browser and the original filename is restored. Nothing is uploaded and the key is not persisted in browser storage.
http://localhost:8000/transfer-admin/ (after the port-forward above) is a single-page console that wraps steps 0-3 in a form instead of curl:
- Admin token — enter the
X-Admin-Tokenonce; it is kept insessionStoragefor the tab (neverlocalStorage) and sent on every admin request, with a "Forget token" button to drop it again. - Create an upload link — a form for
label, TTL, and max size, plus a choice between generating a fresh key pair in the browser (default; only the public key is ever posted) or reusing a previously saved one. On success it shows theurlwith a copy button and stores the new key pair locally, tagged with the returned ticketid. - Saved keys — the key pairs generated by this browser, kept in
localStorage. Only this storage and a downloaded.jwk.jsonbackup hold the private key; the server never has a copy, and clearing site data or forgetting a saved key without a backup makes its uploads permanently unrecoverable. - Received uploads — lists objects via
GET /transfer-admin/uploads(optionally filtered by ticket id), and for each one offers Decrypt & save when the matching key is saved locally (fetches the ciphertext, decrypts it in the browser with a progress bar, and hands back the original file), or Download ciphertext with a pointer todecrypt.htmlwhen it is not.
keys.html and decrypt.html are linked from the console for standalone use, and link back to it in turn.
wwwroot/transfer/crypto.js documents the on-disk layout. Each frame's AES-GCM IV and additional data bind it to its frame index and to the header, and the encrypted metadata frame declares how many frames follow, so reordering, splicing, truncation, and cross-ticket substitution all fail to decrypt rather than silently returning partial data.
The browser-side crypto has its own test suite:
node --test 'tests/*.test.mjs'
It covers round-trips (single chunk, many chunks, empty), wrong key, tampered header, tampered ciphertext, truncation, trailing data, and frame reordering. It is not run by the container CI, which builds and runs the .NET tests only.
The DevServer Android console can open this page through a bastion and native
Kubernetes port forwarding. Its document-start coflnetTransfer bridge stores
private transfer keys in the phone's biometric and passphrase-protected vault,
awaits durable writes before offering a newly created upload link, and exports
files through Android's save chooser. transfer-admin/mobile.js keeps ordinary
browser storage and downloads compatible. The native bridge is supplied only by
the app; this does not expose any admin routes publicly or change admin-token
requirements. Deploy these admin assets together with the mobile update.