diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
 # Changelog
 
+## 0.4.0.0 — 2026-06-08
+
+### Breaking changes
+
+- **`NarInfo` drops the `niSystem` field** — `System` is not part of the Nix
+  narinfo wire format (Nix ignores unknown keys), so it is removed from the
+  exported record and `renderNarInfo` no longer emits a `System:` line.
+
+### Security
+
+- **API key required for writes** — the server refuses to start when
+  `CACHE_API_KEY` is unset, unless `--allow-open-writes` is passed explicitly,
+  so a missing environment variable can no longer leave the cache
+  world-writable.
+- **Streaming request-body cap** — request bodies are read in bounded chunks
+  with a running size check, so an unsized (chunked) upload can no longer
+  buffer past the 100 MB limit into memory before it is rejected.
+- **Stricter path validation** — `sanitizePath` now accepts only
+  `[A-Za-z0-9._+-]` names that are neither dotfiles nor Windows reserved device
+  names (`nul`, `con`, `com1`…), rejecting device access, alternate-data-stream
+  syntax, and the temporary-write prefix in addition to traversal.
+- **No internal detail in error responses** — store reads tolerate I/O errors
+  (treated as absence) and any uncaught handler exception maps to a plain 500,
+  so filesystem paths and error text are never returned to clients.
+
+### Bug fixes
+
+- **CRLF-tolerant narinfo parsing** — lines are split on the first colon with a
+  trailing carriage return stripped, so narinfo produced by other tools (or
+  with `\r\n` line endings) parses correctly instead of corrupting text fields
+  or rejecting valid integer fields.
+
 ## 0.3.2.1 — 2026-03-18
 
 - Replace partial `decodeUtf8` with total `decodeLatin1` in `Base64.encode`
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,396 +1,97 @@
 <div align="center">
 <h1>nova-cache</h1>
