Skip to content

Security: lazor-kit/wallet-program

docs/SECURITY.md

LazorKit V2 — Security Model

Defense Summary

Threat Defense Implementation
Cross-wallet Ed25519 replay Precompile introspection binds wallet_pda into signed hash auth/ed25519.rs
Same-wallet signature replay Per-authority monotonic counter (pre-incremented, persisted atomically) auth/ed25519.rs, auth/secp256r1/mod.rs
Rent theft via payer swap payer pubkey bound into the Ed25519 message hash auth/ed25519.rs
Cross-program replay program_id bound into the Ed25519 message hash auth/ed25519.rs
Legacy v1 account exploitation assert_current_version(v2) enforced on every account read state/mod.rs, all 17+ processors
Required approvals bypass required_approvals > 0 invariant enforced in rules_for() + propose.rs pending_action/rules.rs
Rolling-window overflow checked_add for window_start + window_slots (not saturating_add) execute/enforce.rs
Action data tampering sha256(action_data) == action_data_hash re-verified at execute time execute_pending.rs
Vault freeze bypass vault_state checked on every Execute, regardless of signer type (Authority or Session) execute/mod.rs
Unauthorized authority management privilege_rank hierarchy prevents escalation; Guardian/Owner always route through PendingAction manage_authority.rs, state/roles.rs
Permission blob corruption validate_blob runs before fee collection or signature verification manage_authority.rs
SPL Token unchecked bypass Plain Transfer (disc 3) denied whenever any TokenLimit record exists execute/enforce.rs

Ed25519 Precompile Introspection

Before this refactor, the Ed25519 authenticator only checked is_signer() && pubkey_match on the transaction's account list — a fundamental vulnerability that allowed cross-wallet signature replay.

After the refactor, every Ed25519-authenticated call requires the caller to prepend a native Ed25519SigVerify precompile instruction. The on-chain authenticator:

  1. Scans the accounts slice for the Instructions sysvar (by INSTRUCTIONS_ID match)
  2. Loads the previous instruction and asserts program_id == Ed25519SigVerify
  3. Recomputes sha256(discriminator || wallet || counter+1 || auth_payload || signed_payload || payer || program_id)
  4. Calls verify_ed25519_instruction_data to confirm the precompile verified this exact (pubkey, hash) pair
  5. Persists header.counter += 1

The replay test (08-security-config.test.ts::Exploit: Cross-Wallet Replay Attack) explicitly constructs the attack (walletA's precompile ix paired with walletB's LazorKit ix) and asserts rejection.

Secp256r1 (Passkey) Authentication

Secp256r1 authentication follows the same precompile-introspection pattern but additionally includes:

  • WebAuthn authenticator data validation (rpId hash, user presence flag)
  • SlotHashes nonce — each passkey signature must reference a recent slot (+150 slots) via the SlotHashes sysvar, providing a liveness proof
  • Counter validation — the WebAuthn counter must be strictly monotonically increasing

Audit Findings (Final Pass)

Fixed (Critical)

Finding Fix
veto.rs: missing assert_current_version on wallet write in B6 decrement path Added version gate before wallet mutation (commit 7ac3692)
buildSecp256r1PrecompileIx: no length validation on signature (64 bytes) or pubkey (33 bytes) Added assertions matching the Ed25519 pattern (commit 7ac3692)

Clean (No Issues)

  • Ed25519 precompile introspection — correct INSTRUCTIONS_ID scan, counter persistence, wallet binding
  • Permission enforcement pipeline — all 7 kinds enforced, checked_add for window math, no bypass via mixed call kinds
  • PendingAction state machine — strict Pending→Approved→Executed flow, tamper check covers full 256 bytes
  • Authority management — privilege_rank prevents escalation, first-Guardian exception properly guarded
  • Version gating — all 17+ processors version-gated
  • Common helpers — seeds + discriminator + version + wallet binding validated
  • SDK error codes — all 27 variants match error.rs
  • SDK Ed25519 message hash — matches Rust byte-for-byte
  • Drift fixture test — catches SDK↔Rust layout divergence

Accepted Risks

Risk Rationale
toPubkey() duck-typing in SDK could misidentify third-party classes with .publicKey Defensive check is reasonable; strict branded types are a future improvement
Hardcoded example.com defaults for rpId/origin in Secp256r1 helpers Production callers must always override; defaults are for test convenience
No static assertion for precompile layout constants against solana_sdk The D5 drift fixture test catches layout changes at test time

Test Coverage

248 total tests (129 Rust + 119 TypeScript):

  • Every permission kind has at least one integration test
  • Cross-wallet Ed25519 replay explicitly tested and asserted as rejected
  • Multi-Guardian propose→approve→execute via litesvm with svm.set_account + svm.warp_to_slot
  • DuplicateApprover, action-data tamper, expiry boundary — all litesvm integration tests
  • TokenLimit via real SPL Token mint/ATA setup (TransferChecked happy path, per-tx cap, scenario c2 plain Transfer denial)
  • Secp256r1 RBAC parity: freeze, propose, veto all via passkey signer
  • Freeze×Session interaction: session created before/after freeze, both blocked on Execute
  • Rust↔TS permission builder drift fixture with frozen hex bytes

There aren’t any published security advisories