Skip to content
docs

@anywalletkit/extension-kit

npm

The security-relevant core of a non-custodial Chromium (MV3) wallet extension, built on the Any Wallet Kit chain and protocol packages: an encrypted vault, the unlocked-key session with auto-lock, the dApp request router with per-origin permissions and an approval queue, signing-request classification, and the review helpers for swaps and Earn plans. No UI, no @types/chrome, no network code of its own: the chrome adapters take structural objects (StorageAreaLike, AlarmsLike), so everything runs under Vitest with in-memory adapters.

Terminal window
npm install @anywalletkit/extension-kit
# or: pnpm add @anywalletkit/extension-kit · yarn add @anywalletkit/extension-kit

On npm: @anywalletkit/extension-kit, published from the release workflow with provenance.

This package is for wallet developers who build their own extension. It is a library, not an extension: the popup, approval windows, injected providers, service-worker wiring, build and release pipeline are the wallet’s. The Vaulty browser extension is one product built on it.

Terminal window
pnpm add @anywalletkit/extension-kit @anywalletkit/core @anywalletkit/keyring
Area Exports What
Vault Vault, chromeLocalVaultStorage, memoryVaultStorage, VAULT_SCRYPT, VAULT_LIMITS, VAULT_MIN_PASSWORD_LENGTH every secret at rest in chrome.storage.local, two key layers (a KEK wraps a DEK, the DEK seals each secret); passkey (WebAuthn PRF) unlock as a second wrap of the same DEK
Session Session, chromeSessionStorage, chromeAlarms, SESSION_DEFAULT_AUTOLOCK_MS the DEK in chrome.storage.session with an expiry and an alarm; getKey() re-checks the expiry itself
Keyring adapters chromeKeyringStorage, vaultSecretStorage @anywalletkit/keyring metadata as plain JSON, secrets through the vault, one DEK use per call
Relay PageRequest / RelayRequest shapes and guards, isRelayMethod, pageRequestFrom, stampOrigin, checkSenderOrigin, RELAY_ERRORS page ⇄ content script ⇄ service worker messages; the content script takes only messages the page window itself posted (pageRequestFrom(event, window): an iframe’s parent.postMessage is not the site), and the origin is the browser’s (port.sender), never the page’s
Router RequestRouter, READ_METHODS, SIGNING_METHODS, toRelayError what a dApp may call: accounts only for connected origins, chain add / switch through the approval window, reads from the wallet’s own RPCs, eth_sign never offered, everything else 4200
Permissions PermissionStore, CHAIN_ACCOUNT_KEYS per-origin accounts (EVM, Solana, TON, Tron, Bitcoin, NEAR) and chain
Approvals ApprovalQueue, ReviewDeclined, QueueLimitError, APPROVALS_MAX_PENDING_PER_ORIGIN one approval window at a time, at most 3 pending requests per origin and one pending connect; transport notes (setVia) for WalletConnect peers
Signing classifySignRequest, prepareTransaction, createSignHandler, messageText connected-address and chain checks, EIP-7702 delegations from sites refused, nonce / gas / fees from the wallet’s RPC
Networks NetworksTable, DEFAULT_NETWORKS, parseAddChainParams, Solana / TON / Tron endpoints EVM networks with public RPC failover lists; wallet_addEthereumChain only for catalog chains, https only, eth_chainId verified, site-supplied RPCs stamped custom
Review gate ReviewGate, evmCallMatcher, chainTxMatcher after one sheet reviewed a whole plan, exactly those operations pass once within a TTL
Swaps quoteRoutes, reviewSwapPlan, executionFloor, SwapsStore, SwapTracker, parseSwapId progressive quotes over swap2 providers, one review sheet per plan (a gasless order is shown and matched by its EIP-712 hash), pre-approvals bound to the reviewing account (meta.account), pending swaps resumed across worker restarts
Earn reviewEarnPlan, earnPlanMatchers one sheet for an Earn module’s plan (approvals + call), bound to meta.account
TON Connect parseConnectRequest, parseSendTransaction, parseSignData, tonProofMessage, tonSignDataMessage the pure-data half of TON Connect v2 (the digests ton_proof and signData sign, to verify them); the account signs with signTonProof / signTonConnectData (wallet-ton) — sign() refuses 32-byte digests
Phishing PhishingDetector MetaMask eth-phishing-detect list format, shipped with the build (never fetched at runtime); a listed site is refused (refusal → refuse on ApprovalQueue and RequestRouter, 4100, no window); fuzzy lookalikes, brand-in-label, punycode warn
SIWE parseSiwe, siweProblems, describeSiwe EIP-4361 sign-in messages: domain vs. the asking site, address, chain, expiry, nonce (warnings)
Address poisoning lookalikeOf, RecipientBook warns when a new recipient imitates one the user confirmed
WebAuthn verifyAssertion, approvalChallenge, derToRaw, rawToDer ES256 assertion check for “passkey on every signature”; the worker never trusts the page’s word
  • Keys at rest. Envelope v2: scrypt N=2^17 r=8 p=1 (@noble/hashes) → AES-256-GCM wraps a random 32-byte DEK; the DEK seals each secret under AAD forgewallet-vault/secret/<id> (boxes cannot be swapped). The DEK box’s AAD binds the exact KDF parameters, so an envelope whose salt or cost was edited on disk fails to open; unlock refuses sub-floor costs (weak-kdf), and costs or sizes above VAULT_LIMITS (scrypt N ≤ 2^20, r ≤ 16, p ≤ 4; PBKDF2 ≤ 5 000 000 iterations; 1 000 secrets, 32 passkeys, 4 MiB) before any KDF runs (unsupported-envelope). Writes to the envelope are serialized (one Vault per storage area), so parallel imports keep every secret. v1 envelopes (PBKDF2-SHA256, ≥ 600 000 iterations) still open and are re-sealed as v2. Passwords shorter than 8 characters are refused by the vault itself. Wrong password and tampering are the same failure (wrong-password). create never overwrites; reset is an explicit destroy().
  • Passkeys. A passkey wraps the same DEK under HKDF-SHA256(PRF output, salt = credential id); the password stays the recovery path.
  • Re-keying. changePassword re-wraps the same DEK, and removePasskey does not take back a DEK the passkey already unwrapped. After a suspected compromise, rotateVaultKey(password) makes a fresh DEK, re-seals every secret, drops the passkeys (register them again) and writes one envelope; hand the returned DEK to session.unlock, which replaces the old one. setSecret refuses a DEK that does not open the vault’s secrets.
  • Unlocked state. Only the DEK is kept, in chrome.storage.session (memory-only, trusted contexts), with an expiry enforced on every read and an alarm that locks; key buffers this package owns are zeroized after use. unlock / lock / touch / getKey run one at a time, so activity racing a lock never brings the key back.
  • Errors to pages. toRelayError(e, { log }) passes the wallet’s own refusals (EIP-1193 codes) and a node’s JSON-RPC error (code, revert data) with URLs removed; anything else is -32603 Internal error on the page and goes to log.
  • Origins. The content script relays only what the page window itself posted (pageRequestFrom: event.source is the page, event.origin its origin), so an ad or embed in an iframe cannot speak as the site. The service worker answers a relay message only when the content script’s stamped origin equals port.sender.origin. Permissions are keyed by that origin, never by a URL a page or a WalletConnect peer claims.
  • Requests. Everything that reveals accounts or moves funds goes through the approval queue; blind signing (eth_sign) is not offered; typed data for another chain, deployments and site-originated EIP-7702 delegations are refused; read-only RPC goes to the wallet’s own endpoints.
  • Network. The kit makes no requests of its own beyond the RPC / provider calls the wallet wires in; the phishing list is data the wallet ships.

