diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
 # Changelog
 
+## 0.4.2.0 — 2026-06-10
+
+- **Signing: public-key derivation and rendering** — new `toPublicKey`
+  (derive the `PublicKey` for a `SecretKey`) and `renderPublicKey` (render
+  the `name:base64` trust-anchor format clients put in
+  `trusted-public-keys`).
+- **Signing: `normalizeKeyText`** — strips byte-order marks and surrounding
+  whitespace from key text. The server applies it to `CACHE_API_KEY` and
+  the signing key file, so keys that picked up a BOM or stray CRLF in
+  transit load byte-clean instead of silently failing auth or signing.
+- **Server: the landing page shows the cache public key**, derived at
+  startup from the live signing key, as a copyable `trusted-public-keys`
+  line. The published trust anchor can no longer drift from the key in
+  use.
+- README: the public cache key now matches the key the server signs with.
+- Removed the CI seed action and its README section; native pushing from
+  nova-nix replaces it.
+
 ## 0.4.1.1 — 2026-06-10
 
 - Documentation-only release: the 0.4.1.0 entry below now records everything
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@
 
 | Method | Path | Description |
 | --- | --- | --- |
+| `GET` | `/` | Landing page: live stats and the cache public key |
 | `GET` | `/nix-cache-info` | Cache metadata |
 | `GET` | `/narinfo-hashes` | All cached narinfo hashes, newline-delimited |
 | `GET` | `/<hash>.narinfo` | Fetch a narinfo |
