SecureFileUpload.Core
3.0.1
See the version list below for details.
dotnet add package SecureFileUpload.Core --version 3.0.1
NuGet\Install-Package SecureFileUpload.Core -Version 3.0.1
<PackageReference Include="SecureFileUpload.Core" Version="3.0.1" />
<PackageVersion Include="SecureFileUpload.Core" Version="3.0.1" />
<PackageReference Include="SecureFileUpload.Core" />
paket add SecureFileUpload.Core --version 3.0.1
#r "nuget: SecureFileUpload.Core, 3.0.1"
#:package SecureFileUpload.Core@3.0.1
#addin nuget:?package=SecureFileUpload.Core&version=3.0.1
#tool nuget:?package=SecureFileUpload.Core&version=3.0.1
SecureFileUpload.Core
Defense-in-depth file upload pipeline for ASP.NET Core 10+ — AES-256-GCM envelope encryption, Argon2id key derivation, deep content validation, and pluggable virus scanning.
SecureFileUpload.Core is a battle-tested file-upload pipeline lifted from a production ASP.NET Core document-intake workflow, de-branded, hardened, and shipped as a single NuGet package. Every layer is implemented in code you can read; every limitation is named in KNOWN-GAPS.md; every security claim traces to a specific line in src/ per the audit in SECURITY-ANALYSIS.md.
"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31
What's New in 3.0.0
3.0.0 is the hardened download-surface release. The 8-layer upload pipeline and on-disk crypto formats are unchanged from 2.0.0. This release is about the staff-download contract, release validation, and operator-facing correctness.
- Opaque download tokens replace path-based download links. The reference controller now accepts
fileToken, issued byIFileAccessTokenService, instead of a storage-relative path. If you linked staff downloads withrelativePath, update that integration before upgrading. - Release validation is now an actual gate. The solution tests and the runtime smoke harness both run in CI before pack/publish, so the NuGet package is validated against the same path documented in this repo.
- Scanner outage logs now match runtime behavior. ClamAV and Windows Defender unavailability are logged as
NotScannedfail-open conditions instead of incorrectly implying fail-closed rejection.
2.0.0 was the first stable release of the modernized line. If you're upgrading from 1.0.0, the major crypto/runtime changes from that release still apply: the TFM moved from net8.0 to net10.0, and the KEK derivation default changed to Argon2id.
- Argon2id for KEK derivation. The master Key Encryption Key is now derived via Argon2id (RFC 9106, OWASP 2024+ recommendation) with memory-hard defaults —
m=64 MiB, t=3, p=4. Memory-hardness raises the cost-per-guess on GPUs and ASICs by orders of magnitude over the prior PBKDF2-SHA256 derivation. - Backward-compatible online upgrade. Files wrapped under prior PBKDF2 KEKs (600 000 and 210 000 iterations) still decrypt via
FileUpload:KeyDerivation:LegacyKekFallback=true(default). No file on disk is bricked by the upgrade. New writes always use the Argon2id-derived KEK. - Configurable KDF. Argon2id is the default;
KeyDerivation:Algorithm = "Pbkdf2"is available for FIPS-restricted environments. All Argon2id parameters and the PBKDF2 iteration count are tunable fromappsettings.json. - .NET 10. Target framework consolidated on
net10.0only. Pin1.0.x-preview.0if you need anet8.0-only build. - Packaging. Deterministic build, Source Link,
.snupkgsymbols, andREADME.md/LICENSE/SECURITY-ANALYSIS.md/KNOWN-GAPS.mdbundled inside the package itself.
The crypto posture, parameters, and honest residual risks are documented in Implementation & Crypto Posture below and in SECURITY-ANALYSIS.md.
Why this exists
File upload is one of the most consistently mishandled surfaces in web development. Most tutorials show you how to receive a file. Very few defend against:
- Polyglot files (a valid JPEG that is also a working PHP shell)
- Double-extension attacks (
photo.pdf.exe) - MIME spoofing and magic-byte forgery
- Path traversal via filename manipulation
- PDF JavaScript injection (including inside FlateDecode-compressed object streams)
- ZIP-bomb / pixel-flood attacks via image decoding
- Log poisoning via crafted filenames
- Disk exhaustion via batched uploads
- Direct web-serving of attacker-controlled bytes
This package addresses every item on that list in code, then names the gaps it does not close. The red-team review in SECURITY-ANALYSIS.md traces each claim to its source line.
The 8-layer pipeline
Every uploaded file passes through eight serial layers. Failure at any content-decision layer rejects the file. The pipeline is fail-closed on content; the single fail-open seam is virus-scanner availability (Layer 7), and that is documented, counted, and never silently relabelled as clean — see KNOWN-GAPS.md §Gap 9.
┌──────────────────────────────────────────────────────────────────────────┐
│ INCOMING FILE UPLOAD │
└──────────────────────────────┬───────────────────────────────────────────┘
│
┌───────────────────────▼────────────────────────┐
│ Layer 1 File size (per-file + batch total) │
│ Minimum size per format │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 2 Extension allowlist │
│ .jpg .jpeg .png .webp .pdf │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 3 MIME ↔ extension cross-validation │
│ Browser MIME must match extension │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 4 Magic-byte signature check │
│ JPEG / PNG / WebP fourCC / PDF │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 5 Filename inspection │
│ Double-extension, Unicode bidi, │
│ path traversal, Windows reserved │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 6 Deep content validation │
│ JPEG/PNG/WebP structural walkers, │
│ PDF byte-pattern scan, │
│ FlateDecode-compressed PDF stream │
│ decompression and re-scan │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 7 Virus scan (pluggable) │
│ Windows Defender OR ClamAV / clamd │
│ Detection fail-closed, │
│ Availability fail-open (tracked) │
└───────────────────────┬────────────────────────┘
┌───────────────────────▼────────────────────────┐
│ Layer 8 Encrypted storage │
│ AES-256-GCM envelope (v2): │
│ per-file random DEK │
│ wrapped under Argon2id-derived KEK │
│ Image recompression strips polyglot │
│ tails before encryption. │
│ Randomized filename, outside wwwroot. │
│ Path traversal re-checked before │
│ write via PathHelper.IsPathUnderBase. │
└──────────────────────────────────────────────────┘
Install
dotnet add package SecureFileUpload.Core
Requires .NET 10+ with ASP.NET Core. The package references Microsoft.AspNetCore.App as a framework reference, so nothing extra ships inside it — your runtime's existing ASP.NET Core does the heavy lifting.
If you need a net8.0 build, pin to 1.0.0 (the prior stable line) — Argon2id and net10.0 arrived in the 1.0.0-preview.2 candidates and are the default in 2.0.0.
Quick start
1. Register the services
// Program.cs
using SecureFileUpload.Services;
builder.Services.AddSecureFileUpload();
// Match this to FileUpload:MaxTotalUploadBytes in appsettings.json.
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 53_477_376; // 51 MB
});
AddSecureFileUpload() registers FileContentValidator, the platform-appropriate IVirusScanService (Windows Defender on Windows, ClamAV elsewhere), IFileUploadService, and IFileAccessTokenService in one call. The scanner backend is picked at startup; download tokens are issued through ASP.NET Core Data Protection with a short lifetime by default.
2. Receive an upload
[HttpPost]
[RequestSizeLimit(53_477_376)]
public async Task<IActionResult> Submit(MyInputModel model)
{
if (!ModelState.IsValid)
return View(model);
var result = await _fileUploadService.UploadFilesAsync(
Request.Form.Files, model.LastName, "intake");
if (!result.Success)
{
// result.Errors are user-safe; result.WorkflowOutcome is
// AllSaved | PartialSaved | AllRejected | NoFiles.
foreach (var msg in result.Errors)
ModelState.AddModelError(string.Empty, msg);
return View(model);
}
return RedirectToAction(nameof(Submitted), new { id = result.SubmissionFolder });
}
3. Serve a file safely
using SecureFileUpload.Services;
builder.Services
.AddAuthentication("Cookies")
.AddCookie("Cookies");
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("StaffFiles", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Staff");
// Add your own MFA / claim requirements here.
});
});
// SecureFileDownloadController is [Authorize] by default. Apply your stricter
// staff-only policy at the endpoint layer so the sample policy is actually used.
builder.Services.AddControllers().AddApplicationPart(typeof(SecureFileDownloadController).Assembly);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization("StaffFiles");
4. Issue an opaque download token
public sealed class StaffFilesController : Controller
{
private readonly IFileAccessTokenService _fileAccessTokenService;
public StaffFilesController(IFileAccessTokenService fileAccessTokenService)
{
_fileAccessTokenService = fileAccessTokenService;
}
public IActionResult DownloadFirst(FileUploadResult result)
{
string token = _fileAccessTokenService.CreateToken(result.UploadedFilePaths[0]);
string url = $"/staff/files/download?fileToken={Uri.EscapeDataString(token)}";
return Redirect(url);
}
}
The token is opaque, signed, and short-lived by default. Staff-facing URLs never need to expose a storage-relative path. If your application has both public and staff-only controllers, scope the RequireAuthorization("StaffFiles") call to the staff route set instead of every controller endpoint in the app.
A complete appsettings.json reference is in Configuration below.
Deployment notes
Data Protection and multi-instance deployments
AddSecureFileUpload() calls services.AddDataProtection() so that IFileAccessTokenService can sign download tokens. With the default registration, each process generates its own ephemeral key ring — fine for a single-instance app, but broken across replicas: a token issued by node A will not validate on node B, so any load-balanced staff request to /staff/files/download has a chance of returning 400 Invalid file reference.
For any deployment with more than one instance (Kubernetes, multiple App Service workers, an autoscaled VM scale set, dev → staging container, blue-green), configure Data Protection to share a key store and pin an application name before AddSecureFileUpload():
// Pick ONE persistence backend that all instances can read.
// Shared filesystem mount (Linux + ReadWriteMany PVC, Windows file share):
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys/secure-file-upload"))
.SetApplicationName("SecureFileUpload");
// — OR — Azure Blob + Key Vault (recommended on Azure):
// builder.Services.AddDataProtection()
// .PersistKeysToAzureBlobStorage(blobUri, credential)
// .ProtectKeysWithAzureKeyVault(keyIdentifier, credential)
// .SetApplicationName("SecureFileUpload");
builder.Services.AddSecureFileUpload();
SetApplicationName(...) is the gate that makes keys interchangeable across processes — without it, ASP.NET Core derives a per-content-root application discriminator and tokens still won't cross instances even with a shared key store. The string itself is not a secret; just keep it stable across deployments.
Symptom of getting this wrong: intermittent DOWNLOAD_REJECTED_BAD_TOKEN warnings in the logs, only on multi-instance environments, only for tokens issued by a different instance than the one handling the download. The single-instance happy-path keeps working, which makes it easy to ship the misconfiguration. Catch it in load-balanced staging.
Token replay window
A signed download token is reusable for its configured lifetime (FileDownload:TokenLifetimeMinutes, default 15 minutes). If a token leaks via a referrer, a screen recording, a log entry, or shared-screen support, an attacker with network access to the staff endpoint can replay it until expiry. Mitigations layered into the library and the recommended deployment:
Cache-Control: no-store, no-cache, must-revalidate, privateon every download response — no shared proxy keeps a copy.Referrer-Policy: no-referrerso the token doesn't leak to other origins.[Authorize]onSecureFileDownloadControllerplus the recommendedRequireAuthorization("StaffFiles")policy — a leaked token is useless to an unauthenticated attacker.- Short default lifetime; lower it further for high-sensitivity workflows.
There is no single-use / nonce-redemption mode in v3. If you need one, track it as a v3.x feature request — the right shape is a redemption store keyed by token hash, gated by IFileAccessTokenService.
Implementation & Crypto Posture
This section names primitives, parameters, and residual risks. It is the single source of truth for the cryptographic posture; the marketing tagline is at the top of this README.
| Aspect | Implementation | Notes |
|---|---|---|
| Symmetric encryption | AES-256-GCM via System.Security.Cryptography.AesGcm |
96-bit nonce, 128-bit auth tag — NIST SP 800-38D / RFC 5288. |
| Encryption scheme | Envelope (v2) — per-file random 256-bit DEK wrapped under a master KEK | KEK rotation rewraps DEKs without re-encrypting file payloads. |
| KEK derivation (writes) | Argon2id via Konscious.Security.Cryptography.Argon2 1.3.x |
RFC 9106; OWASP 2024+ recommendation. Memory-hard. |
| Argon2id parameters (defaults) | m=64 MiB, t=3, p=4, fixed application salt |
Above OWASP server-side minimum; targets ~250–500 ms derivation on a modern x64 core. |
| KEK derivation (decrypt fallback) | PBKDF2-SHA256, 600 000 and 210 000 iterations | Tried during decryption when LegacyKekFallback=true; never for new writes. |
| RNG | RandomNumberGenerator (CSPRNG) for DEKs, nonces, filename suffixes |
No System.Random, no Guid.NewGuid(), no DateTime.Ticks in security paths. |
| Storage format markers | ENCGCM\0\x01 (legacy single-key) / ENCGCM\0\x02 (current envelope) |
Layout: marker‖dek_nonce‖dek_tag‖wrapped_dek‖file_nonce‖file_tag‖ciphertext. |
| Buffer hygiene | Plaintext, DEK, and password buffers zeroed via CryptographicOperations.ZeroMemory |
Reduces in-memory exposure window. Not a guarantee against GC copies. |
| Startup guards | EncryptionEnabled=true + missing/placeholder secret ⇒ InvalidOperationException |
Misconfigurations fail loudly at deploy time, not silently at runtime. |
| FIPS posture | Not FIPS-validated. Argon2id is not in FIPS 140-3 ASMs as of 2026 | Opt into KeyDerivation:Algorithm = "Pbkdf2" for FIPS-only deployments. |
| TLS / transport | Not provided by this library | Enforce HSTS and HTTPS at Kestrel / reverse-proxy level. |
| At-rest device encryption | Not provided by this library | BitLocker / LUKS / dm-crypt strongly recommended on the storage volume. |
Honest limitations
- The KDF salt is in source. It is identical across deployments of this library version. The protection model assumes the secret lives in a real secrets manager (Key Vault, AWS Secrets Manager, env var injected by the platform) — never
appsettings.jsoncommitted to a repo. - Argon2id is not FIPS-validated. Compliance-bound deployments must select
Pbkdf2explicitly. - The KEK lives in process memory. A memory-disclosure or core-dump capability on the host bypasses the KDF entirely. Mitigations are deployment-level (least privilege, ASLR, sealed VMs, confidential compute).
- No HSM / KMS integration in v1. A KMS-backed KEK is tracked in
docs/hardening-roadmap.mdas the right next step for high-assurance deployments. - Argon2id parameters are CPU/RAM-bound. On a constrained container, startup derivation may take longer than the ~250–500 ms target. The library logs
KDF_ARGON2ID_DERIVED | ElapsedMs=...so this is measurable.
For the full code-traced security review, see SECURITY-ANALYSIS.md. For things this pipeline does not protect against, see KNOWN-GAPS.md.
Configuration
{
"FileUpload": {
"StorageRoot": "../uploads",
"MaxFileSizeBytes": 10485760,
"MaxFileCount": 5,
"MaxTotalUploadBytes": 52428800,
"MinStorageFreeBytes": 536870912,
"MinTempFreeBytes": 536870912,
"LowDiskWarningBytes": 2147483648,
"RecompressImages": true,
"JpegRecompressQuality": 95,
"EncryptionEnabled": false,
"EncryptionSecret": "CHANGE_THIS_TO_A_REAL_SECRET_MINIMUM_32_CHARS",
"KeyDerivation": {
"Algorithm": "Argon2id",
"Argon2id": {
"MemoryKiB": 65536,
"Iterations": 3,
"Parallelism": 4
},
"Pbkdf2": {
"Iterations": 600000
},
"LegacyKekFallback": true
}
},
"FileContent": {
"InspectCompressedPdfStreams": true,
"MaxCompressedStreamsToInspect": 64,
"MaxDecompressedStreamBytes": 16777216,
"RejectEncryptedPdfs": true,
"RejectInteractivePdfs": false,
"MaxImageWidth": 10000,
"MaxImageHeight": 10000,
"MaxImagePixels": 40000000
},
"FileDownload": {
"TokenLifetimeMinutes": 15
},
"VirusScan": {
"Enabled": false,
"WindowsDefender": {
"MpCmdRunPath": "C:\\Program Files\\Windows Defender\\MpCmdRun.exe",
"TempScanPath": "C:\\Temp\\VirusScan",
"TimeoutSeconds": 30
},
"ClamAv": {
"Host": "localhost",
"Port": 3310,
"TimeoutSeconds": 30,
"MaxStreamBytes": 26214400
}
}
}
| Setting | Purpose |
|---|---|
FileUpload:StorageRoot |
Resolved relative to ContentRootPath. Must land outside wwwroot. The service refuses to start otherwise. |
FileUpload:EncryptionSecret |
≥ 32 chars, must not contain CHANGE_THIS. Store in a secrets manager — never in checked-in config. |
FileUpload:KeyDerivation:Algorithm |
Argon2id (default) or Pbkdf2 (FIPS-restricted environments only). |
FileUpload:KeyDerivation:Argon2id:* |
Tune for your CPU/RAM budget. Library logs derivation time at startup. |
FileUpload:KeyDerivation:LegacyKekFallback |
true (default) keeps PBKDF2 fallback KEKs available for decryption only. Set false after every file has been re-wrapped. |
FileUpload:RecompressImages |
true (default) strips polyglot tails by re-encoding JPEG/PNG/WebP through ImageSharp. |
FileDownload:TokenLifetimeMinutes |
Lifetime for opaque download tokens issued by IFileAccessTokenService. Default 15 minutes; max 24 hours. |
VirusScan:Enabled |
When false, Layer 7 is bypassed. Layers 1–6 + 8 still run. |
VirusScan:ClamAv:MaxStreamBytes |
Must align with StreamMaxLength in your clamd.conf. |
Dependencies
Declared by the NuGet package — no manual installation required:
- ASP.NET Core 10+ shared framework (via
FrameworkReference) - SixLabors.ImageSharp 3.1.x — image identification and polyglot-tail recompression
- Konscious.Security.Cryptography.Argon2 1.3.x — Argon2id KEK derivation
Required at runtime if VirusScan:Enabled=true (not NuGet packages):
- Windows Defender (
MpCmdRun.exe) — Windows only, used byWindowsDefenderScanService - ClamAV (
clamdlistening on TCP) — Linux / macOS / containers, used byClamAvScanService
The scanner is selected automatically by AddSecureFileUpload() based on OperatingSystem.IsWindows().
Source layout
| File | Responsibility |
|---|---|
src/FileUploadService.cs |
Orchestrates the 8-layer pipeline. Batch limits, capacity checks, image recompression (Gap 1), envelope encryption (v2), Argon2id KEK derivation with PBKDF2 fallback, log-poisoning-safe filename handling. |
src/FileContentValidator.cs |
Layer 6 deep validation. JPEG / PNG / WebP structural walking, PDF pattern scan, FlateDecode-compressed PDF stream inspection (Gap 2). Fail-closed on unknown types. |
src/WindowsDefenderScanService.cs |
Layer 7 — Windows Defender MpCmdRun.exe. Secure-delete (zero-before-delete) of temp files. |
src/ClamAvScanService.cs |
Layer 7 — clamd over TCP using zINSTREAM. No temp file is written. Cross-platform. |
src/FileAccessTokenService.cs |
Opaque, signed, time-limited download tokens backed by ASP.NET Core Data Protection. |
src/SecureFileDownloadController.cs |
Reference hardened download surface. Tokenized file reference, Content-Disposition: attachment, strict CSP, COOP/COEP/CORP, re-checks resolved path traversal. |
src/DependencyInjection/SecureFileUploadServiceCollectionExtensions.cs |
AddSecureFileUpload() one-liner DI registration. |
src/Utilities/PathHelper.cs |
Canonicalized IsPathUnderBase — defeats the string.StartsWith("/uploads") prefix-confusion bug. |
tests/Fuzz/ |
SharpFuzz + AFL++ harness for FileContentValidator.ValidateAsync. |
tests/SmokeTest/ |
Runtime smoke test — Argon2id round-trip, v2 envelope, legacy PBKDF2 fallback, misconfig guard. Executed in CI and runnable locally with dotnet run --project tests/SmokeTest -c Release. |
Docs
SECURITY-ANALYSIS.md— full code-traced security review, with each claim pointing at source linesKNOWN-GAPS.md— honest limitations and what this does NOT protect againstdocs/threat-model.md— what attack each layer defeatsdocs/hardening-roadmap.md— recommended next steps toward the strongest realistic posturetests/attack-vectors.md— per-layer attack test casestests/Fuzz/— SharpFuzz + AFL++ harness for the deep content validator
Release process
Publishing is handled by GitHub Actions in .github/workflows/nuget-publish.yml.
- Push to
mainruns library build, solution tests, the runtime smoke harness, pack, and a fuzz-harness build — no publish. - Push a
v*tag to publish to NuGet.org. The workflow derives the package version from the tag (v2.0.0→2.0.0). --skip-duplicateis used, so re-running on an existing version is non-destructive.
git tag v2.0.0
git push origin v2.0.0
The <Version> in src/SecureFileUpload.Core.csproj is the source of truth for local packs and CI artifacts; tag builds override it for the published NuGet version.
Contributing
Issues and PRs welcome — especially:
- Unit-test coverage for the per-layer validation paths
- Additional format validators (GIF, BMP deep content validation)
- Async / queued virus-scan worker for higher-volume deployments
- KMS / HSM-backed KEK provider
If you're filing a security report, please open a private advisory on GitHub rather than a public issue.
License
MIT. Use freely. Attribution appreciated, not required.
"So whether you eat or drink or whatever you do, do it all for the glory of God." — 1 Corinthians 10:31
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- SixLabors.ImageSharp (>= 3.1.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.3 | 69 | 6/2/2026 |
| 3.0.2 | 97 | 5/30/2026 |
| 3.0.1 | 90 | 5/30/2026 |
| 3.0.0 | 91 | 5/30/2026 |
| 2.0.0 | 83 | 5/30/2026 |
| 1.0.0 | 112 | 4/21/2026 |
| 1.0.0-preview.3 | 46 | 5/30/2026 |
| 1.0.0-preview.2 | 37 | 5/30/2026 |
v3.0.1 — documentation and code-hygiene patch.
No behavioral or breaking changes. Safe in-place upgrade from 3.0.0.
• README: new "Deployment notes" section covering the multi-instance Data
Protection key-persistence requirement (SetApplicationName +
PersistKeysToFileSystem / Azure Blob / KMS-backed key store) so download
tokens validate across replicas. Without this, load-balanced staff
requests can intermittently fail with DOWNLOAD_REJECTED_BAD_TOKEN.
• README: token-replay window explicitly documented along with the
mitigations already in the library (no-store cache, Referrer-Policy,
[Authorize], short default lifetime).
• README: small markdown-rendering fixes in the "Issue a download token"
sample so the example renders cleanly on the NuGet gallery page.
• Pruned the unused SanitizeForLog helper from SecureFileDownloadController
(left over from the pre-3.0.0 path-based input — opaque tokens don't
need user-input sanitization).
• AssemblyVersion stays at 3.0.0.0 so this is a drop-in upgrade with no
binding-redirect change required.
v3.0.0 — hardened download-surface release.
BREAKING CHANGES vs. 2.0.0:
• The reference download endpoint now accepts an opaque `fileToken` instead
of a storage-relative `relativePath` query parameter. Consumers must issue
tokens via `IFileAccessTokenService.CreateToken(...)` and pass that token to
`/staff/files/download?fileToken=...`.
• `AddSecureFileUpload()` now registers `IFileAccessTokenService` and the
reference controller/docs assume tokenized download links rather than
path-based file references.
Highlights:
• Opaque, signed, time-limited download tokens backed by ASP.NET Core Data
Protection. Staff-facing URLs no longer expose storage-relative paths.
• Release validation now includes solution tests plus the runtime smoke harness
before pack/publish.
• Regression coverage expanded for download authorization, token tampering,
infected-file rejection, scanner fail-open semantics, and storage-root token
enforcement.
• Scanner outage logging now matches the actual `NotScanned` fail-open pipeline
behavior, avoiding misleading fail-closed wording in production logs.
The 8-layer upload pipeline, on-disk envelope formats, and Argon2id / PBKDF2
decryption compatibility are unchanged from 2.0.0.