Skip to content

Commit 810975e

Browse files
onspeedhpclaude
andcommitted
feat(processor): port wallet/authority/execute/session processors from upstream
Byte-identical with lazorkit-protocol/program/src/processor/{wallet/create, authority/manage, authority/transfer_ownership, execute/deferred, session/revoke}.rs. Brings the on-chain authority data layout into alignment with upstream: Secp256r1 authority = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145B Previously program-v2 stored variable-length raw rpId; the new layout stores a precomputed SHA256 digest at offset 113. Saves one sol_sha256 syscall per Execute. Critical for slot-share: existing wallets created on lazorkit-protocol must remain readable after binary swap. File names stay flat (program-v2 keeps `processor/create_wallet.rs` rather than upstream's `processor/wallet/create.rs`); content identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e574a15 commit 810975e

5 files changed

Lines changed: 217 additions & 71 deletions

File tree

program/src/processor/create_wallet.rs

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,13 @@ pub fn process(
7979

8080
let (id_seed, full_auth_data) = match args.authority_type {
8181
0 => {
82-
if rest.len() != 32 {
82+
// Use minimum-length check (consistent with AddAuthority / TransferOwnership).
83+
// Exact-length check would reject clients that append trailing context bytes.
84+
if rest.len() < 32 {
8385
return Err(ProgramError::InvalidInstructionData);
8486
}
85-
(rest, rest)
87+
let (pubkey, _) = rest.split_at(32);
88+
(pubkey, pubkey)
8689
},
8790
1 => {
8891
// [credential_id_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)]
@@ -91,6 +94,12 @@ pub fn process(
9194
}
9295
let (credential_id_hash, rest_after_cred) = rest.split_at(32);
9396
let rp_id_len = rest_after_cred[33] as usize;
97+
// Enforce a sane upper bound: max valid domain name is 253 chars.
98+
// Without this an attacker-controlled payer could create a 369-byte
99+
// authority account with 255 bytes of arbitrary rpId data.
100+
if rp_id_len == 0 || rp_id_len > 253 {
101+
return Err(ProgramError::InvalidInstructionData);
102+
}
94103
let total_auth_data = 32 + 33 + 1 + rp_id_len;
95104
if rest.len() < total_auth_data {
96105
return Err(ProgramError::InvalidInstructionData);
@@ -188,11 +197,18 @@ pub fn process(
188197
}
189198

190199
// --- 2. Initialize Authority Account ---
191-
// Authority accounts have a variable size depending on the authority type (e.g., Secp256r1 keys are larger).
200+
// Fixed sizes per auth type:
201+
// Ed25519 = header(48) + pubkey(32) = 80 bytes
202+
// Secp256r1 = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes
203+
//
204+
// For Secp256r1 we hash rpId once at creation and store the digest, so
205+
// every subsequent Execute saves one sol_sha256 syscall.
192206
let header_size = std::mem::size_of::<AuthorityAccountHeader>();
193-
let variable_size = full_auth_data.len();
194-
195-
let auth_space = header_size + variable_size;
207+
let auth_space = match args.authority_type {
208+
0 => header_size + 32, // Ed25519
209+
1 => header_size + 32 + 33 + 32, // Secp256r1 fixed
210+
_ => return Err(AuthError::InvalidAuthenticationKind.into()),
211+
};
196212
let auth_rent = rent.minimum_balance(auth_space);
197213

198214
// Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4)
@@ -228,18 +244,50 @@ pub fn process(
228244
wallet: *wallet_pda.key(),
229245
};
230246

231-
// safe write
247+
// safe write of header
232248
let header_bytes = unsafe {
233249
std::slice::from_raw_parts(
234250
&header as *const AuthorityAccountHeader as *const u8,
235-
std::mem::size_of::<AuthorityAccountHeader>(),
251+
header_size,
236252
)
237253
};
238-
auth_account_data[0..std::mem::size_of::<AuthorityAccountHeader>()]
239-
.copy_from_slice(header_bytes);
254+
auth_account_data[0..header_size].copy_from_slice(header_bytes);
240255

241-
let variable_target = &mut auth_account_data[header_size..];
242-
variable_target.copy_from_slice(full_auth_data);
256+
// Write variable data
257+
match args.authority_type {
258+
0 => {
259+
// Ed25519: pubkey(32) — full_auth_data is exactly 32 bytes
260+
auth_account_data[header_size..header_size + 32]
261+
.copy_from_slice(&full_auth_data[..32]);
262+
}
263+
1 => {
264+
// Secp256r1: cred_hash(32) ∥ pubkey(33) ∥ rpIdHash(32).
265+
// full_auth_data layout as parsed above:
266+
// [cred_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)]
267+
auth_account_data[header_size..header_size + 32]
268+
.copy_from_slice(&full_auth_data[..32]);
269+
auth_account_data[header_size + 32..header_size + 32 + 33]
270+
.copy_from_slice(&full_auth_data[32..32 + 33]);
271+
// Compute rpIdHash from rpId
272+
let rp_id_len = full_auth_data[32 + 33] as usize;
273+
let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len];
274+
let rp_id_hash_offset = header_size + 32 + 33;
275+
#[cfg(target_os = "solana")]
276+
unsafe {
277+
let _ = pinocchio::syscalls::sol_sha256(
278+
[rp_id].as_ptr() as *const u8,
279+
1,
280+
auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(),
281+
);
282+
}
283+
#[cfg(not(target_os = "solana"))]
284+
{
285+
let _ = rp_id;
286+
auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0);
287+
}
288+
}
289+
_ => unreachable!(),
290+
}
243291

244292
Ok(())
245293
}

program/src/processor/execute_deferred.rs

Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
compact::parse_compact_instructions,
2+
compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef},
33
error::AuthError,
44
state::{deferred::DeferredExecAccount, AccountDiscriminator},
55
};
@@ -99,14 +99,14 @@ pub fn process(
9999
return Err(AuthError::DeferredAuthorizationExpired.into());
100100
}
101101

102-
// Parse compact instructions
103-
let compact_instructions = parse_compact_instructions(instruction_data)?;
102+
// Parse compact instructions and track consumed length. We hash the
103+
// raw instruction_data[..consumed] directly — the parse/encode format
104+
// is byte-identical, so there's no need to re-serialize.
105+
let (compact_instructions, compact_len) =
106+
parse_compact_instructions_ref_with_len(instruction_data)?;
104107

105-
// Serialize compact instructions to compute hash
106-
let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions);
107-
108-
// Verify instructions hash
109-
let instructions_hash = compute_sha256(&compact_bytes);
108+
// Verify instructions hash against the exact bytes we parsed from
109+
let instructions_hash = compute_sha256(&instruction_data[..compact_len]);
110110
if instructions_hash != deferred.instructions_hash {
111111
return Err(AuthError::DeferredHashMismatch.into());
112112
}
@@ -140,46 +140,47 @@ pub fn process(
140140
let close_data = unsafe { deferred_pda.borrow_mut_data_unchecked() };
141141
close_data.fill(0);
142142

143+
// Reuse Vecs across inner CPI iterations — allocated once, cleared +
144+
// repushed each iteration. Same optimisation as execute::immediate.
145+
const MAX_INNER_ACCOUNTS: usize = 32;
146+
let mut account_metas: Vec<AccountMeta> = Vec::with_capacity(MAX_INNER_ACCOUNTS);
147+
let mut cpi_accounts: Vec<Account> = Vec::with_capacity(MAX_INNER_ACCOUNTS);
148+
149+
let vault_bump_arr = [vault_bump];
150+
let seeds = [
151+
Seed::from(b"vault"),
152+
Seed::from(wallet_pda.key().as_ref()),
153+
Seed::from(&vault_bump_arr),
154+
];
155+
143156
// Execute each compact instruction via CPI with vault PDA signing
144157
for compact_ix in &compact_instructions {
145158
let decompressed = compact_ix.decompress(accounts)?;
146159

147-
// Build AccountMeta array
148-
let account_metas: Vec<AccountMeta> = decompressed
149-
.accounts
150-
.iter()
151-
.map(|acc| AccountMeta {
152-
pubkey: acc.key(),
153-
is_signer: acc.is_signer() || acc.key() == vault_pda.key(),
154-
is_writable: acc.is_writable(),
155-
})
156-
.collect();
157-
158160
// Prevent self-reentrancy
159161
if decompressed.program_id.as_ref() == program_id.as_ref() {
160162
return Err(AuthError::SelfReentrancyNotAllowed.into());
161163
}
162164

165+
account_metas.clear();
166+
cpi_accounts.clear();
167+
for &acc in &decompressed.accounts {
168+
account_metas.push(AccountMeta {
169+
pubkey: acc.key(),
170+
is_signer: acc.is_signer() || acc.key() == vault_pda.key(),
171+
is_writable: acc.is_writable(),
172+
});
173+
cpi_accounts.push(Account::from(acc));
174+
}
175+
163176
let ix = Instruction {
164177
program_id: decompressed.program_id,
165178
accounts: &account_metas,
166-
data: &decompressed.data,
179+
data: decompressed.data,
167180
};
168181

169-
let vault_bump_arr = [vault_bump];
170-
let seeds = [
171-
Seed::from(b"vault"),
172-
Seed::from(wallet_pda.key().as_ref()),
173-
Seed::from(&vault_bump_arr),
174-
];
175182
let signer: Signer = (&seeds).into();
176183

177-
let cpi_accounts: Vec<Account> = decompressed
178-
.accounts
179-
.iter()
180-
.map(|acc| Account::from(*acc))
181-
.collect();
182-
183184
unsafe {
184185
invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]);
185186
}
@@ -209,26 +210,26 @@ fn compute_sha256(data: &[u8]) -> [u8; 32] {
209210
}
210211

