Skip to content

External signatures, V2 signer types, session keys, and sync execution#30

Open
0xLeo-sqds wants to merge 2 commits intofeat/implement-account-utilizationfrom
feat/external-signatures
Open

External signatures, V2 signer types, session keys, and sync execution#30
0xLeo-sqds wants to merge 2 commits intofeat/implement-account-utilizationfrom
feat/external-signatures

Conversation

@0xLeo-sqds
Copy link
Collaborator

Summary

Adds external (non-Solana-native) signer support to the smart account program. External signers — P256/WebAuthn passkeys, secp256k1/Ethereum keys, and Ed25519 keys held off-chain — can now participate in consensus alongside native Solana signers. Includes session key delegation, synchronous mixed-signer execution, and per-signer nonce replay protection.

59 program files changed, 145 SDK files, 94 test files.

V2 Signer System

Five signer variants, all stored in the unified SmartAccountSigner enum:

Variant Curve Verification Key Size
Native Ed25519 AccountInfo.is_signer 32 bytes
P256Webauthn secp256r1 Precompile introspection + WebAuthn clientDataJSON reconstruction 33 bytes compressed
P256Native secp256r1 Precompile introspection, raw message hash (no WebAuthn wrapping) 33 bytes compressed
Secp256k1 secp256k1 Precompile introspection or secp256k1_recover syscall 64 bytes uncompressed
Ed25519External curve25519 Precompile introspection or curve25519 syscall verification 32 bytes

SmartAccountSignerWrapper handles V1↔V2 format migration with custom packed serialization. V1 format stores Vec<LegacySmartAccountSigner>; V2 uses a tagged entry format with per-entry headers. Adding an external signer to a V1 wrapper requires explicit migration via force_v2().

Cross-P256 duplicate detection: P256Webauthn and P256Native sharing the same compressed pubkey are treated as duplicates and rejected at registration.

Signer registration validation

Compressed P256 pubkeys must have 0x02 or 0x03 prefix. Secp256k1 and Ed25519 pubkeys reject all-zero keys. rp_id_hash for WebAuthn signers is derived on-chain from the provided rp_id — never trusted from user input. eth_address for secp256k1 is derived on-chain via keccak256(uncompressed_pubkey)[12..32].

Precompile Introspection

Single precompile instruction constraint: one precompile instruction per transaction, always at instruction index 0. Multiple signatures of the same type are packed in that single instruction (num_signatures > 1).

Two verification paths per signer type:

Precompile path — loads the precompile instruction at index 0 via load_instruction_at_checked, parses signature offsets, extracts pubkey/message/signature, and verifies pubkey matches the stored signer. The Solana runtime has already verified the cryptographic signature before the program executes.

Syscall path — for Ed25519 and Secp256k1 when no precompile instruction is present. Ed25519 uses sol_curve_group_op / sol_curve_validate_point syscalls (~40-50k CU). Secp256k1 uses secp256k1_recover syscall (~25k CU). P256 signers always require the precompile path — PrecompileRequired error if attempted via syscall.

Batch verification in sync path: verify_precompile_signers loads the instruction once and verifies all signers by direct index — signature at position i corresponds to signers[i]. Enforces exact match between num_signatures in the precompile data and the number of signers being verified.

Nonce Replay Protection

Every external signer stores a u64 nonce, incremented by 1 after each successful verification. The nonce is appended to the message hash before signing:

expected_message = SHA256(domain_prefix || context_keys || operation_data || next_nonce)

checked_add(1) prevents overflow — returns NonceExhausted after u64::MAX. WebAuthn signers additionally track a monotonically-increasing u32 counter from the authenticator hardware, persisted after each verification.

Message Domain Separation

Each operation uses a unique prefix to prevent cross-operation signature replay:

Operation Prefix
Sync consensus "squads-sync"
Proposal vote "proposal_vote_v2"
Proposal create "proposal_create_v2"
Transaction execute "transaction_execute_v2"
Session key create "create_session_key_v2"
Buffer create/extend "tx_buffer_create_v2" / "tx_buffer_extend_v2"

All messages include account-specific keys (consensus account, proposal, etc.) preventing cross-account replay.

WebAuthn Verification

The precompile signs authenticatorData || clientDataHash. The program:

  1. Splits the signed message at len - 32 to extract auth_data and client_data_hash
  2. Validates rpIdHash against stored value, checks user presence flag
  3. Validates monotonic counter (sign_counter > stored_counter)
  4. Reconstructs clientDataJSON from compact ClientDataJsonReconstructionParams (3 bytes: type/flags byte + port u16)
  5. Hashes the reconstructed JSON and compares against client_data_hash

