Skip to content

Security: perawallet/pera-react-native

Security

docs/SECURITY.md

Security

This is a non-custodial wallet. Users trust us with keys we cannot recover for them.

Reporting a vulnerability

A flaw that exposes key material, forges a signature, or makes a transaction display as something other than what it does has no remedy after the fact. Report it privately to security@perawallet.app rather than opening an issue, pull request or discussion, and please don't publish it before a fix ships.

Send whatever you have: affected version and platform, the steps or payload that reproduce it, and what an attacker gains. A rough report early beats a polished one late.

In scope is anything that reaches key material, produces a signature the user did not authorise, or makes a transaction render as something other than what it will do on chain — the mobile app in apps/mobile, the extension vault in extensions/vault-chrome, the hardware-wallet and passkey transports under extensions/, and the shared logic in packages/. A build or dependency path that could get unreviewed code into a released binary counts too.

Out of scope: findings that need a device the attacker already controls (rooted or jailbroken, screen unlocked), that need the user to hand over their own passphrase, or that rest on a compromised third-party service we call. Scanner output without a working reproduction is rarely actionable on its own.

We triage privately, fix on a private branch, and publish an advisory once a release carrying the fix is out. Say whether you want credit and how you would like to be named.

Non-negotiables

Private keys, mnemonics and passwords never appear in a log, a crash report, an analytics event or an error message. Addresses and transaction hashes are safe to log.

Key material lives in the keystore and nowhere else: @algorandfoundation/react-native-keystore on native; on the browser extension, @algorandfoundation/keystore-web's IndexedDB store, with every record sealed under the master key that the extensions/vault-chrome vault releases only while unlocked. A profile written before this layout reaches that state only at its first unlock on the new build, when the re-seal sweep runs. It never goes into keyValueStorage, a Zustand store, or React state that outlives the operation.

That covers signing key material. WalletConnect v1's session key is a bridge secret rather than a signing key, and on the extension it lives in keyValueStorage: the offscreen document revives sockets before the vault is unlocked, so it has no key source to seal one under (native keeps it in the keystore). Reading it lets an attacker read and inject that dApp session's bridge traffic; it signs nothing. WalletConnect v2's keychain (pairing and session symKeys, the client seed) is the same class of secret and lives in keyValueStorage on native as well, under the wc2: prefix, because WalletKit persists it as one row on every change. See Connections.

Validate user input and API responses before acting on them. Keep secrets in .env, not in source.

Known limitation: changing the vault password does not rotate the key

On the browser extension, changePassword re-wraps the same 32-byte master key under a key derived from the new password. That master key is also the key every keystore record is sealed under, so a password change changes who can open the vault going forward and nothing about what is inside it.

An attacker holding a copy of the old vault:wrapped-master-key blob together with the old password decrypts their copy with their blob, and any later copy of the profile's IndexedDB opens under the same master key. A password change is therefore not a remedy for a leaked password. The passkey (PRF) blob likewise keeps wrapping the unchanged key.

Rotation — a fresh master key, every record re-sealed under it, both blobs re-wrapped — would close that case. It is not built. Until it is, the remedy for a leaked password is to move the funds to a new wallet. "Delete all data" destroys the vault (both blobs and the session key), so a wallet created after it is sealed under a fresh master key; one created alongside the old vault is not.

What the vault defends: a cold attacker holding a copy of the browser profile and no password (an infostealer, a backup, a shared machine). Only Argon2id's cost stands between them and the material. What it does not defend: a script running in the extension's own origin while the vault is unlocked — a compromised dependency, XSS in an approval page, an open devtools session — which can read the session key from chrome.storage.session and use it directly.

EVM

The secp256k1 seam

Only the keystore shim in extensions/provider/src/keystore/shims/secp256k1 holds secp256k1 private key bytes. pera/secp256k1-library-seam fails any other import of @noble/secp256k1, @scure/bip32 or @noble/curves/secp256k1, and pera/single-copy-packages keeps the lockfile to one ox and one @noble/curves and @noble/hashes per major, so a dependency bump can't slip a second implementation into the bundle. Chain code reaches a key only through the KMS's sign, in the pera.accounts access domain (SIGNING_ACCESS_DOMAIN).

