Skip to main content
Building secure FHE applications requires attention to several areas.

Key Management

FHE Keys

KeyStorageAccess
Public KeyDistributedAnyone can encrypt
Private KeyMPC distributedThreshold decryption only
The FHE private key is never held by a single party.

User Keys

// Derive X25519 from Solana keypair
const userCrypto = privora.userCrypto(keypair);

// Same keypair = same X25519 key
// Protect Solana keypair = protect recovery ability

Authorization Security

Principle of Least Privilege

// Good: Only authorize what's needed
if order.owner == user_pubkey {
    create_decryption_auth(&order.price_ref.hash(), &user_pubkey)?;
}

// Bad: Over-broad authorization
create_decryption_auth(&data_hash, &any_user)?;

Verify Before Authorizing

pub fn authorize_match_result(
    match_result: &MatchResult,
    user: &Pubkey,
) -> ProgramResult {
    // Verify user was part of the match
    let user_was_buyer = match_result.buyer == *user;
    let user_was_seller = match_result.seller == *user;

    if !user_was_buyer && !user_was_seller {
        return Err(ProgramError::InvalidArgument);
    }

    // Only then authorize
    create_decryption_auth(&match_result.fill_price_ref.hash(), user)?;
    Ok(())
}

Authorization is Permanent

Once an authorization PDA is created, it grants decryption rights permanently. There is no revocation after MPC decryption occurs.

Data Privacy

What’s Encrypted vs Public

DataVisibility
Account existencePublic
Account ownerPublic
Hash referencesPublic
Encrypted valuesPrivate
Computation resultsPrivate (until authorized)

Metadata Leakage

Even with encryption, some information leaks:
  • Timing: When operations occur
  • Frequency: How often values change
  • Patterns: Access patterns to accounts
Consider if this metadata is sensitive for your use case.

Input Validation

Client-Side

// Validate before encryption
function validatePrice(price: number): boolean {
  return price >= 0 && price <= 255 && Number.isInteger(price);
}

if (!validatePrice(price)) {
  throw new Error('Invalid price');
}
const encrypted = privora.encrypt(price, 'u8');

Program-Side

// Verify instruction data format
if instruction_data.len() < 33 {
    return Err(ProgramError::InvalidInstructionData);
}

// Verify accounts
if !order_owner.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

// Verify state
if order.status != OrderStatus::Open {
    return Err(ProgramError::InvalidArgument);
}

Recovery Data Security

Storage

// Bad: Store in plain localStorage
localStorage.setItem('recovery', JSON.stringify(recoveryData));

// Better: Encrypt at rest
const encryptedRecovery = await encryptWithDeviceKey(recoveryData);
secureStorage.setItem('recovery', encryptedRecovery);

Transmission

Never transmit recovery data unencrypted. The recovery data allows decryption without MPC.

Common Vulnerabilities

1. Missing Allocator

// Bug: Missing allocator causes OOM
// privora_sdk_program::setup_fhe_allocator!(); // Missing!

// Fix: Always include at crate root
privora_sdk_program::setup_fhe_allocator!();

2. Type Confusion

// Bug: Wrong type interpretation
let price_u64: Encrypted<u64> = EncryptedRef::<u64>::from_hash(hash).load()?;
// But hash was from u8 encryption!

// Fix: Track types carefully, use typed instruction data

3. Missing Signer Checks

// Bug: No signer verification
let owner = next_account_info(accounts_iter)?;
// Anyone can claim to be owner!

// Fix: Always verify signers
if !owner.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

Audit Checklist

  • Allocator is set up
  • All signers are verified
  • Authorization is properly scoped
  • Input data is validated
  • Account ownership is checked
  • State transitions are valid
  • Recovery data is protected
  • Types are consistent throughout