Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SFAS — Secure File Access Service

SFAS — Secure File Access Service

License: MIT .NET 8 Tests: 89 xUnit

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,

  1. Authentication — JWT Bearer, one identity per consumer (service-to-service),
  2. Authorization — per-consumer, per-area policies with default-deny and deny-wins,
  3. Path resolution — logical → physical with multi-layer anti path-traversal protection,
  4. 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.


Contents


Architecture

Solution layout

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).

Request pipeline

                 ┌─────────────────────────────────────────────────────────────┐
                 │                       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)

Key design decisions

  • Streaming end-to-end — file content moves as Stream from the HTTP body to disk and back; it is never buffered in memory (NFR-008). Uploads are capped by Sfas: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 seamLocalFileStorageAdapter is the only component that touches System.IO on file content, so the security boundary has a single point to audit and swap.

Security model

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.

1. Authentication

  • 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, fallback sub).
  • 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 gets 403.

2. Authorization

  • Policies are configured per consumer × area as a list of {Operation, Pattern, Effect} rules (operations: Read, Write, Delete, ListDirectory, CreateDirectory, DeleteDirectory, or All).
  • Evaluation semantics:
    • default-deny — an operation/area with no matching Allow rule is denied;
    • deny-wins — a matching Deny rule 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).
  • Move authorizes 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.

3. Path resolution & anti-traversal (FR-013)

Three independent layers:

  1. 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).
  2. Path.GetFullPath normalization on the area root and the candidate path.
  3. Separator-aware prefix check — the resolved path must equal the root or start with root + separator, so documents can never resolve into documents-evil. Area names are case-sensitive.

Any violation → 400 INVALID_RESOURCE, no filesystem access, no physical path in the error body.

4. Encryption at rest (FR-014/015/017)

  • AES-256-GCM (AEAD), chunked (default 1 MiB, configurable) with a fresh 12-byte nonce per chunk.
  • AAD = file header ‖ chunk index binds the header and the chunk order to the ciphertext: bit-flips, chunk reordering/insertion/deletion, and key swapping are all detected (INTEGRITY_ERROR on 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; sizeBytes in write responses is the at-rest size.
  • Unknown key or version → DECRYPTION_ERROR.

5. Key management (FR-016, §25)

  • 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).

6. Audit

  • Daily JSONL files (logs/audit-YYYY-MM-DD.jsonl) plus structured ILogger.
  • 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.

7. Error model without leaks (FR-020, NFR-013, §29)

  • A fixed set of 12 machine-readable codes; message is 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.

8. Security guarantees & spec coverage

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

API reference

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}

Error model

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

Path rules

Accepted: {area}/{**path}, segments [A-Za-z0-9._~-], separator /, length ≤ 1024. Rejected: .., absolute paths, backslashes, drive letters, null characters.


Getting started

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

Quick smoke test

# 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>" -i

Runtime 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.


Clients

Sfas.Console — terminal-style console (WinForms)

Typed commands, colored terminal-style output, ↑/↓ history. Run with the API already up:

dotnet run --project src/Sfas.Console
sfas> 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.

Sfas.Manager — GUI file manager (WinForms)

Lazy remote directory tree, upload/download/delete, new folder, copy/move:

dotnet run --project src/Sfas.Manager
  1. Set the consumer (e.g. PORTAL-A) and press Token — the tree loads; root areas are configured in the Areas field (default DOCUMENTS;SYSTEM).
  2. Select a directory → Upload file… (existing target asks for overwrite).
  3. Select a file → Download… (double-click), Delete, Copy…/Move… (logical destination path). Non-empty directories ask for recursive confirmation.
  4. 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".


Configuration

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-ADOCUMENTS: Read / ListDirectory / Write / CreateDirectory / DeleteDirectory on **; Delete on ** is Denied (demonstrates deny-wins). SYSTEM: Read public/** only.
  • BATCH-1SYSTEM: All on **.

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 SigningKeyBase64 is a development shared key used by the dev token flow. In production, switch Auth:Jwt:Mode to Oidc (or rotate the symmetric key) and never ship the real key in the repository.


Testing

dotnet test Sfas.sln -c Release

89 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-evil prefix trick → all INVALID_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 the SFAE magic.

Repository structure

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.


License

Released under the MIT License © 2026 TheMax-Lab.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages