Architecture

The trust model

Loroco is an MV3 extension that exposes a window.chia-style provider. Its whole design is a sequence of layers, each one stripping capabilities. A hostile dApp can only reach the signing engine via a correctly-shaped path through every prior layer's gate.

Components

Four layers, in order of trust

Each step removes power. A dApp page can only reach layer 4 through every gate above it — most security bugs come from a layer doing something a higher layer assumed it didn't.

1

MAIN-world page script

The dApp itself, plus the injected inpage.js. Fully untrusted. Has no access to chrome.runtime or chrome.storage.

2

ISOLATED-world content script

content.ts + content-bridge.ts. The only relay from page to background. Verifies ev.source === window, checks the message target, and caps payload size.

3

Service worker / background

background.ts + src/background/*. Canonicalises the method, gates on the approval class, and uses Chrome-supplied identity (sender.origin).

4

WASM engine

sage-wasm. Holds the master secret key in memory and performs all signing. JS dispatches a single engine.request(method, json).

Key rule
chrome.runtime.sendMessage is never exposed to the MAIN world. Pages can only reach the service worker via window.postMessage → content-bridge → runtime.sendMessage. Don't add externally_connectable to the manifest without a strong reason.
The engine

Sage as one engine — not 110 WASM exports

The Sage Rust library is used as a single engine instance, the same pattern Ozone uses today through sage_flutter_binding (Dart FFI). The JS side only knows two calls:

const engine = new Sage(idbCallbacks);          // boot the engine
const res = await engine.request(method, jsonParams);  // single dispatch

Loroco does not ship individual wasm-bindgen exports, and does not glue together other JS chia libs (chia-bls.js, clvm-rs.wasm, greenweb…). Everything the wallet needs lives inside the sage engine.

Guarantees

Security invariants

These must hold. If a change breaks one of them, it's a security regression even if the test suite passes.

Origin authenticity

The origin a handler receives is the page's real origin — sender.origin, set by Chrome at the process boundary. msg.origin is a last-resort fallback for same-extension senders only.

Connect is the only entry

Every non-connect method requires a prior grant (requireConnected + ensurePermissions). A new method goes into NO_APPROVAL_METHODS or ALWAYS_APPROVAL_METHODS — never bypasses the gate.

Approval is per-call

The user re-approves every mutating call. Granting at connect time covers reads only; methods: ["*"] is the only mode today.

Popup is authoritative

Whatever the popup renders is what the user consents to. If a handler normalises params (to ?? address), the popup must normalise identically — otherwise a dApp can spoof what the user sees.

Per-origin isolation in shared storage

Any per-fingerprint store shared across dApps (offers.<fp>, watched_assets.<fp>) stamps the creating origin and filters by it in every mutating handler.

WASM holds the SK

The master secret key and mnemonic are never persisted to chrome.storage.* in plaintext. chrome.storage.session holds the encrypted keychain blob; the unlock password is supplied each browser session.

Hard-won lessons

Anti-patterns the code guards against

Real findings from the audit suite, not hypotheticals. Every new handler, popup screen, or message-passing layer is audited against these.

1 · Alias the popup doesn't show

transfer accepted to ?? address while the popup showed only to. A dApp sending {address:"evil"} made the popup show a blank recipient while the handler spent to evil. Fix: handler & popup normalise the same way.

2 · Cross-origin shared storage

offers.<fp> listed every offer. Any connected origin could cancel another origin's offer and — with secure:true — drain the maker coin back. Fix: stamp origin, filter by sender.origin.

3 · Unbounded payload

A 10 MiB string in signMessage invalidated the whole extension context — a silent DoS any unconnected page could trigger. Fix: 4 MiB cap in the bridge + SW, surfaced as 4029.

4 · Trusting msg.origin

A forged origin in a page postMessage could authenticate as a victim. Fix: const origin = sender.origin ?? msg.origin — never invert that order.

5 · Iframe → parent bridge

An iframe doing parent.postMessage({target:"loroco-content"}) could forward as if it came from the parent. Fix: the bridge checks ev.source === window.

7 · Alias bypassing gating

Adding an alias to a canonical that isn't registered in an approval class makes it silently mutating-without-approval. Fix: every alias maps to a canonical present in one of the two sets.

8 · Writable window.chia

A dApp doing window.chia = fake could shadow the provider for later scripts. Fix: defineProperty(..., writable:false, configurable:false).

6 · Blind-signing opaque blobs

signCoinSpends / sendTransaction must decode & summarise the bundle — recipients, amounts, fees, asset ids. A bare "N coin spends" count is not consent.