morphit/docs/adr/0019-release-trust-anchor-frontend.md

14 KiB
Raw Permalink Blame History

ADR-0019: Release-trust-anchor frontend wiring (Batch J)

Status: Accepted Date: 2026-04-29 Deciders: Agorise team (Claude collaborating) Supersedes:Related:

  • ADR-0010 (key custody) — defines the trust-anchor pubkey model; this ADR's frontend uses the same configured MORPHIT_OFFICIAL_POSTING_PUBKEY.
  • ADR-0008 (Phase 3b indexer architecture) — the indexer's morphit_release_v1 handler that this ADR's schema reconciles with.

Context

Morphit's release distribution is decentralized in spirit but single-origin in practice: the user loads https://morphit.example.com and runs whatever JavaScript that origin serves. A compromised CDN, hijacked DNS, or operator-account takeover could silently substitute the running code with a malicious build. The user has no easy way to tell.

The chain has, since Phase 3a, supported a morphit_release_v1 custom_json op signed by the operator account. The op carries:

  • version — semver of the announced release.
  • hash_manifest — SRI-style SHA-256 hashes of the build's assets.
  • endpoints — announced relay/indexer/avatar endpoint pools.
  • signature — optional secondary PGP-style attestation.

The indexer validates these ops at chain replay time, gates them on the operator's posting key matching the configured trust anchor, and exposes a /v1/release endpoint surfacing the latest verified row.

What was missing through Batch I: the frontend wasn't using any of this. The schema lived in release.ts, the trust anchor was pinned in config.ts, but no code fetched the announced release, verified it independently, or compared the running bundle against the signed manifest.

The user flagged the gap; Batch J closes it.

Decision

Frontend fetches the release op DIRECTLY from chain RPC, not via the indexer

Trusting the indexer's verification means trusting the indexer. Since the indexer is one of the things a compromised CDN could substitute (different origin, fine; but the running code that chooses which indexer to hit is itself the thing we're trying to verify), this would be circular. The frontend goes to chain RPC directly via the existing BlurtClient's getLatestCustomJson helper, walking up to 10,000 ops back from the head looking for the latest morphit_release_v1 authored by @morphit.

Chain consensus already verified the signature when the block was produced. The frontend's only remaining job is:

  1. Confirm the op is in @morphit's account history (already filtered by getLatestCustomJson).
  2. Confirm @morphit's CURRENT posting authority on chain contains the pinned MORPHIT_OFFICIAL_POSTING_PUBKEY with non-zero weight.
  3. Validate the payload's structural shape.

Trust-anchor pubkey check requires NON-ZERO WEIGHT

Audit J-1 (caught during Batch J's mini-audit) closed the following hole: a naive "is the pinned pubkey present in key_auths" check would pass even if the pinned key has weight 0 (operationally inert; can't sign anything). An attacker who rotated the operator account to a hostile key could leave the old pinned key listed at weight 0 to fool the check. The fix requires the pinned key with weight > 0.

The pure helper checkPinnedKeyInAuthority lives in @morphit/release-schema (cp170; formerly apps/web/src/lib/net/releaseTrustAnchor.ts — extracted into the shared package so the indexer's parity smoke imports it without reaching into apps/web source). Its smoke (apps/indexer/scripts/release-validator-smoke.ts) covers eight scenarios including the explicit J-1 attack:

pinned weight-0 alongside hostile weight-1 → REJECT

Schema reconciliation

The pre-Batch-J ReleasePayloadV1 had speculative field names (release, hashes, notes) that didn't match what the indexer accepted (version, hash_manifest, endpoints). Reconciled in this batch: the frontend now uses the indexer's canonical names. No migration needed because the speculative shape was never broadcast on chain.

Asset-hash verification

After the verified release is fetched, checkManifestAgainstRunningBundle re-fetches each asset listed in the manifest from the running origin and computes its SHA-256 + base64 prefix. Mismatch fires the tamper banner.

Three threat-model decisions:

  1. No cache: 'no-store'. Counter-intuitive: we want to verify what's RUNNING (which came from cache), not what's NEWLY FETCHED. A 'no-store' fetch could miss a tamper that filtered only the original page-load request. Default cache behavior gives us bytes the running scripts came from.
  2. Same-origin enforcement. The asset path is resolved via new URL(path, location.origin + '/') and the resolved URL's origin compared to location.origin. Any escape attempt (manifest paths like //evil.com/x, https://evil.com/x) is rejected.
  3. Sophisticated attacker who serves clean copies on verify fetches but tampered on initial load is OUT OF SCOPE. SRI attributes set on the original <script> tags by the deploy- time build pipeline are the actual defense against that. This module is the post-hoc detection layer that catches deploy-time mismatches and trust-anchor rotation events.

Three error categories trigger banners

The release fetch can fail in five ways. Two surface as silent no-ops (no positive evidence of tamper):

  • rpc_failed — chain RPC unreachable. We can't tell.
  • no_release — no release op found in @morphit's history yet. Pre-launch state.

Three surface as the critical tamper banner:

  • pubkey_mismatch — chain key doesn't match pin. Either legitimate rotation (and the user's bundle is stale) or hostile key swap. Either way, refuse to trust the release.
  • invalid_payload — release op malformed. Shouldn't happen with a legitimate release.
  • asset_mismatch — running bundle's bytes don't match the signed manifest. CDN tampering or deploy-time mismatch.

