Manual Payload Encryption
This guide explains how to encrypt payloads when calling the Sona API directly without using the JavaScript SDK. This is essential for developers building in other languages (Python, Rust, Go) or implementing native mobile apps.
Overview
The Sona API uses libsodium sealed boxes (anonymous public-key encryption) for all attested operations. The process involves:
- Getting the enclave's public encryption key
- Encrypting your payload using
crypto_box_seal(X25519 + XSalsa20-Poly1305) - Sending the encrypted payload to the API
- Verifying the attestation signature before signing transactions
Step-by-Step Guide
Step 1: Get the Encryption Key
First, fetch the enclave's public keys from the session endpoint:
GET https://api.sona.build/session
The response includes:
{
"encryptionPubKeyB64": "base64-encoded-x25519-key",
"integrityPubkeyB64": "base64-encoded-ed25519-key"
}
Key fields:
encryptionPubKeyB64: The X25519 public key used for encrypting payloads (persistent until enclave restart)integrityPubkeyB64: The Ed25519 public key used for verifying transaction signatures
Note: The session endpoint returns only the essential encryption keys. Full attestation documents are available through individual API endpoints when requested via the includeAttestation parameter.
Step 2: Prepare Your Payload
The payload must have this exact structure:
{
"envelope": {
"t": 1700000000000,
"rid": "550e8400-e29b-41d4-a716-446655440000",
"origin": "https://your-app.com"
},
"context": {
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"origin": "https://your-app.com"
},
"params": {
"amount": 100_000_000
}
}
Security Requirements:
envelope.t: Current timestamp in milliseconds (must be within 2 minutes for freshness)envelope.rid: Unique UUID v4 for this request (prevents replay attacks)envelope.origin: Your application's origin URLcontext.wallet: User's Solana wallet public keycontext.origin: Must exactly matchenvelope.origin(tamper detection)params: Protocol-specific parameters (see API Reference for each endpoint)
Step 3: Encrypt with Sealed Box
Use libsodium's sealed box encryption to encrypt the JSON payload:
JavaScript/Node.js
import sodium from 'libsodium-wrappers-sumo';
await sodium.ready;
// Decode the encryption key
const encryptionPubKey = sodium.from_base64(encryptionPubKeyB64);
// Create your payload
const payload = {
envelope: {
t: Date.now(),
rid: crypto.randomUUID(),
origin: 'https://your-app.com'
},
context: {
wallet: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU',
origin: 'https://your-app.com'
},
params: {
amount: 100_000_000
}
};
// Encrypt as sealed box
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const ciphertext = sodium.crypto_box_seal(plaintext, encryptionPubKey);
const encrypted = sodium.to_base64(ciphertext);
Python
import nacl.public
import nacl.encoding
import json
import time
import uuid
# Decode encryption key
encryption_key = nacl.public.PublicKey(
encryption_pubkey_b64,
encoder=nacl.encoding.Base64Encoder
)
# Create payload
payload = {
"envelope": {
"t": int(time.time() * 1000),
"rid": str(uuid.uuid4()),
"origin": "https://your-app.com"
},
"context": {
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"origin": "https://your-app.com"
},
"params": {
"amount": 100_000_000
}
}
# Encrypt as sealed box
sealed_box = nacl.public.SealedBox(encryption_key)
ciphertext = sealed_box.encrypt(json.dumps(payload).encode())
encrypted = nacl.encoding.Base64Encoder.encode(ciphertext).decode()
Rust
use sodiumoxide::crypto::box_::{PublicKey, seal};
use base64::{Engine as _, engine::general_purpose};
use serde_json::json;
// Decode encryption key
let encryption_key_bytes = general_purpose::STANDARD.decode(encryption_pubkey_b64)?;
let encryption_key = PublicKey::from_slice(&encryption_key_bytes).unwrap();
// Create payload
let payload = json!({
"envelope": {
"t": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64,
"rid": Uuid::new_v4().to_string(),
"origin": "https://your-app.com"
},
"context": {
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"origin": "https://your-app.com"
},
"params": {
"amount": 100_000_000
}
});
// Encrypt as sealed box
let plaintext = serde_json::to_string(&payload)?;
let ciphertext = seal(plaintext.as_bytes(), &encryption_key);
let encrypted = general_purpose::STANDARD.encode(&ciphertext);
Step 4: Send to API
Send the encrypted payload along with a plaintext "hint" that matches the encrypted content:
POST https://api.sona.build/solend/deposit
Content-Type: application/json
{
"encrypted": "base64-encrypted-payload-from-step-3",
"hint": {
"context": {
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"origin": "https://your-app.com"
},
"params": {
"amount": 100_000_000
}
},
"includeAttestation": false
}
Critical: The hint object must exactly match what's inside the encrypted payload. The enclave validates this to prevent parameter tampering.
Step 5: Verify the Response
The API returns an attested transaction:
{
"transaction": "base64-encoded-solana-transaction",
"attestation": {
"signature": "base64-ed25519-signature"
},
"integrityPubkeyB64": "base64-ed25519-pubkey",
"metadata": {
"protocol": "solend",
"operation": "deposit"
}
}
Always verify the attestation before signing:
JavaScript
import * as ed from '@noble/ed25519';
// Decode values
const txBytes = Buffer.from(response.transaction, 'base64');
const signature = Buffer.from(response.attestation.signature, 'base64');
const pubkey = Buffer.from(response.integrityPubkeyB64, 'base64');
// Verify Ed25519 signature
const isValid = await ed.verify(signature, txBytes, pubkey);
if (!isValid) {
throw new Error('Attestation verification failed - DO NOT SIGN!');
}
// Safe to sign and send
const signedTx = await wallet.signTransaction(txBytes);
Python
from nacl.signing import VerifyKey
from nacl.encoding import Base64Encoder
import base64
# Decode values
tx_bytes = base64.b64decode(response['transaction'])
signature = base64.b64decode(response['attestation']['signature'])
verify_key = VerifyKey(
response['integrityPubkeyB64'],
encoder=Base64Encoder
)
# Verify Ed25519 signature
try:
verify_key.verify(tx_bytes, signature)
print("Attestation valid - safe to sign")
except Exception:
raise Exception("Attestation verification failed - DO NOT SIGN!")
Security Considerations
Why Sealed Boxes?
Sealed boxes provide anonymous encryption where:
- Only the enclave can decrypt (has the private key)
- No sender authentication needed (you don't need a keypair)
- Provides confidentiality and authenticity (via AEAD)
Replay Protection
Each request must have a unique envelope.rid. The enclave tracks request IDs and rejects duplicates, preventing replay attacks.
Freshness
The envelope.t timestamp must be within 2 minutes of the enclave's time. This ensures requests can't be captured and replayed later.
Tamper Detection
The enclave validates that envelope.origin == context.origin. If they don't match, it indicates the request was tampered with.
Hint Validation
The plaintext hint must match the encrypted payload. The enclave decrypts the payload and compares:
hint.contextmust equal encryptedcontexthint.paramsmust equal encryptedparams
If validation fails, the request is rejected with "hint context tampered" or "hint params tampered" errors.
Common Errors
"stale ciphertext"
Cause: Your system clock is skewed or the request took too long to reach the server.
Solution: Ensure your system time is accurate (use NTP) and minimize network latency.
"decryption failed"
Cause: Wrong encryption key, corrupted ciphertext, or encryption format mismatch.
Solution:
- Verify you're using the latest encryption key from
/session - Ensure you're using sealed box encryption (not regular box)
- Check that the base64 encoding is correct
"hint context tampered" / "hint params tampered"
Cause: The plaintext hint doesn't match the encrypted payload.
Solution: Ensure the hint object is an exact copy of what you encrypted.
"origin mismatch"
Cause: envelope.origin doesn't match context.origin, or the origin doesn't match the expected value.
Solution: Make sure both origin fields are identical and match your application's actual origin.
"replay detected"
Cause: The same envelope.rid was used twice.
Solution: Generate a fresh UUID v4 for each request.
Full Attestation Documents
For additional security, you can request the full Nitro attestation document:
{
"encrypted": "...",
"hint": {...},
"includeAttestation": true
}
The response will include:
{
"transaction": "...",
"attestation": {
"signature": "...",
"attestationDoc": "cbor-encoded-document",
"imageSha384": "enclave-image-hash",
"pcrs": {
"PCR0": "...",
"PCR1": "...",
"PCR2": "..."
},
"timestamp": "attestation-timestamp"
}
}
You can verify the full attestation using AWS Nitro Enclaves attestation verification. This provides cryptographic proof that the transaction was generated inside a genuine Nitro Enclave running the expected code.
Next Steps
- See the API Reference for all available endpoints and their parameters
- Learn about Intents for understanding the transaction signing flow
- Check out the Protocols guide for protocol-specific examples
Need Help?
If you're implementing manual encryption in a language not covered here, please reach out. We're happy to help with implementation-specific questions about the encryption format.