Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Software Engineering

Building Local-First Password Management Architecture with Sesame

Design secure local first password manager. Sesame architecture uses client-side encryption, offline storage, peer sync. Keep credentials private.

Dian Rijal Asyrof/August 31, 2026/5 min read
Illustration for Building Local-First Password Management Architecture with Sesame

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)]
  1. User inputs master password.
  2. Read 16-byte salt from unencrypted database header page.
  3. Execute Argon2id. Output 32-byte Master Key MK.
  4. Derive Master Encryption Key MEK and Master HMAC Key MHK from MK using 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:

  1. Generate random 32-byte Data Encryption Key (DEK) via CSPRNG.
  2. Encrypt DEK using MEK (AES-GCM-256).
  3. Store Encrypted DEK in database metadata.
  4. Encrypt vault items using DEK (AES-GCM-256).

On master password change:

  1. Decrypt DEK using old MEK.
  2. Derive new MEK from new master password.
  3. Encrypt DEK with new MEK.
  4. Save new encrypted DEK to 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

  1. Change Tracking: Every modification increments local sync_version and updates updated_at.
  2. Delta Generation: Client queries for records where updated_at is greater than last successful sync timestamp.
  3. Transmission: Client packages changes into JSON payload. Each record includes encrypted payload, record ID, timestamp, and deletion status.
  4. Conflict Resolution: Server compares incoming items with current state database using record ID.
    • If incoming updated_at is greater than server updated_at, server updates record.
    • If incoming updated_at is lower, server discards incoming record and sends newer version to client.
  5. Tombstoning: Deleting item does not immediately remove database row. It sets is_deleted = 1 and updates updated_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 (kSecClassGenericPassword with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly).
  • 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.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleObserving Unsupervised AI Agent Behavior Without Defined DirectivesNext articleTransforming LLM Context Memory into Program Analysis Engines
Local-FirstSesameSQLiteRustSqlcipher
On this page↓
  1. Shift to Local-First Password Management
  2. Storage Layer: SQLite and Page-Level Encryption
  3. Cryptographic Key Derivation and Envelope Encryption
  4. Key Derivation Process
  5. Envelope Encryption
  6. Data Schema and Zero-Knowledge Design
  7. Payloads and Metadata Separation
  8. Local-First Synchronization
  9. Sync Protocol Walkthrough
  10. Cryptographic Implementation Details
  11. Memory Security and Platform Integration
  12. 1. Memory Pinning (mlock)
  13. 2. Zeroization
  14. 3. Native Keychains for Session Keys

On this page

  1. Shift to Local-First Password Management
  2. Storage Layer: SQLite and Page-Level Encryption
  3. Cryptographic Key Derivation and Envelope Encryption
  4. Key Derivation Process
  5. Envelope Encryption
  6. Data Schema and Zero-Knowledge Design
  7. Payloads and Metadata Separation
  8. Local-First Synchronization
  9. Sync Protocol Walkthrough
  10. Cryptographic Implementation Details
  11. Memory Security and Platform Integration
  12. 1. Memory Pinning (mlock)
  13. 2. Zeroization
  14. 3. Native Keychains for Session Keys

See also

Illustration for Functional State Machines in Rust via Typestate and Newtype Patterns
Programming/Aug 31, 2026

Functional State Machines in Rust via Typestate and Newtype Patterns

Prevent invalid transitions. Use rust typestate pattern state design to catch bugs at compile time. Eliminate runtime overhead now.

7 min read
RustTypestate
Illustration for SQLite as a Production Document Store Using Native JSON Functions
Software Engineering/Aug 31, 2026

SQLite as a Production Document Store Using Native JSON Functions

Run lightweight sqlite json document database. Use native JSON functions to query unstructured data fast without MongoDB overhead.

5 min read
SQLiteJson
Illustration for Fuzzing the Gleam Compiler to Find Type System Edge Cases
Programming/Aug 27, 2026

Fuzzing the Gleam Compiler to Find Type System Edge Cases

Automate the search for type system edge cases. Fuzzing gleam compiler bugs reveals hidden parser errors and breaks functional language type checkers.

6 min read
FuzzingGleam