Verified Release & Build Integrity Verification
June 30, 2026 · View on GitHub
Language: English | 中文
Verified Release & Build Integrity Verification
ZeroLink publishes a signed build manifest with every release, and official signed frontend builds now use that manifest during bootstrap before the React app loads. This lets the browser detect tampering in the published runtime assets before the user can interact with sensitive UI.
What is verified
- Ed25519 signature —
manifest.sigis a cryptographic signature overmanifest.jsonusing the ZeroLink signing key. - Signed entry binding —
manifest.jsonrecords the expected bootstrap entry bundle path inentryAssetPath, and the browser refuses to trust a release if the currently executing entry asset does not match it. - Runtime file hashes —
manifest.jsonlists SHA-256 hashes for the stable runtime build outputs underdist/assets/, such as hashed JS, CSS, fonts, and other immutable asset files. - Manifest hash —
manifest-hash.txtcontains the SHA-256 ofmanifest.jsonitself; this is displayed in the app's Verified Release card as a public fingerprint, not as the trust anchor.
Pages control files such as _headers and _redirects are intentionally excluded from the signed
runtime manifest because they are deployment metadata, not browser-fetched release assets. Root
documents such as index.html, robots.txt, icons, and other non-asset files are also excluded.
The SPA entry document index.html in particular is left unsigned because edge platforms can
inject request-specific HTML into the bootstrap shell, which makes byte-for-byte signing of that
document unstable even when the underlying deployment is healthy.
What the browser does during bootstrap
When a deployment is built with VITE_RELEASE_VERIFICATION_REQUIRED=true, ZeroLink starts with a
small bootstrap entry instead of loading the React app immediately. That bootstrap entry:
- Fetches
manifest.jsonandmanifest.sig - Verifies the Ed25519 signature using the embedded public key
- Confirms the currently executing bootstrap entry bundle matches
manifest.entryAssetPath - Re-hashes the signed same-origin runtime assets
- Loads the React app only if every check passes
If verification fails or cannot be completed, ZeroLink shows a blocking verification screen and does not load the normal app UI. If the entry bundle does not match the signed manifest, ZeroLink will attempt one controlled page reload before failing closed, which helps recover from stale entry HTML or stale entry-bundle caches without looping forever.
Unsigned environments such as a plain pnpm build, vite preview, or a manual static upload
without signed release artifacts remain runnable, but they are treated as unverified boots and do
not show the Verified Release card.
Artifacts per release
| File | Description |
|---|---|
dist/manifest.json | Signed build manifest with entryAssetPath plus file hashes |
dist/manifest-hash.txt | SHA-256 of manifest.json |
dist/manifest.sig | Ed25519 signature over manifest.json |
keys/manifest-signing.pub | Public key for verification (committed to this repo) |
The release workflow builds the frontend with VITE_RELEASE_VERIFICATION_REQUIRED=true,
generates the signed manifest, and deploys via wrangler deploy (Cloudflare Workers).
Browser trust surface
When bootstrap verification succeeds, the shell renders a Verified Release card with:
- App version
- Build date
- Commit
- Manifest hash
- Verified file count
- Publisher key fingerprint
The Worker serves SPA entry requests with Cache-Control: no-store so the HTML/bootstrap
shell is never reused across deployments, while hashed /assets/* files remain immutable. The
signed manifest is intentionally limited to dist/assets/* runtime build outputs; the HTML
document itself is not hashed, but the bootstrap entry asset it launches must still match the
signed manifest.
For GitHub Actions deployments, the manifest version field is injected from CI via
ZEROLINK_VERSION: production releases use the pushed v* tag without the leading v, while
staging builds use 0.0.0-dev+<short_sha>. Local/manual builds fall back to
packages/frontend/package.json when that environment variable is unset.
Quick verification (automated)
After downloading a release build into packages/frontend/dist/:
pnpm manifest:verify
This will:
- Read
manifest.json,manifest.sig, andkeys/manifest-signing.pub - Verify the Ed25519 signature
- Confirm
index.htmlboots the same entry asset recorded inmanifest.entryAssetPath - Re-hash every signed runtime file and compare against
manifest.json - Print a pass/fail result for each file
Manual verification
1. Verify the Ed25519 signature
# Decode the base64url signature to binary (adds required = padding)
SIG=$(cat packages/frontend/dist/manifest.sig | tr -d '\n' | \
tr -- '-_' '+/' | \
awk '{ l=length(\$0); pad=(4-l%4)%4; printf "%s%.*s\n", \$0, pad, "====" }' | \
base64 --decode)
# Verify using openssl
openssl pkeyutl \
-verify \
-pubin \
-inkey keys/manifest-signing.pub \
-sigfile <(printf '%s' "$SIG") \
-in packages/frontend/dist/manifest.json
2. Verify a specific file hash
# Check a signed runtime asset
# Note: Vite outputs content-hashed filenames such as index-Abc123.js
sha256sum packages/frontend/dist/assets/index-*.js
# Compare with the value in manifest.json (use the actual hashed filename):
jq '.files | to_entries[] | select(.key | startswith("assets/index-"))' \
packages/frontend/dist/manifest.json
3. Verify the manifest hash
sha256sum packages/frontend/dist/manifest.json
cat packages/frontend/dist/manifest-hash.txt
# Both should match
Public key fingerprint
To confirm you are using the correct public key, compute its fingerprint:
openssl pkey -in keys/manifest-signing.pub -pubin -outform DER | sha256sum
Compare the output against the fingerprint shown in the app's Verified Release card or the
fingerprint listed in keys/manifest-signing.pub.
Key rotation
If the signing key is rotated, a new public key will be committed to keys/manifest-signing.pub and announced in the release notes. Old signatures remain valid for old releases.
Reporting issues
If verification fails for a published release, please open a security issue at https://github.com/yclgkd/zerolink/security.