314 lines
14 KiB
Markdown
314 lines
14 KiB
Markdown
# 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.svelte` — `initRelease()` 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.
|