SecureFileUpload.Core 3.2.1

dotnet add package SecureFileUpload.Core --version 3.2.1
                    
NuGet\Install-Package SecureFileUpload.Core -Version 3.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SecureFileUpload.Core" Version="3.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SecureFileUpload.Core" Version="3.2.1" />
                    
Directory.Packages.props
<PackageReference Include="SecureFileUpload.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SecureFileUpload.Core --version 3.2.1
                    
#r "nuget: SecureFileUpload.Core, 3.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SecureFileUpload.Core@3.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SecureFileUpload.Core&version=3.2.1
                    
Install as a Cake Addin
#tool nuget:?package=SecureFileUpload.Core&version=3.2.1
                    
Install as a Cake Tool

SecureFileUpload.Core

The defense-in-depth file-upload pipeline from a real production library system, packaged for ASP.NET Core 8 / 9 / 10.

NuGet NuGet downloads Build Targets: net8.0 / net9.0 / net10.0 License: MIT

SecureFileUpload.Core gives ASP.NET Core a production-ready, defense-in-depth file-upload pipeline. It validates, scans, encrypts, and safely stores uploaded files behind eight serial security layers — attacker-controlled bytes never land in wwwroot, never get served back verbatim, and never sit unencrypted on disk. One AddSecureFileUpload() call wires the whole thing up.

The eight layers, in pipeline order:

  1. File size + batch limits — per-file and per-batch byte caps enforced before buffering
  2. Extension allowlist.jpg .jpeg .png .webp .pdf; everything else stops here
  3. MIME ↔ extension cross-validation — browser-reported Content-Type must match the extension
  4. Magic-number (file signature) validation — header bytes must match the claimed format
  5. Filename / path sanitization — NFKC-normalized; blocks traversal, double extensions, NTFS ADS, Unicode bidi, Windows reserved names
  6. Deep structural content validation — JPEG/PNG/WebP/GIF/BMP walkers plus a token-aware PDF scanner that inflates FlateDecode streams, under hard caps for ratio, time, and recursion depth
  7. Malware scanning — Windows Defender or ClamAV, pluggable; detection always fail-closed
  8. AES-256-GCM encrypted storage — per-file random DEK wrapped under an Argon2id-derived master KEK, randomized filename, outside the web root

Reads go back out through a hardened reference download surface that takes opaque, signed, time-limited tokens instead of storage paths — optionally bound to the issuing user.

The pipeline is lifted from the document-intake workflow of a live public-library patron-registration system, de-branded and hardened for general use. 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.

This package is independent of the PostQuantum.* family. It is a classical (non-PQC) security library: AES-256-GCM at rest, Argon2id for the master KEK, no post-quantum asymmetric layer. See SECURITY.md → Crypto classification for the explicit posture statement.

"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.2.1

3.2.1 is a documentation patch. No code, IL, or on-disk format change; AssemblyVersion stays at 3.2.0.0, so it is a drop-in upgrade from 3.2.0.

  • The PDF object-stream config key was documented wrong in 3.2.0. The correct key is FileContent:RejectPdfObjectStreams, not FileUpload:ContentValidation:RejectPdfObjectStreamsAddSecureFileUpload() binds FileContentValidatorOptions to the top-level "FileContent" section, so every property on that class lives under that prefix. If you set the previously-documented key, it was silently ignored and the default (false) applied. That default is the recommended value, so nothing was weakened — but an operator deliberately opting in to rejecting object streams was not getting it.
  • Documentation links on the NuGet gallery now work. The packaged README used relative markdown paths, which the gallery does not resolve against the source repository — every doc link on the package page was dead. All of them are now absolute GitHub URLs.
  • The packaged README was two releases stale (its newest section was 3.0.3) and carried the pre-3.2.0 image dimension caps in the sample appsettings block. Release notes, config sample, and settings table are current; see CHANGELOG.md for the full list.

What's New in 3.2.0

3.2.0 makes the deep content validator token-aware and fixes the false rejections that a substring-matching PDF scanner produces on real-world files. No public API break: FileContentValidatorOptions gains one property, and three defaults change — all revertible by config.

This release back-ports the validator rework from the production intake application this package was lifted from and merges it with the compressed-stream scanner that only existed here. Neither side had both.

  • PDF tokens are matched by extracting real name objects and decoding #xx hex escapes. /J#53 no longer slips past the /JS check, and /JSON no longer trips it. The same lexer now drives the FlateDecode scanner, so /JavaScript or /Launch declared inside a compressed object stream is found by name extraction rather than raw substring — hex-escaped spellings inside object streams are inspected for the first time.
  • StripPdfStreamPayloads treats stream as a lexical token. A plain IndexOf matched inside comments, literal strings, and names such as /upstream, which discarded the rest of the document and hid every dangerous token that followed. Similarly, FindPdfToken no longer treats ( and % as string/comment markers while scanning raw stream bytes — that mis-parse swallowed the real endstream and structurally rejected any PDF with an indirect /Length (Ghostscript, MFP firmware, PDF/A converters).
  • FileContent:RejectPdfObjectStreams defaults to false. Object streams are standard in PDF 1.5+ — Word, Acrobat, browser "Print to PDF", and every mainstream phone scanner app emit them — so refusing them outright refused ordinary documents. Their contents are now inflated and name-scanned instead, a net increase in coverage. Operators with a controlled producer set can flip it to true.
  • Post-EOI JPEG data is scanned but no longer rejected for failing to match a recognized container. The old allowlist refused MPF second images (Samsung, Sony, Fujifilm), Samsung Motion Photo SEF footers, and alignment padding — the most common trailers in the wild. Detection now comes from scanning those bytes, and JPEG end-of-image is located by walking segments and the entropy-coded scan, which is what makes post-EOI data findable at all.
  • Image dimension caps raised to 30 000 px / 300 MP (from 10 000 px / 40 MP). These guard against decompression pixel bombs only; the previous values false-rejected ordinary 48–200 MP smartphone photos.
  • Image text-threat scans are confined to metadata and comment segments (JPEG APPn/COM, PNG tEXt/iTXt/zTXt, WebP EXIF/XMP, GIF comment/application) instead of running across entropy-coded pixel data, so coincidental byte patterns in photo data no longer false-reject. Regions are delimited so a pattern cannot be synthesized across two unrelated segments. Compressed PNG text chunks (zTXt, flagged iTXt) are inflated before scanning, under per-chunk and total output bounds. Ambiguous PHP short-open-tag patterns are gone, ending false JPEG-EmbeddedShell rejections of legitimate phone photos.
  • Atomic upload writes. Files are written to a temporary sibling and moved into place, so an interrupted write never leaves a partial file at the destination; success is logged only after the move. GetDecryptedFileStreamAsync now normalizes the path, enforces containment under the storage root, and rejects reparse points in every path component.
  • VirusScanOutcome.NotScanned splits into Disabled and Unavailable so "no scanner configured" and "configured scanner failed" are distinguishable in logs. Both remain fail-open and both count toward ScanNotScannedCount. Scan counters increment only after storage succeeds, so a file that is scanned and then fails to write is no longer reported as stored.
  • SixLabors.ImageSharp pinned to [3.1.11,4.0.0). ImageSharp moved to a commercial Six Labors license at v4 — the build fails without a paid license key, and shipping it would push that obligation onto every consumer. The range still admits 3.1.x security patches, which matters because ImageSharp is fed attacker-controlled bytes by design.
  • Threat scans iterate every occurrence of a pattern instead of stopping at the first (a short decoy failing the printable-run gate used to abandon the pattern entirely), and cancellation is checked before deep validation for every file type, not only PDFs carrying a compressed stream.
  • No change to the 8-layer pipeline order, the on-disk envelope formats, the Argon2id KEK derivation, or the legacy PBKDF2 decrypt fallback.

What's New in 3.1.0

3.1.0 closes the sanitization fallback described in KNOWN-GAPS.md Gap 1 and adds cryptographic user-binding for download tokens. No public API break — FileAccessTokenService gains one optional, DI-resolved constructor parameter, so existing call sites compile unchanged. One deliberate behavioral change, revertible by config.

  • Image recompression now fails closed. Previously, if the sanitizing re-encode failed, the pipeline logged a warning and stored the original validated bytes. But a file whose header parses while its pixel data fails a full decode is exactly the shape of a crafted polyglot, so that fallback silently defeated the mitigation and kept any appended tail on disk. The upload is now rejected with SECURITY_EVENT | FILE_SAVE_BLOCKED_SANITIZATION. Set FileUpload:RejectOnRecompressFailure=false to restore the old behavior.
  • Download tokens can be cryptographically bound to the issuing user. FileDownload:BindTokensToUser (default false). When enabled, the authenticated identity (ClaimTypes.NameIdentifier, falling back to Identity.Name) is folded into the Data Protection purpose chain at creation — a token replayed by a different account fails cryptographic verification, not merely a policy check. Issuing a token on an unauthenticated request throws, and startup fails fast if the flag is on with no IHttpContextAccessor. AddSecureFileUpload() now calls AddHttpContextAccessor().
  • User-facing error strings no longer echo HTML-active characters. SanitizeForLog neutralizes <, >, ", '?. Its output is embedded in FileUploadResult.Errors; a consumer rendering those without encoding could previously be handed markup from a filename like <svg onload=…>.jpg.
  • Two scanner misconfigurations that silently disabled scanning are fixed. The Windows Defender timeout now clamps with a 1-second lower bound (matching ClamAV) — a configured 0 previously made every scan time out instantly, silently disabling scanning under the fail-open availability default. ClamAV MaxStreamBytes ≤ 0 now falls back to the 25 MiB default with a warning instead of failing every scan.
  • Supply chain. GitHub Actions pinned to commit SHAs in both workflows (tag pinning is mutable); Dependabot enabled for the nuget and github-actions ecosystems so ImageSharp advisories open PRs automatically.
  • HardeningV310Tests cover the fail-closed and fallback recompression paths, a valid-image round-trip (no over-rejection), HTML-neutralized error output, and the full token-binding matrix: same-user resolve, cross-user replay rejection, anonymous issuance refusal, unbound default round-trip.
  • No change to the 8-layer pipeline order, on-disk envelope formats, Argon2id KEK derivation, PBKDF2 legacy decrypt fallback, or the plaintext/DEK zeroing discipline.

What's New in 3.0.3

3.0.3 is a defense-in-depth hardening patch. No public API break.

  • Filename validation is NFKC-normalized. A fullwidth .. (U+FF0E ×2) can no longer pretend not to be ..; fullwidth reserved names (CON.pdf) and fullwidth-disguised double-extensions (evil.exe.pdf) are caught alongside their literal forms. Trailing dot and trailing space are rejected before Windows path resolution strips them. Hard 255-character length cap. Legitimate non-ASCII filenames (accented Latin, CJK, Cyrillic, Greek, etc.) pass through unchanged — NFKC is identity on those.
  • PDF deep validation gains hard caps against decompression bombs and polyglots. Per-stream decompression-ratio cap (default 200×), per-file wall-clock timeout (default 2 000 ms), bounded nested-stream recursion depth (default 2 for /ObjStm), and full CancellationToken propagation through the FlateDecode walker.
  • AV availability mode is now configurable. VirusScan:FailClosedOnUnavailable (default false = prior fail-open behavior). Set true to reject the upload when the scanner cannot give a verdict. Detection mode is always fail-closed regardless of this flag. A uniform VIRUS_SCAN_SKIPPED security event fires in both modes — operators alert on a single signal.
  • Crypto classification is now explicit. At-rest encryption is classical AES-256-GCM (quantum-tolerant by key size for confidentiality, but no PQ asymmetric layer). New SECURITY.md states the posture and the deliberate separation from the PostQuantum.* family.
  • HardeningRegressionTests adds 25 cases covering filename evasions and legitimate-Unicode acceptance, decompression-bomb rejection inside the time budget, nested FlateDecode recursion, cancellation propagation, fail-closed AV mode, concurrent encrypted uploads, and PathHelper.IsPathUnderBase encoded-separator resistance. Fuzz harness gains a triage assertion that treats any Allowed verdict on a curated seed under tests/Fuzz/seeds/ as a finding.
  • No change to the 8-layer pipeline order, the v2 envelope encryption format (ENCGCM\0\x02), the Argon2id KEK derivation, the PBKDF2 legacy decrypt fallback, or the plaintext / DEK / KDF-input zeroing discipline. Smoke harness still 18/18 green.

Earlier releases

Full detail for every release is in CHANGELOG.md.

  • 3.0.2 — restored multi-targeting for net8.0 / net9.0 / net10.0. Narrowing to net10.0-only in 3.0.0 forced consumers on supported LTS/STS runtimes to pin to 2.x or upgrade their host before taking any 3.x hardening. Same source, no conditional compilation — the pipeline already compiled against the .NET 8 BCL.
  • 3.0.1 — documentation and code-hygiene patch: multi-instance Data Protection key-persistence guidance, explicit token-replay window documentation.
  • 3.0.0 — hardened download surface. Breaking: the reference endpoint takes an opaque fileToken from IFileAccessTokenService instead of a storage-relative relativePath; update that integration before upgrading. Release validation became an actual CI gate (solution tests plus runtime smoke harness before pack/publish), and scanner-outage logs were corrected to reflect the real fail-open NotScanned behavior.
  • 2.0.0 — first stable release of the modernized line, and the origin of the current crypto posture. Argon2id (RFC 9106, OWASP 2024+) replaced PBKDF2-SHA256 for master-KEK derivation with memory-hard defaults m=64 MiB, t=3, p=4. Files wrapped under prior PBKDF2 KEKs (600 000 and 210 000 iterations) still decrypt via FileUpload:KeyDerivation:LegacyKekFallback=true (default), so no file on disk was bricked; new writes always use the Argon2id KEK. KeyDerivation:Algorithm = "Pbkdf2" remains available for FIPS-restricted environments. Deterministic build, Source Link, and .snupkg symbols landed here.

The crypto posture, parameters, and honest residual risks are documented in Implementation & Crypto Posture below and in SECURITY-ANALYSIS.md.


What this is

Most ASP.NET Core upload examples show you how to receive a file. This package is the production version of an intake workflow that has accepted thousands of patron documents — driver's licenses, utility bills, library replacement-card paperwork — under real-world adversarial conditions. The eight layers exist because each one caught something in production:

  • Polyglot files (a valid JPEG that is also a working PHP shell)
  • Double-extension attacks (photo.pdf.exe, including fullwidth-Unicode disguises after NFKC normalization)
  • MIME spoofing and magic-byte forgery
  • Path traversal via filename manipulation, NTFS alternate data streams, and Windows reserved device names
  • PDF JavaScript injection (including inside FlateDecode-compressed object streams, with hard caps on stream count, decompressed bytes, decompression ratio, and wall-clock time)
  • 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. You do not need to care about cryptography to adopt the upload pipeline — crypto details are in §Implementation & Crypto Posture below; the validation pipeline runs identically whether or not you read that section.


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. │
       └──────────────────────────────────────────────────┘

What each layer actually does

For an ASP.NET developer adopting this pipeline without any cryptography interest, here is the one-paragraph "what does this defend against" view of each layer. Crypto details remain in §Implementation & Crypto Posture below — the pipeline runs identically whether or not you read that section.

Layer Defends against One-line summary
1 — Size + batch Disk-exhaustion DoS, oversized scans Per-file and per-batch byte caps enforced before any buffering.
2 — Extension allowlist Wrong-format uploads, executables-by-name Only .jpg .jpeg .png .webp .pdf proceed. Everything else stops here.
3 — MIME ↔ extension cross-check MIME-spoofing toolchains Browser-reported Content-Type must match the extension. Mismatch rejects.
4 — Magic-byte signature Renamed .exe → .pdf and disguised payloads Header bytes must match the format; known-dangerous headers (PE/ELF/Mach-O/OLE/PHP/script) are named in the log message so an operator can spot the disguise.
5 — Filename inspection Path traversal, NTFS alternate data streams, Unicode bidi/zero-width tricks, double-extension (photo.pdf.exe), Windows reserved device names (CON, PRN, NUL, COM1-9, LPT1-9), trailing dots/spaces, control characters, fullwidth-Unicode disguises NFKC-normalized scan so a fullwidth .. cannot pretend not to be ..; 255-character length cap; trailing . or space is rejected before Windows path resolution strips it.
6 — Deep content validation Polyglot files, PDF JavaScript / /Launch / /EmbeddedFile / JBIG2, embedded executables, ZIP-bomb image dimensions, PHP shells in image metadata Format-specific structural walkers (JPEG segments, PNG chunks, WebP RIFF tree, GIF blocks, BMP DIB) plus a FlateDecode-compressed PDF stream scanner with hard caps on stream count, total decompressed bytes, per-stream decompression ratio, per-file wall-clock time, and recursion depth for nested compressed object streams. Fail-closed on any unknown type or exception.
7 — Virus scan Known-bad signatures the prior layers can't fingerprint Windows Defender (MpCmdRun.exe) on Windows, ClamAV (clamd over TCP) elsewhere. Detection is always fail-closed; availability is a configured operator choice — see §AV failure mode below.
8 — Encrypted storage At-rest disclosure if the storage volume leaks or is exfiltrated AES-256-GCM envelope (v2): per-file random 256-bit DEK wrapped under an Argon2id-derived master KEK. Image recompression strips polyglot tails before encryption. Randomized filename outside wwwroot. Final PathHelper.IsPathUnderBase re-check at write time. Plaintext, DEK, and KDF-input buffers zeroed in finally blocks.

AV failure mode: fail-open vs. fail-closed (explicit operator choice)

When the virus scanner is unreachable (clamd down, MpCmdRun.exe missing, timeout, parser error, exception), the pipeline must pick one of two behaviors. As of 3.0.3 this is an explicit configuration option:

  • Fail-open on availabilityVirusScan:FailClosedOnUnavailable=false (default; matches prior behavior). The file is accepted and recorded as Unavailable (or Disabled when no scanner is configured — the two were a single NotScanned outcome before 3.2.0), counted in FileUploadResult.ScanNotScannedCount, and a single VIRUS_SCAN_SKIPPED security event is emitted with Reason=ScannerUnavailable so operators can alert on a non-zero count per window. This is the trade-off the original library deployment made: a Defender hiccup must not block patrons from registering for a library card.
  • Fail-closed on availabilityVirusScan:FailClosedOnUnavailable=true. Scanner unavailability rejects the upload with a clear scanner unavailable workflow error. The same VIRUS_SCAN_SKIPPED metric is still emitted so operations can alert identically in either mode.

Detection (an Infected verdict from a reachable scanner) is always fail-closed, regardless of this setting. The knob only controls what happens when the scanner cannot give a verdict at all. Pick the mode that matches your environment's risk tolerance; do not pick by default.


Install

dotnet add package SecureFileUpload.Core

Multi-targets net8.0, net9.0, and net10.0 — same source, same crypto posture, on every currently-supported .NET runtime. 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.

Pick the TFM that matches your host:

Host runtime NuGet picks Notes
.NET 8 (LTS, in support) net8.0 build Same FileUploadService / FileContentValidator source.
.NET 9 (STS, in support) net9.0 build Same source.
.NET 10 (LTS, current) net10.0 build Same source.

Upgrading from any 3.x? No public API break anywhere in the line, and the on-disk envelope formats (ENCGCM\0\x01 / ENCGCM\0\x02) are byte-for-byte compatible throughout — no re-wrap, no migration. AssemblyVersion tracks the package version from 3.1.0 onward (it was held at 3.0.0.0 across the 3.0.x patches), so coming from 3.0.x on a framework that uses binding redirects, refresh them. Two behavioral changes are worth knowing about, both revertible by config: image recompression fails closed as of 3.1.0 (FileUpload:RejectOnRecompressFailure, default true), and 3.2.0 accepts PDF object streams and larger image dimensions rather than rejecting them (RejectPdfObjectStreams, MaxImageWidth/Height/Pixels). If you are coming from 2.x, the 3.0.0 download-endpoint change is breaking — see Earlier releases above.


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, private on every download response — no shared proxy keeps a copy.
  • Referrer-Policy: no-referrer so the token doesn't leak to other origins.
  • [Authorize] on SecureFileDownloadController plus the recommended RequireAuthorization("StaffFiles") policy — a leaked token is useless to an unauthenticated attacker.
  • Short default lifetime; lower it further for high-sensitivity workflows.
  • User binding (3.1.0, opt-in): set FileDownload:BindTokensToUser=true and the authenticated user's identity (ClaimTypes.NameIdentifier, falling back to Identity.Name) is folded into the Data Protection purpose chain at token creation. The token then only unprotects for a request carrying the same identity — a token replayed by any other account fails cryptographic verification, not just a policy check. Issuing a token on an unauthenticated request throws. Requires IHttpContextAccessor, which AddSecureFileUpload() registers for you. Tokens issued before enabling the flag stop validating (they are unbound) — a non-issue in practice given the 15-minute default lifetime.

With user binding enabled, the replay window shrinks to "the same authenticated account within the token lifetime." There is still 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
Crypto classification Classical, not post-quantum. AES-256-GCM provides quantum-resistant confidentiality by key size (Grover's algorithm halves the effective key size to 128 bits, which remains comfortable). There is no PQ asymmetric layer — no ML-KEM, no ML-DSA, no asymmetric primitives at all. This package is intentionally separate from the PostQuantum.* family. No PQC migration path is planned for v3.x. See SECURITY.md for the explicit posture.
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.json committed to a repo.
  • Argon2id is not FIPS-validated. Compliance-bound deployments must select Pbkdf2 explicitly.
  • 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.md as 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,
    "RejectOnRecompressFailure": true,
    "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,
    "MaxDecompressionRatio": 200,
    "MaxPdfStreamScanMilliseconds": 2000,
    "RejectEncryptedPdfs": true,
    "RejectInteractivePdfs": false,
    "RejectPdfObjectStreams": false,
    "MaxImageWidth": 30000,
    "MaxImageHeight": 30000,
    "MaxImagePixels": 300000000
  },
  "FileDownload": {
    "TokenLifetimeMinutes": 15,
    "BindTokensToUser": false
  },
  "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.
FileUpload:RejectOnRecompressFailure true (default, 3.1.0) rejects the upload when the sanitizing re-encode fails — a header that parses but a decode that fails is the shape of a crafted polyglot. Set false to restore the pre-3.1.0 store-original-bytes fallback.
FileDownload:TokenLifetimeMinutes Lifetime for opaque download tokens issued by IFileAccessTokenService. Default 15 minutes; max 24 hours.
FileDownload:BindTokensToUser false (default). When true, download tokens are cryptographically bound to the issuing authenticated user; replay from any other account fails verification.
FileContent:RejectPdfObjectStreams false (default, 3.2.0). Object streams are standard in PDF 1.5+, so they are accepted and their inflated contents name-scanned. Set true only with a controlled PDF producer set — expect false rejections of ordinary phone-scanner and Office output.
FileContent:InspectCompressedPdfStreams true (default). Inflates FlateDecode stream payloads and re-runs name extraction plus the pattern scan. This is what makes RejectPdfObjectStreams=false safe.
FileContent:MaxDecompressionRatio / MaxPdfStreamScanMilliseconds Decompression-bomb guards: per-stream inflate ratio (default 200×) and per-file wall-clock budget for the stream scan (default 2000 ms).
FileContent:MaxImageWidth / MaxImageHeight / MaxImagePixels Pixel-bomb guards only (defaults 30000 / 30000 / 300000000, raised in 3.2.0). FileUpload:MaxFileSizeBytes remains the primary size guard.
FileContent:RejectEncryptedPdfs / RejectInteractivePdfs Reject /Encrypt PDFs (true by default — their contents cannot be scanned) and /AcroForm PDFs (false by default).
VirusScan:Enabled When false, Layer 7 is bypassed. Layers 1–6 + 8 still run.
VirusScan:FailClosedOnUnavailable false (default) accepts the file as Unavailable/NotScanned when the scanner cannot give a verdict; true rejects it. Detection is always fail-closed regardless.
VirusScan:ClamAv:MaxStreamBytes Must align with StreamMaxLength in your clamd.conf.

Dependencies

Declared by the NuGet package — no manual installation required:

Required at runtime if VirusScan:Enabled=true (not NuGet packages):

  • Windows Defender (MpCmdRun.exe) — Windows only, used by WindowsDefenderScanService
  • ClamAV (clamd listening on TCP) — Linux / macOS / containers, used by ClamAvScanService

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


Release process

Publishing is handled by GitHub Actions in .github/workflows/nuget-publish.yml.

  • Push to main runs 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.02.0.0).
  • --skip-duplicate is 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 Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.2.1 95 7/29/2026
