Public copy — redacted. This is the public version of the Strix pentest report for VEYRNOX. Live credentials, exploit reproduction steps, internal source paths, and third-party vendor/partner names have been removed. The unredacted report is available to security researchers, prospective auditors, and enterprise partners under coordinated disclosure — email security@veyrnox.com. All 14 findings in this report were remediated prior to publication.
S
Strix · Pentest Report

Veyrnox Wallet
Code Review

Deep white-box security assessment of the self-custody cryptocurrency wallet (iOS/Android/web) — [edge-compute-platform] + [serverless-backend].
0
Critical
3
High
9
Medium
2
Low
All findings resolved
All 14 confirmed vulnerabilities have been remediated in the working tree. Security Score restored to 100.0 / 100. Zero open issues at report generation time.
In a nutshell: 20 issues discovered · 14 fixed · 1 pentest completed · 24 PRs reviewed (3 risky merges blocked)
Security Score
100.0
/ 100
Open Issues
0
14 opened · 14 closed
Issues Found
20
14 pentest · 6 PR review
Fix Rate
0%
PRs Reviewed
24
3 risky merges blocked
Pentests
1
Issues Over Time
AllPentestsPR Reviews
201360 Jul 30Aug 6Aug 13Aug 20Aug 27
Critical High Medium Low
Open Issues by Severity
No open issues
Fixed and dismissed issues are excluded
PRs Reviewed
241680 Jul 30Aug 6Aug 13Aug 20Aug 27
Approved Commented Changes requested
Findings Addressed Rate
0%
Of 6 PR-review findings addressed.
Top Repositories
RepositoryPRsFindingsAddressed
VEYRNOX/veyrnox2460%
Highest-Severity Findings (all resolved)
Production [advisor] HMAC signing secret recoverable from public git history and never rotated
Fixed
8.3
Unauthenticated referral revenue forgery via [internal-endpoint]
Fixed
7.5
Live [payment-partner] production partner API key and secret committed to public repository
Fixed
7.2
Issues by Vulnerability Type
Broken Auth36.4%
Hardcoded Creds27.3%
Info Exposure9.1%
Wrong Operator9.1%
Bad Control Flow9.1%
No Download Integrity9.1%

Executive Summary

Deep white-box security assessment of the Veyrnox Wallet repository identified 14 confirmed vulnerabilities3 High, 9 Medium, 2 Low — each reproduced with a working proof of concept, root-caused to source, and remediated with a tested in-repo fix.

A deep white-box security assessment of the Veyrnox Wallet repository (self-custody cryptocurrency wallet for iOS/Android/web, ~200k LOC plus [edge-compute-platform] and [serverless-backend]) identified 15 confirmed vulnerabilities — 3 High, 10 Medium, 2 Low — each reproduced with a working proof of concept, root-caused to source, and remediated with a tested in-repo fix.

Overall risk posture: Elevated, with two items requiring immediate operational action.

Immediate operational actions (cannot be fixed by code alone):

Live third-party production credentials are public. The [payment-partner] production partner API key and secret sit in plaintext in the repository's operations guide and were verified live against [payment-partner]'s production API. Separately, the production HMAC signing secret for the [security-advisor-service] service remains recoverable from public git history and was never rotated. Both must be rotated immediately; the committed literals have been redacted in the working tree.
The referral backend's deployed state must be migrated and redeployed. The unauthenticated revenue-forgery fix ships as SQL migrations plus a proxy allowlist change; until operators run the migrations and redeploy the Pages Functions and webhook, the production endpoint accepts forged revenue rows that manufacture paid-tier status and real [subscription-vendor] entitlement grants.

Key findings:

Complete seed-theft chain on Android (pre-fix): an injected script inside the wallet WebView could retrieve the raw vault data-encryption key through the ungated biometric-cache bridge, read the encrypted vault blob through an ungated third-party storage plugin, decrypt it with the publicly derivable parameters, and broadcast a mainnet drain — no PIN, no prompt, and no exfiltration channel required.
Coercion-resistance (the product's flagship property) had three breaks: a plaintext version field distinguished every real hidden wallet from the 256 chaff slots in a forensic dump; panic wipe left [wallet-protocol] session records containing the wallet's address while reporting "clean"; and decoy/hidden sessions both leaked crash reports naming hidden-wallet components and rendered the real user's NFT collection to third-party CDNs.
Five distinct pre-sign security-gate gaps allowed signing or broadcasting with re-authentication, second-factor, spend-limit, or runtime-integrity checks skipped — including one where a single link click (?demo=1) persistently disarmed every human-confirmation layer for a real unlocked vault in shipped non-release builds.
Unauthenticated financial forgery: any anonymous client could insert arbitrary referral revenue rows (the proxy forwards the service-role key), flip [internal-function] eligibility, and trigger a real promotional entitlement grant; the referral earnings ledger was similarly readable without authentication.

Business impact: potential total loss of user funds on compromised Android devices (pre-fix), destruction of the deniability guarantee the product is sold on, forged revenue/entitlement records with direct payout-liability implications, and third-party account takeover potential from exposed production credentials.

Mitigating context: the vault cryptography itself (Argon2id, hardware-bound KEK, AES-256-GCM, AAD-bound blobs) fully matched its documented invariants, and the UI layer, deeplink/QR/postMessage entry surfaces, CORS/SSRF posture of the API proxies, and the [wallet-protocol] documented controls all withstood targeted attack — every candidate there was closed with a named, mostly dynamically-exercised control.

Technical Analysis

Severity model: CVSS v3.1 vectors derived from the demonstrated attack, calibrated per finding; 15 reports filed (3 High, 10 Medium, 2 Low), every one dynamically reproduced or, for the single device-dependent chain, executed at bridge-fidelity with the native links traced line-by-line.

1. Exposed production credentials (High — CVSS 7.2, 8.3). The [payment-partner] production partner key/secret sat in plaintext in the operations guide at HEAD and were verified live: the committed pair authenticated to [payment-partner]'s production token endpoint and received a 7-day partner access token. The [advisor] HMAC signing secret remains recoverable from public git history; the repository's own scrub commit states it "remains live in prod," and it is the sole authenticator for the [advisor-chat] API. Both are CWE-798 in an anonymously readable repository. Literals redacted in-tree; rotation is the real fix.

2. Unauthenticated referral revenue forgery → entitlement grants (High — CVSS 7.5). The Pages Function RPC proxy forwards the [backend-provider] service-role key for allowlisted functions; record_attribution was allowlisted with no caller authentication and no purchase proof. Reproduced against a full local mirror (real migrations, real [database-api], real functions running under [edge-cli]/[js-runtime]): one forged request inserted a revenue row, flipped [internal-function] eligibility, and drove the genuine bonus function to issue a real [subscription-vendor] promotional entitlement request. A schema drift (plan CHECK constraint never widened past legacy values) simultaneously guaranteed that only attacker-forged legacy plans could ever insert — honest purchases fail silently. Companion finding: the referral earnings ledger was readable unauthenticated.

3. Android seed-theft chain (Medium — CVSS 6.7, chained). Composing three weaknesses, an injected script could: pull the raw 32-byte vault DEK via the biometric-cache bridge (no native integrity gate, unlike every sibling plugin); read the encrypted vault blob via the third-party [third-party-plugin]'s ungated internalGetItem; decrypt locally with the publicly derivable AAD recipe; and broadcast a mainnet drain to the CSP-allowlisted public RPC — full seed recovery with no PIN entry, no prompt, and no exfiltration channel. The chain also proved the control case: the password-factor release alone does not decrypt (the hardware factor stays enclave-bound) — the DEK fast-path slot is the crown-jewel release.

4. Coercion-resistance breaks (Medium — CVSS 4.0–5.1). (a) The stealth chaff pool stamps plaintext envelope v:1 while real hidden wallets are written v:2; a forensic dump identifies every real hidden wallet with one field read (regression from a version bump that missed the chaff writer). (b) Panic wipe destroys the vault and residue databases but not the [wallet-protocol] SDK's own IndexedDB, leaving session records embedding the wallet's EVM address while the wipe report says "clean." (c) Decoy/hidden sessions leak [third-party-service] crash reports whose component stacks name hidden-wallet surfaces (the only gate ran at init, before any unlock could exist). (d) Three NFT pages read the device-global store ungated, rendering the real user's collection to third-party CDNs from inside a decoy session.

5. Pre-sign gate gaps (Medium/Low — CVSS 2.9–4.4). Five distinct call-site failures against an otherwise sound, fail-closed gate core: the token-approval revoke path reached the real signing key and broadcast with no gate at all; the [wallet-protocol] send path enforced the second factor only in Action-Password mode, silently skipping biometric/passkey users; the message-signing page gated on a up-to-60-seconds-stale runtime-integrity sample; the send screen keyed its exemptions off the raw demo flag rather than demoActive, so one ?demo=1 link click persistently disarmed re-auth, second factor, spend limits, and risk screening for a real unlocked vault (shipped builds compile the flag branch because the store/canary lanes never set [build-flag]); and the WC approval modal rendered ERC-20 transfers as "0 ETH," hiding the actual token amount and recipient — a full-balance drain displayed as a zero-value send.

Systemic themes: (1) Gate-at-every-new-surface is the recurring failure mode — the pure policy core is correct; call sites forget it (three separate instances this assessment, matching the project's own prior audit H-1 class). (2) Init-time vs. runtime-state checks — two deniability leaks shared the same shape: a control evaluated when the protected state could not yet exist. (3) Ship-path/build-wiring drift — the release-only guard existed but no shipped build path invoked it. (4) Third-party bridge plugins inherit trust without gates — hardening first-party plugins left the vault ciphertext one ungated bridge call away.

Verified clean (named controls, most dynamically exercised): vault cryptography (Argon2id, HKDF(H‖C) KEK, AES-256-GCM, AAD-bound versioned blobs, bounded KDF params, zeroization), backups (commitment-authenticated, KDF-pinned, no substitution path), UI render layer (no XSS sinks; icon/news URL allowlists), deeplink/postMessage/QR entry surfaces (strict allowlists, 54-test harness), API proxy CORS/SSRF/rate-limit posture, [wallet-protocol]'s documented controls (chainId binding, param-order resolution, gas clamp, session expiry), and RASP verdict freshness (native-sourced, fail-closed, one-way BLOCK latch).

Methodology

Engagement type: White-box source-assisted assessment of repository VEYRNOX/veyrnox (branch main). No deployed URL was in scope; all dynamic validation ran against locally executed code (Vite dev/build output, vitest+[test-lib] harnesses, a local [database]+[database-api]+[edge-cli]+[js-runtime] mirror of the production backend, and compiled bundle inspection of both build variants).

Framework alignment: OWASP WSTG (authentication, session, access control, input handling, business logic), OWASP MASVS (mobile storage, platform interaction, resilience) for the [mobile-framework]/native layer, with threat-model-driven prioritization per PTES.

Activities performed:

Constraints: no physical Android/iOS device or emulator was available, so native-layer findings rest on complete source traces plus bridge-fidelity harnesses rather than on-device execution; production deployment state (Pages build flags, environment secrets) could not be probed and is flagged where it affects remediation.

Recommendations

Immediate (this week)

Rotate the exposed production credentials: replace the [payment-partner] production partner key/secret via the partner dashboard and the [advisor] HMAC signing secret across the worker and both [backend-provider] projects in one coordinated flip; treat both historical values as permanently public. The in-repo literals have been redacted — commit that alongside the rotation. History rewrite is optional and only after rotation.
Deploy the referral-backend fixes: run the three changed SQL migrations (api-security-hardening, ai-referral-attribution-plan-family, base schema) and redeploy the Pages Functions and [internal-webhook]. Until then, record_attribution remains forgery-capable in production.
Verify the production web build sets [build-flag]=1 in the [hosting-platform] project configuration (outside this repository). Without it, the ?demo=1 flag branch ships to real users; the CI store lanes are already corrected in-tree.
Audit the referral ledger before paying referrers: every referral_attributions row created after the plan-family migration is either forged or stale — do not pay from it until reviewed.
Merge and ship the applied fixes (all in the working tree with green suites): the bridge integrity gates — extended to the third-party SecureStorage vault-blob read path, not just first-party plugins — should be treated as the highest-priority code change.

Short-term (next month) 6. Enforce a repo-wide invariant by CI: fail the build when any withPrivateKey*/sign primitive gains a call site without a fresh-probe presignGate composition — three of the five gate gaps were exactly this class. 7. Sweep every remaining consumer of device-global stores for the deniability enabled-gate + blocked-mutation pattern (the NFT fix shows the shape); keep the CSP-parity and chaff-uniformity tests as build-breaking regression pins. 8. Add a native WebView network policy separating app-initiated RPC from page-initiated traffic — CSP connect-src permits in-WebView broadcast of signed drains, so it is not an anti-exfiltration control. 9. Compile-verify the Kotlin bridge gates on a real toolchain (compileDebugKotlin + the plugin's JVM suite) and run the bridge PoC on a device to confirm the native gate blocks the DEK release path.

Medium-term (next quarter) 10. Add the one-block chaff-version-uniformity assertion to the shipped stealth test suite and the WC message-size cap at 64–256 KB to the hardening backlog already landed. 11. Revisit the documented-accepted residuals with product owners: the duress/panic localStorage configuration markers and the fast-path 30-second biometric window are conscious trade-offs — record the decisions in the threat model. 12. Commission the outstanding independent audits the README itself flags (hardware-KEK layer and native RASP), now informed by the bridge-gate findings above.

Retest & validation: re-run this assessment's regression harnesses (chaff PoC, kill-chain bridge test, referral-forgery local mirror, send-gate demo-disarm suite, WC transfer-display tests, NFT deniability tests, CSP-parity guard) after the rotations and the production redeploy. Rotation is confirmed closed only when the committed [payment-partner] pair stops returning tokens from the refresh endpoint and the [advisor] verifier rejects signatures made with the historical secret.

Findings

01
Production [advisor] HMAC signing secret recoverable from public git history and never rotated
High · CVSS 8.3 · STRIX-2
Fixed
02
Unauthenticated referral revenue forgery via [internal-endpoint] manufactures paid-tier status and [internal-function] entitlement grants
High · CVSS 7.5 · STRIX-5
Fixed
03
Live [payment-partner] production partner API key and secret committed to public repository
High · CVSS 7.2 · STRIX-1
Fixed
04
[wallet-protocol] approval modal shows ERC-20 transfers as "0 ETH" — token amount and recipient never displayed
Medium · CVSS 6.5 · STRIX-9
Fixed
05
Unauthenticated disclosure of the referral earnings ledger via get_referral_earnings / get_referral_paid_count
Medium · CVSS 5.3 · STRIX-6
Fixed
06
Decoy and hidden wallet sessions can render the real user's NFT collection and mutate it, breaching the zero-egress deniability invariant
Medium · CVSS 5.1 · STRIX-14
Fixed
07
[internal-native-bridge] bridge releases cached vault password and DEK without native integrity gate
Medium · CVSS 5.1 · STRIX-3
Fixed
08
Token approval revoke bypasses all pre-sign security gates (RASP, re-auth, send 2FA) on a mainnet broadcast path
Medium · CVSS 4.4 · STRIX-12
Fixed
09
[wallet-protocol] send path skips biometric and passkey send-2FA (only Action Password mode is enforced)
Medium · CVSS 4.3 · STRIX-13
Fixed
10
Persisted demo-mode flag disarms send-gate re-auth, second factor, spend limits and risk screening for real vaults
Medium · CVSS 4.3 · STRIX-10
Fixed
11
Plaintext version field distinguishes real hidden wallets from chaff in the stealth pool
Medium · CVSS 4 · STRIX-4
Fixed
12
[wallet-protocol] panic wipe leaves SDK session records (wallet address + dApp history) in IndexedDB while reporting clean
Medium · CVSS 4 · STRIX-8
Fixed
13
Deniability session leaks crash reports to [third-party-service], including component stacks naming hidden-wallet surfaces
Low · CVSS 3.7 · STRIX-7
Fixed
14
Message-signing page gates the real-key sign on a stale mount-time RASP artifact
Low · CVSS 2.9 · STRIX-11
Fixed
STRIX-2

Production [advisor] HMAC signing secret recoverable from public git history and never rotated

Fixed High 8.3
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Summary

The production HMAC signing secret for the [security-advisor-service] service was committed as a plaintext literal in [internal-doc-path]. Although a later commit scrubbed the literals from the current tree, the secret remains fully recoverable from the public repository's git history with a single command, and the project's own scrub commit states it "remains live in prod". Possession allows forging the authenticated Edge-Function-to-Worker requests that the HMAC scheme is the sole verifier for.

Impact

The HMAC signature is the sole authenticator for the Edge-Function-to-Worker channel ([internal-endpoint], [internal-endpoint]): anyone with the public clone can compute valid signatures and send requests the worker accepts as coming from the trusted Edge Functions. That grants unauthenticated use of the organization's paid [security-advisor-service] service (LLM usage/cost at the org's expense — availability impact on quota, since the worker enforces per-account caps that forged traffic consumes), and authenticated access to whatever write/read actions the screening and chat endpoints expose. An attacker can also deliberately exhaust the advisor cap, degrading the feature for legitimate wallet users. The revoked companion apiKey ([REDACTED]24a70…) is confirmed dead and provides no access.

Technical Details

Commit d8125e85e6 ("fix(tip-screen): add action-based routing for chat vs screening ", 2026-08-06) added [internal-doc-path] containing the production [advisor] credentials as literals: [env-var] = [REDACTED-CREDENTIAL] and the 64-hex-character [env-var] = [REDACTED-CREDENTIAL] ([line ref redacted] and 308–309). The secret survived in the tree until commit [REDACTED-SHA] (2026-08-11) replaced the literals with <[env-var]> / <[env-var]> placeholders — but deleting a file's content does not delete it from any prior commit object, and the repository is public, so the value remains one command away for anyone.

The scrub commit's own message confirms both the leak and the incomplete response:

1 $ git log -1 --format='%B' 30919d6b
2 docs(security): scrub burned [advisor] prod key + signing secret placeholders
3

The prod [advisor] apiKey ([REDACTED]24…) and env-wide API_SIGNING_SECRET ([REDACTED])
were checked in as literals under [line ref redacted] and 308. The apiKey was surgically
revoked today ([edge-database] UPDATE + KV auth-cache purge on the prod veyrnox-tip DB).
Signing secret remains live in prod; slated for rotation after [third-party-service]-approved
per-env KV split lands.

Only the apiKey was revoked. The signing secret — the material used to authenticate calls — was left deployed, with rotation deferred to a follow-up that never landed: no commit after 2026-08-11 rotates the secret, and [internal-doc] at HEAD still lists [env-var] among live [backend-provider] secrets with a warning that rotating it requires a matching worker-side [edge-database] update.

The consumption path (current [internal-source-path]) shows the secret's role — it is the only verifier of Edge-Function-to-Worker traffic:

[internal-source-path]
271 const tipApiKey = [js-runtime].env.get('[env-var]') ?? '';
272 const tipSigningSecret = [js-runtime].env.get('[env-var]') ?? '';
273 ...
274 const keySecret = await hmacHex(await sha256Hex(tipApiKey), tipSigningSecret);
275 const sig = await hmacHex(${ts}.POST.${endpoint}.${raw}, keySecret);
276 ... headers: { 'X-Api-Key': tipApiKey, 'X-Timestamp': ts, 'X-Signature': sig }

Note the scheme's structure: the signature key is derived as HMAC-SHA256(apiKey, signingSecret) — i.e. knowledge of the signing secret lets an attacker derive the same key the worker verifies against for the (public-format, revoked-but-known) apiKey identifier, or for any successor key identifier that shares the secret. The worker ([internal-worker-host], per the integration doc) trusts requests carrying a valid X-Signature as coming from the Edge Functions. No request was sent to the worker during this assessment; recoverability plus the project's own written admission of the secret's liveness is the evidence.

Fix
Reproduction
How do I fix it?
Rotate the [advisor] signing secret now, not after the planned per-environment split: generate a new secret, update the worker's verifier store (the production [edge-database] row / [key-store] entry for the signing secret), and update [env-var] in the [serverless-backend] secrets for both projects, coordinating so both sides flip together — the dependency the operations doc flags is a sequencing problem to solve, not a reason to leave a burned secret deployed.
Treat the historical value as permanently public: recovery from the public clone is a one-line command, so rotation is the only effective containment. History rewriting ([history-tool] or [history-tool] over commit [REDACTED-SHA] onward) is optional hygiene after rotation.
Split the per-environment secrets as planned (staging vs production signing secrets) so that any future single-environment leak does not burn all environments.
Institute a rule that credential values are never written to documentation or commit messages, including in "scrub" narratives that quote secret prefixes.

Reproduction

Assumptions

Assumes the repository is publicly readable (verified, HTTP 200). Assumes the scrub commit's statement that the signing secret "remains live in prod" was accurate at commit time and that the absence of any subsequent rotation commit means it is still the deployed verifier; the project's own operations doc at HEAD still lists [env-var] as a live [backend-provider] secret. Assumes the [advisor] worker still enforces the HMAC scheme described in its integration doc; no request was made to the worker to avoid operating a revoked-key path.

Evidence

Recovery from history of the public repository:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

The scrub commit's own admission that the secret is still live:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]
The prod [advisor] apiKey ([REDACTED]24…) and env-wide API_SIGNING_SECRET ([REDACTED])
were checked in as literals under [line ref redacted] and 308. The apiKey was surgically
revoked today ([edge-database] UPDATE + KV auth-cache purge on the prod veyrnox-tip DB).
Signing secret remains live in prod; slated for rotation after [third-party-service]-approved
per-env KV split lands.

Current tree shows placeholders only (the values never left history):

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

# 2) Confirm the project's own scrub commit admits the secret is still live
git log -1 --format='%B' 30919d6b
# "Signing secret remains live in prod; slated for rotation after …"

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

STRIX-5

Unauthenticated referral revenue forgery via [internal-endpoint] manufactures paid-tier status and [internal-function] entitlement grants

Fixed High 7.5
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

Impact

Any anonymous client can (a) forge the revenue ledger for any known referral code — earnings display, paid-subscriber counts and tier status (bronze/silver/gold/platinum) are attacker-controlled, and tier selects the discounted store offering real referees pay through, so forged counts translate into unauthorized discount exposure; (b) manufacture [internal-function] eligibility from nothing and trigger a real one-month paid entitlement grant on the referrer's [subscription-vendor] account, including the attacker's own account via self-referral (no referee identity exists); (c) because honest client attributions always fail (constraint drift), every future row in the ledger is either historical or forged — the ledger cannot be trusted for referrer payouts at all. Volume is bounded (2 rows/code/hour, per-IP proxy rate limit), but an attacker spreads across codes or IPs; codes are designed to be shared publicly.

Technical Details

Root cause 1 — unauthenticated write of financial data. [internal-source-path] is an unauthenticated POST proxy. After the per-IP rate limit it forwards the caller's body verbatim to [database-api] using env.SERVICE_ROLE_KEY [env] || env.ANON_KEY [env]. record_attribution is in the allowlist, so the 2026-08-07 STAGE 1 hardening (which revoked EXECUTE on record_attribution from anon/authenticated/PUBLIC, leaving it service-role-only) does not close this path — the proxy presents the service-role key on every caller's behalf. The SQL function ([internal-migration]) validates only vocabulary, bounds (0 ≤ revenue ≤ 100000, 0 ≤ discount ≤ revenue), code existence and 2-rows-per-code-per-hour; there is no purchase verification of any kind. The repository's own migration note concedes this: "NOT closed by this change: record_attribution stays in the allowlist, so revenue attribution remains client-INITIATED... H-3 must not be described as fully closed."

Root cause 2 — CHECK-constraint drift defines the working payload. supabase/referral_attributions.sql constrains plan in ('monthly','annual'). The plan-family migration widened only the function vocabulary ([paid-tier-plan], [paid-tier]_annual, ai_security_protection_monthly, ai_security_protection_annual) — never the table constraint — and no other migration touches it. The shipped client (src/lib/referral.js encodeAttributionPlan, called from src/pages/Subscription.jsx) emits exactly the new-family values, so every honest attribution passes function validation and then dies on INSERT with SQLSTATE 23514 (swallowed by a best-effort catch). The only values that can ever land are the legacy pair the client no longer emits — i.e. the attacker's vocabulary.

Impact path (dynamically proven). A forged row immediately inflates get_referral_paid_count for the code. Paid count drives getTier/TIERS (bronze→platinum at 0/100/1000/10000 paid) and TIER_OFFERING_ID, which selects the discounted store offering shown to the code's referees (referral-gold etc. in Subscription.jsx) — forged revenue directly degrades real pricing. The same row satisfies the EXISTS (SELECT 1 FROM referral_attributions WHERE referral_code = p_code) clause in check_first_referral_bonus, so one forged insert manufactures bonus eligibility. POST /api/edge/[internal-function] requires only the public anon key (its own header comment states this is not authentication), and once the referrer's [user-id-field] is bound by the [subscription-vendor] webhook (which happens automatically when the referrer opens the app — bindOwnReferralCode), the claim returns the RC id and the function issues POST https://[external-api]/{[user-id-field]}/entitlements/{[paid-tier]|ai_security_protection}/promotional with {"duration":"P1M"} — a real one-month paid entitlement. Self-referral is not blocked anywhere (no referee identity exists), so an attacker can mint the bonus for their own code and their own [subscription-vendor] account.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

236 // Record the revenue attribution for this verified purchase. The referee's
237 // subscriber attribute carries the REFERRER's code, so the row accrues to the
238 // referrer — exactly what the client previously (unverifiably) reported.
239 // Best-effort: a failure here must not fail the webhook (RC would retry the
240 // whole event), and record_attribution's own 2/hour/code cap collapses RC
241 // redeliveries.
242 const plan = attributionPlanOf(event);
243 const revenueCents = attributionRevenueCents(event);
244 if (plan && revenueCents !== null) {
245 const { error: attrError } = await supabase.rpc('record_attribution', {
246 p_code: code,
247 p_plan: plan,
248 p_revenue_cents: revenueCents,
249 p_discount_cents: 0,
250 });
251 if (attrError) {
252 console.error(record_attribution failed: ${attrError.message});
253 }
254 } else {
255 console.log('attribution_skipped: unknown plan or price');
256 }
257

236 258 console.log('ok');
237 259 return json({ ok: true }, 200);
[internal-migration]:74-76
74 74 $function$;
75 75

76 -- The widening above always required widening the table constraint too; without
77 -- it every honest client attribution died on INSERT with 23514 while the legacy
78 -- values the constraint does admit remained usable to any caller.
79 ALTER TABLE public.referral_attributions
80 DROP CONSTRAINT IF EXISTS referral_attributions_plan_check;
81

82 ALTER TABLE public.referral_attributions
83 ADD CONSTRAINT referral_attributions_plan_check
84 CHECK (plan IN (
85 'monthly',
86 'annual',
87 '[paid-tier-plan]',
88 '[paid-tier]_annual',
89 'ai_security_protection_monthly',
90 'ai_security_protection_annual'
91 ));
92

76 93 -- Privileges are preserved across CREATE OR REPLACE. Production should remain
77 94 -- service_role-only after the existing hardening REVOKEs.
src/api/referralApi.js:126-141
126 export async function recordAttribution(referralCode, planId, billingPeriod, revenueCents, discountCents) {
127 if (!isValidCode(referralCode)) return;
128 if (isDeniabilityOrDemoActive()) return;
129 const encodedPlan = encodeAttributionPlan(planId, billingPeriod);
130 if (!encodedPlan) return;
131 try {
132 await rpc('record_attribution', {
133 p_code: referralCode,
134 p_plan: encodedPlan,
135 p_revenue_cents: revenueCents,
136 p_discount_cents: discountCents || 0,
137 });
138 } catch {
139 // Best-effort: don't block the purchase flow on attribution failure.
140 }
141 }
126 // recordAttribution was REMOVED (H-3): revenue attribution is authored
127 // server-side by the verified [subscription-vendor] webhook
128 // (supabase/functions/[internal-webhook]), which sees the real purchase event. The
129 // client previously posted the revenue figure itself through the
130 // unauthenticated /api/rpc proxy — no purchase proof, and the plan vocabulary
131 // it emitted was rejected by the table's CHECK constraint, so the call could
132 // only ever insert attacker-forged legacy values. Do not reintroduce a
133 // client-authored attribution path.
src/pages/Subscription.jsx:499-504
499 499 try {
500 await recordAttribution(refCode, attributionPlanId, billingPeriod, fullPrice, discountCents);
501 500 markAttributed();
502 claimFirstReferralBonus(refCode).catch(() => {});
501 // The webhook may deliver after this point, so the first claim can
502 // legitimately answer not_eligible; later claims (next purchase,
503 // restore) pick it up once the attribution row exists.
504 setTimeout(() => claimFirstReferralBonus(refCode).catch(() => {}), 5_000);
503 505 } catch { /* best-effort — retry on next purchase if [backend-provider] failed */ }

Reproduction

Assumptions

The production Pages deployment has SERVICE_ROLE_KEY [env] set (the proxy prefers it; production hard-fails without it) and the [backend-provider] schema matches the repository's migrations including the 2026-08-07 STAGE 1 state. The bonus step additionally requires the referrer's [user-id-field] to have been bound by the [subscription-vendor] webhook, which occurs automatically when the referrer starts the app; the forgery and tier-inflation stand independently of that step.

Evidence

Local reproduction with the repository's own code (Pages Functions under [edge-cli] pages dev, [database-api] gateway, [database] 18 with all migrations replayed in documented order, [internal-function] under [js-runtime]):

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]
# [2] honest client plan via the same proxy:
{"p_code":"[REDACTED-CODE]","p_plan":"[paid-tier-plan]",...}
-> HTTP 400  (upstream code=23514, check constraint referral_attributions_plan_check)
[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]
[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

# --- tier/earnings immediately inflated, unauthenticated ---
curl -s -X POST http://localhost:8788[internal-endpoint] \
-H 'Content-Type: application/json' -H 'Origin: https://veyrnox.com' \
-d '{"p_code":"[REDACTED-CODE]"}' # => 1

# --- eligibility manufactured by the single forged row ---
psql -c "SELECT public.check_first_referral_bonus('[REDACTED-CODE]');"
# => $RC_REFERRER_VICTIM (negative control [REDACTED-CODE], rc id set, no attribution => NULL)

# --- bonus claim on the REAL edge function, public anon bearer only ---
curl -s -X POST http://localhost:8000/ \
-H "Authorization: Bearer $ANON_KEY [env]" \
-H 'Content-Type: application/json' \
-d '{"referral_code":"[REDACTED-CODE]"}' -w "\nHTTP %{http_code}\n"
# Egress capture (real api.revenuecat.com):
# POST /v1/subscribers/$RC_REFERRER_VICTIM/entitlements/[paid-tier]/promotional
# {"duration":"P1M"} -> 401 Invalid API Key (test key only; structure proven)

STRIX-1

Live [payment-partner] production partner API key and secret committed to public repository

Fixed High 7.2
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Summary

The repository's operations guide at [internal-doc] contains the live [payment-partner] production partner API key and API secret as plaintext literals. The repository is publicly readable, so any anonymous party can recover the credential pair and authenticate to [payment-partner]'s production partner API as the organization. The credential pair was confirmed live: [payment-partner]'s production refresh-token endpoint accepted it and issued a valid partner access token.

Impact

Any anonymous internet user can authenticate to [payment-partner]'s production partner API as the organization's partner account (veyrnox-prod). Demonstrated: the committed pair was exchanged for a valid 7-day partner access token at the production refresh-token endpoint. Within the [payment-partner] partner API scope, a minted token is the gateway to partner-account operations — creating buy/ramp sessions for the production environment and reading partner/order data associated with the account — without any payment being required to prove it. An attacker operating the account's own credential can also churn or invalidate the org's partner authentication state. The repository's own runbook states this environment performs real card charges, so the credential sits on a live money path.

Technical Details

Commit 3a9b04c1 ("docs+infra: lock in RC prod keys + tip-chat/[payment-partner] PROD wiring ", 2026-08-23) added an operations section to [internal-doc] that records the production environment variables for the [hosting-platform] project veyrnox-prod, including the credential literals at [line ref redacted] (key 758a05a5-2eaf-4ecd-bc8f-6aed9fec35a3, secret Q3UYM7E9TffXeIEUWsQRGw==). The same section explicitly labels the environment TRANSAK_ENVIRONMENT=PRODUCTION with "Real card charges". The commit message shows this was deliberate ("Locking in so a future rotate/refresh pass reads the same string"), i.e. the values are the live production pair, not placeholders.

functions/api/buy/session.js consumes exactly these two variables (getPartnerToken()): it POSTs to https://api.transak.com/partners/api/v2/refresh-token with x-api-key and api-secret headers and receives a 7-day partner access token, which is then used for session creation against api-gateway.transak.com. Because the repository is public (HTTP 200 to [private-repo]), the two-factor partner authentication (key + secret) is fully recoverable by anyone:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

Liveness was verified with a single authentication request replicating the production call shape (headers, not body fields): the production endpoint returned HTTP 200 and a signed 193-character JWT access token with a 7-day expiry. The token was not used further. Full-history search (git log --all -S) shows the pair was introduced only in 3a9b04c1 and never removed, so it remains in the default branch at HEAD.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

Reproduction

Assumptions

Assumes the repository is publicly readable, which was verified directly (HTTP 200). Assumes the [payment-partner] partner account 758a05a5… is the organization's active production partner account, as stated in the repository's own operations documentation and confirmed by the vendor endpoint accepting the pair.

Evidence

Recovery from the public repository (single clone, no authentication):

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

Repository visibility:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

Vendor-side liveness check (the exact request functions/api/buy/session.js makes; token redacted and not used):

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

History confirms single introduction and no removal:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

# 2) Confirm the pair is live at [payment-partner]'s PRODUCTION partner endpoint
# (same request shape as functions/api/buy/session.js getPartnerToken)
curl -s -X POST 'https://api.transak.com/partners/api/v2/refresh-token' \
-H 'Content-Type: application/json' \
-H 'x-api-key: 758a05a5-2eaf-4ecd-bc8f-6aed9fec35a3' \
-H 'api-secret: Q3UYM7E9TffXeIEUWsQRGw==' \
--data '{"apiKey":"758a05a5-2eaf-4ecd-bc8f-6aed9fec35a3"}'
# → HTTP 200 {"data":{"accessToken":"eyJhbG…","expiresAt":1788537746}}

STRIX-9

[wallet-protocol] approval modal shows ERC-20 transfers as "0 ETH" — token amount and recipient never displayed

Fixed Medium 6.5
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 59m ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The [wallet-protocol] request-approval modal renders a dApp eth_sendTransaction from the native value field only. An ERC-20 transfer/transferFrom carries value: 0x0 and encodes the actual movement in calldata, so the consent screen shows "Value: 0 ETH" plus a truncated 10-character calldata prefix. The token and amount being transferred — potentially the wallet's entire token balance — never appear anywhere on the screen the user authorizes the broadcast from.

Impact

A connected dApp can request a transfer() of the user's entire ERC-20 balance (including verified assets like USDC/USDT) and the user approves a screen that reads "Value: 0 ETH" with no asset name, amount, or destination. The risk stack does not compensate: the unlimited-approval signal S2 explicitly returns LEVEL.OK ("Not an approval.") for transfers, and the worst-case fee row renders nothing when the dApp omits fee fields. The only other guard — the spend-limit gate — applies solely when the user has configured caps and the token is in the verified registry. This is the standard drainer pattern executed through a consent screen that materially misrepresents the action.

Technical Details

In src/components/walletconnect/RequestApprovalModal.jsx, the SEND_TRANSACTION branch renders three data rows for reqParams[0]: Network (params.chainId → network name), To (txParams.to), and Value:

{reqParams[0]?.value
  ? ethers.formatEther(BigInt(reqParams[0].value)) + ' ' + nativeSymbol
  : '0 ' + nativeSymbol}

plus, when calldata is present, reqParams[0].data.slice(0, 10) — the selector only. Nothing decodes the calldata, so for transfer(address,uint256) / transferFrom(address,address,uint256) — where the entire economic movement lives in calldata — the modal communicates "0 ETH" and a 4-byte selector fragment.

Three adjacent mechanisms were checked and none surfaces the movement:

Risk plane — buildWcTransactionIntelligence scores S2 (unlimited approval), S4 (poisoning), S9 (TIP). s2UnlimitedApproval calls classifyApprove, and for non-approve calldata returns { level: LEVEL.OK, evidence: { reason: 'Not an approval.' } }. A full-balance transfer therefore composes a clean verdict and the intelligence panel reports "No elevated transaction-intelligence findings."
Fee row — resolveWcWorstCaseFeeWei returns null when the dApp omits maxFeePerGas/gasPrice, so no fee row renders either.
Spend-limit gate — resolveWcSpendAmount does correctly value registry ERC-20 transfers (M-6 fix), but it only gates when the user has configured caps; with no caps configured it is not consulted for display, and the decoded amount is discarded rather than rendered.

The wallet already owns a fixed-width decoder for exactly these two selectors (hex-slice, registry-backed, no ethers Interface at module init) — it was inline in resolveWcSpendAmount and used only for limit enforcement. The information was fully decodable on-device; it was simply never surfaced on the consent screen.

This contradicts the product's own display-integrity bar for this exact modal: H-7 added the worst-case fee row because "a value: 0x0 request with the fee pinned at the ceiling read as harmless", and M-1 added the verifying-contract row because "the only asset identity on screen was a NAME the attacker controls". A raw token transfer is the same class: the economic value is invisible at the point of consent.

Fix
Reproduction
How do I fix it?
Create Fix PR

Decode transfer/transferFrom calldata on the [wallet-protocol] consent screen and render the token symbol, exact amount, and recipient as dedicated rows — sourced from the same decoder the spend-limit gate valuates with, so display and enforcement cannot drift. When the to address is outside the verified token registry, display an explicit "unknown token — amount unreadable" row rather than letting the request read as a zero-value send.

src/lib/[wallet-protocol]Provider.jsx:275-279
275 // Decoded by hand rather than through calldata.js's describeErc20Call: that
276 // module instantiates an ethers Interface at MODULE INIT, and several WC test
277 // surfaces mock ethers without Interface (the same constraint documented for
278 // the risk stack at :58). Both shapes are fixed-width, so hex slicing is exact.
279 const ERC20_TRANSFER = '0xa9059cbb'; // transfer(address,uint256)
280 const ERC20_TRANSFER_FROM = '0x23b872dd'; // transferFrom(address,address,uint256)
281 const WORD = 64; // one abi word, in hex chars
275 // The transfer/transferFrom decode itself now lives in
276 // wallet-core/evm/walletconnect/transfer.js (WC-TRANSFER-DISPLAY, audit
277 // 2026-08-28) so the APPROVAL MODAL renders the asset + amount from the SAME
278 // helper the limit gate valuates with — one decode, no display/enforcement
279 // drift. It stays hex-slicing pure (no ethers Interface at module init).
280 import { describeWcTokenTransfer } from '@/wallet-core/evm/walletconnect/transfer.js';
src/lib/[wallet-protocol]Provider.jsx:303-328
303 303 export function resolveWcSpendAmount(txParams, net) {
304 const data = typeof txParams?.data === 'string' ? txParams.data.toLowerCase() : '0x';
305 const selector = data.slice(0, 10);
306 const isTransfer = selector === ERC20_TRANSFER;
307 const isTransferFrom = selector === ERC20_TRANSFER_FROM;
308

309 if (isTransfer || isTransferFrom) {
310 // transfer: 4-byte selector + 2 words. transferFrom: + 3 words. The amount
311 // is always the LAST word. Anything shorter cannot be decoded honestly.
312 const words = isTransfer ? 2 : 3;
313 if (data.length < 10 + words * WORD) {
314 return { valued: false, reason: 'ERC20_CALLDATA_MALFORMED' };
315 }
316 const token = lookupRegistryToken(net?.key, txParams?.to);
317 if (!token) return { valued: false, reason: 'ERC20_TOKEN_UNKNOWN' };
318 let raw;
319 try {
320 raw = BigInt(0x${data.slice(10 + (words - 1) * WORD, 10 + words * WORD)});
321 } catch {
322 return { valued: false, reason: 'ERC20_CALLDATA_MALFORMED' };
323 }
324 return {
325 valued: true,
326 amount: Number(raw) / 10 ** token.decimals,
327 currency: token.symbol,
328 };
329 }
304 const decoded = describeWcTokenTransfer(txParams, net?.key);
305 if (decoded.kind === 'transfer' || decoded.kind === 'transfer_from') {
306 return { valued: true, amount: decoded.amount, currency: decoded.token.symbol };
307 }
308 if (decoded.kind === 'unknown_token') return { valued: false, reason: 'ERC20_TOKEN_UNKNOWN' };
309 if (decoded.kind === 'malformed') return { valued: false, reason: 'ERC20_CALLDATA_MALFORMED' };
src/components/walletconnect/RequestApprovalModal.jsx:14-16
14 14 // Imported from wallet-core, NOT from [wallet-protocol]Provider: this is the same
15 15 // helper the provider uses to CAP the fee, so the ceiling shown here and the
16 16 // ceiling enforced at send time are one value (H-7).
17 import { resolveWcWorstCaseFeeWei } from '@/wallet-core/evm/walletconnect/fee.js';
17 import { resolveWcWorstCaseFeeWei } from '@/wallet-core/evm/walletconnect/fee.js';
18 // WC-TRANSFER-DISPLAY (audit 2026-08-28): decode ERC-20 transfer/transferFrom
19 // calldata so the modal names the asset + amount being moved. Same helper the
20 // spend-limit gate valuates with (single decode, no drift). A transfer that
21 // decodes to an UNKNOWN token is surfaced as "unknown token" — never silently
22 // rendered as the native-symbol value (I4).
23 import { describeWcTokenTransfer } from '@/wallet-core/evm/walletconnect/transfer.js';
src/components/walletconnect/RequestApprovalModal.jsx:79-81
79 // WC-TRANSFER-DISPLAY — what this send moves when the movement lives in
80 // calldata rather than the native value field. Null for non-token sends.
81 const tokenTransfer = (() => {
82 try {
83 if (type !== REQUEST_TYPES.SEND_TRANSACTION) return null;
84 const decoded = describeWcTokenTransfer(reqParams?.[0], wcNetwork?.key);
85 if (decoded.kind === 'none') return null;
86 return decoded;
87 } catch { return null; }
88 })();
89

79 90 const needsReauth = isSendReauthRequired();
src/components/walletconnect/RequestApprovalModal.jsx:380-381
380 {tokenTransfer && (
381 <div className={styles.txRow}>
382 <span>{t('wc.request_approval.token_amount_row_label')}</span>
383 <span className={styles.mono} data-testid="wc-token-amount">
384 {tokenTransfer.kind === 'unknown_token' || tokenTransfer.kind === 'malformed'
385 ? t('wc.request_approval.token_amount_unknown')
386 : ${tokenTransfer.amount} ${tokenTransfer.token.symbol}}
387 </span>
388 </div>
389 )}
390 {tokenTransfer?.recipient && (
391 <div className={styles.txRow}>
392 <span>{t('wc.request_approval.token_recipient_row_label')}</span>
393 <span className={styles.mono}>{tokenTransfer.recipient}</span>
394 </div>
395 )}
380 396 {worstCaseFeeText != null && (
381 397 <div className={styles.txRow}>
src/i18n/locales/en/security.json:240
240 "value_row_label": "Value",
240 "value_row_label": "Value",
241 "token_amount_row_label": "Token amount",
242 "token_amount_unknown": "Unknown token — amount unreadable from this request. Do not approve unless you know exactly what this sends.",
243 "token_recipient_row_label": "Token recipient",

Reproduction

Assumptions

Requires an approved [wallet-protocol] session (the user connected the dApp) and one approval tap on the misrepresented modal — the standard drainer interaction model. The dApp fully controls txParams including value: 0x0 and calldata.

Evidence

Dynamic harness (npx vitest run src/wallet-core/__tests__/wc-transfer-display.test.js) — passes pre-fix against the unmodified helpers, demonstrating the gap:

✓ src/wallet-core/__tests__/wc-transfer-display.test.js (1 test)
   displayed Value row: "0.0" + native symbol
   displayed Data row:  "0xa9059cbb" (selector fragment only)
   S2 risk verdict:     LEVEL.OK — "Not an approval."
   worst-case fee row:  null
   on-device decode of the same calldata: 1,000,000 USDC to 0x2222…2222

Post-fix modal render (npx vitest run src/components/walletconnect/__tests__/RequestApprovalModal.test.jsx):

Token amount   1000000 USDC          [data-testid=wc-token-amount]
Token recipient 0x2222222222222222222222222222222222222222

S2's transfer behavior confirmed at src/risk/signals/s2-unlimited-approval.js — non-approve calldata returns { level: LEVEL.OK, evidence: { reason: 'Not an approval.' } }.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

const USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
const DRAINER = '0x2222222222222222222222222222222222222222';
const data = '0xa9059cbb' + DRAINER.slice(2).toLowerCase().padStart(64, '0')
+ (1_000_000n * 10n ** 6n).toString(16).padStart(64, '0');
const tx = { to: USDC, value: '0x0', data, gas: '0x5208' };

it('what the user saw pre-fix for a 1,000,000 USDC drain', () => {
expect(ethers.formatEther(BigInt(tx.value))).toBe('0.0'); // Value row
expect(tx.data.slice(0, 10)).toBe('0xa9059cbb'); // Data row (selector only)
const inputs = buildRiskInputsFromWcRequest({ txParam: tx, chainId: 1 });
const s2 = s2UnlimitedApproval(inputs.unsignedTx, inputs.activeSetLocalState, inputs.chainData);
expect(s2.level).toBe('OK'); // risk plane: clean
expect(s2.evidence.reason).toBe('Not an approval.');
expect(resolveWcWorstCaseFeeWei(tx, 'mainnet')).toBeNull(); // no fee row either
// ...while the same calldata decodes on-device to the full amount:
expect(resolveWcSpendAmount(tx, { key: 'mainnet' }).amount).toBe(1_000_000);
// Post-fix shared decoder (display + enforcement):
const d = describeWcTokenTransfer(tx, 'mainnet');
expect(${d.amount} ${d.token.symbol}).toBe('1000000 USDC');
expect(d.recipient).toBe(DRAINER);
});

STRIX-6

Unauthenticated disclosure of the referral earnings ledger via get_referral_earnings / get_referral_paid_count

Fixed Medium 5.3
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The referral earnings ledger — per-code plan, revenue, discount amounts and timestamps — is readable by any unauthenticated client through the public /api/rpc proxy. get_referral_earnings and get_referral_paid_count take a bare referral code with no ownership binding, and referral codes are designed to be shared publicly.

Impact

Any party who knows (or enumerates) a referral code — including competitors and referred users, since codes are meant to be shared for discount marketing — can read that referrer's revenue history: what plans converted, gross revenue in cents, discount cents granted, and the timing of every attributed purchase. This supports competitor intelligence and referrer payout inference, and it compounds the forgery finding: forged rows are indistinguishable from real ones in the same public view. Bounded to one code per request (1000 rows) and 60 requests/minute/IP.

Technical Details

get_referral_earnings(p_code) and get_referral_paid_count(p_code) in sql/api-security-hardening.sql accept a referral code with no ownership proof and return the code's entire attributed revenue history. Two independent paths expose them without authentication: (1) /api/rpc/* in [internal-source-path] lists both RPCs in its allowlist and forwards requests with the service-role key server-side, so the 2026-08-07 STAGE 1/2 EXECUTE revokes are irrelevant to this path; (2) in the production state measured on 2026-08-07 ([internal-migration] documents the six-RPC revoke batch as deliberately not run), both functions still carry PUBLIC grants and are reachable directly via [database-api] with the public anon key embedded in the app bundle. The repo's own referrals-select-lockdown.sql removed table-level enumeration but explicitly left these RPCs "pending the same review". Unlike get_referral_count/get_referral_paid_count (single integers, documented as public-by-design because tier display needs them), get_referral_earnings returns per-transaction commercial amounts.

Fix
Reproduction
How do I fix it?
Create Fix PR
Bind earnings reads to code ownership: require a device-id parameter on get_referral_earnings and return rows only when it matches the device_id recorded when the code was registered — the ownership model the other referral RPCs already use.
Return an empty result (not an error) on mismatch so the endpoint cannot be used to confirm which codes exist.
Have the client send its device id with the earnings query, and treat a missing device id as "no data" client-side.
Run the STAGE 2 revokes documented in the hardening migration (or confirm they have been applied) so direct [database-api] access with the anon key is closed in addition to the proxy path.
Keep aggregate, non-financial counts (get_referral_paid_count) public only if that remains an intentional product decision, and revisit it alongside any future payout features.
sql/api-security-hardening.sql:326-333
326 CREATE OR REPLACE FUNCTION get_referral_earnings(p_code text)
326 -- OWNERSHIP GATE: the earnings ledger is commercial data, so the read is bound
327 -- to the device that owns the code — the same binding register_referral_code /
328 -- generate_referral_code / increment_referral already use. A mismatched or
329 -- missing device id returns no rows rather than an error, so the endpoint is
330 -- not an oracle for which codes exist.
331 CREATE OR REPLACE FUNCTION get_referral_earnings(
332 p_code text,
333 p_device_id uuid DEFAULT NULL
334 )
327 335 RETURNS TABLE(plan text, revenue_cents integer, discount_cents integer, created_at timestamptz)
328 336 LANGUAGE plpgsql
329 337 SECURITY DEFINER
330 338 STABLE
331 339 SET search_path = public, extensions, pg_temp
332 340 AS $$
333 BEGIN
341 BEGIN
342 IF p_device_id IS NULL OR NOT EXISTS (
343 SELECT 1 FROM referrals WHERE code = p_code AND device_id = p_device_id
344 ) THEN
345 RETURN; -- not the owner of this code: no rows, no error
346 END IF;
src/api/referralApi.js:143-153
143 143 export async function fetchEarnings(code) {
144 144 if (!isValidCode(code)) return null;
145 145 if (isDeniabilityOrDemoActive()) return null;
146 // The ledger is owner-only: pass the device id so the RPC can bind the read
147 // to the device that owns the code (see get_referral_earnings in
148 // sql/api-security-hardening.sql).
149 const deviceId = getOrCreateDeviceId();
150 if (!deviceId) return null;
146 151 try {
147 152 const data = await rpc('get_referral_earnings', {
148 153 p_code: code,
154 p_device_id: deviceId,
149 155 });

Reproduction

Assumptions

The attacker knows at least one valid referral code; codes are shared publicly by design (marketing/referral flows), so this is the normal state. Direct [database-api] exposure additionally assumes the documented production grant state (STAGE 2 revokes not run as of 2026-08-07); the proxy path requires no such assumption.

Evidence

Local reproduction against the repository's actual code ([edge-cli] running [internal-source-path], [database-api], [database] with all migrations replayed):

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]
[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]
[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

curl -s -X POST http://localhost:8788[internal-endpoint] \
-H 'Content-Type: application/json' -H 'Origin: https://veyrnox.com' \
-d '{"p_code":"[REDACTED-CODE]"}' -w "\nHTTP %{http_code}\n"
# => HTTP 200 ; 1

# --- direct [database-api] with the public anon key (2026-08-07 measured prod state:
# STAGE 2 revokes documented as deliberately not run, grants still PUBLIC) ---
curl -s -X POST http://localhost:5999/rpc/get_referral_earnings \
-H "apikey: $ANON_KEY [env]" -H "Authorization: Bearer $ANON_KEY [env]" \
-H 'Content-Type: application/json' \
-d '{"p_code":"[REDACTED-CODE]"}' -w "\nHTTP %{http_code}\n"
# => HTTP 200 [{"plan":"annual","revenue_cents":4999,...},{"plan":"monthly",...}]

# --- POST-FIX (after applying the ownership gate): same unauthenticated call ---
# => HTTP 200 [] (no device id)
# => HTTP 200 [] (wrong device id)
# => HTTP 200 [rows] (owning device id only)

STRIX-14

Decoy and hidden wallet sessions can render the real user's NFT collection and mutate it, breaching the zero-egress deniability invariant

Fixed Medium 5.1
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

Three NFT-tracking pages (/nft, /nft-multichain, /suspicious-assets) read the shared device-global NFTAsset store with no deniability gate. A decoy (duress-unlocked) or hidden session can therefore render the real user's tracked NFT rows, fetch their artwork from third-party CDNs (network egress correlated with the real user's collection), and delete or mutate the real user's records — violating the product's I3 invariant that deniability sessions make zero network calls and do not touch real-session data.

Impact

During a coerced session (the wallet's core threat model), the device (a) leaks the real user's NFT interests to third-party CDN operators and any network observer — image fetches for the real user's specific NFTs occur at decoy-session time, and the timing/destination pattern is a forensic tell that the open session is not the primary one; and (b) permits destructive or forged writes to the real user's data (deleting tracked NFTs, creating fake entries, toggling spam-token overrides and dismissals) which persist into the real session. Impact assessed as confidentiality-low + integrity-low: the leaked data is user-curated NFT metadata (name/collection/floor/price), not key material, and the writes are confined to the local tracking store.

Technical Details

Root cause: src/api/localClient.js persists entities in a single device-global IndexedDB (veyrnox-appdata, store entities) that is not scoped by wallet session. Other consumers of the same store applied a two-chokepoint deniability gate — SecurityDashboard.jsx gates the identical nft-assets query behind entityQueryEnabled = !isDecoy && !isHidden, and PriceAlerts.jsx (K-2 fix, 2026-08-16) gates both query and mutations behind deniable with DENIABILITY_BLOCKED throws. The three NFT pages never received that pass.

Reachability: all three routes sit inside WalletGate (App.jsx), which renders them whenever the vault is unlocked — a duress-PIN unlock is a full unlock of the decoy vault and sets isDecoy=true with the same route table. The pages are listed in the sidebar (navigation.js Assets/Security groups), the mobile More drawer, and the ⌘K command palette; none of those surfaces filters them out for decoy sessions (only isCut features are removed). The query base44.entities.NFTAsset.list() reads the device-global store, so a decoy session reads the REAL user's rows; each rendered row assigns safeNftImageUrl(nft.image_url) to <img src>, which in a real WebView opens network connections to the allowlisted NFT CDNs (i.seadn.io, openseauserdata.com, cloudflare-ipfs.com, *.mypinata.cloud…) — direct third-party egress during a deniability session, correlated with the real user's specific tracked NFTs. The mutations (delete, create, update, spam-override and dismissal writes) hit the same shared store, so a decoy session destroys or forges real-session records.

Fix
Reproduction
How do I fix it?

Apply the repository's established two-chokepoint pattern (as SecurityDashboard.jsx and PriceAlerts.jsx already do) to every page reading a shared local entity store: gate the query's enabled on !isDecoy && !isHidden, derive the rendered list from an empty array while deniable, and throw a DENIABILITY_BLOCKED error at the top of every mutationFn/write handler before touching the store. Audit remaining base44.entities.* consumers for the same gap.

Reproduction

Assumptions

Assumes the coercion model in the repository's threat model: an attacker with the device unlocks the decoy vault with the duress PIN (a credential deliberately surrendered to a coercer by design) and can navigate the app's full route table, including the sidebar/command-palette entries for the three NFT pages.

Evidence

Dynamic harness src/pages/__tests__/NftPages.deniability.i3.test.jsx (5 tests) rendered the real page components under the real local data layer with the deniability marker set. Pre-fix observations, captured while the assertions targeted the unfixed code: /nft rendered the seeded real-user row and assigned https://i.seadn.io/files/real-user-owned-boredape.png to <img src>; /nft-multichain did the same; the Delete click removed the real user's row from the shared store (1 → 0). Post-fix the same harness asserts: empty grid, zero CDN img src assignments, no Delete button, real row untouched — while the negative control (deniability off) still renders the row and fires the CDN URL, proving the gate is the only difference. Full suite green after the fix: pages 93 files / 583 tests, components+lib+api 297 files / 2384 tests.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

// Seed a real-user NFT row in the shared store (device-global, not session-scoped)
await localBase44.entities.NFTAsset.create({
name: 'Bored Ape #8890',
image_url: 'https://i.seadn.io/files/real-user-owned-boredape.png',
chain: 'ethereum', status: 'holding',
});

// Enter deniability mode exactly like WalletProvider's decoy-unlock branch
setDeniabilitySession(true);
walletState.isDecoy = true;

// Render the real /nft page
renderPage(NFTPortfolio);

// PRE-FIX result (assertions captured before the fix landed):
// screen.getByText('Bored Ape #8890') -> found
// img getAttribute('src') -> https://i.seadn.io/files/...
// (every src assignment = one CDN fetch in a real WebView)
// Delete click -> shared-store row count 1 -> 0
// (decoy session destroyed the real user's record)

// POST-FIX result (current assertions, all passing):
// screen.getByText(/No NFTs tracked yet/i) -> empty state
// screen.queryByText('Bored Ape #8890') -> null
// img src assignments to i.seadn.io -> 0
// delete button -> not rendered
// shared-store row count after render -> 1 (untouched)
// Negative control (marker off): row renders + CDN URL reaches <img src>.

STRIX-3

[internal-native-bridge] bridge releases cached vault password and DEK without native integrity gate

Fixed Medium 5.1
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The Android biometric-cache [mobile-framework] plugin returns the cached plaintext vault password (or the raw 32-byte vault DEK) to any caller that can invoke it from page JavaScript, without any native runtime-integrity check. Every other secret-releasing native plugin in the same package gates its operations on a device-integrity check; this one has none, so the only protection is JavaScript-layer gating that an injected script bypasses by calling the bridge directly.

Impact

An attacker with script execution inside the wallet WebView obtains the cached vault password. On vaults that are not KEK-wrapped (the legacy path still written by storeUnlockSecret), that password decrypts the vault blob directly — full seed/mnemonic disclosure. On KEK-enrolled vaults the unauth/legacy read yields only the C factor (H remains hardware-gated), but getFastpathDek() returns the raw vault DEK itself whenever any device-wide strong biometric authentication occurred within the preceding 30 seconds, which on such devices is likewise full vault decryption. No integrity or availability impact — the methods are read-only.

Technical Details

The plugin maintains three AndroidKeyStore-encrypted password/DEK cache slots and exposes nine @PluginMethods on the [mobile-framework] bridge, which is reachable from ordinary page JavaScript via window.[mobile-framework] (the sibling VeyrnoxEnclavePlugin.kt header documents this exact caller-auth threat model and why token schemes are forgeable — hence native gates).

[internal-native-bridge]Plugin.kt:176
176 fun getSecretUnauth(call: PluginCall) {
177 val ctx = context ?: run {
178 call.reject("Plugin context unavailable", "NO_CONTEXT")
179 return
180 }
181 try {
182 val p = prefs(ctx)
183 val ctB64 = p.getString(dataUnauthKey, null)
184 val ivB64 = p.getString(ivUnauthKey, null)
185 if (ctB64.isNullOrEmpty() || ivB64.isNullOrEmpty()) {
186 call.resolve(JSObject().put("secret", null))
187 return
188 }
189 val cipher = Cipher.getInstance("AES/GCM/NoPadding")
190 cipher.init(
191 Cipher.DECRYPT_MODE,
192 loadSecretKey(storageUnauthAlias),
193 GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
194 )
195 val plaintext = cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP))
196 call.resolve(JSObject().put("secret", String(plaintext, StandardCharsets.UTF_8)))
197 } catch (e: Exception) {
198 call.reject("getSecretUnauth failed: ${e.message}", "ANDROID_BIOMETRIC_CACHE_READ_FAILED")
199 }
200 }

storageUnauthAlias is generated by ensureUnauthStorageKey() → tryGenerateKey(..., requiresAuth = [internal-native-bridge]Config.REQUIRES_USER_AUTH_UNAUTH /* false */, ...) — no setUserAuthenticationRequired(true), so decryption never prompts. The design contract ([internal-native-bridge]Config.kt:35-47) holds that the cached value is only the C factor of DEK = HKDF(H ‖ C) and H is biometric-gated — but that contract is enforced exclusively in the JS wrapper (biometricUnlock.js retrieveUnlockSecretDirect), which a direct bridge call skips. The legacy getSecret path is equally unauthenticated at the native layer: ensureStorageKey() at [line ref redacted] generates storageAlias with requiresAuth = false as well; the only biometric binding on that path is the app-layer prompt inside retrieveUnlockSecret() (JS), plus an invalidation sentinel that only detects biometric re-enrollment, not caller authenticity. getFastpathDek() ([line ref redacted]) releases the raw vault DEK; its only native control is the 30-second setUserAuthenticationParameters(30, AUTH_BIOMETRIC_STRONG) device-wide validity window, satisfied by any recent strong biometric on the device, not by a Veyrnox-initiated prompt.

The gap is an asymmetry against the codebase's own hardening standard: VeyrnoxEnclavePlugin.createWrappingKey/wrap/unwrap, HardwareKekPlugin.getHardwareFactor, and their iOS counterparts all begin with a native BLOCK-tier integrity check (RaspIntegrityPlugin.isBlockTier(ctx) / [RaspIntegrityPlugin earlyCheck], added 2026-08-15 with the explicit rationale that injected JS must not reach key operations). [internal-native-bridge]Plugin — the plugin that releases the most sensitive artifacts — contains no such check on any method.

Fix
Reproduction
How do I fix it?

Add the same native device-integrity gate that the Enclave and hardware-KEK plugins already enforce to all three secret-releasing methods of the Android biometric-cache plugin, rejecting with a distinct error code before any ciphertext is decrypted. Longer term, consider binding the fast-path DEK alias to per-use authentication via a biometric-prompt crypto object rather than the 30-second device-wide validity window, which the plugin's own source already identifies as the upgrade path.

Reproduction

Assumptions

Assumes attacker-controlled JavaScript is already executing in the wallet WebView (e.g. via an XSS rendering bug in attacker-derived content such as memo names or [wallet-protocol] metadata, or via a runtime hook that evades the RASP launch gate). On a clean device with no such precondition the plugin is only callable by the app's own code. The severity reflects this precondition as local vector with high complexity; the finding was identified by source analysis and was not dynamically executed this round (no device available).

Evidence

[internal-native-bridge]Plugin.kt contains zero references to the RASP gate used by its sibling plugins (grep -n "isBlockTier\|earlyCheck\|RASP" [internal-native-bridge]Plugin.kt → no matches), while VeyrnoxEnclavePlugin.kt:155-163, VeyrnoxEnclavePlugin.kt:291-299 (Android), HardwareKekPlugin.m:118, and HardwareKekPlugin.m:305 (iOS) all gate secret-releasing methods on the same check. The unauth alias is created with tryGenerateKey(storageUnauthAlias, requiresAuth = false, ...) and its read path returns String(plaintext) across the bridge (getSecretUnauth, [line ref redacted]). The legacy alias is equally auth-free (ensureStorageKey, [line ref redacted]). The shipped plugin is the app-level copy registered in Main

STRIX-12

Token approval revoke bypasses all pre-sign security gates (RASP, re-auth, send 2FA) on a mainnet broadcast path

Fixed Medium 4.4
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 9h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The Token Approvals page's revoke action reaches the wallet's real signing key and broadcasts an on-chain transaction with no pre-sign gate whatsoever: no RASP runtime-integrity check, no step-up re-auth, no configured send second factor, no spend limits, no transaction risk policy. Every other signing surface in the application enforces this gate stack at the signer; this is the single remaining sign-and-broadcast path a hostile runtime can drive. The signed calldata is bounded to approve(spender, 0), so impact is gas-drain and destruction of standing allowances the victim's DeFi positions rely on (griefing/position-locking), not direct theft.

Impact

An attacker with code execution in the wallet's WebView (the runtime-integrity threat model this wallet is built around) can broadcast arbitrary numbers of real mainnet transactions from the victim's account-0 key, paying gas from the victim's balance and zeroing standing ERC-20 allowances (e.g. Permit2, router allowances) that the victim's DeFi positions depend on — locking positions and forcing costly re-approvals. The victim's configured protections (Action Password/passkey/biometric second factor, re-auth window, runtime-integrity block) are never consulted, so a device that would fail every other signing surface still signs here. Additionally, the hardcoded derivation index 0 signs from the first EVM account regardless of which account owns the approval (wrong-key broadcast → revert → gas burn), and on a clean device the missing second factor diverges from the send flow's enforced policy for an unattended unlocked wallet.

Technical Details

Root cause. src/pages/TokenApprovals.jsx revoke.mutationFn checks only wallet.isUnlocked before handing the private key to sendRevoke. withPrivateKey itself (WalletProvider) enforces only "unlocked + not locked mid-operation" — every policy gate in this codebase is call-site convention, and this call site installs none.

What is skipped (each of these runs on the in-app send path and the [wallet-protocol] path at sign time):

  • RASP pre-sign gate — sendGate/presignGate composition over a fresh getFreshRaspArtifact() probe. The product's documented policy (src/sign-gate/presign.js) is that a BLOCK-tier environment (hooked runtime, emulator, attestation failure) must never reach a signer, and WARN requires explicit acknowledgement. None of it is called.
  • Step-up re-auth window (isSendReauthRequired) — an idle-but-unlocked session can broadcast immediately.
  • Configured send second factor (resolveSend2faMethod → Action Password / passkey / OS biometric) — the send flow enforces all three modes at its chokepoint; this path enforces none.
  • Spend limits / transaction risk policy — not applicable to a value-0 transaction, but the gate order that enforces "policy is computed before the key is touched" is absent entirely.

Aggravators.

Hardcoded derivation index 0 — signs from EVM account 0 regardless of which account owns the approval. The send flow resolves the index by matching the sender's address; a multi-account user gets a wrong-key broadcast that reverts on-chain while still paying gas.
Attacker-influencable row store. The revoke's spender and network come from the approval row, read from the local IndexedDB entity store (veyrnox-appdata, plain indexedDB.open, no integrity wrapper). Any script running in the wallet's origin can write rows. The transaction target contract is safe — it comes from the pinned token registry (getToken, unknown symbols throw) — but the spender is only format-checked, and ALLOW_MAINNET is true, so the store feeds attacker-chosen spender/network choices straight into a real broadcast.

Blast radius is bounded by design (this is what keeps the issue at gas-griefing rather than theft): sendRevoke hardcodes approve(spender, 0n), re-decodes the calldata through buildRevokeCalldata and refuses anything that does not decode to a true zero-allowance approve, re-verifies the live chain id, and the value is 0. No funds can move through this path; only allowances can be destroyed and gas spent.

Pre-fix flow (dynamically reproduced):

1 click Revoke (or dispatchEvent('click') from injected JS)
2 → mutationFn: buildRevokeCalldata (decode self-check)
3 → wallet.isUnlocked === true [the ONLY check]
4 → wallet.withPrivateKey(0, sendRevoke) [real key, index 0]
5 → approve(spender, 0n) signed locally + broadcast on mainnet
6 [getFreshRaspArtifact: never called]
7 [presignGate: never called]
8 [isSendReauthRequired: never called]
9 [send 2FA: never resolved]
Fix
Reproduction
How do I fix it?
Create Fix PR
Route the revoke through the same sign-time gate stack the send flow enforces, inside the mutation function (never at render time): fresh RASP probe → presignGate(tier, level, acknowledged) with no silent-ack affordance → step-up re-auth check → resolved send second factor.
Derive the signing account index by matching the approval's owner address against the unlocked account set (fall back to the primary account only when no owner is present), instead of hardcoding 0.
Consider signing approval rows (or otherwise binding them to the vault) so the local IndexedDB store cannot feed attacker-written rows into a broadcast path.
Add the missing chokepoint to any "every signing surface is gated" audit checklist so a new surface cannot regress this way.
src/pages/TokenApprovals.jsx:96-131
96 96 // NATIVE / testnet: real broadcast through the EXISTING signing path. The
97 97 // private key is handed in transiently by withPrivateKey and never stored.
98 98 if (!wallet.isUnlocked) {
99 99 throw new Error("Unlock your wallet to broadcast a revoke transaction.");
100 100 }
101 const res = await wallet.withPrivateKey(0, (pk) =>
101

102 // SIGN-CHOKEPOINT GATES (RASP §7 / H-NEW-B / audit H-1 parity with the
103 // Send flow): a revoke is a real state-changing broadcast from the user's
104 // key, so it must never reach a signer in a hostile runtime, past a stale
105 // re-auth window, or without the configured send second factor. This page
106 // previously enforced NONE of these — the one ungated broadcast path.
107

108 // (1) Fresh RASP probe at sign time (never a cached/render-time artifact):
109 // a BLOCK-tier environment (hooked runtime, emulator, attestation fail)
110 // must not sign. There is no "sign anyway" affordance here, so WARN/
111 // CONFIRM also refuse (fail closed, I4) — revoke again from a clean device.
112 const raspArtifact = await getFreshRaspArtifact();
113 const raspTier = raspArtifact?.tier ?? TIER.BLOCK;
114 const gate = presignGate(raspTier, LEVEL.CAUTION, false);
115 if (!gate.proceedAllowed) {
116 throw Object.assign(
117 new Error(
118 gate.signerReachable
119 ? "Revoke blocked: this device did not pass a runtime safety check. Confirm the risk and retry from a clean device."
120 : "Revoke blocked: this device did not pass a runtime safety check (signing is disabled here).",
121 ),
122 { code: gate.signerReachable ? "REVOKE_RISK_CONFIRM" : "RASP_BLOCK" },
123 );
124 }
125

126 // (2) Step-up re-auth window (H-NEW-B): an idle-but-unlocked session must
127 // re-enter the vault credential before it broadcasts anything.
128 if (!DEMO && typeof wallet.isSendReauthRequired === "function" && wallet.isSendReauthRequired()) {
129 throw Object.assign(
130 new Error("Re-enter your PIN or password to authorise this revoke."),
131 { code: "REAUTH" },
132 );
133 }
134

135 // (3) Configured send second factor (audit H-1): whatever factor the Send
136 // flow enforces (Action Password / passkey / OS biometric) must ALSO be
137 // satisfied here — resolved through the same single source of truth.
138 const send2fa = resolveSend2faMethod({
139 demo: DEMO,
140 isNative: [mobile-framework].isNativePlatform(),
141 biometric2faEnabled: is2faBiometricEnabled(),
142 passkey2faEnabled: is2faPasskeyEnabled(),
143 passkeyRegistered: isPasskeyRegistered(),
144 actionPasswordConfigured: wallet.actionPasswordConfigured === true,
145 isDecoy: wallet.isDecoy === true,
146 isHidden: wallet.isHidden === true,
147 });
148 const twoFactorVerified = twoFactorVerifiedRef.current;
149 twoFactorVerifiedRef.current = false; // one-shot: consumed per attempt
150 if (!DEMO && send2fa !== SEND_2FA.NONE && !twoFactorVerified) {
151 throw Object.assign(
152 new Error("Confirm the two-factor prompt to authorise this revoke."),
153 { code: "TWO_FACTOR" },
154 );
155 }
156

157 // Derive the signing account from the approval's owner when the row
158 // carries one; otherwise the primary EVM account (index 0), matching
159 // every other single-account surface ([wallet-protocol], message signing).
160 const owner = typeof a.owner_address === "string" ? a.owner_address.toLowerCase() : null;
161 const ownerAccount = owner
162 ? (wallet.accounts || []).find((acct) => typeof acct?.address === "string" && acct.address.toLowerCase() === owner)
163 : null;
164 const index = ownerAccount ? ownerAccount.index : 0;
165

166 const res = await wallet.withPrivateKey(index, (pk) =>
102 167 sendRevoke({
103 168 networkKey: a.network,
104 169 privateKey: pk,
105 170 symbol: a.token_symbol,
106 171 spender: a.spender_address,
107 172 })
108 173 );
src/pages/TokenApprovals.jsx:14-25
14 14 import { fetchRiskNoteAsync } from "@/lib/approvalRiskNotes";
15 import { useApprovalMonitor } from "@/hooks/useApprovalMonitor";
15 import { useApprovalMonitor } from "@/hooks/useApprovalMonitor";
16 import { [mobile-framework] } from "@capacitor/core";
17 import { TIER, getFreshRaspArtifact } from "@/rasp";
18 import { presignGate } from "@/sign-gate/presign";
19 import { LEVEL } from "@/risk/levels";
20 import { resolveSend2faMethod, SEND_2FA } from "@/lib/send2faMethod";
21 import { is2faBiometricEnabled } from "@/lib/biometric";
22 import { is2faPasskeyEnabled, isPasskeyRegistered } from "@/lib/passkey";
23 import { useActionGuard } from "@/components/security/useActionGuard";
src/pages/TokenApprovals.jsx:56-64
56 56 export default function TokenApprovals() {
57 57 const qc = useQueryClient();
58 58 const wallet = useWallet();
59 const { requireTwoFactor, gateModal } = useActionGuard();
59 60 const [filter, setFilter] = useState("active");
src/pages/TokenApprovals.jsx:360-366
360 onClick={() => revoke.mutate(a)}
360 onClick={() => {
361 // Critical-action 2FA (shared resolver + gate UI): with a
362 // second factor configured, the revoke runs only after it
363 // passes; with none configured, it runs immediately
364 // (opt-in baseline, unchanged). The one-shot token is set
365 // for the mutationFn to consume at the chokepoint above.
366 twoFactorVerifiedRef.current = false;
367 requireTwoFactor(() => {
368 twoFactorVerifiedRef.current = true;
369 revoke.mutate(a);
370 }, { title: "Revoke token approval" });
371 }}

Reproduction

Assumptions

Assumes the attacker holds code execution in the wallet's WebView (the runtime-integrity adversary this wallet's RASP layer exists for) or physical/unattended access to an unlocked wallet; assumes the vault is unlocked (isUnlocked is the one check the path does enforce); assumes mainnet is enabled (ALLOW_MAINNET = true in the repository). The gas-griefing/allowance-destruction impact is bounded by the hardcoded zero-allowance calldata — no fund transfer is possible through this path.

Evidence

Pre-fix dynamic run (all assertions passed against the vulnerable code, captured in the repository test artifact sign-gate-prefix-evidence):

✓ reaches wallet.withPrivateKey (the signer) with ZERO security gates consulted
✓ broadcasts approve(spender, 0) with the REAL key — index 0, audit row recorded
✓ structural: the component wires no gate APIs into the revoke mutation

Post-fix, the same harness (updated expectations) confirms the closed path:

✓ a BLOCK-tier device is probed FRESH and never reaches the signer
✓ a clean (ALLOW-tier) device with no second factor still broadcasts approve(spender, 0)
✓ a configured send second factor gates the click — signer unreachable until the gate completes
✓ structural: the chokepoint re-asserts the second factor (one-shot token)
Tests 15 passed (15) — including the full [wallet-protocol] and send-gate suites (215 files / 1851 tests in src/lib; 190 files / 1156 tests in src/pages + src/components) and a clean typecheck.

Gate-spy call log proving the ordering violation (pre-fix): getFreshRaspArtifact and presignGate had zero invocations while withPrivateKey had one — the inverse of the send path's enforced order.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

vi.mock("@/rasp", () => ({
TIER: { ALLOW: "allow", WARN: "warn", BLOCK: "block" },
getFreshRaspArtifact: vi.fn(async () => ({ tier: "block" })), // BLOCK-tier device
useRaspArtifact: vi.fn(() => ({ tier: "block" })),
}));
vi.mock("@/sign-gate/presign", () => ({
presignGate: vi.fn(() => ({ proceedAllowed: false, signerReachable: false })), // would BLOCK
}));

const walletCalls = { withPrivateKey: [], recordAudit: [] };
vi.mock("@/lib/WalletProvider", () => ({
useWallet: () => ({
isUnlocked: true,
accounts: [],
actionPasswordConfigured: false, // a biometric-2FA user: AP not set
isSendReauthRequired: () => false,
withPrivateKey: vi.fn(async (index, fn) => {
walletCalls.withPrivateKey.push({ index });
return fn("0x" + "deadbeef".repeat(8)); // "the real key"
}),
recordAudit: (ev) => walletCalls.recordAudit.push(ev),
}),
}));

vi.mock("@/api/base44Client", () => ({ base44: { entities: { TokenApproval: {
list: vi.fn(async () => [{
id: "ta1", network: "mainnet", token_symbol: "USDC", decimals: 6,
spender_address: "0xe592427a0aece92de3edee1f18e0157c05861564",
allowance_raw: "115792089237316195423570985008687907853269984665640564039457584007913129639935",
status: "active",
}] }),
update: vi.fn(async () => ({})),
}}}}));

// ethers mocked so the broadcast is captured, not sent:
vi.mock("ethers", () => ({ /* Interface with encode+parse, Contract.approve recorder, Wallet */ }));

// drive it:
await screen.findByText("Revoke");
fireEvent.click(screen.getByText("Revoke"));

// PRE-FIX RESULT (asserted, all pass):
expect(walletCalls.withPrivateKey.length).toBe(1); // signer reached
expect(walletCalls.withPrivateKey[0].index).toBe(0); // hardcoded index 0
expect(approveCalls.find(c => c.broadcast).value).toBe(0n); // approve(spender, 0) broadcast
expect(getFreshRaspArtifact).not.toHaveBeenCalled(); // RASP never probed
expect(presignGate).not.toHaveBeenCalled(); // gate never consulted
expect(walletCalls.recordAudit).toContain("approval_revoked");// treated as real

STRIX-13

[wallet-protocol] send path skips biometric and passkey send-2FA (only Action Password mode is enforced)

Fixed Medium 4.3
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The [wallet-protocol] eth_sendTransaction handler enforces the user's configured send second factor only when that factor is an Action Password. Users whose configured second factor is OS biometric or a passkey — the two modes the in-app Send screen fully enforces — get dApp transactions signed and broadcast after a single approval tap, with no second factor ever requested. The check keys off actionPasswordConfigured alone instead of the resolved send2faMethod, so the strongest opt-in protections are silently not enforced on the remote (least-trusted) signing surface.

Impact

A user who enabled "OS biometric" or "passkey" as their send second factor — a protection Settings presents as applying to every send — has that protection silently absent when signing through a paired dApp. A remote dApp (the semi-trusted peer in the wallet's threat model) obtains a signed, broadcast transaction after one approval tap, where the identical wallet state on the in-app Send screen is refused until the second factor passes. The bypass matters most against UI automation or a coerced/confused-user scenario: the OS biometric prompt that would stop a scripted or pressured confirmation on the Send screen never appears on the dApp path. The transaction is still subject to the independent RASP, re-auth-window, spend-limit, chain, address, and gas-cap gates; only the second factor is skipped.

Technical Details

Root cause. src/lib/[wallet-protocol]Provider.jsx _handleSendTransaction gates on a single boolean:

if (actionPasswordConfigured === true) {
  await rejectRequest(topic, id, 'WC_TWO_FACTOR_REQUIRED')...
}

actionPasswordConfigured is true only for the password mode. The application's single source of truth for "which second factor applies to a send" is resolveSend2faMethod (src/lib/send2faMethod.js), which returns BIOMETRIC (native + pref on), PASSKEY (pref on + registered), PASSWORD (Action Password configured), or NONE. For the first two, actionPasswordConfigured is false — so the handler's only 2FA check passes and the transaction is signed inside withPrivateKey with no second factor presented.

The asymmetry. The in-app Send flow resolves the method and enforces all three modes at its sign chokepoint: twoFactorRequired: send2faMethod !== SEND_2FA.NONE feeds evaluateSendGate gate 6b, and a TwoFactorGate runs the mode-specific verification (verifyBiometric2fa for biometric, verifyPasskeyAssertion for passkey, verifyActionPassword for password) before the signer. This is the exact hole audit H-1 closed on the Send screen: "the previous Send-screen code keyed the 2FA gate solely off actionPasswordConfigured. For a PASSKEY-ONLY 2FA configuration that flag is false, so the gate was skipped" (send2faMethod.js header). The [wallet-protocol] path reintroduced the same keying bug on the remote surface.

Why the in-code justification does not hold. The comment argues WC "has NO in-band affordance to collect the Action Password mid-flow" — honest, but it only explains why the factor cannot be satisfied here; the fail-closed remedy the password branch already uses (reject with WC_TWO_FACTOR_REQUIRED, routing the user to the in-app Send screen) was available and is exactly what the fix applies. It is also not true that the factors are unsatisfiable in-band in every mode (verifyBiometric2fa is a plain async call that raises the native OS prompt from any surface), but rejecting is the conservative, uniform behavior.

Pre-fix flow (dynamically reproduced): biometric-2FA user (native, pref on, no Action Password) sends via a paired dApp → RASP gate passes (clean device) → actionPasswordConfigured === false → no second factor → withPrivateKey signs → hash returned to the dApp. The same wallet state on the Send screen returns TWO_FACTOR from evaluateSendGate and renders the biometric gate before any signer access.

Fix
Reproduction
How do I fix it?
Create Fix PR
Resolve the send second factor through resolveSend2faMethod (the single source of truth both the Send screen and the action guard already use) at the [wallet-protocol] send chokepoint, and reject with WC_TWO_FACTOR_REQUIRED whenever the resolved method is not NONE — mirroring the existing password branch, which already implements the correct fail-closed shape.
Alternatively, satisfy the factor in-band where the platform allows it (the native OS biometric prompt is callable from any surface); rejecting uniformly is simpler and conservative.
Keep the reject message honest about which factor is configured so the user understands why they are being routed to the in-app Send screen.
Add a regression pin that covers all three configured modes on the dApp path (the existing test only covered password mode, which is why this asymmetry survived).
src/lib/[wallet-protocol]Provider.jsx:570-583
570 // #1090 — Action Password 2FA gate. The in-app Send flow requires the second
571 // factor at the sign chokepoint (see sendGate.js §6b). The WC surface has NO
572 // in-band affordance to collect the Action Password mid-flow; the honest
573 // fail-closed path (I4) is to REJECT so the user routes through the in-app
574 // Send screen where the full 2FA dance runs. Never bypass.
575 if (actionPasswordConfigured === true) {
570 // #1090 — send second-factor gate. The in-app Send flow requires the RESOLVED
571 // second factor (Action Password, passkey, or OS biometric — resolveSend2faMethod,
572 // see sendGate.js §6b) at the sign chokepoint. The WC surface has NO in-band
573 // affordance to satisfy any of those mid-flow; the honest fail-closed path (I4)
574 // is to REJECT so the user routes through the in-app Send screen where the full
575 // 2FA dance runs. Never bypass — and never key off one factor's configured flag
576 // alone: a BIOMETRIC or PASSKEY user has actionPasswordConfigured === false and
577 // previously signed on a single Approve tap (the same class audit H-1 closed on
578 // the Send screen).
579 if (send2faMethod !== SEND_2FA.NONE) {
576 580 await rejectRequest(topic, id, 'WC_TWO_FACTOR_REQUIRED').catch(() => {});
577 581 throw new Error(
578 Rejected transaction [WC_TWO_FACTOR_REQUIRED]: an Action Password +
579 is configured for this wallet. Complete the send from the in-app +
580 Send screen so the second factor can be entered.,
582 Rejected transaction [WC_TWO_FACTOR_REQUIRED]: a send second factor +
583 (${send2faMethod}) is configured for this wallet. Complete the send +
584 from the in-app Send screen so the second factor can be entered.,
581 585 );
582 586 }
src/lib/[wallet-protocol]Provider.jsx:519-521
519 519 export async function _handleSendTransaction(
520 { withPrivateKey, evmAddress, actionPasswordConfigured = false, txLimits = [], history = [], knownAddresses = [], whitelist = [], usdRates = USD_RATES, remoteScreenEnabled = false },
520 { withPrivateKey, evmAddress, send2faMethod = SEND_2FA.NONE, txLimits = [], history = [], knownAddresses = [], whitelist = [], usdRates = USD_RATES, remoteScreenEnabled = false },
521 521 topic, id, params, caip2ChainId,
522 522 ) {
src/lib/[wallet-protocol]Provider.jsx:687-688
687 687 import { evaluateSendAgainstLimits } from '@/lib/txLimits';
688 import { USD_RATES } from '@/lib/cryptos';
688 import { USD_RATES } from '@/lib/cryptos';
689 import { resolveSend2faMethod, SEND_2FA } from '@/lib/send2faMethod';
690 import { is2faBiometricEnabled } from '@/lib/biometric';
691 import { is2faPasskeyEnabled, isPasskeyRegistered } from '@/lib/passkey';
src/lib/[wallet-protocol]Provider.jsx:1033-1041
1033 1033 await _handleSendTransaction(
1034 1034 {
1035 1035 withPrivateKey,
1036 1036 evmAddress,
1037 actionPasswordConfigured,
1037 send2faMethod: resolveSend2fa(),
1038 1038 txLimits,
src/lib/[wallet-protocol]Provider.jsx:694-695
694 694 const { accounts, isUnlocked, isDecoy, isHidden, withPrivateKey, isSendReauthRequired, actionPasswordConfigured } = useWallet();
695 const evmAddress = accounts?.[0]?.address ?? null;
695 const evmAddress = accounts?.[0]?.address ?? null;
696

697 // #1090 — resolve the SAME send second-factor method the in-app Send screen
698 // enforces (resolveSend2faMethod is the single source of truth; mirrors
699 // useActionGuard.resolveMethod). The WC surface cannot satisfy any factor
700 // in-band, so _handleSendTransaction REJECTS whenever this is not NONE —
701 // previously it only checked actionPasswordConfigured, silently skipping the
702 // gate for BIOMETRIC and PASSKEY users.
703 const resolveSend2fa = useCallback(() => resolveSend2faMethod({
704 demo: DEMO,
705 isNative: [mobile-framework].isNativePlatform(),
706 biometric2faEnabled: is2faBiometricEnabled(),
707 passkey2faEnabled: is2faPasskeyEnabled(),
708 passkeyRegistered: isPasskeyRegistered(),
709 actionPasswordConfigured,
710 isDecoy,
711 isHidden,
712 }), [actionPasswordConfigured, isDecoy, isHidden]);

Reproduction

Assumptions

Assumes the victim has a paired, live [wallet-protocol] session with the requesting dApp (normal dApp usage), a configured send second factor of biometric or passkey type (Settings exposes both toggles), and taps Approve on the request modal. The RASP, re-auth-window, spend-limit, chain-binding, address-binding, and gas-cap gates are independent of this finding and remain enforced; only the second factor is skipped.

Evidence

Pre-fix dynamic run (all assertions passed against the vulnerable code):

✓ the resolved method for a biometric-2FA user is BIOMETRIC (not password)
✓ the resolved method for a passkey-2FA user is PASSKEY (not password)
✓ the in-app Send chokepoint BLOCKS this state at gate 6b (second factor required)
✓ the WC send path SIGNS for a biometric-2FA user with no second factor (the bypass)
✓ the WC send path SIGNS for a passkey-2FA user with no second factor (the bypass)
✓ control: a PASSWORD-2FA user IS rejected (the branch that works)

Post-fix, the same harness (updated expectations) pins the closed path alongside the unchanged cases:

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

// Wallet state of a user who chose "OS biometric as my send second factor":
const BIOMETRIC_USER = {
demo: false, isNative: true, biometric2faEnabled: true,
passkey2faEnabled: false, passkeyRegistered: false,
actionPasswordConfigured: false, isDecoy: false, isHidden: false,
};

// 1. The user HAS a configured second factor:
resolveSend2faMethod(BIOMETRIC_USER); // => 'biometric'
resolveSend2faMethod(PASSKEY_USER); // => 'passkey'

// 2. The in-app Send chokepoint BLOCKS this exact state:
evaluateSendGate({
canSend: true, isUnlocked: true,
twoFactorRequired: resolveSend2faMethod(BIOMETRIC_USER) !== SEND_2FA.NONE,
twoFactorVerified: false,
});
// => { allowed: false, code: 'TWO_FACTOR' }

// 3. PRE-FIX: the WC handler signs it anyway (real _handleSendTransaction,
// mocked ethers/WC session, recorder withPrivateKey):
await _handleSendTransaction(
{ withPrivateKey, evmAddress: WALLET_ADDR,
actionPasswordConfigured: false, // the ONLY 2FA input it reads
txLimits: [], history: [], usdRates: {} },
'topic', 71, [baseTx], 'eip155:11155111',
);
// => withPrivateKey called, respondToRequest called (hash to dApp),
// NO 'WC_TWO_FACTOR_REQUIRED' reject.

// 4. Control: password-mode user IS rejected:
await _handleSendTransaction({ ..., actionPasswordConfigured: true, ... }, ...);
// => rejectRequest('topic', 73, 'WC_TWO_FACTOR_REQUIRED'), signer not reached.

STRIX-10

Persisted demo-mode flag disarms send-gate re-auth, second factor, spend limits and risk screening for real vaults

Fixed Medium 4.3
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Marked as Fixed 1h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

A single visit to any link with ?demo=1 persists localStorage['veyrnox-demo']='1' in shipped non-[build-flag] builds. The Send screen keys its security exemptions off that raw flag instead of demoActive (DEMO && wallets.length === 0), so once a real vault is unlocked, every send skips re-auth step-up and the configured send second factor (Action Password / passkey / biometric), reads spend limits from the empty demo store, and disables transaction simulation, the S7 recipient-contract check and remote threat screening — persistently, with no visible indicator.

Impact

For a victim with the full security configuration the product advertises — PIN step-up (2-minute re-auth window), "Action Password on every send" (or passkey/OS-biometric second factor), per-transaction and daily spend caps, and remote threat-intelligence screening — a single attacker-supplied link silently removes all of it for every subsequent real send: the send is signed and broadcast after no second factor at all, spend caps read from an empty store so they never trigger, sanctions/look-alike-address screening is suppressed (local IOC cache only), and pre-sign simulation of the recipient contract never runs. The vault unlock PIN still applies and the victim still types each send, but every configured human-confirmation and screening layer is gone — precisely the friction a coercion or social-engineering attacker (the wallet's own threat model) needs removed. The state persists across sessions until the victim happens to visit a ?demo=0 link.

Technical Details

Two different "demo" meanings exist in the Send screen. DEMO (src/api/demoClient.js) is a module-load snapshot that is true when a persisted veyrnox-demo=1 flag exists — which any visited ?demo=1 link sets, permanently, in any build compiled without [build-flag]=1:

demoClient.js:31
31 if (import.meta.env.[build-flag] !== "1") {
32 try {
33 const p = new URLSearchParams(window.location.search);
34 if (p.get("demo") === "0") localStorage.removeItem("veyrnox-demo");
35 else if (p.has("demo")) localStorage.setItem("veyrnox-demo", "1");
36 if (localStorage.getItem("veyrnox-demo") === "1") return true;
37 } catch {

demoActive (SendCrypto.jsx) is the truth the rest of the component already uses: "actually in the fake walkthrough, no vault" — DEMO && wallets.length === 0. The codebase elsewhere explicitly treats the divergent state as a live hazard: [wallet-protocol]Provider.jsx refuses to open the WC relay because "a stale persisted veyrnox-demo=1 can coexist with a REAL unlocked, non-decoy vault (the known localStorage trap)", and WalletPortfolioPage.jsx seals its queries on the same predicate. The send gate trusted the raw flag instead:

SendCrypto.jsx:1359
1359 demo: DEMO,
1360 reauthRequired: DEMO ? false : isSendReauthRequired(),

In sendGate.js gates 6 and 6b are if (!demo && …), so with demo:true both the re-auth step-up and the configured second factor (twoFactorRequired is derived from useSend2faMethod({ demo: DEMO … }), which short-circuits to NONE under the raw flag) are dead. Spend limits are read via base44.entities.TransactionLimit.list(), and the same raw flag switches the whole data layer (base44Client.js: BACKEND = DEMO ? 'demo' : 'local') to the in-memory demo store, whose TransactionLimit seed is empty — so the caps the user configured can never block. Pre-sign simulation and the S7 recipient-contract fact are suppressed (txSimApplies … && !DEMO; recipientCode = DEMO ? '0x' : …), and remote [advisor-screen]ing is suppressed by isDeniabilityOrDemoActive() reading the same raw localStorage flag. Meanwhile the form binds to the REAL wallets (demoActive is false, so srcWallets = wallets), the dispatch contains no simulation branch, and the broadcast is real.

Reachability of the flag itself is a build property. The guard import.meta.env.[build-flag] !== "1" is only effective when the release flag is set at build time. The repository's own release scripts set it (build:release, mobile:build:release, build:sit, build:uat), but no CI workflow calls any of them: ci.yml builds the store-channel Android web assets with plain npm run build ([line ref redacted], 462-468), canary-release.yml deploys to the veyrnox-prod [hosting-platform] project with npx vite build --mode canary, and .env.production/.env.staging/.env.canary do not define [build-flag]. Compiling the bundle with the exact CI command confirms the emitted chunk contains the live localStorage.setItem("veyrnox-demo","1") branch and zero occurrences of the guard constant — meaning the runtime hard-throw in demoClient.js is dead code in every artifact these paths produce. On web, WalletGate is a pass-through while DEMO is true (WALLET_GATE = WALLET_AUTH || NATIVE; WALLET_AUTH = BACKEND === 'local'), and the real vault remains unlockable through the Wallet Manager PIN pad or /onboarding/restore-shares (which is routed outside the gate), after which every send runs with all exemptions active. There is no visible demo indicator: the DemoBanner disclosure was removed from the app in PR #1063 and is no longer imported.

Fix
Reproduction
How do I fix it?

Key every send-gate security exemption off the "no real vault" truth (demoActive = DEMO && wallets.length === 0), never the raw DEMO flag — the second-factor resolver input, the sign-time gate demo/reauthRequired inputs, the UI re-auth checks, and the simulation/screening applicability predicates. Additionally, build every shipped artifact (store channels and production web deploys) with [build-flag]=1 so the ?demo=1 localStorage opt-in is statically eliminated and the runtime hard-throw is compiled in; the release script already exists and only the CI wiring was missing. Consider also clearing a stale veyrnox-demo flag whenever a real vault is detected as unlocked.

Reproduction

Assumptions

Applies to any deployed web build of the wallet produced without [build-flag]=1 (staging, canary, and preview lanes shipped by CI are confirmed to build that way; the production web build command is configured outside the repository). Requires the victim to visit a single ?demo=1 link on the wallet origin once, and to later hold an unlocked real vault on that origin.

Evidence

Runtime PoC harness (all assertions green):

run A — no flag:      evaluateSendGate({... demo:false, reauthRequired:true ...}) → { allowed:false, code:'REAUTH' }
run B — flag set:     evaluateSendGate({... demo:true,  reauthRequired:true ...}) → { allowed:true }
2FA resolver:         resolveSend2faMethod({ demo:true, actionPasswordConfigured:true }) → 'none'
                      resolveSend2faMethod({ demo:false, actionPasswordConfigured:true }) → 'password'
demoClient import:    URL '/?demo=1' → DEMO=true, localStorage['veyrnox-demo']='1'
                      later normal visit '/send' with flag set → DEMO=true (persists)
demo store:           demoBase44.entities.TransactionLimit.list() → []

Compiled bundle evidence (built with the exact CI store-channel command npm run build):

// dist/assets/demoClient-*.js — the shipped, live trap (no [build-flag] test anywhere in the chunk)
var C=(()=>{try{const a=new URLSearchParams(window.location.search);
if(a.get("demo")==="0"?localStorage.removeItem("veyrnox-demo"):
a.has("demo")&&localStorage.setItem("veyrnox-demo","1"),
localStorage.getItem("veyrnox-demo")==="1")return!0}catch{}return!1})()
// dist/assets/SendCrypto-*.js — the gate inputs consume the raw flag C
demo:z,reauthRequired:z?!1:Pt()

Rebuilding with npm run build:release ([build-flag]=1) removes every occurrence of veyrnox-demo from the bundle — proving the shipped-vs-protected difference is purely the missing release flag in CI.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

// The exact gate inputs SendCrypto.jsx builds inside evaluateCurrentSendGate
// for a real send on a real unlocked vault (re-auth lapsed, AP 2FA configured,
// not yet verified, RASP clean, limits present):
const realSend = (demo, reauthRequired) => evaluateSendGate({
canSend: true, devUngated: false, currency: 'ETH', isUnlocked: true,
demo,
reauthRequired, // SendCrypto passes DEMO ? false : isSendReauthRequired()
twoFactorRequired: true, twoFactorVerified: false,
limit: { blocked: false, reasons: [] }, limitAck: false,
riskScoreFailed: false,
txPolicy: { decision: 'ALLOW' },
presign: { proceedAllowed: true, signerReachable: true },
btcRiskBlocked: false, blockedByApproval: false,
});

console.log('no flag :', realSend(false, true)); // { allowed:false, code:'REAUTH' }
console.log('flag + vault :', realSend(true, true)); // { allowed:true } ← the bypass

// The second factor the user configured resolves to NONE under the raw flag:
console.log(resolveSend2faMethod({ demo: true, actionPasswordConfigured: true })); // 'none'
console.log(resolveSend2faMethod({ demo: false, actionPasswordConfigured: true })); // 'password'

// Flag persistence from one link click (module-level IIFE in demoClient.js):
// visiting /?demo=1 → localStorage.setItem('veyrnox-demo','1'); DEMO=true
// any later visit → localStorage.getItem('veyrnox-demo')==='1' → DEMO=true

// Spend limits silently read from the empty demo store:
const { demoBase44 } = await import('@/api/demoClient.js');
console.log(await demoBase44.entities.TransactionLimit.list()); // [] ← caps never bite

// Build-side proof:
// npm run build → dist/assets/demoClient-*.js contains
// localStorage.setItem("veyrnox-demo","1") (live, no [build-flag] guard)
// npm run build:release → zero occurrences of "veyrnox-demo" in dist/
// Harness: src/lib/__tests__/sendGate.demoDisarm.strix.test.js,
// src/api/__tests__/demoClient.flagPersistence.strix.test.js (13 tests, green)

STRIX-4

Plaintext version field distinguishes real hidden wallets from chaff in the stealth pool

Fixed Medium 4
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The stealth hidden-wallet chaff pool stamps a plaintext envelope version of v:1 on every decoy slot while genuine hidden wallets are written with v:2. Because v sits outside the encrypted ciphertext, a forensic dump of device storage distinguishes every real hidden wallet from chaff with a single field read, defeating the wallet's flagship coercion-resistance guarantee (plausible deniability of hidden wallets and their count).

Impact

An adversary who images the device (the exact arrest/border-seizure scenario the product targets — README "S3 coercion resistance") reads the IndexedDB records of the veyrnox-vault store and, without any secret, identifies exactly which of the 256 vault:N slots hold real hidden wallets and how many exist. This breaks the deniability/count-hiding invariant the pool was built to provide: it enables targeted coercion ("open the wallet in slot N") and lets an attacker concentrate offline PIN brute-force exclusively on the real slots instead of spreading attempts across all 256. It does not by itself decrypt any wallet: ciphertexts remain AES-GCM output indistinguishable from random, and no key material, seed, or balance is disclosed.

Technical Details

Hidden ("stealth") wallets are stored in a fixed pool of 256 slots (vault:1..vault:256) in the shared IndexedDB store veyrnox-vault/vault, alongside 255+ chaff blobs so that a raw dump cannot tell real from decoy or reveal the count. Each slot holds a vault-shaped envelope { v, kdf, salt, iv, ct } in which only ct is ciphertext — v, kdf, salt, and iv are plaintext fields readable without any secret.

Two writers populate the pool, and they disagree on the version field:

Chaff (src/wallet-core/stealth.js, makeChaff): hardcoded v: 1 at the original landing of the feature. ensureStealthPool backfills only missing slots and never rewrites existing ones, so every chaff slot keeps its stamped version forever.
Real hidden wallets: createHiddenWallet, moveWalletToHidden, and setHiddenActionPasswordRecord all route through encryptDeniabilityVault → encryptVault, which stamps v: VAULT_VERSION (src/wallet-core/vault.js, = 2 since the M-8 AAD-binding change). vaultAad includes v in the authenticated header, and decryptVault accepts v ∈ {1, 2}.

The codebase already treats plaintext envelope fields as a real-vs-chaff attack surface and fixed exactly this class of bug twice for the kdf field (SAST M3 parity; H-2 weekly-audit fix stamping chaff at the device's recorded KDF era, plus a reveal-time opportunistic repair). The version field was never given the same treatment. History shows the divergence is a regression: when the stealth pool landed (commit [REDACTED-SHA], 2026-06-01), encryptVault also wrote v:1, so chaff and real were consistent; the M-8 change (2026-07-17, 2b0afd72) bumped the real-writer to v:2 and left makeChaff's hardcoded v:1 behind. Three subsequent deniability-parity fixes (3bba571e H-2, 7aaffbe1 #2103, 1d69be2c #2116) equalized the kdf field across the pool without noticing v.

The opportunistic repair in tryRevealHidden cannot heal it: it fires only after a successful decrypt (i.e. the legitimate owner typed the secret), its predicate vaultKdfDiffersFrom compares only the four KDF parameters — never v — and even when it fires it rewrites via encryptVault at v:2, so a same-era device never converges and a divergent real slot remains the unique non-baseline blob in the dump.

Fix: export VAULT_VERSION and stamp chaff at the writer's version (the direction that preserves M-8 AAD binding on real wallets; the alternative — writing real slots at v:1 — would strip AAD from genuine hidden wallets and is a security downgrade). Chaff never decrypts (random ct), so the stamped version is inert on every code path; it exists purely to be read by a forensic dump and must therefore read identically to the real writer's stamp.

Fix
Reproduction
How do I fix it?

Ensure the chaff generator and every hidden-wallet writer stamp the same envelope version, and keep them coupled so future version bumps cannot reintroduce the divergence. The applied fix exports the single writer-side constant and stamps chaff from it, so any future VAULT_VERSION bump propagates automatically. Adding a regression assertion that the plaintext v distribution over the pool is single-valued after creating a hidden wallet would lock the property in. For devices already running the pre-fix build, a whole-pool reseed (panic wipe already produces one) is the only way to uniformize existing mixed-version pools; the fix provides forward parity, consistent with how the analogous KDF-era divergence was handled.

Reproduction

Assumptions

Assumes an adversary with offline access to a dump of device storage (IndexedDB of the wallet app) — precisely the coercion/forensic scenario the product's S3 duress/hidden-wallet design is built against. Does not assume any secret, PIN, or unlocked device.

Evidence

Dynamic PoC output (pre-fix), a vitest harness driving the genuine ensureStealthPool / createHiddenWallet paths against the real Argon2id + AES-GCM crypto with a fake IndexedDB, then dumping the raw vault object store with no secret:

[PoC] plaintext `v` distribution over the 256 stealth slots: { '1': 255, '2': 1 }
[PoC] slots identified as REAL hidden wallets by v != 1: ["vault:15"]
[PoC] uniform non-v observables — shapes:1 kdf-eras:1 ct-len:1 salt-len:1 iv-len:1

vault:15 is exactly the slot returned by createHiddenWallet. Every other plaintext observable the design equalizes (blob field set and order, kdf era object, ciphertext length, salt/iv lengths) is uniform across all 256 slots — v is the sole distinguisher. After the fix, the same harness prints { '2': 256 } and an empty identified list.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

import { describe, it, expect, beforeEach } from 'vitest';
import {
ensureStealthPool,
createHiddenWallet,
wipeStealthPool,
} from '../../src/wallet-core/stealth.js';
import { VAULT_VERSION } from '../../src/wallet-core/vault.js';

const POOL_SIZE = 256;

function dumpVaultStore() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('veyrnox-vault', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('vault')) db.createObjectStore('vault');
};
req.onsuccess = () => {
const db = req.result;
const st = db.transaction('vault', 'readonly').objectStore('vault');
const keysReq = st.getAllKeys();
const valsReq = st.getAll();
keysReq.onsuccess = () => {
valsReq.onsuccess = () => {
const out = {};
keysReq.result.forEach((k, i) => { out[k] = valsReq.result[i]; });
db.close();
resolve(out);
};
keysReq.onerror = () => reject(keysReq.error);
};
req.onerror = () => reject(req.error);
});
}

describe('PoC: chaff version tell in the stealth pool', () => {
beforeEach(async () => {
await wipeStealthPool();
localStorage.clear();
});

it('a raw storage dump identifies the real hidden wallet by the plaintext v field', async () => {
await ensureStealthPool(); // baseline pool every device gets
const created = await createHiddenWallet( // one REAL hidden wallet
'correct horse battery staple !!',
);
expect(created.existing).toBe(false);

// Forensic dump — no secret, just the raw records.
const store = await dumpVaultStore();
const slots = Object.keys(store).filter((k) => k.startsWith('vault:'));
expect(slots.length).toBe(POOL_SIZE);

const versionCounts = {};
for (const k of slots) versionCounts[store[k].v] = (versionCounts[store[k].v] ?? 0) + 1;
console.log('[PoC] plaintext v distribution over the 256 stealth slots:', versionCounts);

// Attacker's one-line sort: any slot whose plaintext v differs from the
// chaff baseline (majority value) is a REAL hidden wallet.
const baseline = Number(
Object.entries(versionCounts).sort((a, b) => b[1] - a[1])[0][0],
);
const identified = slots.filter((k) => store[k].v !== baseline);
console.log('[PoC] slots identified as REAL hidden wallets by v != ' + baseline + ':',
JSON.stringify(identified));

const tellPresent = store[created.slot].v !== baseline;
if (tellPresent) { // VULNERABLE: exactly the real slot is picked out
expect(identified.length).toBe(1);
expect(identified[0]).toBe(created.slot);
expect(store[created.slot].v).toBe(VAULT_VERSION);
} else { // FIXED: nothing separates real from chaff
expect(identified.length).toBe(0);
}

// v is the SOLE distinguisher: every other plaintext observable is uniform.
const shapes = new Set(slots.map((k) => Object.keys(store[k]).sort().join(',')));
expect(shapes.size).toBe(1);
expect([...shapes][0]).toBe('ct,iv,kdf,salt,v');
expect(new Set(slots.map((k) => JSON.stringify(store[k].kdf))).size).toBe(1);
expect(new Set(slots.map((k) => store[k].ct.length)).size).toBe(1);
expect(new Set(slots.map((k) => store[k].salt.length)).size).toBe(1);
expect(new Set(slots.map((k) => store[k].iv.length)).size).toBe(1);
console.log('[PoC] uniform non-v observables — shapes:' + shapes.size +
' kdf-eras:1 ct-len:1 salt-len:1 iv-len:1');
});

STRIX-8

[wallet-protocol] panic wipe leaves SDK session records (wallet address + dApp history) in IndexedDB while reporting clean

Fixed Medium 4
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 1h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The panic-wipe primitive destroys the vault and four residue databases but never the [wallet-protocol] SDK's own IndexedDB database (WALLET_CONNECT_V2_INDEXED_DB). Approved [wallet-protocol] sessions persist there and embed the wallet's EVM address (eip155:<chain>:<address>) plus the dApps the wallet connected to. After a panic wipe, that forensic residue remains readable on the device while the wipe report claims nothing recoverable remains.

Impact

An attacker or coercer with the device after a panic wipe can read the surviving [wallet-protocol] records and recover the real wallet's EVM addresses, which dApps it connected to (e.g. a DEX or bridge), and pairing/session topics. This directly undermines the coercion-resistance contract the wipe exists to enforce: the wipe UI asserts complete destruction while proof of real-wallet dApp usage survives. No key material is exposed.

Technical Details

The [wallet-protocol] v2 client (@walletconnect/sign-client via src/wallet-core/evm/walletconnect/session.js) persists every approved session, pairing, and pending record through @walletconnect/keyvaluestorage. In a browser/WebView that driver writes to a single IndexedDB database named WALLET_CONNECT_V2_INDEXED_DB (store keyvaluestorage, idb-keyval driver; older SDK versions also wrote wc@2:* keys to localStorage). A session record's namespaces.eip155.accounts array contains the wallet address as eip155:<chainId>:<address>, and peer.metadata carries the dApp name/URL.

panicWipeLocal() in src/wallet-core/panic.js destroys veyrnox-vault, veyrnox-appdata, veyrnox-threat-intel, veyrnox-ioc-cache and veyrnox-phishing-feed, sweeps a curated localStorage residue list plus veyrnox--prefixed keys, and then returns inspectKeyMaterial() whose clean verdict enumerates only those same five side databases. WALLET_CONNECT_V2_INDEXED_DB and wc@2:* localStorage keys are in neither the erase set nor the inspection set, so:

the session records survive the wipe, and
inspectKeyMaterial().clean === true reports total destruction while they do.

destroy[wallet-protocol]() (called on lock/deniability transition) disconnects sessions over the relay and nulls the client singleton but performs no storage deletion either, so the records also survive a transition into a decoy session.

A dynamic harness seeded a byte-shaped copy of a real session record (namespaces.eip155.accounts = ["eip155:1:0x1234…5678"]), ran the real panicWipeLocal(), and observed: report.clean === true while the record — address included — remained readable in WALLET_CONNECT_V2_INDEXED_DB.

Fix
Reproduction
How do I fix it?
Create Fix PR

Treat the [wallet-protocol] SDK's storage as panic-wipe residue. Clear the keyvaluestorage store through a live transaction, sweep legacy wc@-prefixed localStorage keys, and delete WALLET_CONNECT_V2_INDEXED_DB, mirroring the existing threat-intel erase pattern. Include the database in the wipe's clean inspection so a surviving [wallet-protocol] database fails the verdict instead of being invisible to it.

src/wallet-core/panic.js:158-171
158 const PHISHING_FEED_DB_NAME = 'veyrnox-phishing-feed';
158 const PHISHING_FEED_DB_NAME = 'veyrnox-phishing-feed';
159 // [wallet-protocol] SDK storage database. @walletconnect/keyvaluestorage's browser
160 // driver (idb-keyval) persists EVERYTHING the dApp connector keeps — approved
161 // sessions (whose namespaces embed the wallet's EVM address as
162 // eip155:<chain>:<address>), pairing symKeys, and dApp peer metadata — to this
163 // single IndexedDB database. None of it is key material, but it is exactly the
164 // forensic-residue class the rest of this wipe exists to erase: after a panic
165 // wipe the database still proves which dApps this wallet connected to and the
166 // real account addresses it exposed to them. The SDK also keeps a legacy
167 // localStorage fallback (wc@2:* keys) on older installs, so both stores are
168 // swept. WC-PANIC-RESIDUE (audit 2026-08-28).
169 const WALLETCONNECT_DB_NAME = 'WALLET_CONNECT_V2_INDEXED_DB';
src/wallet-core/panic.js:1029-1032
1029 // Best-effort erase of the [wallet-protocol] SDK storage database
1030 // (WALLET_CONNECT_V2_INDEXED_DB). Same pattern as the other side-databases:
1031 // delete-only is not enough because the [wallet-protocol] client may hold the
1032 // database open (its keyvaluestorage keeps a live idb-keyval store), which
1033 // would pend the delete on onblocked and leave session rows readable for the
1034 // rest of the session — so CLEAR the store through a normal transaction first
1035 // (takes effect immediately), then delete the database belt-and-braces. Also
1036 // sweeps the legacy localStorage wc@2: keys older SDK versions wrote before
1037 // the IndexedDB migration. WC-PANIC-RESIDUE (audit 2026-08-28): session records
1038 // embed the wallet's EVM address and the dApps it connected to — pure forensic
1039 // residue a wipe must not leave behind.
1040 async function erase[wallet-protocol]Database() {
1041 // 1. Clear rows through a normal transaction (works while a handle is open).
1042 await new Promise((resolve) => {
1043 let settled = false;
1044 const finish = () => { if (!settled) { settled = true; resolve(); } };
1045 let req;
1046 try {
1047 req = indexedDB.open(WALLETCONNECT_DB_NAME);
1048 } catch {
1049 finish();
1050 return;
1051 }
1052 // A wipe must not CREATE the store (an empty WC database is itself a tell
1053 // that a [wallet-protocol] install existed). Abort the open if it did not exist.
1054 req.onupgradeneeded = () => {
1055 try { req.transaction?.abort(); } catch { /* ignore */ }
1056 finish();
1057 };
1058 req.onerror = finish;
1059 req.onblocked = finish;
1060 req.onsuccess = () => {
1061 const db = req.result;
1062 try {
1063 // The idb-keyval driver creates the store lazily on first write; guard
1064 // on the name the browser build uses ('keyvaluestorage').
1065 const storeName = (db.objectStoreNames || []).length > 0
1066 ? db.objectStoreNames[0]
1067 : null;
1068 if (!storeName) { db.close(); finish(); return; }
1069 const tx = db.transaction(storeName, 'readwrite');
1070 tx.objectStore(storeName).clear();
1071 tx.oncomplete = () => { db.close(); finish(); };
1072 tx.onerror = () => { db.close(); finish(); };
1073 tx.onabort = () => { db.close(); finish(); };
1074 } catch {
1075 try { db.close(); } catch { /* ignore */ }
1076 finish();
1077 }
1078 };
1079 });
1080

1081 // 2. Sweep the legacy localStorage fallback keys older SDK versions wrote.
1082 try {
1083 if (typeof localStorage !== 'undefined') {
1084 const doomed = [];
1085 for (let i = 0; i < localStorage.length; i++) {
1086 const k = localStorage.key(i);
1087 if (k != null && (k.startsWith('wc@') || k.startsWith('wc_') || k.startsWith('wallet_connect'))) {
1088 doomed.push(k);
1089 }
1090 }
1091 for (const k of doomed) localStorage.removeItem(k);
1092 }
1093 } catch { /* storage unavailable — nothing to sweep */ }
1094

1095 // 3. Delete the database so even the empty store structure is gone.
1096 await new Promise((resolve) => {
1097 let settled = false;
1098 const finish = () => { if (!settled) { settled = true; resolve(); } };
1099 let req;
1100 try {
1101 req = indexedDB.deleteDatabase(WALLETCONNECT_DB_NAME);
1102 } catch {
1103 finish();
1104 return;
1105 }
1106 req.onsuccess = finish;
1107 req.onerror = finish;
1108 req.onblocked = finish;
1109 });
1110 }
1111

1029 1112 /**
1030 1113 * NON-DESTRUCTIVE inspection of what local key material currently exists.
src/wallet-core/panic.js:1141-1143
1141 const SIDE_DB_NAMES = [APPDATA_DB_NAME, THREATINTEL_DB_NAME, IOC_CACHE_DB_NAME, PHISHING_FEED_DB_NAME];
1141 const SIDE_DB_NAMES = [APPDATA_DB_NAME, THREATINTEL_DB_NAME, IOC_CACHE_DB_NAME, PHISHING_FEED_DB_NAME, WALLETCONNECT_DB_NAME];
src/wallet-core/panic.js:1225-1227
1225 1225 await eraseIocCacheDatabase();
1226 await erasePhishingFeedDatabase();
1226 await erasePhishingFeedDatabase();
1227 await erase[wallet-protocol]Database(); // WC-PANIC-RESIDUE: sessions name the wallet address + dApps

Reproduction

Assumptions

The attacker must gain filesystem/WebView storage access to the device after a panic wipe (the forensic analyst in the product's own coercion threat model, boundary I2/I3). No key material is exposed — the impact is deniability/forensic residue, not fund theft.

Evidence

Pre-fix harness output (before the fix was applied):

✓ src/wallet-core/__tests__/wc-panic-residue.test.js (1 test) 61ms
   Test Files  1 passed (1)

The test asserted report.clean === true AND that the [wallet-protocol] session record containing 0x1234…5678 was still readable after panicWipeLocal() — both assertions held simultaneously. Storage write path confirmed from the vendored SDK browser driver:

node_modules/@walletconnect/keyvaluestorage/dist/index.es.js
  → idb-keyval createStore({ dbName: 'WALLET_CONNECT_V2_INDEXED_DB', storeName: 'keyvaluestorage' })

Post-fix, the same harness (updated to assert destruction) passes: the database is deleted, legacy wc@2: localStorage keys are swept, and clean remains true.

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

const WC_DB = 'WALLET_CONNECT_V2_INDEXED_DB';
const WC_STORE = 'keyvaluestorage';
const walletAddress = '0x1234567890abcdef1234567890abcdef12345678';
const sessionKey = 'wc@2:client:[REDACTED-HEX]:6f3c2a10-9b1e-4c2d-8e3f-4a5b6c7d8e9f';

function wcPut(key, value) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(WC_DB, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(WC_STORE)) req.result.createObjectStore(WC_STORE);
};
req.onsuccess = () => {
const db = req.result;
const r = db.transaction(WC_STORE, 'readwrite').objectStore(WC_STORE).put(value, key);
r.onsuccess = () => { db.close(); resolve(); };
r.onerror = () => { db.close(); reject(r.error); };
};
req.onerror = () => reject(req.error);
});
}

function wcGet(key) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(WC_DB, 1);
req.onsuccess = () => {
const db = req.result;
const r = db.transaction(WC_STORE, 'readonly').objectStore(WC_STORE).get(key);
r.onsuccess = () => { db.close(); resolve(r.result ?? null); };
r.onerror = () => { db.close(); reject(r.error); };
};
req.onerror = () => reject(req.error);
});
}

describe('panic wipe vs [wallet-protocol] SDK storage', () => {
it('[wallet-protocol] session records survive panicWipeLocal() pre-fix', async () => {
await wcPut(sessionKey, JSON.stringify({
topic: '[REDACTED-HEX]',
namespaces: { eip155: { chains: ['eip155:1'], accounts: [eip155:1:${walletAddress}] } },
peer: { metadata: { name: 'Uniswap', url: 'https://app.uniswap.org' } },
}));
const report = await panicWipeLocal(); // the REAL destruction primitive
expect(report.clean).toBe(true); // wipe claims total destruction
const survivor = await wcGet(sessionKey); // ...while the session record
expect(survivor).toContain(walletAddress); // with the wallet address survives
});

STRIX-7

Deniability session leaks crash reports to [third-party-service], including component stacks naming hidden-wallet surfaces

Fixed Low 3.7
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Create Fix PR

Marked as Fixed 3h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

When a user unlocks a decoy or hidden wallet, any JavaScript error (uncaught exception or ErrorBoundary crash) is transmitted to the [third-party-service] ingest endpoint. The module's only deniability check runs at initialization time, which always precedes any wallet unlock, so it can never block a coerced session. The transmitted envelope also carries the React component stack verbatim, which can name hidden-wallet and duress components.

Impact

A network observer or the crash-reporting backend operator learns that errors occurred during a decoy/hidden session, and receives component names (e.g. hidden-wallet page components) proving the user operates the deniability features. This directly undermines the coercion model: the product's guarantee is that a decoy or hidden session makes zero backend calls, and a seized or subpoenaed error-reporting account becomes evidence of hidden-wallet usage. Reachability requires the crash-reporting DSN to be configured (currently unset in shipped environment files, documented as a supported build option) and telemetry consent previously granted.

Technical Details

The deniability marker (src/wallet-core/deniabilitySession.js) is an in-memory boolean that flips true only when WalletProvider.unlock() opens a decoy or hidden wallet — long after application boot. initSentry() runs at boot (src/main.jsx) and its deniability guard therefore always sees false:

src/lib/sentry.js
1 export function initSentry() {
2 if (initialised) return;
3 const dsn = import.meta.env.VITE_SENTRY_DSN;
4 if (!dsn) return;
5 if (DEMO || isDeniabilityOrDemoActive()) return; // boot-time only: always false here
6 ...
7 [third-party-service].init({ ... });
8 initialised = true;
9 }

Once initialised, the installed globalHandlersIntegration listeners and the ErrorBoundary's reportError() capture errors for the entire app lifetime with no re-check:

src/lib/sentry.js
1 export function reportError(error, errorInfo) {
2 if (!initialised) return;
3 [third-party-service].captureException(error, { extra: { componentStack: errorInfo?.componentStack } });
4 }

Additionally, the wire-level scrubber (scrub(), used as beforeSend) strips request/user/breadcrumb data but never inspects event.extra, so the component stack ships verbatim. Deniability-surface component names (hidden-wallets page, stealth slot rows) and error messages thrown by the app's own I3 gates (I3: no egress in deniability session) are all transmissible.

The root cause is the gate placement: an init-time check cannot protect against a state that only becomes true after init. The fix moves the authoritative check to the send-time chokepoint that every capture path flows through.

Fix
Reproduction
How do I fix it?
Create Fix PR
Enforce the deniability invariant at the transport chokepoint rather than initialization: the beforeSend scrubber must drop (return null for) every event while a deniability or demo session is active, so no capture path — global handlers, error boundary, or any future direct capture call — can egress.
Refuse to capture at all in the error-boundary reporting helper while the session marker is active (defense in depth).
Extend the scrubber to strip or inspect event.extra (component stacks) — init-time checks can never protect against session state that begins after boot.
Add regression tests that reproduce the boot-then-unlock ordering and assert zero ingest requests during a decoy session, with a primary-session negative control.
src/lib/sentry.js:52-63
52 function scrub(event) {
52 export function scrub(event) {
53 // I3 — SEND-TIME GATE. A deniability (decoy/hidden) or demo session makes
54 // ZERO backend calls: an uncaught error, an ErrorBoundary crash, or any
55 // later captureException must never reach the ingest endpoint while the
56 // coerced session is open. Returning null drops the event client-side —
57 // no request is issued. Checked FIRST so nothing below can leak around it,
58 // and fail-closed: a throwing check also drops the event.
59 try {
60 if (isDeniabilityOrDemoActive()) return null;
61 } catch {
62 return null;
63 }
53 64 // Drop request details entirely — never send URL, query, cookies, body.
54 65 event.request = undefined;
src/lib/sentry.js:89-97
89 89 export function initSentry() {
90 90 if (initialised) return;
91 91 const dsn = import.meta.env.VITE_SENTRY_DSN;
92 92 if (!dsn) return;
93 if (DEMO || isDeniabilityOrDemoActive()) return;
93 // DEMO is a load-time constant persisted in localStorage — check it here so
94 // a demo-flagged install never installs handlers. The LIVE decoy/hidden
95 // marker is handled at send time in scrub() (see the I3 note above).
96 if (DEMO) return;
94 97 if (!hasConsent()) return;

Reproduction

Assumptions

Assumes a build with VITE_SENTRY_DSN configured (documented build option) and telemetry consent previously granted in a primary session; assumes the attacker is a network observer or the crash-reporting backend operator, consistent with the product's untrusted-backend threat model.

Evidence

Dynamic proof captured by the vitest/[test-lib] egress recorder before remediation:

[
  {
    "url": "https://o123456.ingest.sentry.io/api/7777/envelope/?sentry_version=7&sentry_key=abcdef1234567890&sentry_client=sentry.javascript.react%2F10.71.0",
    "method": "POST",
    "deniability": true
  }
]

Envelope-body scan for deniability-sensitive strings: ["StealthWallets","HiddenSlotRow","hidden"] — the component stack transmitted verbatim through event.extra, which scrub() did not inspect.

Post-fix verification: the same three harnesses assert an empty recorder during deniability while the negative control (marker off) still egresses; the full repository suite passes (328 files, 2828 tests).

Proof of Concept
Build with a crash-reporting DSN configured and grant telemetry consent in a primary session, then keep the app open ([third-party-service] initialises at boot; the deniability marker is false at this point in every session).
Unlock the decoy wallet with the duress password (the deniability marker flips true).
Trigger any render error or uncaught exception inside the decoy session.
Observe a POST request to the [third-party-service] ingest endpoint leave the device while the decoy session is open.
Inspect the request body: the component stack names hidden-wallet components.
Negative control: repeat the identical error path in a primary session — the request is sent, proving the block is the deniability gate and not a broken reproduction.

The reproduction was executed as an automated [test-lib]/vitest harness that replaces fetch with a recorder, reproduces the exact boot order, flips the deniability marker through the same setter the real unlock path uses, and asserts on recorded requests.

PoC Script
// Harness: src/api/__tests__/sentry.deniability.transport.test.js (pre-fix output)
// Boot order: consent granted + DSN set + marker false -> initSentry()
// -> decoy unlock (marker true) -> ErrorBoundary error.
//
// Recorded (BEFORE the fix):
// POST https://o123456.ingest.sentry.io/api/7777/envelope/?sentry_version=7
// &sentry_key=abcdef1234567890&sentry_client=sentry.javascript.react/10.71.0
// { "url": "...", "method": "POST", "deniability": true }
//
// Payload proof (src/api/__tests__/sentry.payload.test.js, BEFORE the fix):
// componentStack: 'at StealthWallets (http://localhost/src/pages/StealthWallets.jsx:312:19)
// at HiddenSlotRow (http://localhost/src/components/HiddenSlotRow.jsx:44:5)'
// -> deniability-sensitive strings present in envelope body: ["StealthWallets","HiddenSlotRow","hidden"]
//
// Global-handler proof (src/api/__tests__/sentry.globalhandler.test.js, BEFORE the fix):
// window.onerror fired during deniability -> ingest POST with deniability: true.
//
// AFTER the fix, all three tests assert zero ingest requests in deniability
// plus a negative control (primary session still reports).

STRIX-11

Message-signing page gates the real-key sign on a stale mount-time RASP artifact

Fixed Low 2.9
↻ Retest Copy Fix Prompt Create ticket Share
Marked as Fixed
Automatically marked fixed after retest scan completed with no findings.

Marked as Fixed 10h ago

“Automatically marked fixed after retest scan completed with no findings.”

Summary

The message-signing page gates its real-key EIP-191 sign on the mount-time useRaspArtifact sample, which on native can be up to 60 seconds stale. A runtime hook injected after the last clean probe passes the gate inside that window, so the wallet's real key signs attacker-chosen text even though the device's current integrity verdict is BLOCK — the only sign chokepoint that does not re-probe at the moment of signing.

Impact

A signature from the victim's real wallet key over attacker-chosen text is obtainable during a window in which the device's runtime-integrity verdict is BLOCK — the exact state the product's RASP policy says must never reach a signer. Such a signature is a verifiable commitment to the victim's identity (usable in sign-in/proof-of-ownership phishing) and, unlike key theft, leaves no key exposure to notice. The exposure is bounded: the feature is opt-in and off by default, the message is typed into the page (though a hooked runtime controls the textarea), and the window closes at the next heartbeat (≤60 s) or foreground re-probe, after which the gate refuses.

Technical Details

The page signs arbitrary text with the user's real wallet key via withPrivateKey(0, …), and carries the product-wide H13 policy that a hostile runtime must never reach the signer. The guard it uses, however, reads the React hook's artifact:

CryptoSigning.jsx:33
33 const raspArtifact = useRaspArtifact();
CryptoSigning.jsx:51
51 const raspGuardAllowsSigning = () => {
52 const tier = raspArtifact?.tier ?? TIER.BLOCK;
53 const gate = presignGate(tier, LEVEL.OK, false);
54 return gate.proceedAllowed && gate.signerReachable;
55 };

On native, useRaspArtifact probes once per mount plus on foreground and at most every 60 s by heartbeat (useRaspArtifact.js), and the module itself documents the artifact as UI/read-only, "non-authoritative". Every other key-operation surface re-probes at the decision moment: SendCrypto's signer awaits getFreshRaspArtifact() and composes both the pre-sign verdict and the signing policy from that fresh tier; [wallet-protocol]Provider.presignGateOrReject computes the tier itself with fail-closed timeouts; the seed-reveal surfaces use a fresh local probe at confirm time. CryptoSigning.jsx contains no getFreshRaspArtifact call at all. Consequently, a hook injected in the window between the last clean probe and the sign tap is evaluated against a verdict up to 60 s old: presignGate('allow', LEVEL.OK, false) returns proceedAllowed:true and the real key signs, even though a probe at that instant returns the block tier (and every sibling chokepoint would refuse). On web the browser-probe getters re-sample per read, so the window is effectively native-only. The ?? TIER.BLOCK fallback shows the fail-closed intent — the gap is purely the staleness of the input.

Fix
Reproduction
How do I fix it?

Re-probe the runtime-integrity verdict at the moment of signing, exactly as the Send and dApp chokepoints do: await the fresh artifact inside the sign handler, fail closed to the block tier when it is unavailable, and keep the acknowledgement argument hardcoded to false. Do not source sign-time decisions from the UI/read-only hook artifact.

Reproduction

Assumptions

Requires the device-local attacker position the RASP system is designed against (runtime hooking/injection on the unlocked device). Message signing must be enabled (opt-in, off by default) and the sign page must be open; the injection must land inside the staleness window between the last clean probe and the sign tap.

Evidence

PoC harness output (real component, real presignGate/compose modules; only the RASP sampling layer is a controllable time-machine mock):

control:   clean env at mount and at click      → withPrivateKey called, signature shown
control:   BLOCK-tier artifact at mount          → "Signing is blocked…" shown, withPrivateKey never called
bypass:    CLEAN at mount, BLOCK before click   → withPrivateKey called with the stale artifact
           fresh probe of the same moment       → { proceedAllowed:false, signerReachable:false }
           (the page never requested a fresh probe: freshProbes === 0)

Source divergence (pre-fix):

// CryptoSigning.jsx — the sign gate reads the frozen mount-time sample
const raspArtifact = useRaspArtifact();
const raspGuardAllowsSigning = () => {
  const tier = raspArtifact?.tier ?? TIER.BLOCK;   // ≤60 s old on native

[Reproduction and Proof-of-Concept exploit steps have been redacted from the public copy. Complete details are available to security researchers under coordinated disclosure — email security@veyrnox.com.]

walletState = { isUnlocked: true, accounts: [{ address: REAL_ADDR }], withPrivateKey };
renderPage(); // t0: probe CLEAN at mount
const signBtn = await screen.findByRole('button', { name: /sign message/i });

envTier = 'block-signing'; // t0+ε: hook lands → device is hostile
fireEvent.click(signBtn); // t0+2ε: victim taps Sign

// PRE-FIX behaviour (the finding): the stale artifact lets the sign through.
await waitFor(() => expect(withPrivateKey).toHaveBeenCalledTimes(1)); // real key signs
expect(screen.getByText('0xSIGNATURE')).toBeTruthy();
expect(freshProbes).toBe(0); // this page never re-probed

// POST-FIX behaviour: the fresh probe blocks the identical click.
await waitFor(() => expect(freshProbes).toBe(1));
expect(withPrivateKey).not.toHaveBeenCalled();
expect(screen.getByText(/blocked/i)).toBeTruthy();

// Negative controls (both pass pre- and post-fix — the gate itself is sound):
// • CLEAN at mount and at click → signs normally.
// • BLOCK at mount → "Signing is blocked…", signer never reached.
// Harness: src/pages/__tests__/CryptoSigning.staleRasp.strix.test.jsx (6 tests, green)