-<p><strong>Nix Binary Cache Protocol for Haskell</strong></p>
-<p>Pure-first implementation of the complete Nix binary cache protocol — base32, NAR, narinfo, Ed25519 signing, store paths — with an optional WAI server.</p>
-<p><a href="#quick-start">Quick Start</a> · <a href="#modules">Modules</a> · <a href="#server">Server</a> · <a href="#api-reference">API Reference</a></p>
-<p>
+<p><strong>The Nix binary cache protocol, in Haskell.</strong></p>
+<p>nix-base32, NAR archives, narinfo, store paths, and Ed25519 signing — with an optional WAI cache server. A pure core; IO is confined to the compression, storage, and server boundaries.</p>
 
 [![CI](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml)
 [![Hackage](https://img.shields.io/hackage/v/nova-cache.svg)](https://hackage.haskell.org/package/nova-cache)
-![Haskell](https://img.shields.io/badge/haskell-GHC%209.8-purple)
+![GHC](https://img.shields.io/badge/GHC-9.8-purple)
 ![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)
 
-</p>
 </div>
 
 ---
 
-## What is nova-cache?
-
-A focused, minimal library implementing the full Nix binary cache protocol:
-
-- **Base32** — Nix-specific encoding with the custom `0123456789abcdfghijklmnpqrsvwxyz` alphabet, O(n) ST-based decode
-- **Hash** — SHA-256 hashing with `sha256:<nix-base32>` formatting, composition pipeline over crypton
-- **StorePath** — Store path parsing, validation, and hash extraction with enforced invariants via newtypes
-- **NAR** — Binary serialization and deserialization of the Nix ARchive format using `Builder` monoid composition
-- **NarInfo** — Parse and render the key-value narinfo text format
-- **Signing** — Ed25519 fingerprint signing and verification for binary cache trust
-- **Compression** — xz compress/decompress for NAR transport (behind `compression` cabal flag, default on)
-- **Store** — Filesystem storage backend for narinfo and NAR files
-- **Validate** — Pure protocol validation: field semantics, content hashes, Ed25519 signatures — all errors collected, not short-circuited
-- **Server** — Optional WAI/Warp HTTP server implementing the cache protocol (behind `server` cabal flag)
-
-Every module is pure by default. IO lives at the boundaries only.
-
----
-
-## Quick Start
-
-Add to your `.cabal` file:
+## Installation
 
 ```cabal
 build-depends: nova-cache
 ```
 
-### Hash a Store Path
+The `compression` flag (on by default) requires the system `liblzma`. Build
+with `-f-compression` if you only need hashing, NAR, or narinfo.
 
+## Usage
+
 ```haskell
 import NovaCache.Hash (hashBytes, formatNixHash)
 import qualified Data.ByteString as BS
 
-main :: IO ()
-main = do
-  contents <- BS.readFile "/nix/store/abc123-hello/bin/hello"
-  let nixHash = hashBytes contents
-  putStrLn (show (formatNixHash nixHash))
-  -- "sha256:0m6g5r7c..."
+-- Hash file contents into sha256:<nix-base32>
+hash <- formatNixHash . hashBytes <$> BS.readFile path
 ```
 
-### Parse a NarInfo
-
 ```haskell
-import NovaCache.NarInfo (parseNarInfo, niStorePath, niNarHash)
+import NovaCache.NarInfo (parseNarInfo)
+import NovaCache.Signing (parseSecretKey, sign)
 
-main :: IO ()
-main = do
-  let raw = "StorePath: /nix/store/abc...-hello\n\
-            \URL: nar/abc....nar.xz\n\
-            \Compression: xz\n\
-            \FileHash: sha256:...\n\
-            \FileSize: 1234\n\
-            \NarHash: sha256:...\n\
-            \NarSize: 5678\n\
-            \References: \n"
-  case parseNarInfo raw of
-    Left err -> putStrLn ("Parse error: " ++ err)
-    Right ni -> do
-      putStrLn ("Store path: " <> show (niStorePath ni))
-      putStrLn ("NAR hash:   " <> show (niNarHash ni))
+-- Parse a narinfo and sign it
+case (parseNarInfo raw, parseSecretKey "mykey:base64...") of
+  (Right ni, Right sk) -> print (sign sk ni)  -- Right "mykey:<base64 sig>"
+  _                    -> error "parse failed"
 ```
 
-### Sign and Verify
-
 ```haskell
-import NovaCache.Signing (parseSecretKey, parsePublicKey, sign, verify)
-
-main :: IO ()
-main = do
-  let Right sk = parseSecretKey "mykey:base64secretkey..."
-      Right pk = parsePublicKey "mykey:base64pubkey..."
-  case sign sk narinfo of
-    Left err  -> putStrLn ("Sign error: " ++ err)
-    Right sig -> putStrLn ("Verified: " <> show (verify pk narinfo sig))
-```
-
-### Validate an Upload
-
-```haskell
-import NovaCache.Validate (validateFull, ValidationError)
-import NovaCache.Signing (parsePublicKey)
-
--- Pure validation — no IO needed
-validateUpload :: PublicKey -> NarInfo -> ByteString -> ByteString -> IO ()
-validateUpload pk narinfo narBytes fileBytes =
-  case validateFull pk narinfo narBytes fileBytes of
-    Right ()   -> putStrLn "Upload valid"
-    Left errs  -> mapM_ (putStrLn . ("  " ++) . show) errs
-
--- Individual checks compose too
-import NovaCache.Validate (validateNarInfo, validateNarHash)
-
-checkNarInfo :: NarInfo -> Either [ValidationError] NarInfo
-checkNarInfo = validateNarInfo  -- collects ALL errors, not just the first
-```
-
----
-
-## Modules
-
-### NovaCache.Base32
-
-Nix uses a non-standard base32 alphabet that omits `e`, `o`, `u`, `t` and encodes in reverse byte order. The encoder extracts 5-bit groups from descending positions; the decoder uses an O(n) ST-based bit scatter with mutable vectors.
-
-```haskell
-import NovaCache.Base32 (encode, decode)
-
-encode "\xff"       -- "8z"
-decode "0z"         -- Right "\x1e"
-decode (encode bs)  -- Right bs  (roundtrip)
-```
-
-### NovaCache.Hash
-
-SHA-256 via crypton, formatted as `sha256:<nix-base32>`:
-
-```haskell
-import NovaCache.Hash (hashBytes, hashFile, formatNixHash, parseNixHash)
-
-formatNixHash (hashBytes "hello")
--- "sha256:0m6g5r7c..."
-
-nixHash <- hashFile "/nix/store/abc123-hello"
-```
-
-### NovaCache.StorePath
-
-Newtypes enforce invariants — store path hashes are always 32 characters, names are validated against the Nix character set:
-
-```haskell
-import NovaCache.StorePath
-
-let Right sp = parseStorePath defaultStoreDir "/nix/store/abc123...-hello-1.0"
-storePathHashString sp  -- "abc123..."
-storePathBaseName sp    -- "abc123...-hello-1.0"
-renderStorePath defaultStoreDir sp  -- "/nix/store/abc123...-hello-1.0"
-```
-
-### NovaCache.NAR
-
-The NAR tree ADT with `Builder`-based serialization:
-
-```haskell
-import NovaCache.NAR
-
-let entry = NarRegular False "file contents"
-let bytes = serialise entry
-deserialise bytes  -- Right entry (roundtrip)
-
--- Hash a NAR directly (serialise + SHA-256)
-narHash entry  -- NixHash
-
--- Build from filesystem
-tree <- serialiseFromPath "/nix/store/abc123-hello"
-```
-
-### NovaCache.Signing
-
-Ed25519 signing and verification of narinfo fingerprints. Keys are `name:base64` pairs:
-
-```haskell
-import NovaCache.Signing
-
--- Fingerprint format: 1;<StorePath>;<NarHash>;<NarSize>;<refs>
-fingerprint narinfo
--- "1;/nix/store/abc...;sha256:...;5678;"
-```
-
-### NovaCache.Validate
-
-Pure protocol validation — field semantics, content hashes, and signatures. All validators collect every error instead of short-circuiting:
-
-```haskell
-import NovaCache.Validate
-
--- Validate narinfo fields (sizes, store path, hash formats, references)
-case validateNarInfo narinfo of
-  Right ni   -> processUpload ni
-  Left errs  -> rejectWith errs  -- [InvalidStorePath ..., NegativeFileSize ...]
-
--- Verify content hashes match declared values
-validateNarHash narinfo rawNarBytes       -- Either ValidationError ()
-validateFileHash narinfo compressedBytes  -- Either ValidationError ()
+import NovaCache.Validate (validateFull)
 
--- Full pipeline: fields + nar hash + file hash + signatures
-validateFull publicKey narinfo narBytes fileBytes
--- Either [ValidationError] ()
+-- Validate an upload: fields + NAR hash + file hash + signatures.
+-- Pure, and every error is collected rather than failing on the first.
+case validateFull publicKey ni narBytes fileBytes of
+  Right ()  -> accept
+  Left errs -> reject errs
 ```
 
----
-
 ## Server
 
-Enable the WAI server with the `server` cabal flag:
-
 ```bash
-cabal build --flag server
-cabal run nova-cache-server -- --port 5000 --store ./nix-cache
-```
-
-Or via environment variables:
-
-```bash
-PORT=5000 NIX_CACHE_DIR=./nix-cache nova-cache-server
+cabal run --flag server nova-cache-server -- --port 5000 --store ./nix-cache
 ```
 
-### Environment Variables
+### Configuration
 
 | Variable | Description |
-|----------|-------------|
-| `PORT` | Server listen port (default: 5000) |
+| --- | --- |
+| `PORT` | Listen port (default: 5000) |
 | `NIX_CACHE_DIR` | Store directory (default: `./nix-cache`) |
-| `CACHE_API_KEY` | Bearer token required for PUT requests. Omit for open writes. |
-| `SIGNING_KEY_FILE` | Path to Ed25519 secret key file for server-side narinfo signing. |
-| `LOG_REQUESTS` | Set to `0` to disable request logging. Enabled by default (Apache Combined format). |
+| `CACHE_API_KEY` | Bearer token required for `PUT`. The server refuses to start without it unless `--allow-open-writes` is passed. |
+| `SIGNING_KEY_FILE` | Ed25519 secret key file for server-side narinfo signing |
+| `LOG_REQUESTS` | Set to `0` to disable request logging |
 
 ### Endpoints
 
 | Method | Path | Description |
-|--------|------|-------------|
-| `GET` | `/nix-cache-info` | Cache metadata (StoreDir, WantMassQuery, Priority) |
-| `GET` | `/narinfo-hashes` | All cached narinfo hashes (newline-delimited) |
-| `GET` | `/<hash>.narinfo` | Fetch narinfo by store path hash |
-| `GET` | `/nar/<file>` | Fetch compressed NAR file |
-| `PUT` | `/<hash>.narinfo` | Upload narinfo |
-| `PUT` | `/nar/<file>` | Upload NAR file |
+| --- | --- | --- |
+| `GET` | `/nix-cache-info` | Cache metadata |
+| `GET` | `/narinfo-hashes` | All cached narinfo hashes, newline-delimited |
+| `GET` | `/<hash>.narinfo` | Fetch a narinfo |
+| `GET` | `/nar/<file>` | Fetch a NAR |
+| `PUT` | `/<hash>.narinfo` | Upload a narinfo (authenticated, validated) |
+| `PUT` | `/nar/<file>` | Upload a NAR (authenticated) |
 
-### Public Binary Cache
+### Public cache
 
-A free, public binary cache is available at `cache.novavero.ai`. Add it to
-your Nix configuration:
+A public instance runs at `cache.novavero.ai`:
 
-```nix
-# nix.conf or /etc/nix/nix.conf
+```
 extra-substituters = https://cache.novavero.ai
 extra-trusted-public-keys = cache.novavero.ai-1:2yJK0UZWlDDTpThzEdqfGWaj+j3ljOCGoA50Ims47dM=
 ```
 
-Or use it directly:
-
-```bash
-nix build --substituters "https://cache.nixos.org https://cache.novavero.ai" \
-          --trusted-public-keys "cache.nixos.org-1:DLD/YGKmo6OceLp6RsiGCbi5FwMExRzJcoJKanMPe/Q= cache.novavero.ai-1:2yJK0UZWlDDTpThzEdqfGWaj+j3ljOCGoA50Ims47dM="
-```
-
-### Self-Hosted Cache
-
-Run your own instance:
-
-```bash
-# Push to your cache
-nix copy --to http://cache.example.com /nix/store/abc123-hello
-
-# Pull from your cache
-nix build --substituters http://cache.example.com --trusted-public-keys "mykey:base64..."
-```
-
----
-
-## Architecture
-
-```
-                    Pure Core (no IO)
-  ┌──────────────────────────────────────────┐
-  │                                          │
-  │  Base32 ──→ Hash ──→ StorePath           │
-  │                         │                │
-  │                      NarInfo ──→ Signing │
-  │                         │         │      │
-  │                        NAR    Validate   │
-  │                                          │
-  └──────────────────────────────────────────┘
-                       │
-              IO Boundary (thin)
-  ┌──────────────────────────────────────────────────────┐
-  │  Compression (flag)    Store    Server (flag)       │
-  └──────────────────────────────────────────────────────┘
-```
-
-- **10 modules**, 8 pure + 2 at the IO boundary (Compression optional via flag)
-- **75 core tests + 4 compression tests**, hand-rolled harness, no framework dependencies
-- **Zero partial functions** — total by construction
-- **Strict by default** — bang patterns on all data fields
-
----
-
-## API Reference
-
-### Base32
-
-```haskell
-encode :: ByteString -> Text
-decode :: Text -> Either String ByteString
-```
-
-### Hash
-
-```haskell
-hashBytes     :: ByteString -> NixHash
-hashFile      :: FilePath -> IO NixHash
-formatNixHash :: NixHash -> Text
-parseNixHash  :: Text -> Either String NixHash
-```
-
-### StorePath
-
-```haskell
-parseStorePath      :: StoreDir -> Text -> Either String StorePath
-renderStorePath     :: StoreDir -> StorePath -> Text
-storePathHashString :: StorePath -> Text
-storePathBaseName   :: StorePath -> Text
-```
-
-### NAR
-
-```haskell
-serialise         :: NarEntry -> ByteString
-deserialise       :: ByteString -> Either String NarEntry
-narHash           :: NarEntry -> NixHash
-serialiseFromPath :: FilePath -> IO NarEntry
-```
-
-### NarInfo
-
-```haskell
-parseNarInfo  :: Text -> Either String NarInfo
-renderNarInfo :: NarInfo -> Text
-```
-
-### Signing
-
-```haskell
-parseSecretKey :: Text -> Either String SecretKey
-parsePublicKey :: Text -> Either String PublicKey
-fingerprint    :: NarInfo -> Text
-sign           :: SecretKey -> NarInfo -> Either String Text
-verify         :: PublicKey -> NarInfo -> Text -> Bool
-```
-
-### Validate
-
-```haskell
-validateNarInfo  :: NarInfo -> Either [ValidationError] NarInfo
-validateNarHash  :: NarInfo -> ByteString -> Either ValidationError ()
-validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()
-validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()
-validateFull     :: PublicKey -> NarInfo -> ByteString -> ByteString -> Either [ValidationError] ()
-```
-
-### Compression
-
-```haskell
-compressXz   :: ByteString -> ByteString
-decompressXz :: ByteString -> IO (Either String ByteString)
-```
-
-### Store
-
-```haskell
-newFileStore      :: FilePath -> IO FileStore
-readNarInfo       :: FileStore -> Text -> IO (Maybe ByteString)
-writeNarInfo      :: FileStore -> Text -> ByteString -> IO Bool
-readNar           :: FileStore -> Text -> IO (Maybe ByteString)
-writeNar          :: FileStore -> Text -> ByteString -> IO Bool
-listNarInfoHashes :: FileStore -> IO [Text]
-getCacheInfo      :: FileStore -> (Text, Bool, Int)
-```
-
-Full Haddock documentation is available on [Hackage](https://hackage.haskell.org/package/nova-cache).
-
----
-
-## GitHub Action — CI Cache Seeding
+## CI cache seeding
 
-A reusable composite action is included for pushing Nix store paths to a nova-cache server from CI:
+A composite action pushes store paths to a nova-cache server from CI. It
+resolves runtime paths via `nix-build`, diffs against the server's
+`/narinfo-hashes`, and uploads only what is missing.
 
 ```yaml
 - uses: Novavero-AI/nova-cache/.github/actions/seed@main
@@ -399,36 +100,17 @@
     api-key: ${{ secrets.CACHE_API_KEY }}
 ```
 
-| Input | Required | Description |
-|-------|----------|-------------|
-| `cache-url` | yes | Base URL of the nova-cache server |
-| `api-key` | yes | Bearer token for authenticating uploads |
-| `paths` | no | Explicit store paths (space-separated). Defaults to all paths from `shell.nix` / `default.nix` |
-| `parallel` | no | Max concurrent uploads (default: 32) |
-
-The action resolves runtime store paths via `nix-build`, diffs against the server's `GET /narinfo-hashes`, exports only missing paths, and uploads narinfo + NAR files in parallel. Includes per-upload timeouts, failure diagnostics, and a round-trip validation check. Works with any CI that has Nix installed.
-
----
+Inputs: `cache-url` and `api-key` (required); `paths` and `parallel` (optional).
 
-## Build & Test
+## Build & test
 
 ```bash
-cabal build                              # Build library (compression on by default)
-cabal build -f-compression               # Build without lzma C dependency
-cabal test                               # Run all tests
-cabal build --ghc-options="-Werror"      # Warnings as errors
-cabal build --flag server                # Build with WAI server
-cabal haddock                            # Generate docs
+cabal build
+cabal test
 ```
 
-Consumers that only need hashing, NAR, or narinfo can disable the `compression` flag to avoid the system `liblzma` dependency:
-
-```cabal
-constraints: nova-cache -compression
-```
+Optional flags: `-f-compression` skips the `liblzma` dependency, and `--flag server` builds the cache server. Requires GHC 9.8+ and cabal-install 3.10+.
 
 ---
 
-<p align="center">
-  <sub>BSD-3-Clause · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub>
-</p>
+<p align="center"><sub>BSD-3-Clause · <a href="https://github.com/Novavero-AI">Novavero AI</a></sub></p>
diff --git a/exe/Main.hs b/exe/Main.hs
--- a/exe/Main.hs
+++ b/exe/Main.hs
@@ -1,5 +1,6 @@
 module Main (main) where
 
+import Control.Exception (SomeException)
 import Data.Bifunctor (first)
 import Data.ByteArray (constEq)
 import qualified Data.ByteString as BS
@@ -16,12 +17,12 @@
     RequestBodyLength (..),
     Response,
     ResponseReceived,
+    getRequestBodyChunk,
     pathInfo,
     requestBodyLength,
     requestHeaders,
     requestMethod,
     responseLBS,
-    strictRequestBody,
   )
 import qualified Network.Wai.Handler.Warp as Warp
 import Network.Wai.Middleware.RequestLogger (logStdout)
@@ -30,6 +31,7 @@
 import NovaCache.Store (FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)
 import NovaCache.Validate (validateNarInfo)
 import System.Environment (getArgs, lookupEnv)
+import System.Exit (exitFailure)
 import System.IO (hPutStrLn, stderr)
 import Text.Read (readMaybe)
 
@@ -91,6 +93,7 @@
       storeRoot = case args of
         ("--store" : s : _) -> s
         _ -> fromMaybe defaultStoreRoot storeEnv
+      allowOpenWrites = "--allow-open-writes" `elem` args
 
   store <- newFileStore storeRoot
   sigKey <- loadSigningKey sigKeyPath
@@ -110,8 +113,25 @@
   putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)
   putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))
   putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")
-  Warp.run port (requestLogger (app cfg))
 
+  case cfgApiKey cfg of
+    Nothing
+      | not allowOpenWrites -> do
+          hPutStrLn stderr $
+            "FATAL: "
+              ++ apiKeyEnvVar
+              ++ " is not set — refusing to start with unauthenticated writes. "
+              ++ "Set "
+              ++ apiKeyEnvVar
+              ++ ", or pass --allow-open-writes to override (not recommended on a public host)."
+          exitFailure
+    _ -> pure ()
+
+  let settings =
+        Warp.setPort port $
+          Warp.setOnExceptionResponse onExceptionResponse Warp.defaultSettings
+  Warp.runSettings settings (requestLogger (app cfg))
+
 -- | Load a signing key from a file, if configured.
 loadSigningKey :: Maybe FilePath -> IO (Maybe SecretKey)
 loadSigningKey Nothing = pure Nothing
@@ -212,11 +232,20 @@
 readBodyLimited req = case requestBodyLength req of
   KnownLength len
     | len > fromIntegral maxBodySize -> pure Nothing
-  _ -> do
-    body <- BL.toStrict <$> strictRequestBody req
-    if BS.length body > maxBodySize
-      then pure Nothing
-      else pure (Just body)
+  _ -> readChunks [] 0
+  where
+    -- Stream the body in chunks, aborting as soon as the running total
+    -- exceeds the cap — so an unsized (chunked) upload can never buffer
+    -- more than 'maxBodySize' into memory.
+    readChunks acc total = do
+      chunk <- getRequestBodyChunk req
+      if BS.null chunk
+        then pure (Just (BS.concat (reverse acc)))
+        else
+          let newTotal = total + BS.length chunk
+           in if newTotal > maxBodySize
+                then pure Nothing
+                else readChunks (chunk : acc) newTotal
 
 -- | Run an action with the limited request body, responding 413 if too large.
 withLimitedBody :: Request -> (Response -> IO ResponseReceived) -> (BS.ByteString -> IO ResponseReceived) -> IO ResponseReceived
@@ -312,6 +341,11 @@
 -- | 400 Bad Request with a text error message.
 badRequest :: Text -> Response
 badRequest msg = responseLBS HTTP.status400 textHeaders (BL.fromStrict (TE.encodeUtf8 msg))
+
+-- | Map any uncaught handler exception to a generic 500, so internal error
+-- detail (filesystem paths, exception text) is never leaked to clients.
+onExceptionResponse :: SomeException -> Response
+onExceptionResponse _ = responseLBS HTTP.status500 textHeaders "internal server error"
 
 -- | Content-Type: text/plain headers.
 textHeaders :: HTTP.ResponseHeaders
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,12 +1,11 @@
 cabal-version:      3.0
 name:               nova-cache
-version:            0.3.2.1
-synopsis:           Pure Nix binary cache protocol library
+version:            0.4.0.0
+synopsis:           Pure-first Nix binary cache protocol library
 description:
-  A focused, minimal, pure-first library implementing the full Nix binary
-  cache protocol — nix-base32, NAR serialization, narinfo parsing, Ed25519
-  signing, store path handling, content validation — with an optional WAI
-  server.
+  A pure-first library implementing the Nix binary cache protocol —
+  nix-base32, NAR serialization, narinfo parsing, Ed25519 signing, store
+  path handling, and content validation — with an optional WAI server.
 
 license:            BSD-3-Clause
 license-file:       LICENSE
diff --git a/src/NovaCache/Base32.hs b/src/NovaCache/Base32.hs
--- a/src/NovaCache/Base32.hs
+++ b/src/NovaCache/Base32.hs
@@ -6,6 +6,7 @@
 module NovaCache.Base32
   ( encode,
     decode,
+    isBase32Char,
   )
 where
 
@@ -48,6 +49,13 @@
 -- | Sentinel value for characters outside the alphabet.
 invalidMarker :: Word8
 invalidMarker = 0xFF
+
+-- | Is a character part of the Nix base32 alphabet
+-- (@0123456789abcdfghijklmnpqrsvwxyz@)?  Used to validate store-path hashes.
+isBase32Char :: Char -> Bool
+isBase32Char c = case nixDecodeLut V.!? ord c of
+  Just v -> v /= invalidMarker
+  Nothing -> False
 
 -- | Bits per base32 character.
 bitsPerChar :: Int
diff --git a/src/NovaCache/NAR.hs b/src/NovaCache/NAR.hs
--- a/src/NovaCache/NAR.hs
+++ b/src/NovaCache/NAR.hs
@@ -252,10 +252,20 @@
 readStr bs
   | BS.length bs < wordSize =
       Left "unexpected end of NAR: need 8 bytes for string length"
-  | totalLen > BS.length payload =
+  -- Compare the Word64 length to the remaining bytes BEFORE narrowing it to
+  -- Int: a hostile length above maxBound::Int would otherwise wrap negative
+  -- and slip past the totalLen check below.
+  | len > fromIntegral (BS.length payload) =
       Left
         ( "unexpected end of NAR: string length "
             ++ show len
+            ++ " exceeds remaining "
+            ++ show (BS.length payload)
+        )
+  | totalLen > BS.length payload =
+      Left
+        ( "unexpected end of NAR: padded string length "
+            ++ show totalLen
             ++ " exceeds remaining "
             ++ show (BS.length payload)
         )
diff --git a/src/NovaCache/NarInfo.hs b/src/NovaCache/NarInfo.hs
--- a/src/NovaCache/NarInfo.hs
+++ b/src/NovaCache/NarInfo.hs
@@ -2,7 +2,7 @@
 --
 -- A @.narinfo@ file is a simple @Key: Value@ text format describing a
 -- store path in a binary cache. The format supports multiple @Sig@ lines
--- and optional fields for @Deriver@, @System@, and @CA@.
+-- and optional fields for @Deriver@ and @CA@.
 module NovaCache.NarInfo
   ( NarInfo (..),
     parseNarInfo,
@@ -11,7 +11,7 @@
 where
 
 import Data.List (find)
-import Data.Maybe (mapMaybe)
+import Data.Maybe (fromMaybe, mapMaybe)
 import Data.Text (Text)
 import qualified Data.Text as T
 
@@ -30,7 +30,6 @@
     niNarSize :: !Integer,
     niReferences :: ![Text],
     niDeriver :: !(Maybe Text),
-    niSystem :: !(Maybe Text),
     niSigs :: ![Text],
     niCA :: !(Maybe Text)
   }
@@ -52,19 +51,18 @@
 keyNarSize = "NarSize"
 keyReferences = "References"
 
-keyDeriver, keySystem, keySig, keyCA :: Text
+keyDeriver, keySig, keyCA :: Text
 keyDeriver = "Deriver"
-keySystem = "System"
 keySig = "Sig"
 keyCA = "CA"
 
--- | Key-value separator in narinfo format.
+-- | Key-value separator used when rendering narinfo lines.
 kvSeparator :: Text
 kvSeparator = ": "
 
--- | Length of the key-value separator.
-kvSeparatorLen :: Int
-kvSeparatorLen = 2
+-- | Field delimiter used when parsing (Nix splits on the first colon).
+fieldColon :: Text
+fieldColon = ":"
 
 -- ---------------------------------------------------------------------------
 -- Parsing
@@ -92,7 +90,6 @@
         niNarSize = narSize,
         niReferences = parseRefs (lookupFirst keyReferences kvs),
         niDeriver = lookupFirst keyDeriver kvs,
-        niSystem = lookupFirst keySystem kvs,
         niSigs = lookupAll keySig kvs,
         niCA = lookupFirst keyCA kvs
       }
@@ -122,7 +119,6 @@
       kv keyReferences (T.unwords (niReferences ni))
     ]
       ++ optionalKV keyDeriver (niDeriver ni)
-      ++ optionalKV keySystem (niSystem ni)
       ++ map (kv keySig) (niSigs ni)
       ++ optionalKV keyCA (niCA ni)
 
@@ -131,11 +127,18 @@
 -- ---------------------------------------------------------------------------
 
 -- | Parse a single @Key: Value@ line.
+--
+-- Splits on the first colon (matching Nix), strips a single leading space
+-- from the value, and tolerates a trailing carriage return so CRLF-terminated
+-- narinfo from other tools parses correctly.
 parseLine :: Text -> Maybe (Text, Text)
-parseLine line = case T.breakOn kvSeparator line of
+parseLine rawLine = case T.breakOn fieldColon line of
   (_, rest)
     | T.null rest -> Nothing
-  (key, rest) -> Just (key, T.drop kvSeparatorLen rest)
+  (key, rest) -> Just (key, dropLeadingSpace (T.drop 1 rest))
+  where
+    line = T.dropWhileEnd (== '\r') rawLine
+    dropLeadingSpace value = fromMaybe value (T.stripPrefix " " value)
 
 -- | Render a key-value pair.
 kv :: Text -> Text -> Text
diff --git a/src/NovaCache/Signing.hs b/src/NovaCache/Signing.hs
--- a/src/NovaCache/Signing.hs
+++ b/src/NovaCache/Signing.hs
@@ -134,8 +134,13 @@
       niStorePath ni,
       niNarHash ni,
       T.pack (show (niNarSize ni)),
-      T.intercalate referenceSep (niReferences ni)
+      T.intercalate referenceSep (map (storeDir <>) (niReferences ni))
     ]
+  where
+    -- References in a narinfo are basenames, but the fingerprint signs them as
+    -- full store paths (/nix/store/<hash>-<name>), matching C++ Nix.  The store
+    -- directory is the leading path of the (already absolute) niStorePath.
+    storeDir = T.dropWhileEnd (/= '/') (niStorePath ni)
 
 -- ---------------------------------------------------------------------------
 -- Signing and verification
diff --git a/src/NovaCache/Store.hs b/src/NovaCache/Store.hs
--- a/src/NovaCache/Store.hs
+++ b/src/NovaCache/Store.hs
@@ -19,9 +19,10 @@
   )
 where
 
-import Control.Exception (SomeException, catch, onException)
+import Control.Exception (IOException, SomeException, catch, onException)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
 import Data.Text (Text)
 import qualified Data.Text as T
 import System.Directory
@@ -159,20 +160,38 @@
 -- Path sanitization
 -- ---------------------------------------------------------------------------
 
--- | Validate a path component for safe filesystem use.
+-- | Validate a path component for safe filesystem use via a positive allowlist.
 --
--- Rejects components containing directory separators (@\/@, @\\@),
--- traversal sequences (@..@), or empty strings. Returns 'Just' the
--- sanitized 'FilePath' on success, 'Nothing' on rejection.
+-- Accepts only non-empty names of @[A-Za-z0-9._+-]@ that do not start with a
+-- dot and are not a Windows reserved device name. This rejects directory
+-- separators, @.@\/@..@ traversal, dotfiles (including the temp-write prefix),
+-- NUL bytes, alternate-data-stream (@name:stream@) syntax, and device names
+-- like @nul@ — so a client-supplied hash or NAR filename can never escape the
+-- store directory or resolve to a device, on any platform.
 sanitizePath :: Text -> Maybe FilePath
 sanitizePath txt
   | T.null txt = Nothing
-  | T.any isPathSeparator txt = Nothing
-  | txt == ".." = Nothing
+  | T.isPrefixOf "." txt = Nothing
+  | T.any (not . isSafeChar) txt = Nothing
+  | isReservedName txt = Nothing
   | otherwise = Just (T.unpack txt)
   where
-    isPathSeparator c = c == '/' || c == '\\'
+    isSafeChar c =
+      isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-+" :: [Char])
 
+-- | Is the name a Windows reserved device (@con@, @prn@, @aux@, @nul@,
+-- @com1@-@com9@, @lpt1@-@lpt9@)? Matched case-insensitively on the portion
+-- before the first dot, since @nul.txt@ also opens the device. Enforced on
+-- every platform so a Windows-hosted cache is safe too.
+isReservedName :: Text -> Bool
+isReservedName txt = T.toLower (T.takeWhile (/= '.') txt) `elem` reservedNames
+  where
+    reservedNames =
+      ["con", "prn", "aux", "nul"]
+        ++ ["com" <> n | n <- digits]
+        ++ ["lpt" <> n | n <- digits]
+    digits = [T.pack (show n) | n <- [1 .. 9 :: Int]]
+
 -- ---------------------------------------------------------------------------
 -- Internal
 -- ---------------------------------------------------------------------------
@@ -205,9 +224,17 @@
     silenceException _ = pure ()
 
 -- | Read a file if it exists, returning 'Nothing' otherwise.
+--
+-- Any I/O error (bad permissions, a race with deletion, a name that slips
+-- through validation) is treated as absence rather than propagating and
+-- crashing the request handler.
 readIfExists :: FilePath -> IO (Maybe ByteString)
-readIfExists path = do
-  exists <- doesFileExist path
-  if exists
-    then Just <$> BS.readFile path
-    else pure Nothing
+readIfExists path = readExisting `catch` ignoreIOError
+  where
+    readExisting = do
+      exists <- doesFileExist path
+      if exists
+        then Just <$> BS.readFile path
+        else pure Nothing
+    ignoreIOError :: IOException -> IO (Maybe ByteString)
+    ignoreIOError _ = pure Nothing
diff --git a/src/NovaCache/StorePath.hs b/src/NovaCache/StorePath.hs
--- a/src/NovaCache/StorePath.hs
+++ b/src/NovaCache/StorePath.hs
@@ -20,6 +20,7 @@
 import Data.Maybe (fromMaybe)
 import Data.Text (Text)
 import qualified Data.Text as T
+import NovaCache.Base32 (isBase32Char)
 
 -- ---------------------------------------------------------------------------
 -- Types
@@ -108,6 +109,8 @@
       Left ("store path too short: " ++ T.unpack basename)
   | T.index basename storePathHashLen /= hashNameSeparator =
       Left ("expected '-' after hash in store path: " ++ T.unpack basename)
+  | not (T.all isBase32Char hashPart) =
+      Left ("invalid nix-base32 hash in store path: " ++ T.unpack hashPart)
   | T.null name =
       Left ("empty name in store path: " ++ T.unpack basename)
   | not (T.all validNameChar name) =
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -330,7 +330,6 @@
       "NarSize: 67890",
       "References: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-glibc-2.38",
       "Deriver: cccccccccccccccccccccccccccccccc-hello-1.0.drv",
-      "System: x86_64-linux",
       "Sig: cache.example.com:c2lnbmF0dXJl",
       "Sig: backup.example.com:YW5vdGhlcnNpZw=="
     ]
@@ -352,9 +351,8 @@
             ok5 <- assertEqual "narSize" 67890 (NarInfo.niNarSize ni)
             ok6 <- assertEqual "refs count" 2 (length (NarInfo.niReferences ni))
             ok7 <- assertEqual "deriver" (Just "cccccccccccccccccccccccccccccccc-hello-1.0.drv") (NarInfo.niDeriver ni)
-            ok8 <- assertEqual "system" (Just "x86_64-linux") (NarInfo.niSystem ni)
-            ok9 <- assertEqual "sigs count" 2 (length (NarInfo.niSigs ni))
-            pure (ok1 && ok2 && ok3 && ok4 && ok5 && ok6 && ok7 && ok8 && ok9),
+            ok8 <- assertEqual "sigs count" 2 (length (NarInfo.niSigs ni))
+            pure (ok1 && ok2 && ok3 && ok4 && ok5 && ok6 && ok7 && ok8),
       test "parse/render roundtrip" $
         case NarInfo.parseNarInfo sampleNarInfoText of
           Left err -> do
@@ -386,11 +384,10 @@
                 pure False
               Right ni -> do
                 ok1 <- assertEqual "deriver" Nothing (NarInfo.niDeriver ni)
-                ok2 <- assertEqual "system" Nothing (NarInfo.niSystem ni)
-                ok3 <- assertEqual "sigs" [] (NarInfo.niSigs ni)
-                ok4 <- assertEqual "ca" Nothing (NarInfo.niCA ni)
-                ok5 <- assertEqual "refs" [] (NarInfo.niReferences ni)
-                pure (ok1 && ok2 && ok3 && ok4 && ok5),
+                ok2 <- assertEqual "sigs" [] (NarInfo.niSigs ni)
+                ok3 <- assertEqual "ca" Nothing (NarInfo.niCA ni)
+                ok4 <- assertEqual "refs" [] (NarInfo.niReferences ni)
+                pure (ok1 && ok2 && ok3 && ok4),
       test "parse missing required key fails" $
         let incomplete = T.unlines ["StorePath: /nix/store/aaaa-test", "URL: nar/test.nar.xz"]
          in assertLeft "missing key" (NarInfo.parseNarInfo incomplete),
@@ -424,6 +421,12 @@
               ok1 <- assertTrue "starts with 1;" (T.isPrefixOf "1;" fp)
               ok2 <- assertTrue "contains storePath" (T.isInfixOf "/nix/store/" fp)
               pure (ok1 && ok2),
+      test "fingerprint renders references as full store paths" $
+        let ni = mkTestNarInfo {NarInfo.niReferences = ["00000000000000000000000000000000-glibc-2.40"]}
+            fp = Signing.fingerprint ni
+         in assertTrue
+              "reference is a full /nix/store path in the fingerprint"
+              (T.isInfixOf "/nix/store/00000000000000000000000000000000-glibc-2.40" fp),
       test "parseSecretKey valid" $
         let keyBytes = BS.pack ([1 .. 32] ++ [33 .. 64])
             keyB64 = TE.decodeUtf8 (B64.encode keyBytes)
@@ -485,7 +488,6 @@
       NarInfo.niNarSize = 200,
       NarInfo.niReferences = ["aaaa-hello-1.0"],
       NarInfo.niDeriver = Nothing,
-      NarInfo.niSystem = Nothing,
       NarInfo.niSigs = [],
       NarInfo.niCA = Nothing
     }
@@ -545,6 +547,10 @@
         assertEqual "empty" Nothing (Store.sanitizePath ""),
       test "sanitizePath accepts valid hash" $
         assertEqual "valid" (Just "abc123def456") (Store.sanitizePath "abc123def456"),
+      test "sanitizePath rejects windows device name" $
+        assertEqual "device nul" Nothing (Store.sanitizePath "nul"),
+      test "sanitizePath rejects dotfile" $
+        assertEqual "dotfile" Nothing (Store.sanitizePath ".hidden"),
       test "read rejects traversal" $ do
         tmpDir <- createTestDir
         store <- Store.newFileStore tmpDir
@@ -605,7 +611,6 @@
       NarInfo.niNarSize = 5,
       NarInfo.niReferences = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0"],
       NarInfo.niDeriver = Nothing,
-      NarInfo.niSystem = Nothing,
       NarInfo.niSigs = [],
       NarInfo.niCA = Nothing
     }
