Skip to content

Commit d1eaaeb

Browse files
authored
Merge pull request #49 from lazor-kit/fix/audit-hardening
fix: audit hardening — RevokeSession, signed expiry, checked arithmetic
2 parents 1477920 + d941167 commit d1eaaeb

18 files changed

Lines changed: 912 additions & 14 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Claude Code
2+
.claude/
3+
14
# Build outputs
25
ts-sdk/dist
36
target

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2525
- ExecuteDeferred instruction (disc=7): TX2 verifies hashes and executes via CPI with vault signing
2626
- ReclaimDeferred instruction (disc=8): closes expired DeferredExec accounts, refunds rent to original payer
2727
- DeferredExecAccount (176 bytes): stores instruction/account hashes, wallet, authority, payer, expiry
28+
- RevokeSession instruction (disc=9): Owner/Admin can close session accounts early, refunding rent to specified destination
29+
- Error code 3019 (InvalidSessionAccount) for invalid session PDA during revocation
2830
- Devnet smoke test (`tests-sdk/tests/devnet-smoke.ts`): exercises all 9 instructions across Ed25519/Secp256r1/Session auth types and Owner/Admin/Spender roles, reporting CU/TX size/rent
2931
- Deferred execution benchmarks (CU + tx size measurements for TX1/TX2)
3032
- Error codes 3014-3018 for deferred execution (expired, hash mismatch, invalid expiry, unauthorized reclaim)
@@ -69,6 +71,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6971

7072
### Fixed
7173

74+
- Authorize signed payload now includes `expiry_offset` (66 bytes total), preventing relayers from modifying the expiry window
75+
- `sol_assert_bytes_eq` now uses the `len` parameter instead of `left.len()` (latent OOB read on-chain)
76+
- `reclaim_deferred` uses `checked_add` for lamports (consistent with `execute_deferred` and `manage_authority`)
7277
- `PublicKey.default` collision with `SystemProgram.programId` in SDK execute methods: both are 32 zero bytes, causing `buildCompactLayout` to map SystemProgram to the sysvar slot (index 4) instead of adding it as a remaining account. Replaced with `SYSVAR_INSTRUCTIONS_PUBKEY`.
7378
- Synced passkey lockout: WebAuthn hardware counter=0 no longer causes rejection
7479
- 17/17 audit issues resolved (Accretion audit)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256
1010

1111
- **Multi-Protocol Authentication**: Ed25519 (native Solana) + Secp256r1 (WebAuthn/Passkeys/Apple Secure Enclave)
1212
- **Role-Based Access Control**: Owner / Admin / Spender with strict permission hierarchy
13-
- **Ephemeral Session Keys**: Time-bound keys with absolute slot-based expiry (max 30 days)
13+
- **Ephemeral Session Keys**: Time-bound keys with absolute slot-based expiry (max 30 days), revocable by Owner/Admin
1414
- **Odometer Replay Protection**: Monotonic u32 counter per authority — works reliably with synced passkeys (iCloud, Google)
1515
- **Clock-Based Slot Freshness**: 150-slot window via `Clock::get()` — no SlotHashes sysvar needed
1616
- **Zero-Copy Serialization**: Raw byte casting via pinocchio, no Borsh overhead

