This is a non-custodial wallet. Users trust us with keys we cannot recover for them.
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/keystore-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.
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/keystore-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.
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.
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.
Review dependency updates carefully. Several layers of automated defence back that up.
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.
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 PRsSee .github/workflows/ci-pre-merge.yml.
pnpm audit: moderate+ advisories in prod deps block merge.- Lockfile drift:
pnpm-lock.yamlmust be in sync with everypackage.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: readis the default permission and jobs elevate only what they need. Everyactions/checkoutusespersist-credentials: false.
- 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
mainand 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.
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.