The fourth banner (informational, not critical) fires when the running version differs from the announced version: the stale-build banner with a "Reload now" button.

Banner ordering in the layout

Two banners mounted above the existing OperatorBlockBanner:

TamperAlertBanner   (red, urgent, non-dismissible)
StaleBuildBanner    (green, informational, "Reload now")
OperatorBlockBanner (advisory, "you're blocked here")

Tamper alert FIRST so it visually dominates if both fire.

Refresh cadence

Once per session at app boot. Releases are infrequent (a handful per year). Long-lived sessions see the latest at next page reload. No periodic refresh in the store — the marginal value isn't worth the marginal chain-RPC traffic.

Build-time version bake-in

The frontend needs to know its OWN version to compare against the announced version. Vite's define injects __MORPHIT_VERSION__ from apps/web/package.json at build time. Declared globally in apps/web/src/app.d.ts for TypeScript.

When deploying a new release, the deploy pipeline:

  1. Bumps apps/web/package.json version field.
  2. Runs the build (Vite bakes the new value into the bundle).
  3. Generates the SHA-256 manifest of the built assets.
  4. Operator broadcasts a morphit_release_v1 op carrying the new version + manifest.
  5. Old client visits → sees running !== announced, stale banner.

Consequences

Positive

  • Closed the open trust-anchor gap that has existed since Phase 3a.
  • Tamper detection covers the realistic CDN-tampering threat model (deploy-time mismatch, hostile mirror substitution).
  • Stale-build detection lets users know when their cached bundle has aged out.
  • Pubkey rotation is detected (pubkey_mismatch banner) — useful even for legitimate rotations as a "your bundle is too old to trust the new key" signal.
  • Audit J-1 closed a real hole that a naive implementation would have shipped with.
  • Endpoint-list announcement infrastructure is in place but NOT auto-applied. Future enhancement: a Settings affordance to merge announced endpoints with user-configured ones, with user consent.

Negative

  • One extra chain-RPC call (or two — release op fetch + signer account fetch) at app boot. Acceptable: the boot path already hits the chain for other things, and these calls are cached per-session.
  • The trust anchor must be rotated coordinately with software releases. If @morphit rotates their key, every running client fires the pubkey_mismatch banner until they reload to a build with the new pin. This is by design — old clients SHOULD refuse to trust a rotated key — but it requires the operator to coordinate the rotation with a software release.
  • Sibling instances run the SAME canonical pin. If a sibling operator wants to publish their own release-discovery, this needs to be configurable (currently a constant). Deferred to Batch K or beyond.

Trade-offs explicitly considered

  • Verify via indexer instead of chain-direct? Rejected. Trusting the indexer is exactly what the trust anchor is meant to NOT require.
  • Use existing /v1/release endpoint at all? No. The endpoint exists for indexer-internal consumers (e.g. ops-cli health checks), not for the trust-establishing frontend.
  • Verify on every page nav vs once per session? Once per session is sufficient — releases are rare. Re-running on every navigation would multiply chain-RPC traffic for no real-world detection win.
  • Auto-apply announced endpoints? No. Endpoint adoption is a user-trust decision; the announced list is a recommendation, not a mandate. The ReleaseEndpoints data is captured but no consumer auto-applies it.