What stays the wallet’s job: the extension-page CSP (script-src 'self', no remote images), a host allowlist for the worker’s fetch, isolating third-party network SDKs (WalletConnect, Omniston) in a sandbox page, the reproducible release build and the store account.

import {
Vault, Session, PermissionStore, NetworksTable, ApprovalQueue, RequestRouter,
chromeLocalVaultStorage, chromeSessionStorage, chromeAlarms, chromeJsonStorage,
chromeKeyringStorage, vaultSecretStorage, checkSenderOrigin, isRelayRequest, PORT_NAME,
} from '@anywalletkit/extension-kit'
// service worker
const vault = new Vault({ storage: chromeLocalVaultStorage(chrome.storage.local) })
const session = new Session({ storage: chromeSessionStorage(chrome.storage.session), alarms: chromeAlarms(chrome.alarms) })
// onboarding
const dek = await vault.create(password) // refuses if a vault exists or the password is too short
await vault.setSecret(dek, 'main', utf8(seedPhrase))
await session.unlock(dek) // dek is zeroized here
// @anywalletkit/keyring on top of the vault
const keyringStorage = chromeKeyringStorage(chrome.storage.local)
const secrets = vaultSecretStorage(vault, session) // VaultError('locked') once the session expired
// the dApp side
const permissions = new PermissionStore(chromeJsonStorage(chrome.storage.local, 'my-wallet.permissions'))
const networks = new NetworksTable(chromeJsonStorage(chrome.storage.local, 'my-wallet.networks'))
const approvals = new ApprovalQueue(myApprovalWindow) // chrome.windows.create({ type: 'popup' }) behind ApprovalWindow
const router = new RequestRouter({ permissions, networks, approvals, accounts, rpc, emit, sign })
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== PORT_NAME) return
port.onMessage.addListener(async (msg) => {
if (!isRelayRequest(msg)) return
const origin = checkSenderOrigin(port.sender, msg.origin)
if (!origin) return // forged origin: dropped, not answered
const result = await router.handle(origin, msg.chain, msg.method, msg.params)
// post a RelayResponse { kind: 'response', id: msg.id, … } back on the port
})
})

memoryVaultStorage, memorySessionStorage, memoryAlarms, memoryJsonStorage and memoryApprovalWindow are the in-memory twins for tests.