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.
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.
The dApp itself, plus the injected inpage.js. Fully untrusted. Has no access to chrome.runtime or chrome.storage.
content.ts + content-bridge.ts. The only relay from page to background. Verifies ev.source === window, checks the message target, and caps payload size.
background.ts + src/background/*. Canonicalises the method, gates on the approval class, and uses Chrome-supplied identity (sender.origin).
sage-wasm. Holds the master secret key in memory and performs all signing. JS dispatches a single engine.request(method, json).
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 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.
These must hold. If a change breaks one of them, it's a security regression even if the test suite passes.
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.
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.
The user re-approves every mutating call. Granting at connect time covers reads only; methods: ["*"] is the only mode today.
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.
Any per-fingerprint store shared across dApps (offers.<fp>, watched_assets.<fp>) stamps the creating origin and filters by it in every mutating handler.
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.
Real findings from the audit suite, not hypotheticals. Every new handler, popup screen, or message-passing layer is audited against these.
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.
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.
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.
msg.originA forged origin in a page postMessage could authenticate as a victim. Fix: const origin = sender.origin ?? msg.origin — never invert that order.
An iframe doing parent.postMessage({target:"loroco-content"}) could forward as if it came from the parent. Fix: the bridge checks ev.source === window.
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.
window.chiaA dApp doing window.chia = fake could shadow the provider for later scripts. Fix: defineProperty(..., writable:false, configurable:false).
signCoinSpends / sendTransaction must decode & summarise the bundle — recipients, amounts, fees, asset ids. A bare "N coin spends" count is not consent.