Implementation

  • packages/release-schema/src/release.ts — schema (reconciled to indexer's canonical fields). Extracted from apps/web/src/lib/net/release.ts into the shared @morphit/release-schema package at cp170 so both the frontend and the indexer import one canonical copy.
  • packages/release-schema/src/releaseValidate.ts — pure validator; mirrors indexer's rejection reasons. (Was apps/web/src/lib/net/releaseValidate.ts pre-cp170.)
  • packages/release-schema/src/releaseTrustAnchor.ts — pure pubkey- authority check (carved out for smoke-testability). (Was apps/web/src/lib/net/releaseTrustAnchor.ts pre-cp170; moved into the package so the indexer's parity smoke imports it without a cross-app reach.)
  • apps/web/src/lib/net/releaseFetch.ts — chain-direct fetch + trust-anchor verification + payload validation.
  • apps/web/src/lib/net/releaseHashCheck.ts — asset-hash verification (browser SubtleCrypto-backed).
  • apps/web/src/lib/stores/release.ts — orchestration store with derived staleBuild and tamperedAssets.
  • apps/web/src/lib/components/StaleBuildBanner.svelte — informational banner.
  • apps/web/src/lib/components/TamperAlertBanner.svelte — critical banner.
  • apps/web/src/routes/+layout.svelteinitRelease() in onMount, banners mounted above OperatorBlockBanner.
  • apps/web/vite.config.js — Vite define injects __MORPHIT_VERSION__ from package.json.
  • apps/web/src/app.d.ts — global type declaration for the injected constant.
  • Smoke: apps/indexer/scripts/release-validator-smoke.ts — 47 scenarios covering the validator (29) + the J-1 pubkey- authority check (8) + miscellaneous shape edge cases.
  • i18n: 12 banner keys × 10 locales = 120 strings, drift = 0.

Open questions / future work

  • Sibling-instance support: currently RELEASE_SIGNER_ACCOUNT and MORPHIT_OFFICIAL_POSTING_PUBKEY are constants. For a sibling operator to publish their own release-discovery, these need to be configurable. Deferred.
  • Endpoint-list adoption UX: a Settings affordance ("merge announced endpoints into your rotator config") with explicit user consent. Deferred.
  • Release-notes URL: the indexer schema doesn't carry a release notes link. The optional signature field is opaque; could be used as a URL pointer in the future. Deferred.
  • Multi-version compatibility: today's frontend treats any version mismatch as "stale." A future enhancement could distinguish "patch update available" vs "MAJOR update — you must reload." Deferred.

Amendment (cp410, 2026-07-04) — the sole browser→node reader

cp410 made a project-wide change: the browser must NEVER contact a Blurt RPC node directly. Every other browser chain read — account lookups, history, the chain head, block/tx fetches for chat payment + identity verification, and the broadcast path — now routes through the operator's OWN indexer (same-origin POST /v1/chain/condenser and /v1/broadcast), so third-party node operators never see the user's IP or reads.

Release verification is the ONE exception, and this ADR is why. Its whole purpose is to detect a malicious or compromised operator serving a tampered build, so it MUST read the real chain rather than the operator's own indexer — routing it through the indexer would let that operator forge a "verified" release and defeat the check entirely (it does not re-verify the op signature; it trusts that an op in @morphit's history was signed by @morphit, which only holds against the real chain). Ken confirmed this trade-off explicitly (privacy vs. anti-tamper): release verification stays direct-to-chain.

Implementation: fetchVerifiedRelease now uses getDirectChainClient() ($blurt/client) — a BlurtClient wired to the node-hopping rotator instead of the indexer relay. It is the SOLE sanctioned browser→Blurt-node reader; a smoke (rpc-privacy-routing-smoke) sweeps every web source and fails if any file OTHER than releaseFetch uses it, and fails if releaseFetch regresses to the indexer. The rotator (net/endpoints.ts) and the edge CSP's RPC connect-src origins are retained specifically for this boot-time read.

Residual limits (unchanged, accepted): the check is fail-open (a node that can't be reached → no banner), and a hostile operator who also controls the served frontend can still strip the check from the JS. It remains defense in depth / detection, not a hard guarantee.