Managing passwords at scale requires a balance between security and accessibility. Cloud-based password managers centralize credentials in third-party servers, creating high-value targets for attackers.
Shift to Local-First Password Management
Cloud password managers fail. Centralized stores attract attackers. Cloud breach exposes vaults to offline brute-force. Network down blocks access.
Local-first architecture fixes this. Sesame stores data locally. Sync is optional helper, not dependency.
Principles:
- Local ownership: User controls vault files.
- Offline work: Read, write need no network.
- Client crypto: Zero-knowledge encryption on device, compatible with interoperable passkey records.
- Multi-device sync: Peer-to-peer or blind relay.
Storage Layer: SQLite and Page-Level Encryption
Sesame uses SQLite. Portable, tested, single local file.
Plaintext SQLite on disk risks exposure. Sesame uses SQLCipher for page-level encryption.
+===============+
| SQLCipher File |
| +=====+ +=====+ +=+ |
| | Page 1 (Salt/IV) | | Page 2 (Encrypted)| | ... | |
| +=====+ +=====+ +=+ |
+===============+
|
v
[AES-256-CBC / HMAC Validation]
|
v
+===============+
| Decrypted Pages |
| +=====+ +=====+ +=+ |
| | Page 1 (Header) | | Page 2 (Data) | | ... | |
| +=====+ +=====+ +=+ |
+===============+
SQLCipher encrypts every page. Default page size 4096 bytes. Page end reserves 48 bytes for:
- Initialization Vector (IV): 16 bytes, page-derived.
- HMAC-SHA512 signature: 32 bytes, verifies integrity before decryption.
Page-level approach stops identical plaintext producing identical ciphertext. Protects against chosen-ciphertext attacks. Header stays plaintext for SQLite identification. Schema, data, indexes, journals encrypt.
Cryptographic Key Derivation and Envelope Encryption
Weak key derivation invites dictionary attacks. Sesame uses Argon2id (RFC 9106).
Argon2id parameters:
- Memory cost
m: 65536 KB (64 MiB) - Time cost
t: 3 iterations - Parallelism
p: 4 threads
Key Derivation Process
[Master Password] \
|-> [Argon2id] -> [Master Key (MK)]
[16-Byte Salt] /
|
v
[HKDF-SHA256 Derivation]
|
+====+====+
| |
v v
[Master Encryption Key (MEK)] [Master HMAC Key (MHK)]
- User inputs master password.
- Read 16-byte salt from unencrypted database header page.
- Execute Argon2id. Output 32-byte Master Key
MK. - Derive Master Encryption Key
MEKand Master HMAC KeyMHKfromMKusing HKDF-SHA256.
Envelope Encryption
Direct encryption with MEK is slow. Changing master password forces re-encrypting all records. Risks data corruption.
Sesame uses envelope encryption:
- Generate random 32-byte Data Encryption Key (DEK) via CSPRNG.
- Encrypt DEK using MEK (AES-GCM-256).
- Store Encrypted DEK in database metadata.
- Encrypt vault items using DEK (AES-GCM-256).
On master password change:
- Decrypt
DEKusing oldMEK. - Derive new
MEKfrom new master password. - Encrypt
DEKwith newMEK. - Save new encrypted
DEKto database. Vault items remain untouched.
Data Schema and Zero-Knowledge Design
Schema prevents metadata leakage. Leaked domains or folders compromise privacy.
CREATE TABLE vault_folders (
id TEXT PRIMARY KEY,
encrypted_name TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE vault_items (
id TEXT PRIMARY KEY,
folder_id TEXT,
encrypted_payload TEXT NOT NULL,
sync_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_deleted INTEGER DEFAULT 0,
FOREIGN KEY(folder_id) REFERENCES vault_folders(id)
);Payloads and Metadata Separation
Plaintext search over columns requires decrypting all records to memory. Slows mobile devices.
Sesame stores sensitive fields in one JSON payload. Payload encrypts into encrypted_payload.
{
"title": "GitHub Account",
"username": "developer_user",
"password": "correct-horse-battery-staple",
"uris": ["https://github.com/login"],
"notes": "Backup codes stored in physical safe.",
"fields": [
{
"name": "Recovery PIN",
"value": "9942",
"type": "hidden"
}
]
}Unencrypted columns hold only structural metadata:
id: Random UUIDv4.sync_version: Monotonic counter.updated_at: Unix timestamp (millisecond precision).is_deleted: Soft-delete flag.
To search without disk exposure, Sesame builds in-memory SQLite FTS5 index at runtime. Decrypts on startup. Index lives in volatile memory, dies on lock.
Local-First Synchronization
Sync must not break zero-knowledge model. Server acts as blind relay. Cannot read payloads, folders, or graphs.
Sesame uses state-based CRDTs. LWW-Element-Set resolves conflicts.
Device A (Offline Edit) Sync Server (Blind Relay) Device B (Offline Edit)
+======+ +======+ +======+
| Item 1 | | | | Item 1 |
| Val: Encrypted(A) | | | | Val: Encrypted(B) |
| TS: 1718900050 | | | | TS: 1718900090 |
+======+ | | +======+
| | | |
| Push Changes | | |
+=======>| Stores Encrypted Blobs| |
| No Decryption Keys | |
+======+ |
^ |
| Sync Request |
+===========+
|
v
[Compares Timestamps]
[1718900090 > 1718900050]
|
v
Device B version wins.
Propagates to Device A.
Sync Protocol Walkthrough
- Change Tracking: Every modification increments local
sync_versionand updatesupdated_at. - Delta Generation: Client queries for records where
updated_atis greater than last successful sync timestamp. - Transmission: Client packages changes into JSON payload. Each record includes encrypted payload, record ID, timestamp, and deletion status.
- Conflict Resolution: Server compares incoming items with current state database using record ID.
- If incoming
updated_atis greater than serverupdated_at, server updates record. - If incoming
updated_atis lower, server discards incoming record and sends newer version to client.
- If incoming
- Tombstoning: Deleting item does not immediately remove database row. It sets
is_deleted = 1and updatesupdated_at. This ensures deletion propagates to other devices during next sync cycle.
Cryptographic Implementation Details
Rust implementation using ring library:
use ring::pbkdf2;
use ring::rand::{SecureRandom, SystemRandom};
use ring::aead::{self, BoundKey, SealingKey, OpeningKey, Nonce, NonceSequence, NONCE_LEN};
use std::num::NonZeroU32;
pub struct SimpleNonceSequence {
current: [u8; NONCE_LEN],
}
impl SimpleNonceSequence {
pub fn new(rng: &SystemRandom) -> Self {
let mut bytes = [0u8; NONCE_LEN];
rng.fill(&mut bytes).unwrap();
Self { current: bytes }
}
}
impl NonceSequence for SimpleNonceSequence {
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
let nonce = Nonce::assume_unique_for_key(self.current);
// Increment nonce bytes to prevent reuse
for byte in self.current.iter_mut().rev() {
*byte = byte.wrapping_add(1);
if *byte != 0 {
break;
}
}
Ok(nonce)
}
}
pub fn derive_master_key(password: &str, salt: &[u8]) -> [u8; 32] {
let mut derived_key = [0u8; 32];
let iterations = NonZeroU32::new(100_000).unwrap();
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA256,
iterations,
salt,
password.as_bytes(),
&mut derived_key,
);
derived_key
}
pub fn encrypt_payload(
plaintext: &[u8],
key_bytes: &[u8; 32],
rng: &SystemRandom,
) -> Result<(Vec<u8>, [u8; NONCE_LEN]), ring::error::Unspecified> {
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key_bytes)?;
let mut nonce_seq = SimpleNonceSequence::new(rng);
let nonce_val = nonce_seq.current;
let mut sealing_key = SealingKey::new(unbound_key, nonce_seq);
let mut in_out = plaintext.to_vec();
sealing_key.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)?;
Ok((in_out, nonce_val))
}
pub fn decrypt_payload(
ciphertext_with_tag: &[u8],
key_bytes: &[u8; 32],
nonce_bytes: &[u8; NONCE_LEN],
) -> Result<Vec<u8>, ring::error::Unspecified> {
let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key_bytes)?;
let mut nonce_seq = SimpleNonceSequence { current: *nonce_bytes };
let mut opening_key = OpeningKey::new(unbound_key, nonce_seq);
let mut in_out = ciphertext_with_tag.to_vec();
let decrypted_data = opening_key.open_in_place(aead::Aad::empty(), &mut in_out)?;
Ok(decrypted_data.to_vec())
}Memory Security and Platform Integration
OS caches memory pages to disk. Unlocked vaults in memory can leak to swap. Physical access allows memory dumps to extract keys.
Sesame protects memory three ways:
1. Memory Pinning (mlock)
Unix: Sesame calls mlock on key pages. Prevents OS writing pages to swap.
#[cfg(target_family = "unix")]
fn lock_memory(ptr: *mut u8, len: usize) -> bool {
unsafe {
libc::mlock(ptr as *const libc::c_void, len) == 0
}
}Windows: VirtualLock does same.
2. Zeroization
Clear sensitive buffers after use. Deallocation does not overwrite physical sectors. Sesame implements Zeroize trait for keys, passwords, payloads.
use zeroize::Zeroize;
pub struct SensitiveData {
pub key: [u8; 32],
pub raw_password: String,
}
impl Drop for SensitiveData {
fn drop(&mut self) {
self.key.zeroize();
self.raw_password.zeroize();
}
}3. Native Keychains for Session Keys
To avoid master password prompts for auto-fill, Sesame stores derived key in platform secure storage:
- macOS / iOS: Keychain Services (
kSecClassGenericPasswordwithkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly). - Linux: Secret Service API via D-Bus.
- Windows: DPAPI (user-level scope).
- Android: Android Keystore.
Local-first architecture keeps keys under user control. Isolates decryption from network. Secure even if sync relay compromised.



