Skip to content

Client SDK Specification

A language-agnostic specification for implementing VaultSandbox client libraries. This document provides all necessary information to build a fully-functional SDK in any programming language.


  1. Overview
  2. Architecture
  3. Naming Conventions
  4. Authentication
  5. Cryptographic Requirements
  6. API Reference
  7. Data Structures
  8. Delivery Strategies
  9. Error Handling
  10. Behavioral Specifications
  11. Implementation Checklist

VaultSandbox is a secure, receive-only SMTP server designed for QA/testing environments. The client SDK enables:

  • Creating temporary email inboxes with quantum-safe encryption
  • Receiving and decrypting emails in real-time or via polling
  • Validating email authentication (SPF/DKIM/DMARC)
  • Zero cryptographic knowledge required from end users
  • Quantum-safe encryption: ML-KEM-768 (Kyber768) + AES-256-GCM
  • Signature verification: ML-DSA-65 (Dilithium3) before decryption
  • Real-time delivery: Server-Sent Events (SSE) with polling fallback
  • Automatic retry: Exponential backoff for transient failures

┌────────────────────────────────────────────────────────────┐
│ User-Facing API │
│ (VaultSandboxClient, Inbox, Email classes) │
├────────────────────────────────────────────────────────────┤
│ Delivery Strategy Layer │
│ (SSE Strategy / Polling Strategy) │
├────────────────────────────────────────────────────────────┤
│ HTTP Layer │
│ (API Client with retry logic) │
├────────────────────────────────────────────────────────────┤
│ Crypto Layer │
│ (ML-KEM-768, ML-DSA-65, AES-256-GCM, HKDF-SHA-512) │
└────────────────────────────────────────────────────────────┘
ComponentResponsibility
VaultSandboxClientMain entry point; manages inboxes, strategies, lifecycle
InboxRepresents a single inbox; email operations, subscriptions
EmailRepresents a decrypted email with content and metadata
ApiClientHTTP communication with retry logic
DeliveryStrategyAbstract interface for SSE/polling implementations
Crypto ModuleKeypair generation, decryption, signature verification

SDKs should support these configuration options when creating a client:

OptionTypeDefaultDescription
apiKeystring(required)API key for authentication
baseUrlstringPlatform-specificVaultSandbox API base URL
strategyenumautoDelivery strategy: auto, sse, polling
timeoutnumber30000HTTP request timeout (ms)
maxRetriesnumber3Maximum retry attempts for failed requests
retryDelaynumber1000Base delay between retries (ms)
retryOnnumber[][408,429,500,502,503,504]HTTP status codes that trigger retry
pollingIntervalnumber2000Initial polling interval (ms)
pollingMaxBackoffnumber30000Maximum polling interval (ms)
pollingBackoffMultipliernumber1.5Polling backoff multiplier
pollingJitterFactornumber0.3Random jitter factor (0-1)
sseReconnectIntervalnumber5000SSE reconnection interval (ms)
sseMaxReconnectAttemptsnumber10Max SSE reconnection attempts
sseConnectionTimeoutnumber5000SSE connection timeout for auto fallback (ms)
httpClientobjectPlatform defaultCustom HTTP client (optional)
onSyncErrorfunctionnullCallback for background sync errors (optional)
OptionTypeDefaultDescription
ttlnumber3600Time-to-live in seconds (60-604800)
emailAddressstringnullDesired email address or domain (optional)

This specification uses camelCase for all identifiers in the wire format (JSON payloads, API responses). SDK implementations should adapt to their language’s idiomatic conventions:

LanguagePublic API StyleExample MethodExample Field
JavaScriptcamelCasecreateInbox()email.authResults
GoPascalCaseCreateInbox()Email.AuthResults
Pythonsnake_casecreate_inbox()email.auth_results
Rustsnake_casecreate_inbox()email.auth_results
JavacamelCasecreateInbox()email.authResults

Wire format remains camelCase regardless of SDK language. For example, the JSON field emailAddress becomes:

  • Go: EmailAddress (struct field)
  • Python: email_address (attribute)
  • JavaScript: emailAddress (property)

SDKs should handle this translation transparently during serialization/deserialization.


All API requests require the X-API-Key header.

X-API-Key: your-api-key
Content-Type: application/json

Before any operations, validate the API key:

GET /api/check-key
X-API-Key: your-api-key

Response:

{
"ok": true
}

PurposeAlgorithmStandard
Key EncapsulationML-KEM-768 (Kyber768)NIST FIPS 203
SignatureML-DSA-65 (Dilithium3)NIST FIPS 204
Symmetric EncryptionAES-256-GCMNIST SP 800-38D
Key DerivationHKDF-SHA-512RFC 5869
Key TypeSize (bytes)
ML-KEM-768 Public Key1184
ML-KEM-768 Secret Key2400
ML-KEM-768 Ciphertext1088
ML-KEM-768 Shared Secret32
ML-DSA-65 Public Key1952
ML-DSA-65 Signature3309
AES-256 Key32
AES-GCM Nonce12
AES-GCM Tag16
HKDF_CONTEXT = "vaultsandbox:email:v1"
ALGS_CIPHERSUITE = "ML-KEM-768:ML-DSA-65:AES-256-GCM:HKDF-SHA-512"
PUBLIC_KEY_START_OFFSET = 1152 # Byte offset where public key starts within ML-KEM-768 secret key

Generate an ML-KEM-768 keypair for each inbox:

# Pseudocode
keypair = ml_kem768.keygen()
# Returns:
# - publicKey: Uint8Array (1184 bytes)
# - secretKey: Uint8Array (2400 bytes)
# - publicKeyB64: base64url(publicKey)

All encrypted data from the server follows this structure:

{
"v": 1,
"algs": {
"kem": "ML-KEM-768",
"sig": "ML-DSA-65",
"aead": "AES-256-GCM",
"kdf": "HKDF-SHA-512"
},
"ct_kem": "<base64url: KEM ciphertext>",
"nonce": "<base64url: 12-byte nonce>",
"aad": "<base64url: additional authenticated data>",
"ciphertext": "<base64url: AES-GCM ciphertext + tag>",
"sig": "<base64url: ML-DSA-65 signature>",
"server_sig_pk": "<base64url: server's signing public key>"
}

Implementations MUST validate payloads before processing:

  1. Version: v MUST be 1. Reject payloads with unknown versions.
  2. Algorithms: All algorithm fields MUST match expected values. Implementations MUST reject payloads specifying different algorithms.
  3. Size validation: Decoded binary fields MUST have correct sizes:
FieldExpected size
ct_kem1088 bytes
nonce12 bytes
sig3309 bytes
server_sig_pk1952 bytes

CRITICAL: Always verify signature BEFORE decryption to detect tampering.

1. VERIFY SIGNATURE (security-critical)
├── Build transcript from encrypted payload
├── Verify ML-DSA-65 signature
└── ABORT if verification fails
2. KEM DECAPSULATION
├── Decode ct_kem from base64url
└── sharedSecret = ml_kem768.decapsulate(ct_kem, secretKey)
3. KEY DERIVATION (HKDF-SHA-512)
├── salt = SHA-256(ct_kem)
├── info = context || aad_length(4 bytes, big-endian) || aad
└── aesKey = HKDF-Expand(sharedSecret, salt, info, 32 bytes)
4. AES-256-GCM DECRYPTION
├── Decode nonce, aad, ciphertext from base64url
└── plaintext = AES-GCM-Decrypt(aesKey, nonce, aad, ciphertext)

Build the transcript exactly as the server does:

transcript = version (1 byte)
|| algs_ciphersuite (string: "ML-KEM-768:ML-DSA-65:AES-256-GCM:HKDF-SHA-512")
|| context (string: "vaultsandbox:email:v1")
|| ct_kem (bytes)
|| nonce (bytes)
|| aad (bytes)
|| ciphertext (bytes)
|| server_sig_pk (bytes)
valid = ml_dsa65.verify(signature, transcript, server_sig_pk)

CRITICAL: When verifying signatures, the client MUST compare the payload’s server_sig_pk against the pinned server public key captured at inbox creation time. This prevents man-in-the-middle attacks where an attacker could inject payloads signed with their own key.

# Pseudocode
def verify_with_pinning(encrypted_payload, pinned_server_pk):
payload_server_pk = from_base64url(encrypted_payload.server_sig_pk)
# Security check: reject if server keys don't match
if payload_server_pk != pinned_server_pk:
raise ServerKeyMismatchError("Server key in payload does not match pinned key - possible MITM attack")
# Proceed with signature verification using the pinned key
return verify_signature(encrypted_payload, pinned_server_pk)

In ML-KEM-768, the secret key structure is:

secretKey = cpaPrivateKey || cpaPublicKey || h || z

Where:

  • cpaPrivateKey: 1152 bytes (12 × k × n / 8, k=3, n=256)
  • cpaPublicKey: 1184 bytes (the public key)
  • h: 32 bytes (hash of public key)
  • z: 32 bytes (random seed)

The public key starts at byte offset 1152:

public_key = secret_key[1152:2336] # Bytes 1152-2335 (1184 bytes)

Library-Specific Note: The offset approach (1152) follows NIST FIPS 203 and works with libraries like cloudflare/circl. Some libraries may use different internal representations. Always verify with your chosen library’s documentation and test against the server.


Configurable per deployment. Example: https://smtp.vaultsandbox.com

Returns server cryptographic configuration.

Response:

{
"serverSigPk": "<base64url: server ML-DSA-65 public key>",
"algs": {
"kem": "ML-KEM-768",
"sig": "ML-DSA-65",
"aead": "AES-256-GCM",
"kdf": "HKDF-SHA-512"
},
"context": "vaultsandbox:email:v1",
"maxTtl": 604800,
"defaultTtl": 3600,
"sseConsole": false,
"allowedDomains": ["vaultsandbox.test", "example.com"]
}
FieldTypeDescription
serverSigPkstringBase64URL-encoded server signing public key for ML-DSA-65
algsobjectCryptographic algorithms supported by the server
contextstringContext string for the encryption scheme
maxTtlnumberMaximum time-to-live for inboxes in seconds
defaultTtlnumberDefault time-to-live for inboxes in seconds
sseConsolebooleanWhether server SSE console logging is enabled
allowedDomainsstring[]List of domains allowed for inbox creation

Creates a new inbox.

Request:

{
"clientKemPk": "<base64url: client ML-KEM-768 public key>",
"ttl": 3600,
"emailAddress": "[email protected]"
}
FieldTypeRequiredDescription
clientKemPkstringYesBase64url-encoded ML-KEM-768 public key
ttlnumberNoTime-to-live in seconds (min: 60, max: 604800)
emailAddressstringNoDesired email address or domain (max 254 chars)

Response:

{
"emailAddress": "[email protected]",
"expiresAt": "2024-01-15T12:00:00.000Z",
"inboxHash": "<base64url: SHA-256 hash of client KEM public key>",
"serverSigPk": "<base64url: server signing public key>"
}
FieldTypeDescription
emailAddressstringThe email address assigned to the inbox
expiresAtstringISO 8601 timestamp when the inbox will expire
inboxHashstringBase64URL-encoded SHA-256 hash of the client KEM public key
serverSigPkstringBase64URL-encoded server signing public key for verification

Deletes a specific inbox. Idempotent.

Response: 204 No Content

Deletes all inboxes for the API key.

Response:

{
"deleted": 5
}

Returns inbox sync status for efficient polling.

Response:

{
"emailCount": 3,
"emailsHash": "hash-of-email-ids"
}
FieldTypeDescription
emailCountnumberNumber of emails currently in the inbox
emailsHashstringHash of email IDs; changes indicate new/deleted emails

Usage: Compare emailsHash to detect changes without fetching all emails. This enables efficient polling by skipping unchanged inboxes.

Lists all emails in an inbox (metadata only).

Note: The server returns only metadata (sender, subject, date) for this endpoint. To retrieve the full email content (body, attachments), the client library must fetch each email individually using GET /api/inboxes/{emailAddress}/emails/{emailId}.

Response:

[
{
"id": "email-uuid",
"inboxId": "inbox-hash",
"receivedAt": "2024-01-15T12:00:00.000Z",
"isRead": false,
"encryptedMetadata": {
/* EncryptedPayload */
}
}
]

GET /api/inboxes/{emailAddress}/emails/{emailId}

Section titled “GET /api/inboxes/{emailAddress}/emails/{emailId}”

Retrieves a specific email with full content.

Response:

{
"id": "email-uuid",
"inboxId": "inbox-hash",
"receivedAt": "2024-01-15T12:00:00.000Z",
"isRead": false,
"encryptedMetadata": {
/* EncryptedPayload - email headers */
},
"encryptedParsed": {
/* EncryptedPayload - full email body, attachments, auth results */
}
}

Note: Unlike the list endpoint, this returns encryptedParsed which contains the complete email body, HTML content, attachments, links, and authentication results.

GET /api/inboxes/{emailAddress}/emails/{emailId}/raw

Section titled “GET /api/inboxes/{emailAddress}/emails/{emailId}/raw”

Retrieves the raw email source (encrypted).

Response:

{
"id": "email-uuid",
"encryptedRaw": {
/* EncryptedPayload */
}
}

PATCH /api/inboxes/{emailAddress}/emails/{emailId}/read

Section titled “PATCH /api/inboxes/{emailAddress}/emails/{emailId}/read”

Marks an email as read.

Response: 204 No Content

DELETE /api/inboxes/{emailAddress}/emails/{emailId}

Section titled “DELETE /api/inboxes/{emailAddress}/emails/{emailId}”

Deletes a specific email.

Response: 204 No Content

Server-Sent Events endpoint for real-time email notifications.

Query Parameters:

ParameterTypeDescription
inboxesstringComma-separated inbox hashes

Headers:

X-API-Key: your-api-key
Accept: text/event-stream

Event Format:

data: {"inboxId":"inbox-hash","emailId":"email-uuid","encryptedMetadata":{...}}
FieldTypeDescription
inboxIdstringThe inbox hash that received the email
emailIdstringUnique identifier for the new email
encryptedMetadataEncryptedPayloadEncrypted email metadata (from, to, subject)

Note: SSE events only include metadata. To get full email content (body, attachments), fetch the email by ID after receiving the notification.

Connection Management:

  1. Subscribe to inboxes by adding their hashes to the query parameter
  2. On connection open, reset reconnection counter
  3. On connection error, disconnect and attempt reconnection with exponential backoff
  4. When adding/removing inbox subscriptions, reconnect with updated hash list
  5. On reconnection success, sync all inboxes to catch emails received during downtime

After decrypting encryptedMetadata:

{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome Email",
"receivedAt": "2024-01-15T12:00:00.000Z"
}

The fully decrypted Email object exposed to users:

{
"id": "email-uuid",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome Email",
"text": "Plain text content",
"html": "<html>HTML content</html>",
"receivedAt": "2024-01-15T12:00:00.000Z",
"isRead": false,
"headers": {
"message-id": "<[email protected]>",
"date": "Mon, 15 Jan 2024 12:00:00 +0000"
},
"attachments": [
/* AttachmentData[] */
],
"links": ["https://example.com/verify?token=abc123"],
"authResults": {
/* AuthResults */
},
"metadata": {}
}
FieldTypeDescription
idstringUnique email identifier
fromstringSender email address
tostring[]Recipient email addresses
subjectstringEmail subject line
textstring | nullPlain text body (null if not available)
htmlstring | nullHTML body (null if not available)
receivedAtDate/stringWhen email was received
isReadbooleanRead status
headersobjectEmail headers as key-value pairs
attachmentsAttachmentData[]Email attachments
linksstring[]URLs extracted from email body
authResultsAuthResultsSPF/DKIM/DMARC/ReverseDNS results
metadataobjectAdditional metadata (reserved for extensions)

After decrypting encryptedParsed:

{
"text": "Plain text content",
"html": "<html>HTML content</html>",
"headers": {
"message-id": "<[email protected]>",
"date": "Mon, 15 Jan 2024 12:00:00 +0000"
},
"attachments": [
{
"filename": "document.pdf",
"contentType": "application/pdf",
"size": 15234,
"contentId": "[email protected]",
"contentDisposition": "attachment",
"content": "<base64: file content>",
"checksum": "<optional: SHA-256 hash>"
}
],
"metadata": {
/* Additional metadata associated with the email */
},
"links": ["https://example.com/verify?token=abc123"],
"authResults": {
"spf": {
/* SPFResult */
},
"dkim": [
/* DKIMResult[] */
],
"dmarc": {
/* DMARCResult */
},
"reverseDns": {
/* ReverseDNSResult */
}
}
}

Client libraries should provide a validation helper that verifies:

  1. SPF: result must be "pass"
  2. DKIM: At least one entry in the array must have result: "pass"
  3. DMARC: result must be "pass"
  4. Reverse DNS: verified must be true

Note: The overall passed status only considers SPF, DKIM, and DMARC. Reverse DNS is checked separately and does not affect the overall pass/fail status.

The validate() method returns a validation summary:

{
"passed": false,
"spfPassed": true,
"dkimPassed": true,
"dmarcPassed": false,
"reverseDnsPassed": true,
"failures": ["DMARC policy: fail (policy: reject)"]
}
FieldTypeDescription
passedbooleanTrue if SPF, DKIM, and DMARC all passed
spfPassedbooleanTrue if SPF result is "pass"
dkimPassedbooleanTrue if at least one DKIM signature passed
dmarcPassedbooleanTrue if DMARC result is "pass"
reverseDnsPassedbooleanTrue if reverse DNS verified is true
failuresstring[]Human-readable descriptions of failed checks

SDKs may also provide a convenience method like isPassing() that returns the passed value directly.

{
"result": "pass",
"domain": "example.com",
"ip": "192.0.2.1",
"details": "SPF record validated"
}
FieldTypeDescription
resultstringSPF check result (see values)
domainstringDomain checked (optional)
ipstringSender IP address (optional)
detailsstringHuman-readable details (optional)
ResultMeaning
passAuthorized sender
failNot authorized
softfailProbably not authorized
neutralNo assertion
noneNo SPF record
temperrorTemporary error
permerrorPermanent error
{
"result": "pass",
"domain": "example.com",
"selector": "selector1",
"signature": "base64-encoded-sig"
}
FieldTypeDescription
resultstringDKIM check result (see values)
domainstringSigning domain (optional)
selectorstringDKIM selector (optional)
signaturestringDKIM signature info (optional)
ResultMeaning
passValid signature
failInvalid signature
noneNo signature
{
"result": "pass",
"policy": "reject",
"aligned": true,
"domain": "example.com"
}
FieldTypeDescription
resultstringDMARC check result (see values)
policystringDomain’s DMARC policy (optional)
alignedbooleanWhether SPF/DKIM aligned (optional)
domainstringDomain checked (optional)
ResultMeaning
passDMARC passed
failDMARC failed
noneNo DMARC policy
PolicyMeaning
noneMonitoring only
quarantineTreat as spam
rejectReject email
{
"verified": true,
"ip": "192.0.2.1",
"hostname": "mail.example.com"
}
FieldTypeDescription
verifiedbooleanWhether reverse DNS verified
ipstringServer IP address (optional)
hostnamestringResolved hostname (optional)

Note: Unlike other auth results, ReverseDNS uses a verified boolean instead of a result string. SDKs may provide a convenience method like isPassing() that returns verified.

For persistence/sharing:

{
"version": 1,
"emailAddress": "[email protected]",
"expiresAt": "2024-01-15T12:00:00.000Z",
"inboxHash": "sha256-hash",
"serverSigPk": "<base64url: server signing key>",
"secretKey": "<base64url: client secret key>",
"exportedAt": "2024-01-14T12:00:00.000Z"
}
FieldTypeRequiredDescription
versionintegerYesExport format version. MUST be 1.
emailAddressstringYesThe inbox email address. MUST contain @.
expiresAtstringYesInbox expiration timestamp (ISO 8601).
inboxHashstringYesUnique inbox identifier. Non-empty.
serverSigPkstringYesServer’s ML-DSA-65 public key (base64url, 1952 bytes decoded).
secretKeystringYesML-KEM-768 secret key (base64url, 2400 bytes decoded).
exportedAtstringYesExport timestamp (ISO 8601).

Note: The public key is NOT included in the export as it can be derived from the secret key (see Deriving Public Key from Secret Key).

Security Warning: Exported data contains private keys. Handle securely.

Implementations MUST validate imported data in the following order:

  1. Parse JSON: Verify the input is valid JSON.

  2. Validate version:

    if version != 1:
    return ERROR_UNSUPPORTED_VERSION
  3. Validate required fields: All fields from the export format MUST be present and non-null.

  4. Validate emailAddress:

    • MUST be a non-empty string
    • MUST contain exactly one @ character
    if emailAddress == "" or count(emailAddress, "@") != 1:
    return ERROR_INVALID_EMAIL
  5. Validate inboxHash:

    • MUST be a non-empty string
    if inboxHash == "":
    return ERROR_INVALID_INBOX_HASH
  6. Validate and decode secretKey:

    secretKeyBytes = base64url_decode(secretKey)
    if decoding fails:
    return ERROR_INVALID_SECRET_KEY
    if len(secretKeyBytes) != 2400:
    return ERROR_INVALID_SECRET_KEY_SIZE
  7. Validate and decode serverSigPk:

    serverSigPkBytes = base64url_decode(serverSigPk)
    if decoding fails:
    return ERROR_INVALID_SERVER_KEY
    if len(serverSigPkBytes) != 1952:
    return ERROR_INVALID_SERVER_KEY_SIZE
  8. Validate timestamps:

    • expiresAt MUST be a valid ISO 8601 timestamp
    • exportedAt MUST be a valid ISO 8601 timestamp

After validation, reconstruct the full keypair:

secret_key = base64url_decode(export.secretKey)
public_key = secret_key[1152:2336] # Derive from secret key
keypair = { secret_key, public_key }

Implementations SHOULD check for existing inboxes with the same email address or inbox hash before import and either:

  • Reject the import with an error, or
  • Prompt the user to confirm replacement

The /api/events SSE endpoint is always available on the server.

StrategyUse Case
sseReal-time updates, low latency
pollingFirewall restrictions, simpler implementation
autoRecommended default; tries SSE first, falls back to polling if unavailable
GET /api/events?inboxes=hash1,hash2,hash3
X-API-Key: your-api-key
Accept: text/event-stream
# Pseudocode
for event in sse_stream:
data = json.parse(event.data)
inbox = find_inbox_by_hash(data.inboxId)
email = decrypt_email(data.encryptedMetadata, inbox.keypair)
notify_callbacks(inbox, email)
  • Initial interval: 5000ms
  • Max attempts: 10
  • Backoff multiplier: 2x
  • Reset attempts on successful connection
# Pseudocode
last_hash = None
current_backoff = initial_interval # 2000ms
while not timeout:
sync_status = GET /api/inboxes/{email}/sync
if last_hash != sync_status.emailsHash:
last_hash = sync_status.emailsHash
emails = GET /api/inboxes/{email}/emails
current_backoff = initial_interval # Reset on change
for email in decrypt_emails(emails):
if matches_filters(email):
return email
else:
# Exponential backoff when no changes
current_backoff = min(current_backoff * 1.5, max_backoff)
jitter = random() * 0.3 * current_backoff
sleep(current_backoff + jitter)
ParameterDefaultDescription
initialInterval2000msStarting poll interval
maxBackoff30000msMaximum backoff delay
backoffMultiplier1.5Backoff growth factor
jitterFactor0.3Random jitter (0-30%)

VaultSandboxError (base)
├── ApiError (HTTP errors)
│ └── Contains: statusCode, message, requestId (optional)
├── NetworkError (connection failures)
├── TimeoutError (operation timeouts)
├── InboxNotFoundError (404 for inbox)
├── EmailNotFoundError (404 for email)
├── InboxAlreadyExistsError (import conflict)
├── InvalidImportDataError (validation failure)
├── DecryptionError (crypto failure)
├── SignatureVerificationError (tampering detected)
│ └── ServerKeyMismatchError (server key doesn't match pinned key - possible MITM)
├── SSEError (SSE connection issues)
├── StrategyError (strategy configuration)
├── RateLimitedError (429 - too many requests)
├── UnauthorizedError (401 - invalid API key)
└── ClientClosedError (operation on closed client)
408 - Request Timeout
429 - Too Many Requests
500 - Internal Server Error
502 - Bad Gateway
503 - Service Unavailable
504 - Gateway Timeout
# Pseudocode
retry_delay = 1000 # ms
max_retries = 3
for attempt in range(max_retries + 1):
try:
return http_request()
except RetryableError:
if attempt < max_retries:
sleep(retry_delay * (2 ** attempt)) # Exponential backoff
else:
raise

SignatureVerificationError and DecryptionError should:

  1. Be logged immediately with full context
  2. Never be silently ignored
  3. Halt the operation
  4. Potentially trigger security alerts

Implementations MUST use constant-time operations for:

  • Server key comparison (pinned vs payload key)
  • Signature verification
  • Any comparison involving secret data

Implementations MUST NOT reveal whether a failure occurred during:

  • Signature verification vs. decryption
  • MAC verification vs. decryption

Use generic error messages to prevent oracle attacks. For example, use a single “decryption failed” error rather than distinguishing between signature failure and AEAD failure.

Implementations SHOULD:

  • Zero secret key material after use when possible
  • Avoid logging or serializing secret keys except for export
  • Use secure memory allocations where available

All random values (keypairs, nonces) MUST be generated using a cryptographically secure random number generator (CSPRNG).


ConfigurationDefaultDescription
HTTP timeout30000msMaximum time for HTTP request completion
Wait timeout30000msMaximum time to wait for email arrival
Poll interval (initial)2000msStarting interval for polling strategy
Poll max backoff30000msMaximum polling interval after backoff
Poll backoff multiplier1.5Multiplier for exponential backoff
Poll jitter factor0.3Random jitter (0-30%) to prevent thundering herd
Max retries3Maximum HTTP retry attempts
Retry delay1000msBase delay between retries (doubles each attempt)
SSE reconnect interval5000msInitial SSE reconnection delay
SSE max reconnect attempts10Maximum SSE reconnection attempts before fallback
SSE backoff multiplier2SSE reconnection backoff multiplier
SSE connection timeout5000msTime to wait for SSE before falling back (auto)
Default inbox TTL3600sDefault inbox time-to-live (1 hour)
Min inbox TTL60sMinimum allowed inbox TTL
Max inbox TTL604800sMaximum allowed inbox TTL (7 days)

waitForEmail supports these filter options:

FilterTypeDescription
subjectstring | regexMatch email subject
fromstring | regexMatch sender address
predicatefunctionCustom filter function
timeoutnumberMax wait time (ms), default 30000
pollIntervalnumberPolling interval (ms)
  1. Create: Generate keypair, register with server
  2. Use: Receive emails, decrypt, process
  3. Export (optional): Save keypair + metadata
  4. Delete: Clean up server resources
MethodDescriptionReturns
getEmails()Fetch and decrypt all emailsEmail[]
getEmail(id)Fetch and decrypt specific emailEmail
getRawEmail(id)Get decrypted raw RFC 5322 sourcestring
waitForEmail(opts)Wait for email matching filtersEmail
waitForEmailCount(n, opts)Wait for at least N matching emailsEmail[]
watch()Subscribe to new emails (returns channel/observable)Channel<Email>
onNewEmail(cb)Subscribe with callbackSubscription
getSyncStatus()Get email count and hash for change detectionSyncStatus
markEmailAsRead(id)Mark email as readvoid
deleteEmail(id)Delete specific emailvoid
delete()Delete inbox and all emailsvoid
export()Export inbox data including keysExportedInboxData
isExpired()Check if inbox TTL has passedboolean

Email objects may include convenience methods for common operations:

MethodDescriptionReturns
markAsRead()Mark this email as readvoid
delete()Delete this emailvoid
getRaw()Fetch raw RFC 5322 sourceRawEmail
MethodDescriptionReturns
createInbox(opts)Create new inbox with auto-generated keypairInbox
importInbox(data)Import inbox from exported dataInbox
importInboxFromFile(path)Import inbox from JSON fileInbox
exportInboxToFile(inbox, path)Export inbox to JSON filevoid
deleteInbox(email)Delete specific inboxvoid
deleteAllInboxes()Delete all inboxes for API keynumber (count)
getInbox(email)Get tracked inbox by email addressInbox?
getInboxes()Get all tracked inboxesInbox[]
watchInboxes(inboxes)Monitor multiple inboxes simultaneouslyInboxMonitor
getServerInfo()Get server configurationServerInfo
checkKey()Validate API keyboolean
close()Close client and release resourcesvoid

For real-time email notifications, implement a subscription interface:

# Pseudocode
class Subscription:
def unsubscribe(self):
"""Stop receiving notifications and clean up resources"""
pass
# Usage
subscription = inbox.on_new_email(lambda email: print(email.subject))
# ... later ...
subscription.unsubscribe()

For monitoring multiple inboxes simultaneously:

# Pseudocode
monitor = client.watch_inboxes([inbox1, inbox2, inbox3])
monitor.on('email', lambda inbox, email:
print(f"New email in {inbox.email_address}: {email.subject}")
)
# ... later ...
monitor.unsubscribe() # Stop all subscriptions
1. Receive encrypted email data
2. Verify ML-DSA-65 signature (MUST be first)
3. Decapsulate KEM ciphertext
4. Derive AES key via HKDF
5. Decrypt metadata (from, to, subject)
6. Decrypt parsed content (text, html, attachments)
7. Decode attachment content from base64
8. Build Email object with all fields

  • ML-KEM-768 keypair generation
  • ML-KEM-768 decapsulation
  • ML-DSA-65 signature verification
  • Server key pinning (compare payload key vs pinned key)
  • AES-256-GCM decryption
  • HKDF-SHA-512 key derivation with correct salt/info construction
  • Base64url encoding/decoding
  • HTTP client with retry logic
  • API key authentication
  • Derive public key from secret key (offset 1152)
  • Create inbox with auto-generated keypair
  • Delete inbox / delete all inboxes
  • Get inbox by email address
  • List all tracked inboxes
  • List emails in inbox (metadata only)
  • Get specific email by ID (with full content)
  • Get raw email source
  • Mark email as read
  • Delete email
  • Wait for email with filters (subject, from, predicate)
  • Wait for email count
  • Watch/subscribe to single inbox
  • Get sync status for change detection
  • Check inbox expiration
  • SSE strategy with reconnection and backoff
  • Polling strategy with exponential backoff and jitter
  • Auto strategy (SSE with polling fallback)
  • Strategy-agnostic subscription interface
  • Sync after SSE reconnection (catch missed emails)
  • Export inbox (keypair + metadata to JSON)
  • Import inbox from exported data
  • Import/export to file
  • Monitor multiple inboxes simultaneously (InboxMonitor)
  • Authentication results validation helper
  • Custom HTTP client support
  • All error types from hierarchy
  • HTTP retry with exponential backoff
  • Timeout handling for all operations
  • SSE reconnection with backoff
  • Server key mismatch detection (MITM protection)
  • Graceful client close/cleanup
  • All fields populated (id, from, to, subject, text, html, etc.)
  • Convenience methods (markAsRead, delete, getRaw)
  • Auth results with validation method
  • Attachment content base64 decoding
  • Unit tests for crypto operations
  • Unit tests for transcript construction
  • Unit tests for HKDF key derivation
  • Integration tests against live server
  • Error scenario coverage
  • Concurrent inbox handling
  • Import/export round-trip tests
  • Interoperability tests (see client-interop)

SDK implementations SHOULD pass the official interoperability test suite at github.com/vaultsandbox/client-interop. This suite validates:

  • Cryptographic operations (KEM, signatures, AEAD, HKDF)
  • Transcript construction for signature verification
  • Export/import format compatibility across implementations
  • Base64URL encoding/decoding

VaultSandbox uses URL-safe Base64 (RFC 4648 Section 5):

Standard Base64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
URL-safe Base64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
  1. Use - instead of +
  2. Use _ instead of /
  3. Do NOT include = padding characters
  4. Implementations MUST reject input containing +, /, or =
def to_base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
def from_base64url(s: str) -> bytes:
# Add padding if needed
padding = 4 - (len(s) % 4)
if padding != 4:
s += '=' * padding
return base64.urlsafe_b64decode(s)

For signature verification, the transcript must be constructed byte-for-byte identical to the server:

def build_transcript(encrypted_data):
version_bytes = bytes([encrypted_data.v]) # 1 byte
algs = encrypted_data.algs
algs_ciphersuite = f"{algs.kem}:{algs.sig}:{algs.aead}:{algs.kdf}"
algs_bytes = algs_ciphersuite.encode('utf-8')
context_bytes = HKDF_CONTEXT.encode('utf-8') # "vaultsandbox:email:v1"
ct_kem = from_base64url(encrypted_data.ct_kem)
nonce = from_base64url(encrypted_data.nonce)
aad = from_base64url(encrypted_data.aad)
ciphertext = from_base64url(encrypted_data.ciphertext)
server_sig_pk = from_base64url(encrypted_data.server_sig_pk)
return (
version_bytes +
algs_bytes +
context_bytes +
ct_kem +
nonce +
aad +
ciphertext +
server_sig_pk
)

def derive_key(shared_secret, context, aad, ct_kem):
# Salt is SHA-256 hash of KEM ciphertext
salt = sha256(ct_kem)
# Info construction
context_bytes = context.encode('utf-8') # "vaultsandbox:email:v1"
aad_length = len(aad).to_bytes(4, 'big') # 4 bytes, big-endian
info = context_bytes + aad_length + aad
# HKDF with SHA-512
return hkdf_sha512(
ikm=shared_secret,
salt=salt,
info=info,
length=32 # 256 bits for AES-256
)

VersionDateChanges
0.8.02026-01-04Aligned with VaultSandbox cryptographic protocol spec:
- Export format: added version field, renamed secretKeyB64
to secretKey, removed publicKeyB64
- Added import validation section with detailed steps
- Added payload validation (version, algorithms, sizes)
- Added security requirements (timing attacks, error messages,
memory handling, CSPRNG)
- Added Base64URL rejection requirement for invalid chars
- Added interoperability testing reference
0.7.02025-12-30Simplified auth results: SDKs use wire format field names
(result, details, signature, verified) directly.
Removed JSON→SDK field mapping requirement.
0.6.02025-12-30Added: key sizes, client config options, inbox/email methods,
server key pinning, auth results JSON mapping, error types,
subscription patterns, expanded checklist
0.5.02025-12Initial specification