Skip to content

Repository files navigation

Gryd Lock 🔒

CI License: MIT Manifest V3 Built with TypeScript Status: Early Build

The Gryd Lock browser extension — warns before signing only when its current protection path can be observed honestly.

Overview

This runs entirely in the user's browser. It hooks supported wallet signing flows, builds a versioned local review of the complete pending transaction, obtains optional risk evidence only for typed account targets, and renders a four-tier warning. A bounded review lets the user decide; malformed or over-limit XDR is cancelled rather than forwarded without an honest review. The toolbar first reports whether the current tab has recently completed the extension's non-financial health check; it never calls a site or wallet flow protected merely because the extension is installed.

For detailed information on data handling, data protection, and our no-telemetry architecture, see PRIVACY.md.

Status: Early build. A Freighter signTransaction proxy reviews the complete envelope and shows the warning before signing. An Albedo adapter intercepts the window.open popup flow. A live oracle connection is not yet built — see the roadmap.

What it does

User initiates a transaction in a Stellar wallet
        │
        ▼
Extension intercepts the unsigned transaction (Freighter signing flow)
        │
        ▼
Decode the transaction XDR → build a complete local review
        │
        ▼
Classify each operation as understood, partial, or opaque
        │
        ▼
Request optional 0–100 risk evidence for typed account targets
        │
        ▼
Aggregate semantic findings, incomplete coverage, and evidence → show the warning
        │
        ▼
User proceeds or cancels — the extension never blocks

Warning tiers

Score Tier Behaviour
0–20 Low Green indicator, proceed
21–50 Elevated Soft warning
51–75 High Strong warning, checkbox enables proceed
76–100 Critical Recommend abort, type CRITICAL to proceed

Why Freighter First

Stellar has no universal injected wallet provider — each wallet exposes its own signing API, so interception is per-wallet, not global. Gryd Lock targets Freighter first (the most widely used browser wallet), proves the interception pattern, then generalises to xBull, Albedo, and Lobstr.

Albedo Interception

Albedo is a web-based signer at albedo.link — it has no browser extension of its own that Gryd Lock can hook. Instead, the @albedo-link/intent npm library (used by dApps) opens a cross-origin popup via window.open('https://albedo.link/confirm', 'auth.albedo.link', ...) and communicates with it using postMessage.

Interception strategy (src/intercept/albedoMainWorldEntry.ts, MAIN world):

dApp calls albedo.tx({ xdr, network }) via @albedo-link/intent
        │
        ▼
albedo-intent calls window.open('https://albedo.link/confirm', 'auth.albedo.link', ...)
        │
        ▼
albedoMainWorldEntry.ts monkey-patches window.open at document_start
  └─▶ detects target === 'auth.albedo.link'
  └─▶ opens the real Albedo popup
  └─▶ returns a Proxy wrapping the real popup window
        │
        ▼
albedo-intent calls proxy.postMessage({ intent: 'tx', xdr, network, __reqid, ... })
        │
        ▼
Proxy intercepts first postMessage for 'tx' / 'pay' intents with an xdr field
  └─▶ routes xdr through GRYDLOCK_REQUEST_OUTCOME → bridge → background (same path as Freighter)
  └─▶ background decodes XDR, scores destinations, opens warning popup
        │
        ▼