3.2.0 94 7/27/2026
3.1.0 117 7/3/2026
3.0.3 120 6/2/2026
3.0.2 117 5/30/2026
3.0.1 116 5/30/2026
3.0.0 112 5/30/2026
2.0.0 115 5/30/2026
1.0.0 128 4/21/2026
1.0.0-preview.3 61 5/30/2026
1.0.0-preview.2 52 5/30/2026

v3.2.1 — documentation patch. No code, IL, or on-disk format change.

AssemblyVersion stays at 3.2.0.0 — drop-in upgrade from 3.2.0.

 • CORRECTED CONFIG KEY: the 3.2.0 notes and CHANGELOG documented the new
   PDF object-stream switch as FileUpload:ContentValidation:RejectPdfObjectStreams.
   The real key is FileContent:RejectPdfObjectStreams — AddSecureFileUpload()
   binds FileContentValidatorOptions to the top-level "FileContent" section, so
   every property on that options class is configured under that prefix. If you
   set the documented key on 3.2.0, it silently did nothing and the default
   (false) applied. The default is the recommended value, so no behavior was
   lost unless you were deliberately opting IN to rejecting object streams.
 • README documentation links are now absolute GitHub URLs. Relative markdown
   paths (KNOWN-GAPS.md, SECURITY-ANALYSIS.md, docs/*, in-page anchors) do not
   resolve on the NuGet gallery, so every doc link on the package page was dead.
 • README now documents the 3.1.0 and 3.2.0 releases, which were missing from
   the packaged copy, and corrects stale FileContent defaults in the sample
   appsettings block (image dimension caps were still shown as the pre-3.2.0
   10000 px / 40 MP values).

v3.2.0 — token-aware PDF lexing, real-world false-rejection fixes.

No public API break. FileContentValidatorOptions gains RejectPdfObjectStreams;
three defaults change, all revertible by config.

This release merges the deep content validator rework from the production
intake application this package was lifted from with the compressed-stream
scanner that only existed here — neither side had both.

 • PDF tokens are matched by extracting real name objects and decoding #xx
   hex escapes, closing the /J#53 spelling bypass and removing /JSON false
   positives. StripPdfStreamPayloads now treats "stream" as a lexical token
   rather than a substring: a plain IndexOf matched inside comments, literal
   strings, and names like /upstream, discarding the rest of the document
   and hiding every dangerous token that followed.
 • The FlateDecode compressed-stream scanner is driven by that same lexer
   instead of its own substring search, and identifies threats by name
   extraction. Object-stream interiors are now inspected with hex-escape
   decoding for the first time.
 • FileContent:RejectPdfObjectStreams defaults to FALSE. Object streams are standard in
   PDF 1.5+, so rejecting them refused output from Word, Acrobat, browser
   print, and every mainstream phone scanner app. Coverage does not regress
   — their contents are now inflated and name-scanned.
 • Post-EOI JPEG data is scanned but no longer rejected for failing to match
   a recognized container; the old allowlist refused MPF second images,
   Samsung Motion Photo SEF footers, and alignment padding.
 • Image dimension caps raised to 30000 px / 300 MP. The previous values
   false-rejected ordinary 48–200 MP smartphone photos. These guard against
   decompression pixel bombs only.
 • Image text threat scans are confined to metadata and comment segments,
   so coincidental byte patterns in entropy-coded pixel data no longer
   false-reject legitimate photos. Compressed PNG text chunks (zTXt, iTXt)
   are inflated before scanning, with output bounds.
 • Uploads are written to a temp file and moved into place, so an
   interrupted write leaves no partial file. GetDecryptedFileStreamAsync
   enforces storage-root containment and rejects reparse points in any path
   component. Virus scan outcomes distinguish Disabled from Unavailable.
 • SixLabors.ImageSharp pinned to [3.1.11,4.0.0): ImageSharp moved to a
   commercial license at v4, which would push that obligation onto every
   consumer. The range still admits 3.1.x security patches.
 • No change to the 8-layer pipeline order, on-disk envelope formats,
   Argon2id KEK derivation, or the legacy PBKDF2 decrypt fallback.

v3.1.0 — fail-closed sanitization, user-bound download tokens, config guards.

No public API break — one optional constructor parameter added to
FileAccessTokenService (DI-resolved; existing call sites compile unchanged).
One deliberate behavioral change, revertible by config:

 • Image recompression now FAILS CLOSED (Gap 1). If the sanitizing
   re-encode fails, the upload is rejected instead of falling back to the
   original bytes — a header that parses but a decode that fails is the
   exact shape of a crafted polyglot, and the old fallback silently kept
   any appended tail on disk. Restore the pre-3.1.0 behavior with
   FileUpload:RejectOnRecompressFailure=false.
 • Download tokens can be cryptographically bound to the issuing user:
   FileDownload:BindTokensToUser (default false). When enabled, the
   authenticated user's identity is folded into the Data Protection
   purpose chain — a token replayed by a different account fails
   verification, and issuing a token on an unauthenticated request
   throws. AddSecureFileUpload() now registers IHttpContextAccessor.
 • User-facing error strings no longer echo HTML-active characters
   (<, >, quotes) from attacker-controlled filenames — SanitizeForLog
   neutralizes them so a consumer rendering FileUploadResult.Errors
   without encoding cannot be handed markup.
 • Windows Defender scan timeout now clamps with a lower bound of 1s
   (matching ClamAV). A configured 0 previously made every scan time out
   instantly — silently disabling scanning under fail-open availability.
 • ClamAV MaxStreamBytes ≤ 0 now falls back to the 25 MiB default with a
   warning instead of failing every scan.
 • Supply chain: GitHub Actions pinned to commit SHAs; Dependabot enabled
   for nuget + github-actions ecosystems.
 • New HardeningV310Tests cover fail-closed/fallback recompression, valid
   image round-trip, HTML-neutralized error output, and the token-binding
   matrix (same-user resolve, cross-user replay rejection, anonymous
   issuance refusal, unbound default round-trip).
 • No change to the 8-layer pipeline order, on-disk envelope formats,
   Argon2id KEK derivation, or legacy PBKDF2 decrypt fallback.

v3.0.3 — defense-in-depth hardening (backward-compatible patch).

No public API break. AssemblyVersion stays at 3.0.0.0 so this is a drop-in
upgrade from any 3.0.x line.

 • Filename validation now NFKC-normalizes before all checks, defeating
   fullwidth-Unicode disguises (U+FF0E '.' → '.'), fullwidth reserved
   device names (CON.pdf), and fullwidth-disguised double-extensions.
   Trailing dots and spaces are rejected (Windows path-strip evasion),
   and a 255-character length cap is enforced. Legitimate non-ASCII
   filenames (accented Latin, CJK, Cyrillic, Greek, etc.) pass through
   unchanged — NFKC is identity on those.
 • PDF deep validation gains hard caps against decompression-bomb and
   polyglot adversarial inputs: per-stream decompression-ratio cap
   (default 200x), per-file wall-clock timeout (default 2000 ms),
   bounded nested-stream recursion depth (default 2 for /ObjStm), and
   full CancellationToken propagation through the FlateDecode walker.
 • Virus-scan availability mode is now an explicit configuration option:
   VirusScan:FailClosedOnUnavailable (default false = prior fail-open
   behavior; set true to reject the upload when the scanner cannot give
   a verdict). Detection mode is always fail-closed regardless of this
   flag. A uniform VIRUS_SCAN_SKIPPED security event is emitted in both
   modes so operators can alert on a single signal.
 • Crypto classification is now explicit: at-rest encryption is classical
   AES-256-GCM (quantum-tolerant by key size for confidentiality, but no
   PQ asymmetric layer). New SECURITY.md states the posture and the
   deliberate separation from the PostQuantum.* family.
 • New HardeningRegressionTests cover the filename evasion matrix (with
   positive coverage for legitimate accented/CJK/Cyrillic/Greek names),
   decompression-bomb rejection within the time budget, nested
   FlateDecode recursion, cancellation propagation, fail-closed AV mode,
   concurrent encrypted uploads, and PathHelper.IsPathUnderBase
   resistance to encoded separators. Fuzz harness gains a triage
   assertion that treats any "Allowed" verdict on a curated seed under
   tests/Fuzz/seeds/ as a finding.
 • No change to the 8-layer pipeline order, the v2 envelope encryption
   format, the Argon2id KEK derivation, the PBKDF2 legacy decrypt
   fallback, or the plaintext/DEK/KDF-input zeroing discipline.

v3.0.2 — multi-targeting restored (net8.0; net9.0; net10.0).

No behavioral, on-disk-format, or crypto-posture changes from 3.0.1. The
3.0.0 / 3.0.1 packages narrowed the target framework to net10.0 only,
which forced consumers on currently-supported .NET 8 LTS or .NET 9 STS
runtimes to either pin to the 2.x line or upgrade their host. This
release re-publishes the same source as a multi-targeted package so any
supported .NET runtime can take 3.x.

 • TargetFrameworks: net8.0;net9.0;net10.0.
 • No conditional compilation needed — the entire pipeline already
   compiled against the .NET 8 BCL (AesGcm two-arg constructor,
   Rfc2898DeriveBytes.Pbkdf2 static, Encoding.Latin1, UTF-8 string
   literals, Konscious Argon2id).
 • AssemblyVersion stays at 3.0.0.0 so this is a drop-in upgrade with
   no binding-redirect change required.
 • README, install instructions, and badges updated to reflect the
   restored multi-target support.

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.