nova-cache 0.4.1.1 → 0.4.2.0
raw patch · 6 files changed
+120/−30 lines, 6 files
Files
- CHANGELOG.md +18/−0
- README.md +2/−16
- exe/Main.hs +32/−12
- nova-cache.cabal +1/−1
- src/NovaCache/Signing.hs +33/−0
- test/Main.hs +34/−1
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> · protocol endpoints: <code>/nix-cache-info</code>, <code>/<hash>.narinfo</code>, <code>/nar/<file></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.