@@ -84,23 +85,8 @@
 
 ```
 extra-substituters = https://cache.novavero.ai
-extra-trusted-public-keys = cache.novavero.ai-1:2yJK0UZWlDDTpThzEdqfGWaj+j3ljOCGoA50Ims47dM=
-```
-
-## CI cache seeding
-
-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
-  with:
-    cache-url: https://cache.example.com
-    api-key: ${{ secrets.CACHE_API_KEY }}
+extra-trusted-public-keys = cache.novavero.ai-1:9gQ7tLWMM+2tdC9H5sKMJltDIPfD7X2GWlZe8Aa8hHQ=
 ```
-
-Inputs: `cache-url` and `api-key` (required); `paths` and `parallel` (optional).
 
 ## Build & test
 
diff --git a/exe/Main.hs b/exe/Main.hs
--- a/exe/Main.hs
+++ b/exe/Main.hs
@@ -27,7 +27,7 @@
 import qualified Network.Wai.Handler.Warp as Warp
 import Network.Wai.Middleware.RequestLogger (logStdout)
 import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo)
-import NovaCache.Signing (SecretKey, parseSecretKey, sign)
+import NovaCache.Signing (SecretKey, normalizeKeyText, parseSecretKey, renderPublicKey, sign, toPublicKey)
 import NovaCache.Store (CacheInfo (..), FileStore, getCacheInfo, listNarInfoHashes, newFileStore, readNar, readNarInfo, writeNar, writeNarInfo)
 import NovaCache.StorePath (defaultStoreDir, parseStorePath, storePathHashString)
 import NovaCache.Validate (validateNarInfo)
@@ -78,7 +78,10 @@
 data Config = Config
   { cfgStore :: !FileStore,
     cfgApiKey :: !(Maybe BS.ByteString),
-    cfgSigningKey :: !(Maybe SecretKey)
+    cfgSigningKey :: !(Maybe SecretKey),
+    -- | The rendered @name:base64@ public key line, derived from the
+    -- signing key at startup; shown on the landing page.
+    cfgPublicKey :: !(Maybe Text)
   }
 
 -- ---------------------------------------------------------------------------
@@ -107,12 +110,14 @@
 
   store <- newFileStore storeRoot
   sigKey <- loadSigningKey sigKeyPath
+  pubKey <- derivePublicKeyLine sigKey
 
   let cfg =
         Config
           { cfgStore = store,
-            cfgApiKey = TE.encodeUtf8 . T.pack <$> apiKeyEnv,
-            cfgSigningKey = sigKey
+            cfgApiKey = TE.encodeUtf8 . normalizeKeyText . T.pack <$> apiKeyEnv,
+            cfgSigningKey = sigKey,
+            cfgPublicKey = pubKey
           }
 
   let logRequests = logRequestsEnv /= Just "0"
@@ -121,6 +126,7 @@
   putStrLn ("nova-cache-server listening on port " ++ show port)
   putStrLn ("store root: " ++ storeRoot)
   putStrLn ("signing: " ++ maybe "disabled" (const "enabled") sigKey)
+  mapM_ (\k -> putStrLn ("public key: " ++ T.unpack k)) pubKey
   putStrLn ("write auth: " ++ maybe "disabled (open writes!)" (const "enabled") (cfgApiKey cfg))
   putStrLn ("request logging: " ++ if logRequests then "enabled" else "disabled (LOG_REQUESTS=0)")
 
@@ -147,12 +153,23 @@
 loadSigningKey Nothing = pure Nothing
 loadSigningKey (Just path) = do
   raw <- BS.readFile path
-  case first show (TE.decodeUtf8' raw) >>= parseSecretKey . T.strip of
+  case first show (TE.decodeUtf8' raw) >>= parseSecretKey . normalizeKeyText of
     Left err -> do
       hPutStrLn stderr ("WARNING: failed to load signing key: " ++ err)
       pure Nothing
     Right sk -> pure (Just sk)
 
+-- | Derive the rendered public key line from the signing key, if any.
+-- The landing page shows this line, so the published trust anchor always
+-- matches the key in use; on a derivation failure the page omits it.
+derivePublicKeyLine :: Maybe SecretKey -> IO (Maybe Text)
+derivePublicKeyLine Nothing = pure Nothing
+derivePublicKeyLine (Just sk) = case toPublicKey sk of
+  Left err -> do
+    hPutStrLn stderr ("WARNING: cannot derive the public key from the signing key: " ++ err)
+    pure Nothing
+  Right pk -> pure (Just (renderPublicKey pk))
+
 -- ---------------------------------------------------------------------------
 -- WAI application
 -- ---------------------------------------------------------------------------
@@ -165,7 +182,7 @@
     pathCount <- length <$> listNarInfoHashes (cfgStore cfg)
     let info = getCacheInfo (cfgStore cfg)
         signingEnabled = isJust (cfgSigningKey cfg)
-        body = TE.encodeUtf8 (landingHtml info signingEnabled pathCount)
+        body = TE.encodeUtf8 (landingHtml info signingEnabled (cfgPublicKey cfg) pathCount)
     respond (responseLBS HTTP.status200 htmlHeaders (BL.fromStrict body))
   -- GET /nix-cache-info
   ("GET", ["nix-cache-info"]) ->
@@ -370,11 +387,12 @@
 boolText True = "1"
 boolText False = "0"
 
--- | The landing page served at @GET /@.  Static apart from three live
--- values (store-path count, store dir, signing status); styled to match
--- novavero.ai.  No user input is interpolated, so no escaping is needed.
-landingHtml :: CacheInfo -> Bool -> Int -> Text
-landingHtml info signingEnabled pathCount =
+-- | The landing page served at @GET /@.  Static apart from four live
+-- values (store-path count, store dir, signing status, public key); styled
+-- to match novavero.ai.  Nothing user-supplied is interpolated — the key
+-- line is operator configuration — so no escaping is needed.
+landingHtml :: CacheInfo -> Bool -> Maybe Text -> Int -> Text
+landingHtml info signingEnabled pubKey pathCount =
   T.unlines
     [ "<!DOCTYPE html>",
       "<html lang=\"en\">",
@@ -416,7 +434,7 @@
       "<div class=\"stat\"><div class=\"value\">" <> T.pack (show (ciPriority info)) <> "</div><div class=\"label\">priority</div></div>",
       "</div>",
       "<h2>Use it</h2>",
-      "<pre><code>substituters = https://cache.novavero.ai</code></pre>",
+      "<pre><code>substituters = https://cache.novavero.ai" <> trustAnchorLine <> "</code></pre>",
       "<p>Store dir: <code>" <> ciStoreDir info <> "</code> &middot; protocol endpoints: <code>/nix-cache-info</code>, <code>/&lt;hash&gt;.narinfo</code>, <code>/nar/&lt;file&gt;</code></p>",
       "<h2>What this is</h2>",
       "<p>The binary cache behind the Novavero Nix toolchain. Powered by <a href=\"https://github.com/Novavero-AI/nova-cache\">nova-cache</a>, a Haskell implementation of the Nix binary cache protocol. Read about the first package built by Nix natively on Windows on <a href=\"https://novavero.ai/blog/first-native-windows-nix-build.html\">the blog</a>.</p>",
@@ -425,6 +443,8 @@
       "</body>",
       "</html>"
     ]
+  where
+    trustAnchorLine = maybe "" ("\ntrusted-public-keys = " <>) pubKey
 
 -- | The Novavero mark (the novavero.ai favicon), inlined so the page stays
 -- a single self-contained response with no external assets.
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               nova-cache
-version:            0.4.1.1
+version:            0.4.2.0
 synopsis:           Pure-first Nix binary cache protocol library
 description:
   A pure-first library implementing the Nix binary cache protocol —
diff --git a/src/NovaCache/Signing.hs b/src/NovaCache/Signing.hs
--- a/src/NovaCache/Signing.hs
+++ b/src/NovaCache/Signing.hs
@@ -9,6 +9,9 @@
     PublicKey (..),
     parseSecretKey,
     parsePublicKey,
+    normalizeKeyText,
+    toPublicKey,
+    renderPublicKey,
     fingerprint,
     sign,
     verify,
@@ -76,10 +79,24 @@
 referenceSep :: Text
 referenceSep = ","
 
+-- | The Unicode byte-order mark.  Windows tooling (PowerShell pipes and
+-- redirects in particular) prepends it to text in transit; it is never
+-- legal key material.
+bomChar :: Char
+bomChar = '\xFEFF'
+
 -- ---------------------------------------------------------------------------
 -- Key parsing
 -- ---------------------------------------------------------------------------
 
+-- | Normalize key text picked up from an environment variable or a file:
+-- drop byte-order marks and surrounding whitespace.  'T.strip' alone is not
+-- enough — a BOM is not whitespace, so it survives stripping and silently
+-- corrupts the key.  None of the dropped characters can be legitimate key
+-- material, so normalizing is always safe.
+normalizeKeyText :: Text -> Text
+normalizeKeyText = T.strip . T.filter (/= bomChar)
+
 -- | Parse a @name:base64@ secret key string.
 parseSecretKey :: Text -> Either String SecretKey
 parseSecretKey txt = do
@@ -93,6 +110,22 @@
   (keyName, decoded) <- splitAndDecode txt "public key"
   expectSize ed25519PublicKeySize "public key" decoded
   pure PublicKey {pkName = keyName, pkBytes = decoded}
+
+-- | Derive the public key corresponding to a secret key, keeping the name.
+--
+-- The 64-byte secret key carries the public half in its second half, but
+-- deriving from the seed through the crypto library validates the key
+-- material instead of trusting it.
+toPublicKey :: SecretKey -> Either String PublicKey
+toPublicKey (SecretKey keyName secretBytes) = do
+  sk <- cryptoSecretKey (BS.take ed25519SeedSize secretBytes)
+  pure PublicKey {pkName = keyName, pkBytes = convert (Ed25519.toPublic sk)}
+
+-- | Render a public key in Nix's @name:base64@ trust-anchor format — the
+-- exact string a client puts in @trusted-public-keys@.
+renderPublicKey :: PublicKey -> Text
+renderPublicKey (PublicKey keyName bytes) =
+  keyName <> keySeparator <> TE.decodeLatin1 (B64.encode bytes)
 
 -- | Split a @name:base64@ string and decode the base64 payload.
 splitAndDecode :: Text -> String -> Either String (Text, ByteString)
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -472,7 +472,40 @@
             pure False
           Right sig ->
             let tampered = ni {NarInfo.niNarSize = 999999}
-             in assertFalse "verify rejects tampered" (Signing.verify pk tampered sig)
+             in assertFalse "verify rejects tampered" (Signing.verify pk tampered sig),
+      test "toPublicKey derives the verifying key" $ do
+        sk <- generateTestSecretKey
+        case Signing.toPublicKey sk of
+          Left err -> do
+            putStrLn ("  toPublicKey failed: " ++ err)
+            pure False
+          Right pk -> do
+            okName <- assertEqual "key name carried over" (Signing.skName sk) (Signing.pkName pk)
+            okBytes <- assertEqual "matches the stored public half" (BS.drop 32 (Signing.skBytes sk)) (Signing.pkBytes pk)
+            case Signing.sign sk mkTestNarInfo of
+              Left err -> do
+                putStrLn ("  sign failed: " ++ err)
+                pure False
+              Right sig -> do
+                okVerify <- assertTrue "derived key verifies a signature" (Signing.verify pk mkTestNarInfo sig)
+                pure (okName && okBytes && okVerify),
+      test "renderPublicKey round-trips through parsePublicKey" $ do
+        sk <- generateTestSecretKey
+        case Signing.toPublicKey sk of
+          Left err -> do
+            putStrLn ("  toPublicKey failed: " ++ err)
+            pure False
+          Right pk -> case Signing.parsePublicKey (Signing.renderPublicKey pk) of
+            Left err -> do
+              putStrLn ("  parse failed: " ++ err)
+              pure False
+            Right reparsed -> assertEqual "round-trip" pk reparsed,
+      test "normalizeKeyText strips BOM and whitespace" $ do
+        ok1 <- assertEqual "BOM stripped" "test-key:abc" (Signing.normalizeKeyText ("\xFEFF" <> "test-key:abc"))
+        ok2 <- assertEqual "CRLF stripped" "test-key:abc" (Signing.normalizeKeyText "test-key:abc\r\n")
+        ok3 <- assertEqual "spaces stripped" "test-key:abc" (Signing.normalizeKeyText "  test-key:abc  ")
+        ok4 <- assertEqual "clean text unchanged" "test-key:abc" (Signing.normalizeKeyText "test-key:abc")
+        pure (ok1 && ok2 && ok3 && ok4)
     ]
 
 -- | Create a test NarInfo for signing tests.