viem hashes, serializes, recovers addresses and speaks JSON-RPC. It never holds a key: nothing imports viem/accounts. A build whose CHAINS lists no EVM chain leaves viem out of the bundle; see Chains.

What gets signed

The key store signs only the 32 bytes Ethereum's planner hashed from the transaction or message it parsed out of the request (packages/chain-ethereum/src/signing/digests.ts), never a hash the request carries. Before hashing, the planner refuses a transaction whose EIP-155 chain id, or typed data whose domain chain id, isn't the request's network, since a signature for another chain would be valid there. The message signer supports personal_sign and eth_signTypedData_v4 only, so eth_sign, which signs a caller's raw hash, is refused. After signing, the planner recovers the signer from the signature and refuses a result that doesn't recover to the sender. How a request's summary is proven from its calldata is in Architecture.

RPC trust

Each EVM network reads through one JSON-RPC URL fixed at build time (<CHAIN>_<NET>_RPC_URL), with no fallback and no certificate pinning: EVM chain modules declare no pinnedHosts. The RPC is trusted for what it reports (balances, nonces, gas and fee data, receipts, contract reads), so a wrong or malicious one can misreport balances and history, stall a send with a bad nonce, or fail it.

It cannot move a signature to another chain or have the wallet sign anything but the transaction the review showed: the EIP-155 chain id comes from the chain table, not the RPC, and the planner hashes the reviewed transaction. A fee it inflates shows on the review, and the build refuses to sign past the max fee the user confirmed; see EVM fees.

A developer's endpoint override is checked before it applies: checkEvmRpcChainId (packages/chain-ethereum/src/blockchain/utils/checkEvmRpcChainId.ts) asks the URL for its chain id and rejects one serving another network, so a testnet can't be pointed at mainnet.

Supply chain

Review dependency updates carefully. Several layers of automated defence back that up.

Freshness window

pnpm-workspace.yaml sets minimumReleaseAge: 10080 (7 days), so packages published in the last week are refused at install time. This is the primary defence against compromised-publish attacks, which are typically detected and yanked within hours to days. Exceptions live under minimumReleaseAgeExclude and must be justified in a comment, for example a security patch needed before the window elapses.

Transitive-dep pins

Vulnerable transitives we can't upgrade directly are pinned under pnpm.overrides in pnpm-workspace.yaml. Use per-major pins (pkg@N: x.y.z). Range-style overrides like pkg@>=a <b don't actually rewrite pnpm's resolution when the caller requests a narrower range.

pnpm audit                            # Fails on any advisory
pnpm audit --prod --audit-level=high  # What CI runs to block PRs

CI-enforced

See .github/workflows/ci-pre-merge.yml.

  • pnpm audit: moderate+ advisories in prod deps block merge.
  • Lockfile drift: pnpm-lock.yaml must be in sync with every package.json.
  • Gitleaks scans the PR diff for committed secrets. The local allowlist lives in .gitleaks.toml; add an entry rather than disabling a rule globally.
  • Every GitHub Action is pinned to a full commit SHA with a trailing version comment. No floating tags (@v4, @main); Dependabot handles SHA bumps.
  • contents: read is the default permission and jobs elevate only what they need. Every actions/checkout uses persist-credentials: false.

Scheduled

  • CodeQL runs JS/TS static analysis weekly and on PR; findings go to the Security tab.
  • OpenSSF Scorecard publishes a weekly posture score to the public Scorecard API.
  • SBOM generates a CycloneDX SBOM on every push to main and weekly, retained 90 days.
  • Dependabot opens weekly grouped updates for npm and github-actions. Framework-tier majors (React, React Native, Expo, TypeScript, ESLint) are ignored and bumped by hand.

Pre-push

tools/pre-push runs gitleaks locally against the commits you're about to push. It soft-skips if gitleaks isn't installed (brew install gitleaks enables it). The CI job is still the authoritative scan.

There aren't any published security advisories