ClientDataJsonReconstructionParams supports: webauthn.create/webauthn.get types, cross-origin flag, HTTP/HTTPS, optional port, and Google Android extra field. RP ID bytes are validated for JSON-safe ASCII at reconstruction time.

Session Keys

External signers can delegate to a native Solana keypair via create_session_key. The session key holder signs Solana transactions normally (is_signer = true), but votes/actions are attributed to the parent external signer's canonical key.

  • Expiration: u64 Unix timestamp, max 3 months (SESSION_KEY_EXPIRATION_LIMIT)
  • One session key per external signer at a time
  • Session key cannot collide with an existing signer's key() or another signer's session key
  • Revocation: owner revokes via revoke_session_key (requires external signer proof), or session key holder self-revokes

classify_signer resolves the canonical key: native signers return their key directly, session keys return the parent external signer's key, external signers return their key_id. This prevents double-counting when someone signs with both a session key and the parent.

Synchronous Execution with Mixed Signers

remaining_accounts layout for sync transactions:

[0..num_signers]           All signers (native first, then external)
                           - Native: AccountInfo.is_signer = true
                           - External: AccountInfo.is_signer = false
[num_signers]              Instructions sysvar (if external signers present)
[num_signers+1..]          Transaction accounts

Verification proceeds in phases:

  1. Native signers: iterate while is_signer = true, break on first false
  2. External signers (EVD-driven): take_while(is_precompile) splits into precompile batch and syscall slices
  3. Threshold checks: verified_keys.len() >= threshold, aggregate permissions cover all bits, vote_permission_count >= threshold

The signed message for sync transactions includes hash(payload), binding external signatures to the exact instructions being executed.

Unified Consensus Trait

Consensus trait abstracts over Settings and Policy accounts. Both implement the same signer verification, threshold enforcement, and stale transaction protection. ConsensusAccount is an Anchor interface account that dispatches to either implementation.

verify_signer handles all three signer types (native, session key, external) through a single code path. Returns the canonical key for vote recording and permission checks.

ExtraVerificationData

Typed enum carried in instruction data, one entry per external signer:

enum ExtraVerificationData {
    P256WebauthnPrecompile { client_data_params },  // 3 bytes
    Ed25519Precompile,                               // unit
    Secp256k1Precompile,                             // unit
    P256NativePrecompile,                            // unit
    Ed25519Syscall { signature: [u8; 64] },          // 64 bytes inline
    Secp256k1Syscall { signature: [u8; 64], recovery_id: u8 }, // 65 bytes inline
}

Precompile entries must precede syscall entries in the array (enforced by take_while(is_precompile) + all(is_syscall) validation).

SDK

Three-layer V2 SDK: instructions/transactions/rpc/. All V2 instruction wrappers override isSigner metadata for the signer account (generated code has isSigner: false because on-chain accounts changed from Signer<'info> to AccountInfo<'info> for external signer support).

Custom SmartAccountSignerWrapper serializer handles the packed V2 format. SmallVec<u8, T> support for instruction-level types vs Vec<T> (4-byte Borsh prefix) for stored state.

Test Suite

Parameterized V1/V2 test suite via SIGNER_FORMAT env var. createSignerObject() helper produces the correct format. Test runners: index.ts (full), index-v1-only.ts, index-v2-only.ts.

Dedicated V2 test suites:

  • externalSignerSyscall.ts — Ed25519/Secp256k1 syscall verification, P256 rejection
  • externalSignerPrecompile.ts — Ed25519/Secp256k1/P256Webauthn precompile verification
  • externalSignerTypes.ts — P256Native signer type
  • sessionKeys.ts — creation, revocation, collision detection, per-type precompile tests
  • mixedSignerSync.ts — 1 native + 2 P256 precompile + 1 secp256k1 syscall + 1 ed25519 syscall in one tx
  • externalSignerSecurity.ts — replay, wrong key, wrong message, permission, threshold, duplicate detection
  • externalSignerNoncePersistence.ts — nonce across sequential operations, stale nonce rejection, cross-proposal nonce, payload mismatch

507 passing, 3 expected failures (all increment_account_index max index).

…nd sync execution

- V2 signer system: P256Webauthn, P256Native, Secp256k1, Ed25519External with
  precompile introspection and syscall verification paths
- Unified consensus trait supporting both Settings and Policy accounts
- Session keys: delegated signing for external signers with expiration
- Synchronous transaction execution with mixed native + external signers
- Per-signer nonce replay protection and WebAuthn counter validation
- SDK: V2 instruction/transaction/rpc wrappers, custom serializers
- Full V1/V2 parameterized test suite
…sion key collisions

- Reject P256 compressed pubkeys without 0x02/0x03 prefix
- Reject all-zero secp256k1 and Ed25519 public keys
- Prevent assigning a session key already used by another signer
@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​noble/​curves@​1.9.710010010086100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant