SFAS is a .NET 8 REST API that acts as the single controlled access point between clients and a protected file system. Clients never touch the file system directly: on every request the service enforces, in order and before any filesystem access,
- Authentication — JWT Bearer, one identity per consumer (service-to-service),
- Authorization — per-consumer, per-area policies with default-deny and deny-wins,
- Path resolution — logical → physical with multi-layer anti path-traversal protection,
- Encrypted I/O — transparent AES-256-GCM at-rest encryption.
Every operation (including denials) is written to an audit trail, and error responses are standardized so they never leak physical paths, OS details, or crypto information.
The full requirements and functional analysis live in
FunctionalAnalysis.md.
- Architecture
- Security model
- API reference
- Getting started
- Clients
- Configuration
- Testing
- Repository structure
- License
| Project | Role |
|---|---|
src/Sfas.Core |
Domain, contracts, error model, path-pattern matcher, encrypted file format. Zero NuGet dependencies. |
src/Sfas.Infrastructure |
Anti-traversal path resolver, filesystem adapter (the only component touching System.IO on content), chunked AES-256-GCM engine, local file-based KMS, JSONL audit logger, policy evaluator. |
src/Sfas.WebApi |
ASP.NET Core 8 pipeline: correlation id → exception handler → JWT Bearer → security middleware (resolve + authorize before any FS access) → controllers → operation manager. |
src/Sfas.Client |
Shared HTTP client of the API (Bearer JWT, standard error model, logical paths only). Used by both GUI clients. |
src/Sfas.Console |
Terminal-style WinForms console for the API: typed commands, colored output, history (token, put, get, list, copy, move, …). |
src/Sfas.Manager |
WinForms file manager for the API: lazy remote directory tree, upload/download/delete, new folder, copy/move. |
tests/Sfas.Tests |
xUnit: unit tests (crypto, paths, policy) + integration tests via WebApplicationFactory (storage and keys in temp dirs). |
┌─────────────────────────────────────────────────────────────┐
│ Sfas.WebApi │
client ──Bearer► │ CorrelationIdMiddleware │
(Console / │ │ │
Manager / │ ▼ │
any HTTP) │ ExceptionHandler ──► JSON {code,message,correlationId} │
│ │ │
│ ▼ │
│ JwtBearer authentication ──► [Authorize] │
│ │ │
│ ▼ │
│ SfasSecurityMiddleware │
│ · resolve {area}/{**path} → physical (anti-traversal) │
│ · authorize (consumer policy, default-deny) │
│ · DENY → 403 + audit entry, zero filesystem side effect │
│ │ │
│ ▼ │
│ Files / Directories controllers │
│ │ │
│ ▼ │
│ FileOperationManager (orchestration + audit) │
│ │ │
└────────┼────────────────────────────────────────────────────┘
▼
LocalFileStorageAdapter ──► AesGcmEncryptionEngine ──► IKeyManagementService
(only type that touches (AEAD chunked streaming) (local KMS: KEK +
System.IO on content) wrapped, versioned keys)
- Streaming end-to-end — file content moves as
Streamfrom the HTTP body to disk and back; it is never buffered in memory (NFR-008). Uploads are capped bySfas:MaxUploadBytes. - Atomic writes (UC-05) — content is written to a temp file in the same directory, flushed, then renamed over the target; a failed write never leaves a partial file.
- Logical paths only — the API accepts and returns paths of the form
{AREA}/{**segments}; physical roots never appear in any API surface (request, response, or error body). - One filesystem seam —
LocalFileStorageAdapteris the only component that touchesSystem.IOon file content, so the security boundary has a single point to audit and swap.
Defense in depth: each layer is enforced independently, and the order is guaranteed — authentication → authorization → path validation happens before any filesystem access (spec §28). Integration tests prove that a denial has zero side effects on disk.
- JWT Bearer on every route except
/healthz(and the dev-only token endpoint). - Two modes:
Symmetric(HS256,Auth:Jwt:SigningKeyBase64) for development,Oidc(Authority) for production. - The consumer identity is read from a configured claim (
client_id, fallbacksub). - Missing/invalid/expired token →
401 AUTHENTICATION_REQUIRED/AUTHENTICATION_FAILED. - The dev token endpoint (
POST /api/v1/dev/token, Development only) issues HS256 tokens for manual testing. It does not bypass authorization: a consumer without a policy still gets403.
- Policies are configured per consumer × area as a list of
{Operation, Pattern, Effect}rules (operations:Read,Write,Delete,ListDirectory,CreateDirectory,DeleteDirectory, orAll). - Evaluation semantics:
- default-deny — an operation/area with no matching Allow rule is denied;
- deny-wins — a matching
Denyrule beats any Allow; - patterns are segment-based:
*= exactly one segment (may contain a glob, e.g.customer*),**= zero or more whole segments (matches the area root;**/*does not).
Moveauthorizes both resources (Read + Delete on the source, Write on the destination) before anything happens.- Every denial is audited with consumer, correlation id, operation, and target path.
Three independent layers:
- Segment validation on the decoded value — segments match
[A-Za-z0-9._~-], separator is/only, total length ≤ 1024. Rejected:.., absolute paths, backslashes, drive letters, null characters (including URL-encoded forms). Path.GetFullPathnormalization on the area root and the candidate path.- Separator-aware prefix check — the resolved path must equal the root or start with
root + separator, sodocumentscan never resolve intodocuments-evil. Area names are case-sensitive.
Any violation → 400 INVALID_RESOURCE, no filesystem access, no physical path in the
error body.
- AES-256-GCM (AEAD), chunked (default 1 MiB, configurable) with a fresh 12-byte nonce per chunk.
AAD = file header ‖ chunk indexbinds the header and the chunk order to the ciphertext: bit-flips, chunk reordering/insertion/deletion, and key swapping are all detected (INTEGRITY_ERRORon tag failure).- On-disk format (v1):
magic "SFAE"·formatVersion·algorithm·keyId·keyVersion·chunkSize, then per chunk:nonce (12B) ‖ ciphertext ‖ tag (16B). Plaintext is never written;sizeBytesin write responses is the at-rest size. - Unknown key or version →
DECRYPTION_ERROR.
- Keys live behind
IKeyManagementService— the local implementation is a swappable adapter (e.g. Azure Key Vault / HSM in production). - Local store (
Sfas:Kms:StorePath): a random KEK (kek.bin, created on first run) plus one key file per area (<keyId>.json) holding wrapped, versioned keys. Keys are never stored as plain app configuration and never sit next to the encrypted files they protect. - Rotation adds a new key version: new writes use the latest version, files written with v1 stay readable (the version is in the file header).
- Daily JSONL files (
logs/audit-YYYY-MM-DD.jsonl) plus structuredILogger. - Fields: timestamp, correlation id, consumer id, client IP, operation, area, logical
path, result (
Success/Denied/Error), error code, file size, duration, physical root. - Audit records never contain file content — by design.
- A fixed set of 12 machine-readable codes;
messageis always a static string. - Error bodies never contain physical paths, OS exception text, or crypto details.
X-Correlation-Id(≤ 64 chars,[A-Za-z0-9._-]) is echoed if valid, generated otherwise; it appears in the response header, the error body, and the audit entry.
| Guarantee | Where |
|---|---|
| Auth → authz → path validation before any FS access; denials have no disk side effect (§28, FR-004) | SfasSecurityMiddleware, ResourceGuard; AuthorizationTests |
| Anti path-traversal (FR-013) | LogicalPathResolver (3 layers) |
| Tamper-evident AEAD at rest (FR-014/015/017) | AesGcmEncryptionEngine |
| KMS abstraction + rotation (FR-016, §25) | LocalFileKeyManagementService |
| No-leak error model (FR-020, NFR-013) | SfasExceptionHandlerMiddleware; ErrorModelTests |
| Atomic writes (UC-05) | LocalFileStorageAdapter |
| No in-memory content buffering (NFR-008) | streaming Stream end-to-end |
| Content-free audit (§16) | JsonFileAuditLogger |
Base path /api/v1; Authorization: Bearer <JWT> required on everything except
/healthz (and /dev/token in Development). File bodies are application/octet-stream.
| Method | Route | Behavior |
|---|---|---|
| PUT | /files/{area}/{**path}?overwrite=false |
201 created / 200 replaced / 409 FILE_ALREADY_EXISTS |
| GET | /files/{area}/{**path} |
200 decrypted stream / 404 FILE_NOT_FOUND |
| HEAD | /files/{area}/{**path} |
200 + Last-Modified, X-Sfas-Encrypted; Content-Length only for unencrypted files |
| DELETE | /files/{area}/{**path} |
204 / 404 if missing (configurable via Sfas:DeleteMissingFile) |
| POST | /files/copy · /files/move |
JSON {source, destination} (logical); both resources authorized |
| GET | /directories/{area}/{**path} |
200 {entries:[{name, logicalPath, type, sizeBytes, lastModifiedUtc}]} — logical paths only |
| PUT | /directories/{area}/{**path} |
201 / 200 if it exists / 409 if it is a file |
| DELETE | /directories/{area}/{**path}?recursive=false |
204 / 400 INVALID_OPERATION if non-empty and not recursive |
| GET | /healthz |
liveness, no auth |
| POST | /api/v1/dev/token |
Development only — {consumerId, lifetimeMinutes} → {token, expiresUtc} |
Every error body: {"code", "message", "correlationId"}.
| Status | Code |
|---|---|
| 401 | AUTHENTICATION_REQUIRED, AUTHENTICATION_FAILED |
| 403 | ACCESS_DENIED |
| 400 | INVALID_RESOURCE, INVALID_OPERATION |
| 404 | FILE_NOT_FOUND |
| 409 | FILE_ALREADY_EXISTS |
| 500 | ENCRYPTION_ERROR, DECRYPTION_ERROR, INTEGRITY_ERROR, STORAGE_ERROR, INTERNAL_ERROR |
Accepted: {area}/{**path}, segments [A-Za-z0-9._~-], separator /, length ≤ 1024.
Rejected: .., absolute paths, backslashes, drive letters, null characters.
Prerequisites: .NET SDK 8.0+ (9.x works; all projects target net8.0) and, for the
GUI clients, Windows. The solution opens in Visual Studio 2022 (Sfas.sln).
dotnet build Sfas.sln -c Release
dotnet test Sfas.sln -c Release
dotnet run --project src/Sfas.WebApi --launch-profile http # http://localhost:5180# 1. healthz (no auth)
curl.exe http://localhost:5180/healthz
# 2. dev token for PORTAL-A (Development only)
curl.exe -X POST http://localhost:5180/api/v1/dev/token \
-H "Content-Type: application/json" -d "{\"consumerId\":\"PORTAL-A\"}"
# → {"token":"...","expiresUtc":"..."}
# 3. write (201) — at-rest the file starts with the "SFAE" magic, never plaintext
curl.exe -X PUT "http://localhost:5180/api/v1/files/DOCUMENTS/customer123/invoice.txt" \
-H "Authorization: Bearer <TOKEN>" --data-binary "invoice 42"
# 4. read back the identical content
curl.exe "http://localhost:5180/api/v1/files/DOCUMENTS/customer123/invoice.txt" \
-H "Authorization: Bearer <TOKEN>"
# 5. duplicate without overwrite → 409 FILE_ALREADY_EXISTS
# 6. path traversal → 400 INVALID_RESOURCE, no physical path in the body
curl.exe "http://localhost:5180/api/v1/files/DOCUMENTS/..%2f..%2fwindows%2fwin.ini" \
-H "Authorization: Bearer <TOKEN>"
# 7. policy-denied operation → 403 ACCESS_DENIED; no token → 401
# 8. correlation id echo
curl.exe -H "X-Correlation-Id: smoke-001" \
"http://localhost:5180/api/v1/files/DOCUMENTS/customer123/invoice.txt" \
-H "Authorization: Bearer <TOKEN>" -iRuntime state is created under src/Sfas.WebApi/ on first run: data/storage/<area>/
(content), data/keys/ (local KMS), logs/ (audit). All three are git-ignored.
Typed commands, colored terminal-style output, ↑/↓ history. Run with the API already up:
dotnet run --project src/Sfas.Consolesfas> health
✓ server reachable (/healthz)
sfas> token PORTAL-A
✓ token issued for PORTAL-A, expires ...
sfas> echo DOCUMENTS/customer123/memo.txt "hello" overwrite
✓ DOCUMENTS/customer123/memo.txt · 45 bytes at-rest (encrypted file) · ...
sfas> get DOCUMENTS/customer123/memo.txt
hello
sfas> list DOCUMENTS/customer123
Commands (help in-app): server <url>, health, token <consumerId> [minutes],
login <jwt>, whoami, put, echo, get [localFile] (inline if ≤ 4 KB), head,
del, copy, move, list, mkdir, rd [recursive], clear, exit.
Lazy remote directory tree, upload/download/delete, new folder, copy/move:
dotnet run --project src/Sfas.Manager- Set the consumer (e.g.
PORTAL-A) and press Token — the tree loads; root areas are configured in the Areas field (defaultDOCUMENTS;SYSTEM). - Select a directory → Upload file… (existing target asks for overwrite).
- Select a file → Download… (double-click), Delete, Copy…/Move… (logical destination path). Non-empty directories ask for recursive confirmation.
- New folder… in the selected directory; Refresh reloads the tree preserving expanded branches.
Both clients share Sfas.Client and never bypass authorization — denials arrive as
403 from the API. For encrypted areas, sizes shown are at-rest and HEAD details on
encrypted files report "n/a".
src/Sfas.WebApi/appsettings.json (read at startup — restart after policy changes):
| Section | Purpose |
|---|---|
Auth:Jwt |
Mode: Symmetric (SigningKeyBase64, dev) or Oidc (Authority, prod); Issuer, Audience, ConsumerClaim, ClockSkewSeconds |
Sfas:StorageAreas |
logical area → physical root; Encrypted: true enables at-rest AEAD |
Sfas:Kms:StorePath |
local key store (kek.bin + <area>.json with KEK-wrapped versions) |
Sfas:Encryption:ChunkSizeBytes |
AEAD chunk size (default 1 MiB) |
Sfas:MaxUploadBytes |
upload cap (default 500 MB) |
Sfas:DeleteMissingFile |
status for DELETE on a missing file (default "404") |
AuthorizationPolicies:Consumers |
per-consumer policies (default-deny, deny-wins) |
Default consumers:
- PORTAL-A —
DOCUMENTS:Read/ListDirectory/Write/CreateDirectory/DeleteDirectoryon**;Deleteon**is Denied (demonstrates deny-wins).SYSTEM:Read public/**only. - BATCH-1 —
SYSTEM:Allon**.
Pattern semantics: * matches exactly one segment (intra-segment globs allowed,
e.g. customer*); ** matches zero or more whole segments — so bare ** also covers
the area root, while **/* requires at least one segment.
Production note: the committed
SigningKeyBase64is a development shared key used by the dev token flow. In production, switchAuth:Jwt:ModetoOidc(or rotate the symmetric key) and never ship the real key in the repository.
dotnet test Sfas.sln -c Release89 xUnit tests, all green:
- Crypto — round-trip (single and multi-chunk), bit-flip tampering →
INTEGRITY_ERROR, key/version swap → controlled error, rotation (v1 files stay readable after a v2 write). - Path resolver —
..(incl. URL-encoded), absolute paths, backslashes, drive letters, unknown area,documents-evilprefix trick → allINVALID_RESOURCE. - Pattern matcher / policy evaluator — table-driven
*/**cases; deny-wins, default-deny,All. - Integration (
WebApplicationFactory) — 401/403 with standard bodies and no filesystem side effect on denials; error-model assertions (no physical root in bodies); correlation id (generated/echoed, present in header, error body, and audit); full CRUD incl. copy/move; at-rest files start with theSFAEmagic.
SFAS/
├── FunctionalAnalysis.md # formal specification (source of requirements)
├── Sfas.sln
├── Directory.Build.props # net8.0, nullable, warnings-as-errors
├── docs/banner.svg
├── src/
│ ├── Sfas.Core/ # domain, contracts, error model, matcher, file format
│ ├── Sfas.Infrastructure/ # resolver, FS adapter, AES-GCM engine, KMS, audit, policies
│ ├── Sfas.WebApi/ # ASP.NET Core pipeline, middleware, controllers
│ ├── Sfas.Client/ # shared HTTP client (Console + Manager)
│ ├── Sfas.Console/ # WinForms terminal-style console
│ └── Sfas.Manager/ # WinForms GUI file manager
└── tests/
└── Sfas.Tests/ # xUnit unit + integration tests
Runtime data (data/, logs/) is created on first run and git-ignored — nothing
sensitive (keys, encrypted content, audit logs) is ever committed.
Released under the MIT License © 2026 TheMax-Lab.