assertions/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub fn sol_assert_bytes_eq(left: &[u8], right: &[u8], len: usize) -> bool {
2424
sol_memcmp_(
2525
left.as_ptr(),
2626
right.as_ptr(),
27-
left.len() as u64,
27+
len as u64,
2828
result.as_mut_ptr() as *mut i32,
2929
);
3030
result.assume_init() == 0
@@ -33,7 +33,7 @@ pub fn sol_assert_bytes_eq(left: &[u8], right: &[u8], len: usize) -> bool {
3333

3434
#[cfg(not(target_os = "solana"))]
3535
pub fn sol_assert_bytes_eq(left: &[u8], right: &[u8], len: usize) -> bool {
36-
(left.len() == len || right.len() != len) && right == left
36+
left.len() >= len && right.len() >= len && left[..len] == right[..len]
3737
}
3838

3939
macro_rules! sol_assert {

docs/Architecture.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Since each authority is a separate PDA, Solana's scheduler sees no writable over
168168

169169
This enables high-throughput wallets where multiple authorized parties (e.g., an admin managing permissions while a spender sends payments, or multiple session keys operating concurrently) never block each other. The per-authority odometer counter provides replay protection without creating a shared bottleneck.
170170

171-
## 5. Instructions (9 total)
171+
## 5. Instructions (10 total)
172172

173173
### CreateWallet (discriminator: 0)
174174

@@ -212,7 +212,7 @@ This enables high-throughput wallets where multiple authorized parties (e.g., an
212212

213213
- Creates a DeferredExec PDA storing pre-authorized instruction/account hashes.
214214
- Only Secp256r1 Owner/Admin can authorize (not Ed25519, not Spender).
215-
- Signed payload: `instructions_hash || accounts_hash` (64 bytes).
215+
- Signed payload: `instructions_hash || accounts_hash || expiry_offset` (66 bytes).
216216
- Expiry offset bounded to 10-9,000 slots (~4 seconds to ~1 hour).
217217
- Uses the authority's odometer counter (post-increment) as PDA seed nonce.
218218
- Instruction data: `[instructions_hash(32)][accounts_hash(32)][expiry_offset(2)][auth_payload(variable)]`.
@@ -237,6 +237,14 @@ This enables high-throughput wallets where multiple authorized parties (e.g., an
237237
- No instruction data (discriminator only).
238238
- Accounts: payer, deferred_exec, refund_destination.
239239

240+
### RevokeSession (discriminator: 9)
241+
242+
- Closes a session account early (before expiry), refunding rent.
243+
- Only Owner or Admin can revoke (Spender cannot).
244+
- Session can be revoked regardless of whether it is expired or active.
245+
- Signature bound to specific session PDA + refund destination (prevents replay).
246+
- Accounts: payer, wallet, admin_authority, session, refund_destination [+ auth_extra].
247+
240248
## 6. CompactInstructions Format
241249

242250
Binary format for packing multiple instructions into Execute:

program/idl.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,65 @@
644644
"type": "u8",
645645
"value": 8
646646
}
647+
},
648+
{
649+
"name": "RevokeSession",
650+
"accounts": [
651+
{
652+
"name": "payer",
653+
"isMut": false,
654+
"isSigner": true,
655+
"docs": [
656+
"Transaction payer"
657+
]
658+
},
659+
{
660+
"name": "wallet",
661+
"isMut": false,
662+
"isSigner": false,
663+
"docs": [
664+
"Wallet PDA"
665+
]
666+
},
667+
{
668+
"name": "adminAuthority",
669+
"isMut": true,
670+
"isSigner": false,
671+
"docs": [
672+
"Owner/Admin authority PDA (counter incremented for Secp256r1)"
673+
]
674+
},
675+
{
676+
"name": "session",
677+
"isMut": true,
678+
"isSigner": false,
679+
"docs": [
680+
"Session PDA to revoke"
681+
]
682+
},
683+
{
684+
"name": "refundDestination",
685+
"isMut": true,
686+
"isSigner": false,
687+
"docs": [
688+
"Account to receive rent refund"
689+
]
690+
},
691+
{
692+
"name": "authExtra",
693+
"isMut": false,
694+
"isSigner": false,
695+
"isOptional": true,
696+
"docs": [
697+
"Ed25519: signer keypair | Secp256r1: sysvar_instructions"
698+
]
699+
}
700+
],
701+
"args": [],
702+
"discriminant": {
703+
"type": "u8",
704+
"value": 9
705+
}
647706
}
648707
],
649708
"metadata": {

program/src/entrypoint.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use pinocchio::{
55

66
use crate::processor::{
77
authorize, create_session, create_wallet, execute, execute_deferred, manage_authority,
8-
reclaim_deferred, transfer_ownership,
8+
reclaim_deferred, revoke_session, transfer_ownership,
99
};
1010

1111
entrypoint!(process_instruction);
@@ -31,6 +31,7 @@ pub fn process_instruction(
3131
6 => authorize::process(program_id, accounts, data),
3232
7 => execute_deferred::process(program_id, accounts, data),
3333
8 => reclaim_deferred::process(program_id, accounts, data),
34+
9 => revoke_session::process(program_id, accounts, data),
3435
_ => Err(ProgramError::InvalidInstructionData),
3536
}
3637
}

program/src/error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub enum AuthError {
2121
InvalidExpiryWindow = 3016,
2222
UnauthorizedReclaim = 3017,
2323
DeferredAuthorizationNotExpired = 3018,
24+
InvalidSessionAccount = 3019,
2425
}
2526

2627
impl From<AuthError> for ProgramError {

program/src/instruction.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,46 @@ pub enum ProgramIx {
249249
desc = "Account to receive rent refund"
250250
)]
251251
ReclaimDeferred,
252+
253+
/// Revoke a session key early (before expiry)
254+
///
255+
/// Only Owner or Admin can revoke. Closes the session account and refunds rent.
256+
#[account(
257+
0,
258+
signer,
259+
name = "payer",
260+
desc = "Transaction payer"
261+
)]
262+
#[account(
263+
1,
264+
name = "wallet",
265+
desc = "Wallet PDA"
266+
)]
267+
#[account(
268+
2,
269+
writable,
270+
name = "admin_authority",
271+
desc = "Owner/Admin authority PDA (counter incremented for Secp256r1)"
272+
)]
273+
#[account(
274+
3,
275+
writable,
276+
name = "session",
277+
desc = "Session PDA to revoke"
278+
)]
279+
#[account(
280+
4,
281+
writable,
282+
name = "refund_destination",
283+
desc = "Account to receive rent refund"
284+
)]
285+
#[account(
286+
5,
287+
optional,
288+
name = "auth_extra",
289+
desc = "Ed25519: signer keypair | Secp256r1: sysvar_instructions"
290+
)]
291+
RevokeSession,
252292
}
253293

254294
#[repr(C)]

program/src/processor/authorize.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,11 @@ pub fn process(
128128
return Err(AuthError::PermissionDenied.into());
129129
}
130130

131-
// The signed_payload for Authorize is: instructions_hash || accounts_hash
132-
let mut signed_payload = Vec::with_capacity(64);
131+
// The signed_payload for Authorize is: instructions_hash || accounts_hash || expiry_offset
132+
let mut signed_payload = Vec::with_capacity(66);
133133
signed_payload.extend_from_slice(&instructions_hash);
134134
signed_payload.extend_from_slice(&accounts_hash);
135+
signed_payload.extend_from_slice(&expiry_offset.to_le_bytes());
135136

136137
// Authenticate — this verifies the Secp256r1 signature and increments the counter
137138
Secp256r1Authenticator.authenticate(

0 commit comments

Comments
 (0)