Summary
w3pk v0.12.3 keeps its central promise: a wallet stored on the device cannot be decrypted without a user-verified assertion from the passkey. The at-rest design, the backup format and the hybrid post-quantum encryption are carefully built, and the project's own threat model is unusually candid about what the SDK cannot protect.
The review found no way to recover a mnemonic from storage alone in the default configuration. It did find two high-severity issues outside that core, four medium ones and a set of lower-severity hardening items.
| ID | Severity | Finding |
|---|---|---|
| H-1 | High | register() succeeds without PRF, leaving a wallet that is never stored and cannot be backed up |
| H-2 | High | The Merkle circuits accept forged membership proofs (path indices are not constrained to bits) |
| M-1 | Medium | onBeforeSign digests can differ from what is signed |
| M-2 | Medium | Stealth addresses are not interoperable with the ScopeLift ERC-5564 SDK |
| M-3 | Medium | Gas values returned by the bundler are signed without bounds |
| M-4 | Medium | The NFT ownership circuit reveals the holder and binds nothing to the prover |
| L-1 to L-9 | Low | See Low severity |
| I-1 to I-8 | Info | See Informational |
Scope and method
- Target: tag
v0.12.3, commitb5c28e2dcfd0539bd2b94cead1569fd96125bc74, which is also the version published on npm aslatest. - Covered: all of
src/(about 17,000 lines of TypeScript), the two Solidity contracts incontracts/src/, the five Circom circuits, the build and CI configuration, and the security documentation. - Method: manual source review, with claims traced through the code paths that reach them.
Limits you should know about:
- This was a static review. Installing the dependencies was blocked in my environment, so I did not run the test suite,
pnpm audit, the Foundry tests or Slither. - Findings H-1, M-1 and M-3 are derived from reading the code and from ethers v6 behavior as I know it. They were not executed as proofs of concept.
- M-2 was checked numerically: I recomputed w3pk's own stealth test vector with an independent script under four encodings of the shared secret.
- The OpenZeppelin submodule was not fetched. The contract review covers w3pk's own 193 lines and relies on my knowledge of the inherited code.
- The deployed bytecode,
W3PK_ACCOUNT_FACTORYandW3PK_ACCOUNT_INIT_CODE_HASHwere not checked against a build.
Severity reflects impact on user funds or privacy, weighted by how likely the condition is. Risks the project already documents and accepts are listed separately in Documented and accepted risks.
Architecture in brief
- A BIP-39 mnemonic is generated in the page and encrypted with AES-GCM under a key derived by HKDF from the WebAuthn PRF extension output. Ciphertext and credential metadata live in
localStorage. STANDARD,STRICTandYOLOmodes sign with secp256k1 keys derived per origin on a fully hardened BIP-32 path modeled on SLIP-0013.PRIMARYmode uses the passkey's P-256 key as the owner ofW3pkAccount, an ERC-4337 account with ERC-7579 modules and ERC-7739 signatures.- Backups are password files (Argon2id) or PRF files, optionally split with Shamir's secret sharing.
- Optional modules add ERC-5564 stealth addresses, a hybrid ML-KEM-1024 + X25519 KEM, Semaphore identities and Groth16 proofs.
High severity
H-1. register() succeeds without PRF, leaving a wallet that cannot be stored or backed up
Where: src/core/sdk.ts lines 364-446, src/core/backup-sync.ts line 44.
When the authenticator returns no PRF output, register() skips storeWallet(), keeps the mnemonic in the in-memory session and returns normally. RegisterResult carries address, username, hasBackup and evictionRisk, and nothing that tells the app the wallet was not persisted.
From that state the user has no way out through the SDK:
createBackupFile()andsetupSocialRecovery()callgetMnemonicFromSession(true), which bypasses the cache, finds no stored wallet and throwsNo wallet found.exportForSync()reads the stored record and throwsNo wallet data found.- The mnemonic is never returned by any public method.
- After a page reload or session expiry (one hour by default),
login()throwsWalletMissingError.
A user on an authenticator without PRF receives a valid address, may fund it, and loses it permanently. The credential metadata and the passkey remain, so the account also looks registered afterwards.
Fix: make register() throw PrfUnsupportedError when no PRF output is available, and call signalUnknownCredential so the orphan passkey is removed. If in-memory wallets must stay possible, return an explicit stored: false and let createBackupFile() use the live session when no record exists.
H-2. The Merkle circuits accept forged membership proofs
Where: membership.circom line 50, nft.circom line 66.
Both circuits select the left and right inputs of each Poseidon node with:
left[i] <== currentHash[i] + pathIndices[i] * (pathElements[i] - currentHash[i]);
right[i] <== pathElements[i] + pathIndices[i] * (currentHash[i] - pathElements[i]);pathIndices[i] is never constrained to 0 or 1. For any current hash c and any real pair of siblings (L, R) in the tree, a prover sets pathElements = L + R - c and pathIndices = (L - c) / (L + R - 2c). The constraints then give left = L and right = R, and the proof continues along the honest path to the real root.
Anyone who knows two sibling values of the tree, which is public in every intended use, can prove membership of an arbitrary leaf. The circuits are not in the npm package, but ZKProofModule.proveMembership() and proveNFTOwnership() are built around them and docs/ZK.md presents them as usable.
Fix: add pathIndices[i] * (pathIndices[i] - 1) === 0; at every level, or reuse the audited tree templates from Semaphore or circomlib.
Medium severity
M-1. onBeforeSign digests can differ from what is signed
Where: src/core/eip1193.ts, src/core/signing-digests.ts, src/core/sdk.ts line 1036.
The hook added in this release shows ERC-8213 digests before the passkey prompt. For eth_signTypedData_v4 two gaps break the link between the digest and the signature:
primaryTypeis ignored when signing in EOA modes. The digest is computed for the request'sprimaryType, using only the types reachable from it. The signature is produced bywallet.signTypedData(domain, types, message), where ethers infers the primary type as the single root of the type graph. A request whoseprimaryTypenames a nested type, with a message that also carries the root type's fields, displays the digest of the nested struct and signs the root struct. A Permit2PermitSingleshown as its innerPermitDetailsis one example.- Time of check, time of use. When the typed data is passed as an object,
JSON.stringify(typedMessage)runs after the hook is awaited. The caller can mutate the message while the user is reading the digests.
PRIMARY mode is not affected by the first gap, since it hashes with the requested primaryType.
Fix: sign with the same reduced type set the digest uses, or reject requests where the inferred root differs from primaryType. Snapshot the request (deep copy or serialized string) before calling the hook and sign the snapshot.
M-2. Stealth addresses are not interoperable with the ScopeLift SDK
Where: src/stealth/crypto.ts lines 305-325.
computeSharedSecret() returns ethers' 65-byte uncompressed point (0x04 || x || y) and the code hashes all of it, although the comment says the x-coordinate is taken. The ScopeLift stealth-address-sdk hashes the shared secret exactly as noble returns it. That is the 33-byte compressed point if noble's default applies, which I did not confirm from the library source.
Recomputing the vector in test/stealth-point.test.ts independently gives:
| Shared-secret encoding | View tag | Stealth address |
|---|---|---|
| 65 bytes uncompressed (w3pk) | 0xe6 | 0xe313…b82c (matches the test) |
| 33 bytes compressed | 0x13 | 0xb1a9…731d |
A payment sent to a w3pk meta-address by a ScopeLift-based sender lands on an address that scanAnnouncements() never reports and signTransaction() refuses to sign for. The funds remain recoverable from the mnemonic with custom tooling, but not through w3pk. The only test vector is self-generated.
Fix: pick the encoding used by the ecosystem you want to interoperate with, add cross-implementation vectors, and document the choice. Changing it moves existing stealth addresses, so scanning needs to try both encodings during a transition.
M-3. Gas values returned by the bundler are signed without bounds
Where: src/core/sdk.ts lines 1411-1425.
In PRIMARY mode, preVerificationGas, verificationGasLimit and callGasLimit come from eth_estimateUserOperationGas, and the fee fields from the RPC endpoint. They go into the UserOperation that the passkey signs with no upper bound. preVerificationGas is paid in full to the bundler's beneficiary whatever the execution costs.
A compromised or dishonest bundler endpoint can therefore set a value that transfers most of the account's balance, or of a paymaster's sponsorship, to itself in a single operation. The security document states that a bundler can delay or drop a UserOperation but not alter it. That holds after signing, but the bundler shapes the operation before signing.
Fix: cap each gas field and the total prefund (sum of gas limits × maxFeePerGas), with a configurable ceiling, and include the maximum fee in what onBeforeSign receives.
M-4. The NFT ownership circuit reveals the holder and binds nothing to the prover
Where: nft.circom, src/zk/proof-generator.ts lines 132-141.
- The public nullifier is
Poseidon(Poseidon(ownerAddress), contractAddress, root). The contract and root are public and the holder list is the tree itself, so hashing each holder identifies the prover's address. The stated goal of hiding the address is not met. - The only private input is the hashed address. Nothing proves control of that address, so anyone can generate a valid proof for any holder.
- The balance check constrains
1 >= minBalance, which says nothing about a balance.
Fix: derive the leaf from a secret the prover holds (as Semaphore does with identity commitments), compute the nullifier from that secret and an external scope, and remove or properly implement the balance claim.
Low severity
L-1. sendTransaction() documents an onBeforeSign option that does not exist. The JSDoc at sdk.ts line 1188 lists options.onBeforeSign, but SendTransactionOptions has no such field and the method never calls a hook. An integrator who passes it gets no confirmation step and no error. Implement it or remove the line.
L-2. The mnemonic outlives the session. currentWallet.mnemonic is set by register(), importMnemonic() and the restore paths, and is cleared only by logout(). It stays in memory after the session expires and with sessionDuration: 0, which the documentation describes as keeping nothing in memory. STRICT calls also cache the mnemonic in the session, contrary to the "except in STRICT" wording. Drop currentWallet.mnemonic once the session is started, and skip caching for STRICT.
L-3. Unauthenticated credential metadata decides the PRIMARY address. getAddress('PRIMARY') derives the smart-account address from the publicKey stored in localStorage. One write to that record (a past XSS, an extension) makes the app display an attacker-controlled deposit address after the attacker is gone. Recompute or verify the key against a fresh assertion before showing a funding address, or authenticate the record with a PRF-derived MAC.
L-4. EIP-1193 request handling is permissive. from and the personal_sign address are not compared with the account. The input alias for calldata is ignored, so such a request is sent as a plain value transfer. gasPrice is silently dropped. wallet_switchEthereumChain accepts any value, including one that parses to NaN, with no check that the chain is configured.
L-5. The smart account trusts every origin in the RP ID scope. The on-chain check verifies the challenge, flags and signature, not the origin or RP ID hash. Any page allowed to use the RP ID, including subdomains of the app's host and related origins, can obtain a valid UserOperation signature. The SDK's own strict origin check is not mirrored on chain. Host the wallet on a dedicated hostname with no user-controlled subdomains and say so in the integration guide.
L-6. PRF outputs are used without checking the assertion in some paths. evaluatePrf() requests user verification but does not check the UV flag or the signature. login() does both. The register() fallback and derivePrfSecret() rely on the browser alone. Apply the same checks everywhere a PRF output becomes key material.
L-7. Shamir implementation details. The x-coordinate inside shareData is not compared with shareIndex or rejected when zero. Only the first threshold shares are used, so one bad share among extras fails the recovery instead of trying another subset. Shares carry the wallet address in clear, which the v4 backup format deliberately avoids. Table lookups are not constant-time, as the June audit already noted.
L-8. Remaining circuit weaknesses. GreaterEqThan(252) and LessEqThan(252) are applied to inputs with no range check, outside circomlib's stated precondition. The membership nullifier is Poseidon(leaf) with no scope, so all proofs by one member are linkable, and guessable leaves can be brute-forced. scripts/compile-circuits.js produces a .zkey with no phase-2 contribution, which must never reach production.
L-9. Release and supply chain. The v0.12.3 tag is lightweight and unsigned. Publishing is a manual pnpm publish without npm provenance. GitHub Actions are pinned by tag rather than commit hash, and CI runs no dependency audit. verifyBuildHash() fetches the build from unpkg, so a page checking itself proves little, which the documentation acknowledges.
Informational
- I-1. The wallet record and persistent-session blob are encrypted without additional authenticated data. Binding
credentialId, address and expiry, as BackupFile v4 does, would make the records self-authenticating. - I-2.
config.storageis accepted but never used, anddebugis read fromDEFAULT_CONFIGinstead of the instance. - I-3. Credential records are keyed by a hash of the credential ID, while the index next to them lists the raw IDs. The hashing adds nothing.
- I-4.
generateSiweNonce()has a small modulo bias (256 mod 62), harmless at 11 characters. The SIWE parser is lenient about field order. - I-5. With
userVerification: 'preferred', a login that is not user-verified ends inWalletMissingError, which misdescribes the cause. - I-6.
supportsEIP7702()posts to public endpoints from chainid.network, exposing the user's IP address to them. Its static chain list dates from December 2024. - I-7.
W3pkAccountinherits OpenZeppelin draft contracts and is deployed on Sepolia only. A bug cannot be patched in place, by design. - I-8. Typed data that includes unused types passes the digest step and then fails inside ethers. This affects availability only.
Documented and accepted risks
These are real exposures that the project states openly. They are not new findings, but any integrator should read them as part of the audit.
- The host app is trusted in every mode. The SDK runs in the page. Any script on the origin can use a live session, and TypeScript
privatefields are reachable at runtime, so the session's mnemonic is readable by same-origin code. Origin-specific derivation does not protect a mnemonic restored on a hostile site. - Blind signing. The passkey prompt shows the site, never the transaction.
- Silent "Remember Me". With
requireReauth: falsethe session key sits next to the ciphertext, and anyone who readslocalStoragegets the mnemonic. - Stealth keys are shared across origins when the same mnemonic is used on several sites (issue #147).
YOLOmode hands private keys to the app.PRIMARYaccounts have one owner key and no built-in recovery: a lost, unsynced passkey loses the account.- Guardian share checksums are unkeyed, and ML-KEM payloads carry no sender authentication.
What is done well
- At-rest keys come only from a user-verified PRF evaluation, with separate HKDF labels for the wallet and session keys and no weaker fallback.
- BackupFile v4 authenticates every metadata field as AES-GCM additional data, bounds the Argon2id parameters it accepts and stores no address.
- Origin paths are fully hardened and use 124 bits of the origin hash.
- The hybrid KEM follows RFC 9180 with a combiner that binds ciphertexts and public keys, and zeroizes intermediate secrets.
- WebAuthn responses are checked for type, challenge, exact origin, RP ID hash and signature counter. The CBOR and DER parsers are strict.
- Raw-hash signing and EIP-7702 authorizations always require a fresh assertion and are refused in
PRIMARY. - SIWE messages for another domain are refused, including when disguised as
personal_signbytes. - The account contract is small, non-upgradeable, initialized atomically by the factory and salted with the owner key.
Recommended order of work
- Make
register()fail, or signal clearly, when the wallet cannot be stored (H-1). - Constrain
pathIndicesand redesign the NFT circuit, or remove both circuits until fixed (H-2, M-4). - Bind
onBeforeSigndigests to the signed payload and implement or remove thesendTransaction()option (M-1, L-1). - Cap bundler-supplied gas (M-3).
- Decide the stealth shared-secret encoding and add external vectors (M-2).
- Clear
currentWallet.mnemonic, harden the EIP-1193 inputs and authenticate credential metadata (L-2 to L-4). - Sign tags, publish with provenance and pin Actions by hash (L-9).
- Run the test suite,
pnpm audit, Foundry tests and Slither against this tag, since none of them were run for this review.