packages feed

nova-cache 0.4.1.1 → 0.4.2.0

raw patch · 6 files changed

+120/−30 lines, 6 files

Files

CHANGELOG.md view
@@ -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
README.md view
@@ -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 
exe/Main.hs view
@@ -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.
nova-cache.cabal view
@@ -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 —
src/NovaCache/Signing.hs view
@@ -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)
test/Main.hs view
@@ -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.