211212
/// Compute SHA256 hash of all account pubkeys referenced by compact instructions.
212-
/// Same logic as execute.rs::compute_accounts_hash.
213+
/// Matches execute::immediate::compute_accounts_hash.
213214
fn compute_accounts_hash(
214215
accounts: &[AccountInfo],
215-
compact_instructions: &[crate::compact::CompactInstruction],
216+
compact_instructions: &[CompactInstructionRef<'_>],
216217
) -> Result<[u8; 32], ProgramError> {
217-
let mut pubkeys_data = Vec::new();
218+
let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4);
218219

219220
for ix in compact_instructions {
220221
let program_idx = ix.program_id_index as usize;
221222
if program_idx >= accounts.len() {
222223
return Err(ProgramError::InvalidInstructionData);
223224
}
224-
pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref());
225+
refs.push(accounts[program_idx].key().as_ref());
225226

226-
for &acc_idx in &ix.accounts {
227+
for &acc_idx in ix.accounts {
227228
let idx = acc_idx as usize;
228229
if idx >= accounts.len() {
229230
return Err(ProgramError::InvalidInstructionData);
230231
}
231-
pubkeys_data.extend_from_slice(accounts[idx].key().as_ref());
232+
refs.push(accounts[idx].key().as_ref());
232233
}
233234
}
234235

@@ -237,15 +238,15 @@ fn compute_accounts_hash(
237238
#[cfg(target_os = "solana")]
238239
unsafe {
239240
pinocchio::syscalls::sol_sha256(
240-
[pubkeys_data.as_slice()].as_ptr() as *const u8,
241-
1,
241+
refs.as_ptr() as *const u8,
242+
refs.len() as u64,
242243
hash.as_mut_ptr(),
243244
);
244245
}
245246
#[cfg(not(target_os = "solana"))]
246247
{
247248
hash = [0xAA; 32];
248-
let _ = pubkeys_data;
249+
let _ = refs;
249250
}
250251

251252
Ok(hash)

program/src/processor/manage_authority.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ pub fn process_add_authority(
9191
}
9292
let (credential_id_hash, rest_after_cred) = rest.split_at(32);
9393
let rp_id_len = rest_after_cred[33] as usize;
94+
if rp_id_len == 0 || rp_id_len > 253 {
95+
return Err(ProgramError::InvalidInstructionData);
96+
}
9497
let total_auth_data = 32 + 33 + 1 + rp_id_len;
9598
if rest.len() < total_auth_data {
9699
return Err(ProgramError::InvalidInstructionData);
@@ -198,6 +201,12 @@ pub fn process_add_authority(
198201
}
199202

200203
// Authorization
204+
// Validate new_role is a known value (0=Owner, 1=Admin, 2=Spender).
205+
// Without this check an Owner could create a role-255 authority that can
206+
// execute but cannot be revoked by any Admin.
207+
if args.new_role > 2 {
208+
return Err(AuthError::PermissionDenied.into());
209+
}
201210
if admin_header.role != 0 && (admin_header.role != 1 || args.new_role != 2) {
202211
return Err(AuthError::PermissionDenied.into());
203212
}
@@ -212,9 +221,13 @@ pub fn process_add_authority(
212221
}
213222
check_zero_data(new_auth_pda, ProgramError::AccountAlreadyInitialized)?;
214223

224+
// Fixed sizes per auth type (see wallet/create.rs for layout).
215225
let header_size = std::mem::size_of::<AuthorityAccountHeader>();
216-
let variable_size = full_auth_data.len();
217-
let space = header_size + variable_size;
226+
let space = match args.authority_type {
227+
0 => header_size + 32, // Ed25519: pubkey
228+
1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash
229+
_ => return Err(AuthError::InvalidAuthenticationKind.into()),
230+
};
218231
let rent_lamports = rent.minimum_balance(space);
219232

220233
// Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4)
@@ -252,8 +265,35 @@ pub fn process_add_authority(
252265
*(data.as_mut_ptr() as *mut AuthorityAccountHeader) = header;
253266
}
254267

255-
let variable_target = &mut data[header_size..];
256-
variable_target.copy_from_slice(full_auth_data);
268+
// Write variable data. For Secp256r1 hash rpId once here so every Execute
269+
// saves a sol_sha256 syscall.
270+
match args.authority_type {
271+
0 => {
272+
data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]);
273+
}
274+
1 => {
275+
data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]);
276+
data[header_size + 32..header_size + 32 + 33]
277+
.copy_from_slice(&full_auth_data[32..32 + 33]);
278+
let rp_id_len = full_auth_data[32 + 33] as usize;
279+
let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len];
280+
let rp_id_hash_offset = header_size + 32 + 33;
281+
#[cfg(target_os = "solana")]
282+
unsafe {
283+
let _ = pinocchio::syscalls::sol_sha256(
284+
[rp_id].as_ptr() as *const u8,
285+
1,
286+
data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(),
287+
);
288+
}
289+
#[cfg(not(target_os = "solana"))]
290+
{
291+
let _ = rp_id;
292+
data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0);
293+
}
294+
}
295+
_ => unreachable!(),
296+
}
257297

258298
Ok(())
259299
}
@@ -398,6 +438,12 @@ pub fn process_remove_authority(
398438
}
399439
}
400440

441+
// Guard: if target == refund_dest the double-write would burn lamports and
442+
// trigger a Solana lamport conservation error, aborting after doing work.
443+
if target_auth_pda.key() == refund_dest.key() {
444+
return Err(ProgramError::InvalidAccountData);
445+
}
446+
401447
let target_lamports = unsafe { *target_auth_pda.borrow_mut_lamports_unchecked() };
402448
let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() };
403449
unsafe {

program/src/processor/revoke_session.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ pub fn process(
136136
return Err(ProgramError::InvalidAccountData);
137137
}
138138

139+
// Guard: session_pda == refund_dest would burn lamports.
140+
if session_pda.key() == refund_dest.key() {
141+
return Err(ProgramError::InvalidAccountData);
142+
}
143+
139144
// Close the session account — zero data and drain lamports
140145
session_data.fill(0);
141146

0 commit comments

Comments
 (0)