'cancel'            → real popup closed; synthetic albedoIntentResult error posted back
                       to the dApp (code -4, matching Albedo's standard rejection shape)
'allow' / 'proceed' → original postMessage forwarded to the real Albedo popup; user signs
                       in Albedo normally

What is not intercepted:

  • Implicit-flow intents — these bypass the popup (they send directly via an iframe/session token established earlier). The XDR is not visible at the window.open layer.
  • Non-XDR intents (public_key, sign_message, trust, exchange) — no transaction destination to score; passed through unmodified.
  • SEP-0007 link handler (Albedo browser extension variant) — uses a page redirect rather than a popup; different surface not covered by this adapter.

Manual verification: Load a dApp that calls albedo.tx({ xdr: '...', network: 'testnet' }), open the extension's dev popup first to confirm the bridge is alive, then trigger the Albedo signing flow. The Gryd Lock warning should appear before the Albedo confirmation dialog receives the intent payload.

Accessibility

The warning popup is fully accessible to screen reader users (e.g., VoiceOver, NVDA). It implements the alertdialog ARIA role and uses an assertive live region to ensure the risk tier is announced immediately upon opening. The popup wires the risk level, destination, and warning message together using aria-describedby so the complete context is conveyed coherently without relying on visual cues.

Tech Stack

  • TypeScript — extension logic
  • React — warning UI in the popup
  • Stellar SDK (JS) — decoding the unsigned transaction
  • Manifest V3 — Chrome / Brave / Edge extension format

The risk score itself is fetched through grydlock-oracle-adapter; this repo holds no scoring logic.

Project Layout

grydlock-extension/
├── manifest.json             # MV3 — popup, background service worker, content scripts
├── scripts/
│   └── build-extension.mjs   # esbuild bundle for background.js / mainWorld.js / albedoMainWorld.js / bridge.js
├── src/
│   ├── adapter/                # Oracle adapter stub — getScore(destination)
│   ├── background/             # Service worker: decodes XDR, scores, opens the warning popup
│   ├── decode/                  # XDR → destination extraction (Stellar SDK)
│   ├── history/                  # Decision history page (extension options page)
│   ├── intercept/                 # Freighter + Albedo proxies + message-bridge protocol
│   ├── lib/                        # Score → tier mapping, local decision history storage
│   └── popup/                       # React warning UI — default (dev) and intercept modes
└── README.md

Decision History

Every proceed/cancel decision made in the warning popup is recorded locally: destination, asset (when present), score, tier, decision, and timestamp. Open it via the extension's Options page (right-click the toolbar icon → Options, or chrome://extensions → Gryd Lock → Details → Extension options).

Privacy:

  • History lives only in chrome.storage.local on your device — it is never transmitted anywhere.
  • Storage is capped to the most recent 200 decisions; older entries are dropped automatically.
  • A Clear history button on the page deletes everything at once.

Protection coverage

The toolbar popup reports a fresh, per-tab health status instead of a placeholder score. A protected result requires a recent MAIN-world → isolated bridge → worker → MAIN-world handshake; the handshake carries only an ephemeral nonce, adapter name, and protocol version. It never signs, submits, reads, or exposes a transaction. The popup distinguishes stale, bridge unavailable, worker unavailable, permission denied, adapter incompatible, unsupported, and unknown states.

This proves Gryd Lock's own execution contexts can communicate, but it cannot prove Chrome will order it before another extension's listener. That browser limitation remains visible in the UI and is a real-wallet release gate. Supported adapters and explicit exclusions are in protection coverage.

Transaction review coverage

Before a signing request reaches a wallet, Gryd Lock builds a local, versioned review of the exact XDR. It shows the exact network passphrase, network-bound digest, envelope/fee source, memo, ordered operations, static facts, typed targets, and semantic findings. Every SDK operation is classified as understood, partial, or opaque. Partial and opaque operations are always shown as incomplete review coverage; they are never presented as a low-risk assessment.

Only Stellar account targets are eligible for the current destination scorer. Contract IDs, claimable-balance IDs, and liquidity-pool IDs remain typed local review data and are never misrepresented as account addresses. Static review does not simulate a transaction or claim to know ledger-dependent outcomes.

How the Pieces Connect

Toolbar click (dev/testing) — unchanged from the stub-only build:

manifest.json (action.default_popup)
        │
        ▼
src/popup/index.html → main.tsx → App.tsx (default mode)
                                        │
                                        ├─▶ src/adapter/oracleAdapter.ts → getScore(destination)
                                        ├─▶ src/lib/tiers.ts → tierForScore(score)
                                        └─▶ src/popup/DevScoreSlider.tsx (dev-only override)

Real Freighter signing@stellar/freighter-api's signTransaction doesn't call a global function; it posts { source: 'FREIGHTER_EXTERNAL_MSG_REQUEST', type: 'SUBMIT_TRANSACTION', ... } to window, and Freighter's own content script replies the same way. That postMessage traffic is the actual interception point:

dApp posts { source: FREIGHTER_EXTERNAL_MSG_REQUEST, type: SUBMIT_TRANSACTION, transactionXdr, networkPassphrase }
        │
        ▼
src/intercept/mainWorldEntry.ts   (MAIN world; grabs the request via stopImmediatePropagation()
        │                          before Freighter's own listener sees it)
        │  window.postMessage (Gryd Lock's own internal request/response, separate from Freighter's)
        ▼
src/intercept/bridgeEntry.ts      (isolated world; only place with chrome.* API access;
        │                          generates the real requestId here, never in MAIN world,
        │                          so a page script can't observe it and forge a decision)
        │  chrome.runtime.sendMessage
        ▼
src/background/background.ts      (service worker)
        │
        ├─▶ src/decode/transactionReview.ts → extractTransactionReview(xdr)
        │      retains the exact network-bound digest, ordered operations, static facts,
        │      typed targets, coverage, and semantic findings
        │
        ├─▶ typed account targets scored independently via src/adapter/oracleAdapter.ts
        │
        └─▶ shared review policy aggregates semantic findings, incomplete coverage, and evidence
                   │
                   ▼
             src/popup/App.tsx retrieves worker-resident review data by request ID; the popup URL
             contains no XDR, memo, target, amount, score, or semantic detail
                   │  chrome.runtime.sendMessage({ type: 'DECISION_MADE', ... })
                   ▼
        background resolves the pending request by its one-shot request ID → bridge → mainWorld
                   │
                   ▼
        'cancel'            → mainWorld synthesizes a decline FREIGHTER_EXTERNAL_MSG_RESPONSE;
                               Freighter's own listener never sees the request at all
        'proceed' / 'allow' → mainWorld re-posts the original request (tagged so it isn't
                               re-intercepted) for Freighter to handle exactly as it would have
  • Registration-order dependent: this only works if mainWorldEntry.ts's listener registers before Freighter's own content script does. Both run at document_start, but Chrome does not guarantee injection order across different extensions — the same tradeoff every postMessage-based wallet-firewall extension accepts.
  • Why the split: mainWorldEntry.ts runs in the page's own JS context (needed to see the page's postMessage traffic) but has no chrome.* API access there; bridgeEntry.ts runs alongside it in the isolated content-script world and is the only piece that can talk to the extension via chrome.runtime. Decoding and scoring happen in the background worker rather than in mainWorldEntry.ts so the Stellar SDK ships once per browser session instead of being injected into every page (mainWorld.js is ~2 KB; the SDK lives in background.js instead).
  • Keyboard-first approval: the warning is an approval dialog for a signing request, so it is fully operable without a mouse. src/popup/TierWarning.tsx renders as a modal dialog (role="dialog", aria-modal, labelled by the tier heading) and:
    • focuses Cancel on open for every tier — the safe choice is always one keypress away, and a High/Critical warning never makes you hunt for focus;
    • traps focus in the popup — Tab and Shift+Tab cycle through its interactive elements and wrap at the ends, so focus can't land on browser or extension UI while a decision is pending;
    • treats Escape as Cancel, routed through the same onCancel path as the button, so an intercepted request declines identically however it was dismissed;
    • leaves Enter/Space activation to native <button> behaviour rather than re-implementing it.
  • Pure logic: src/intercept/resolveOutcome.ts is the testable core — given a decode function, a score function, and a decision function, it returns 'allow' | 'proceed' | 'cancel' with no Chrome APIs involved, so it's covered by ordinary Vitest unit tests.
  • Fail-closed review: malformed or over-limit XDR returns cancel; partial and opaque operations are rendered as incomplete coverage and can never be presented as an unqualified low-risk review. Static review never claims ledger-dependent effects.
  • Typed targets: account, contract, claimable-balance, and liquidity-pool identifiers retain the exact network passphrase. Only account targets are projected to the legacy destination scorer. In particular, a claimable-balance ID is local review data, never an account-oracle query.
  • Tests: src/decode/transactionReview.test.ts, src/decode/sorobanSemantics.test.ts, and src/intercept/resolveOutcome.test.ts cover review construction, bounded Soroban analysis, score projection, aggregation, malformed XDR, and decisions. Popup tests cover keyboard-accessible review rendering and the worker-resident review data flow.

Security

Report suspected vulnerabilities privately using SECURITY.md. The threat model documents the extension's trust boundaries, current guarantees, explicit non-goals, known gaps, and security-hardening roadmap.

manifest.json requests host_permissions: ["<all_urls>"] and matches <all_urls> for its document_start content scripts. docs/permissions.md documents why, the narrower alternatives considered (activeTab + optional_host_permissions), and why they were deferred.

Develop

  1. npm install
  2. npm run build (or npm run dev for a local dev server against the default/dev popup only — the content scripts and background worker require a real chrome://extensions load).
  3. Go to chrome://extensions, enable Developer mode, click Load unpacked, select the dist/ output.
  4. Open the popup from the toolbar to exercise the dev/testing flow — the score comes from the adapter stub, and in dev builds the dev control lets you drag through all four tiers. To exercise real interception, visit a page with Freighter installed and call signTransaction. The popup follows the browser or OS prefers-color-scheme setting in both the default preview and interception flows.

Quality Gates

npm run lint              # ESLint
npm run typecheck         # tsc --noEmit
npm run test:coverage     # Vitest + v8 coverage (enforces thresholds)
npm run validate:manifest # MV3 / Chrome Web Store policy checks
npm run build             # tsc -b && vite build && node scripts/build-extension.mjs
npm run test:visual       # Playwright visual regression snapshots

All six run in CI (.github/workflows/ci.yml) on every push to main and on every pull request. Popup visual regression snapshots run in CI as well via npm run test:visual; if a UI change is intentional, refresh baselines locally with npx playwright test --update-snapshots and commit the updated files from tests/visual/popup.spec.ts-snapshots/.

Content Security Policy. manifest.json declares content_security_policy.extension_pages as script-src 'self'; object-src 'self'. The Vite build emits no inline scripts or styles for the popup or history pages — all scripts are bundled into separate .js files referenced by <script src="..."> tags — so both popup modes (dev and intercept) load cleanly under this CSP with no console violations.

Coverage policy. Thresholds are configured in vite.config.ts and enforced by npm run test:coverage (CI runs this instead of bare vitest run). The following files are excluded from coverage because they require Chrome APIs or a real DOM that unit tests cannot provide:

  • src/intercept/mainWorldEntry.ts / src/intercept/bridgeEntry.ts — depend on chrome.* APIs and postMessage across extension worlds; covered by the e2e harness.
  • src/intercept/albedoMainWorldEntry.ts — monkey-patches window.open in the MAIN world; covered by the e2e harness. Pure behavioural logic is unit-tested in albedoMainWorldEntry.test.ts.
  • src/background/background.ts — service-worker chrome.* calls; covered by the e2e harness.
  • src/popup/main.tsx — React entry-point boilerplate.
  • src/intercept/protocol.ts — constant and type definitions only.

Timeout Behavior for getScore

getScore now includes a built‑in timeout to prevent the signing flow from hanging indefinitely. The default timeout is 5 seconds and can be overridden per call via the optional options parameter. If the operation exceeds the timeout, the function resolves with a fallback score of -1, which is treated as an unknown score and results in a safe warning tier.

You can configure the default timeout by modifying src/adapter/config.ts (DEFAULT_GET_SCORE_TIMEOUT_MS). Tests use a shorter timeout to verify the fallback behaviour.

Contributing

See CONTRIBUTING.md for local setup, architecture guardrails, testing expectations, required quality gates, and the pull request checklist.

Changes that introduce or alter shared abstractions, browser-context responsibilities, message contracts, interception semantics, or security boundaries should follow the lightweight Architecture Decision Record process.

Roadmap

  • Popup renders one score across the four tiers. (stub)
  • Fetch the score through the oracle adapter (stub score) — prove the query path end to end.
  • Freighter interception: proxy signTransaction, decode the XDR, extract the destination, route it through the adapter.
  • Local decision history: persist proceed/cancel decisions to chrome.storage.local (capped, on-device only) with a history page.
  • Albedo interception: monkey-patch window.open to intercept the auth.albedo.link popup, proxy the first tx/pay postMessage through the scoring pipeline before it reaches Albedo.
  • Swap the stub score for a live one from the adapter.
  • Generalise interception beyond Freighter and Albedo (xBull, Lobstr).

Do not build real interception until the adapter returns a real score. Interception without a working score source is a warning with nothing to warn about.

Dependency Maintenance

Dependabot checks npm dependencies and GitHub Actions every Monday. Updates are grouped so routine tooling changes do not obscure runtime changes that affect the wallet-warning path.

Dependabot alerts and security updates are controlled in repository settings and should remain enabled. This file configures the scheduled version-update pull requests.

The auto-merge policy is intentionally narrow:

  • patch updates to direct development dependencies may auto-merge;
  • patch updates to GitHub Actions may auto-merge;
  • runtime dependencies, indirect dependencies, and every minor or major update require human review;
  • a Dependabot PR with maintainer-authored changes is no longer auto-merge eligible.

Auto-merge does not replace CI. The repository must enable GitHub auto-merge, protect main, and require the existing CI verify job. Repository or organization Actions settings must also allow the workflow token to approve pull requests. If those settings are absent, maintainers should merge Dependabot PRs manually after npm run lint, npm run typecheck, npm test, and npm run build pass.

About

Browser extension that warns you before you sign a risky Stellar transaction.

Resources

Contributing

Security policy

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages