Ave implements end-to-end encryption (E2EE) to ensure that only you can read your data. The server stores encrypted blobs that it cannot decrypt, providing a zero-knowledge architecture.
Encryption Architecture
The master key never leaves your device in plaintext . All encryption and decryption happens client-side in your browser.
Master Key Generation
During registration, your browser generates a 256-bit AES-GCM master key:
// Generate master key using Web Crypto API
export async function generateMasterKey () : Promise < CryptoKey > {
return await crypto . subtle . generateKey (
{ name: "AES-GCM" , length: 256 },
true , // extractable (for backup)
[ "encrypt" , "decrypt" ]
);
}
See ave-web/src/lib/crypto.ts:19 .
Key Generation
Master key is created using crypto.subtle.generateKey() with cryptographically secure randomness.
Local Storage
Master key is stored in browser localStorage (base64 encoded) for reuse. export async function storeMasterKey ( masterKey : CryptoKey ) : Promise < void > {
const keyData = await exportMasterKey ( masterKey );
const encoded = btoa ( String . fromCharCode ( ... new Uint8Array ( keyData )));
localStorage . setItem ( "ave_master_key" , encoded );
}
Backup Creation
Master key is encrypted with trust codes and uploaded to server.
Data Encryption
All sensitive data is encrypted before sending to the server:
// Encrypt data with AES-GCM
export async function encrypt (
data : string | ArrayBuffer ,
key : CryptoKey
) : Promise < string > {
const encoder = new TextEncoder ();
const dataBuffer = typeof data === "string"
? encoder . encode ( data )
: data ;
// Generate random 12-byte IV (nonce)
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ));
// Encrypt data
const encrypted = await crypto . subtle . encrypt (
{ name: "AES-GCM" , iv },
key ,
dataBuffer
);
// Combine IV + ciphertext and encode as base64
const combined = new Uint8Array ( iv . length + encrypted . byteLength );
combined . set ( iv , 0 );
combined . set ( new Uint8Array ( encrypted ), iv . length );
return btoa ( String . fromCharCode ( ... combined ));
}
See ave-web/src/lib/crypto.ts:85 .
Encrypted data is stored as base64 in this format:
[12-byte IV][variable-length ciphertext][16-byte auth tag]
IV (Initialization Vector) : Random 12-byte nonce, unique per encryption
Ciphertext : Encrypted data
Auth Tag : 16-byte GCM authentication tag (prevents tampering)
Never reuse IVs! Each encryption operation generates a fresh random IV. Reusing IVs with AES-GCM is catastrophic for security.
Data Decryption
Decryption reverses the process:
export async function decrypt (
encryptedData : string ,
key : CryptoKey
) : Promise < ArrayBuffer > {
// Decode base64
const combined = Uint8Array . from (
atob ( encryptedData ),
( c ) => c . charCodeAt ( 0 )
);
// Extract IV and ciphertext
const iv = combined . slice ( 0 , 12 );
const ciphertext = combined . slice ( 12 );
// Decrypt
return await crypto . subtle . decrypt (
{ name: "AES-GCM" , iv },
key ,
ciphertext
);
}
See ave-web/src/lib/crypto.ts:112 .
Master Key Backup
To enable account recovery, the master key is encrypted with trust codes and backed up to the server:
export async function createMasterKeyBackup (
masterKey : CryptoKey ,
trustCodes : string []
) : Promise < string > {
// Export master key to raw bytes
const masterKeyData = await exportMasterKey ( masterKey );
// Encrypt with each trust code
const backups : string [] = [];
for ( const code of trustCodes ) {
// Derive encryption key from trust code using PBKDF2
const derivedKey = await deriveKeyFromTrustCode ( code );
// Encrypt master key
const encrypted = await encrypt ( masterKeyData , derivedKey );
backups . push ( encrypted );
}
// Store as JSON
return JSON . stringify ({
version: 1 ,
backups
});
}
See ave-web/src/lib/crypto.ts:144 .
Trust Code Key Derivation
Trust codes are converted to encryption keys using PBKDF2:
export async function deriveKeyFromTrustCode ( code : string ) : Promise < CryptoKey > {
// Normalize: uppercase, strip non-alphanumeric
const normalized = code . toUpperCase (). replace ( / [ ^ A-Z0-9 ] / g , "" );
const encoder = new TextEncoder ();
// Import as key material
const keyMaterial = await crypto . subtle . importKey (
"raw" ,
encoder . encode ( normalized ),
"PBKDF2" ,
false ,
[ "deriveKey" ]
);
// Derive AES key using PBKDF2
const salt = encoder . encode ( "ave-trust-code-salt-v1" );
return await crypto . subtle . deriveKey (
{
name: "PBKDF2" ,
salt ,
iterations: 100000 , // 100k iterations
hash: "SHA-256"
},
keyMaterial ,
{ name: "AES-GCM" , length: 256 },
false ,
[ "encrypt" , "decrypt" ]
);
}
See ave-web/src/lib/crypto.ts:51 .
100,000 PBKDF2 iterations provide reasonable protection against brute-force attacks while remaining fast enough for user experience. In production, consider increasing to 600,000+ iterations.
Recovery Flow
When recovering your account with a trust code:
Enter Trust Code
User provides one of their saved trust codes.
Fetch Encrypted Backup
Client retrieves encryptedMasterKeyBackup from server. POST / api / login / trust - code
{
"handle" : "alice" ,
"code" : "ABCDE-12345-FGHIJ-67890-KLMNO" ,
"device" : { ... }
}
Response :
{
"sessionToken" : "..." ,
"encryptedMasterKeyBackup" : "{ \" version \" :1, \" backups \" :[...]}"
}
Derive Decryption Key
Trust code is normalized and converted to encryption key via PBKDF2.
Decrypt Master Key
Each backup in the JSON is tried until one successfully decrypts. export async function recoverMasterKeyFromBackup (
backup : string ,
trustCode : string
) : Promise < CryptoKey | null > {
const data = JSON . parse ( backup );
const derivedKey = await deriveKeyFromTrustCode ( trustCode );
// Try each backup
for ( const encryptedBackup of data . backups ) {
try {
const masterKeyData = await decrypt ( encryptedBackup , derivedKey );
return await importMasterKey ( masterKeyData );
} catch {
continue ; // Wrong trust code, try next
}
}
return null ;
}
Store Master Key
Recovered master key is stored in localStorage for future use.
See ave-web/src/lib/crypto.ts:171 .
Multi-Device Key Transfer
When logging in on a new device via device approval, the master key is transferred using ephemeral ECDH (Elliptic Curve Diffie-Hellman) key exchange:
ECDH Key Exchange
Device 2: Generate Ephemeral Keypair
The requesting device generates a one-time ECDH P-256 keypair: const keyPair = await crypto . subtle . generateKey (
{ name: "ECDH" , namedCurve: "P-256" },
true ,
[ "deriveKey" ]
);
// Export public key to send to server
const publicKeyData = await crypto . subtle . exportKey (
"spki" ,
keyPair . publicKey
);
const publicKey = btoa ( String . fromCharCode ( ... new Uint8Array ( publicKeyData )));
Device 1: Encrypt Master Key
The approving device:
Generates its own ephemeral keypair
Imports Device 2’s public key
Derives shared secret via ECDH
Encrypts master key with shared secret
// Import requester's public key
const recipientPublicKey = await importPublicKey ( requesterPublicKeyB64 );
// Derive shared secret
const sharedKey = await crypto . subtle . deriveKey (
{ name: "ECDH" , public: recipientPublicKey },
senderPrivateKey ,
{ name: "AES-GCM" , length: 256 },
false ,
[ "encrypt" , "decrypt" ]
);
// Encrypt master key
const encrypted = await encrypt ( masterKeyData , sharedKey );
Device 2: Decrypt Master Key
The requesting device:
Imports Device 1’s public key
Derives same shared secret using its private key
Decrypts master key
const senderPublicKey = await importPublicKey ( approverPublicKeyB64 );
const sharedKey = await deriveSharedKey ( recipientPrivateKey , senderPublicKey );
const masterKeyData = await decrypt ( encryptedMasterKey , sharedKey );
const masterKey = await importMasterKey ( masterKeyData );
See ave-web/src/lib/crypto.ts:256 for implementation.
Ephemeral keys are never stored - they’re generated on-demand for each login request and discarded after use. This provides forward secrecy : even if a key is compromised, past transfers remain secure.
PRF-Based Encryption
Passkeys supporting the PRF (Pseudo-Random Function) extension can derive deterministic secrets. Ave uses this to store a master key backup that only unlocks with the specific passkey:
// During passkey registration
const credential = await navigator . credentials . create ({
publicKey: {
... options ,
extensions: {
prf: { eval: { first: salt } } // Request PRF output
}
}
});
const prfOutput = credential . getClientExtensionResults (). prf ?. results . first ;
if ( prfOutput ) {
// Encrypt master key with PRF output
const prfEncrypted = await encryptMasterKeyWithPrf ( masterKey , prfOutput );
// Store with passkey
await updatePasskey ({ prfEncryptedMasterKey: prfEncrypted });
}
During login, the same PRF output decrypts the master key:
const prfOutput = credential . getClientExtensionResults (). prf ?. results . first ;
const masterKey = await decryptMasterKeyWithPrf (
passkey . prfEncryptedMasterKey ,
prfOutput
);
See ave-web/src/lib/crypto.ts:342 .
PRF support varies by platform. Always provide fallback recovery methods (trust codes) for users whose authenticators don’t support PRF.
OAuth App Encryption
When authorizing OAuth apps that support E2EE, each app gets its own encryption key:
// Generate app-specific key
const appKey = await generateAppKey ();
// Encrypt app key with user's master key
const encryptedAppKey = await encryptAppKey ( appKey , masterKey );
// Store on server
await db . insert ( oauthAuthorizations ). values ({
userId: user . id ,
appId: app . id ,
identityId: identity . id ,
encryptedAppKey // Only decryptable by user
});
// Send app key to OAuth app (over secure channel)
return { appKey: await exportAppKey ( appKey ) };
This ensures:
Apps cannot access each other’s encryption keys
Users retain control over app data encryption
Revoking app access also revokes encryption keys
See ave-web/src/lib/crypto.ts:366 .
Security Properties
Confidentiality
✅ Server cannot decrypt data - Server has encrypted blobs without keys
✅ Transit encryption - HTTPS protects data in transit
✅ At-rest encryption - Data encrypted before upload
Integrity
✅ AES-GCM authentication - 16-byte auth tag prevents tampering
✅ Passkey signatures - WebAuthn prevents credential forgery
Availability
✅ Multiple recovery methods - Trust codes, device approval, PRF
✅ Multi-device support - Master key can be transferred securely
⚠️ Trust code requirement - Losing all trust codes and devices means data loss
Cryptographic Primitives
Operation Algorithm Parameters Symmetric Encryption AES-GCM 256-bit keys, 12-byte IV, 16-byte tag Key Derivation PBKDF2 SHA-256, 100k iterations, static salt Key Exchange ECDH P-256 curve (secp256r1) Hashing SHA-256 Server-side for tokens/codes Random Generation crypto.getRandomValues() Browser CSPRNG
All cryptographic operations use the Web Crypto API (crypto.subtle), which provides hardware-accelerated, constant-time implementations.
Best Practices
For Users
Save trust codes immediately - They’re your backup if devices are lost
Use multiple devices - Register passkeys on 2+ devices for redundancy
Enable PRF passkeys - Modern authenticators provide seamless recovery
Don’t share master keys - Never export or share your master key
For Developers
Never log keys - Master keys should never appear in logs
Use constant-time operations - Web Crypto API prevents timing attacks
Validate ciphertext - GCM auth tag verification prevents tampering
Generate fresh IVs - Never reuse IVs with the same key
Secure deletion - Overwrite keys in memory when done (where possible)
Threat Model
Ave’s E2EE protects against:
✅ Compromised server - Server cannot decrypt stored data
✅ Network eavesdropping - HTTPS + E2EE provide defense in depth
✅ Malicious insiders - Database admins cannot read user data
✅ Data breaches - Stolen database remains encrypted
⚠️ Client compromise - Malware on user’s device can steal master key from memory/localStorage
⚠️ Browser vulnerabilities - Web Crypto API relies on browser security
Next Steps
Key Management Master key lifecycle and recovery
WebAuthn Implementation How passkeys protect master keys