athanasios papadopoulos

Research prototype

SecureStore

Zero-knowledge cloud storage — client-side encryption, deduplicated block versioning and a microservice backend.

role:
Sole designer and engineer
period:
2025–2026

source: private repository.Given the nature of this project and its security, the code is kept in a private repository, so there is no GitHub link. Every code excerpt on this page is taken directly from it.

Intro: what the user sees, what the server sees, and the services in between.

A self-initiated research project asking whether a storage platform can keep cryptography entirely under the user’s control and still offer the features people actually expect — synchronisation, version history, sharing and collaboration.

The answer is a set of microservices: a FastAPI gateway in front of authentication and metadata services in Python and upload and download services in Go, backed by PostgreSQL, Redis and MinIO, with a Rust sync engine handling block reassembly on the desktop. Two clients — a PyQt desktop application and a browser client — implement the identical cryptographic contract, so a file encrypted in one opens in the other.

What the server can and cannot see

The design constraint driving every decision is that the server is untrusted. Passwords are stretched client-side with Argon2id and never transmitted. HKDF derives a key-encryption key from the result, one-way, so a leak further down never leads back to the password. Each chunk is encrypted with AES-256-GCM under a key derived from the chunk itself and named by the hash of its ciphertext, which lets the server deduplicate and resume uploads while the keys that decrypt them reach it only in wrapped form. What it can see is which blocks are equal — the design accepts that as the price of deduplication.

Live demo

Encrypt something, in your browser

This runs SecureStore's client cryptography on this page: the same algorithms and parameters, checked byte for byte against the real Python client. Nothing you type is sent anywhere. Blocks are 16 bytes here instead of 4 MB, so you can see several.

Validated, and not

Seventeen of seventeen RBAC tests pass, and both Rust block-reconstruction tests pass. Session revocation propagates, and the trash, restore and version commit flows work end to end.

What is not proven: the gRPC path is only partially integrated, and there is no benchmark instrumentation yet, so every performance claim in the report is explicitly marked as a hypothesis rather than a result. Keeping that boundary honest was a deliberate part of the project.

Zero-knowledge block sync

  1. 01 Argon2id key derivation
  2. 02 HKDF key separation
  3. 03 Keyed convergent encryption
  4. 04 What the server sees
  5. 05 Rust block reconstruction
  6. 06 The same crypto in the browser

Argon2id key derivation

The password never leaves the client. It is stretched into a 32-byte master key using Argon2id with tuned time, memory and parallelism costs.

WhyArgon2id is memory-hard, so an attacker who steals the database cannot mount a cheap GPU dictionary attack against derived keys. The server never sees the password or the master key — only a salt it cannot use on its own.

client_core.py · CryptoPrimitives.derive_key
def derive_key(password: str, salt: bytes) -> bytes:
    kdf = Argon2id(
        salt=salt,
        length=CryptoPrimitives.KEY_LENGTH,
        iterations=CryptoPrimitives.TIME_COST,
        lanes=CryptoPrimitives.PARALLELISM,
        memory_cost=CryptoPrimitives.MEMORY_COST
    )
    return kdf.derive(password.encode('utf-8'))

HKDF key separation

The master key is never used directly. HKDF-SHA256 derives a separate key-encryption key from it, bound to a fixed info string.

WhyHKDF is one-way: the key-encryption key is computable from the master key, never the reverse, so a leaked key-encryption key does not lead back to the master key or the password. The fixed info string is domain separation — a future purpose would get its own string and therefore an unrelated key, rather than quietly reusing this one.

client_core.py · SecureClientSession.login_and_derive_keys
def login_and_derive_keys(self, password: str, encrypt_salt: bytes) -> None:
    self.master_key = CryptoPrimitives.derive_key(password, encrypt_salt)
    self.kek = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=None,
        info=CryptoPrimitives.KEK_INFO,
    ).derive(self.master_key)

Keyed convergent encryption

Files are split into 4 MB chunks. Each chunk is encrypted with AES-256-GCM under its own key — an HMAC of the chunk under the key-encryption key — and the resulting block is named by the SHA-256 of its ciphertext.

WhyThe IV is a fixed zero — fatal with a reused key, safe here, because the key is derived from the chunk itself: a key and IV pair only ever repeats for byte-identical plaintext, which yields byte-identical ciphertext and reveals nothing new. That determinism is the point. Identical blocks get identical names, so unchanged blocks are never re-uploaded and a new version costs only what changed. Keying the derivation with the user's own secret, rather than hashing the content alone, stops anyone without that key from confirming a guessed file. The honest cost: the server can see which blocks under the same key are equal.

client_core.py · chunk, encrypt and hash
CHUNK_SIZE = 4 * 1024 * 1024  # 4MB
        
chunks = [plaintext_data[i:i + CHUNK_SIZE] for i in range(0, len(plaintext_data), CHUNK_SIZE)]
if not chunks:
    chunks = [b'']
        
encrypted_blocks = []  # List of (hash, encrypted_data)
block_hashes = []
block_keys_metadata = []
        
kek_to_use = override_kek if override_kek else self.kek
if not kek_to_use:
     raise Exception("No KEK available for upload")
        
for i, chunk in enumerate(chunks):
    # A. Derive Key: HMAC-SHA256(SyncKey, Chunk)
    h = hmac.new(kek_to_use, chunk, hashlib.sha256)
    derived_key = h.digest()
            
    # B. Encrypt Chunk with Derived Key + STATIC ZERO IV
    chunk_iv = b'\x00' * 12
    cipher = Cipher(algorithms.AES(derived_key), modes.GCM(chunk_iv))
    encryptor = cipher.encryptor()
            
    ciphertext_chunk = encryptor.update(chunk) + encryptor.finalize()
    chunk_tag = encryptor.tag
            
    # Full encrypted block = ciphertext + tag
    encrypted_block = ciphertext_chunk + chunk_tag
            
    # Compute SHA-256 hash of the encrypted block
    block_hash = hashlib.sha256(encrypted_block).hexdigest()
            
    encrypted_blocks.append((block_hash, encrypted_block))

What the server sees

Before uploading a new version, the client sends the metadata service the list of block hashes, and the service answers with the ones it does not already hold for that user.

WhyThis is the zero-knowledge boundary made concrete. The service reasons entirely about ciphertext hashes, and the per-chunk keys that would decrypt those blocks are wrapped client-side under the key-encryption key before they are ever sent. Scoping the lookup to the requesting user keeps deduplication inside one account, so an upload cannot reveal whether anyone else holds the same block.

services/metadata_service/main.py · negotiate_version
@app.post("/file/version/negotiate", response_model=NegotiateVersionResponse)
async def negotiate_version(
    request: NegotiateVersionRequest,
    db: Session = Depends(get_db),
    current_user: str = Depends(get_current_user)
):
    """
    Negotiate which blocks need to be uploaded for a new file version.
    Client sends list of block hashes, server responds with missing blocks.
    """
    try:
        # Query existing blocks
        existing_blocks = db.query(BlockOwner.block_hash).filter(
            BlockOwner.user_id == current_user,
            BlockOwner.block_hash.in_(request.blocks)
        ).all()
        existing_set = {b.block_hash for b in existing_blocks}
        
        missing = [h for h in request.blocks if h not in existing_set]
        
        logger.info(f"Version negotiate for {request.file_id}: {len(request.blocks)} blocks, {len(missing)} missing")
        return NegotiateVersionResponse(missing_blocks=missing)
        
    except Exception as e:
        logger.error(f"Version negotiate error: {e}")
        raise HTTPException(status_code=500, detail=str(e))

Rust block reconstruction

The desktop sync engine is written in Rust and loaded by the PyQt app as a native module. This is its block reassembly path, alongside the hash that names each block.

WhyReassembly is the hot path on download and the one place where an off-by-one silently corrupts a file. Rust's bounds-checked slices turn an out-of-range read into a loud panic rather than a quietly corrupted restore, and two dedicated reconstruction tests cover it.

blocks.rs · reconstruct_file, compute_block_hash
pub fn reconstruct_file(blocks: &[Block]) -> Vec<u8> {
    let mut sorted_blocks = blocks.to_vec();
    sorted_blocks.sort_by_key(|b| b.order);
    
    let total_size: usize = sorted_blocks.iter().map(|b| b.data.len()).sum();
    let mut result = Vec::with_capacity(total_size);
    
    for block in sorted_blocks {
        result.extend_from_slice(&block.data);
    }
    
    result
}

/// Compute the hash of a block's data
pub fn compute_block_hash(data: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(data);
    hex::encode(hasher.finalize())
}

The same crypto in the browser

The web client reproduces the identical derivation chain using hash-wasm for Argon2id and WebCrypto for HKDF, so a file uploaded from the desktop app opens in the browser.

WhyTwo independent implementations of one cryptographic contract is where interoperability bugs live. Pinning both to the same parameters and info string is what makes the desktop and web clients genuinely interchangeable rather than merely similar.

web_client/crypto.js · deriveKeys
async loginAndDeriveKeys(password, encryptSaltHex) {
    try {
        await this.ensureHashWasmLoaded();

        const encryptSalt = this.hexToBytes(encryptSaltHex);
        const mkBytes = await hashwasm.argon2id({
            password: password,
            salt: encryptSalt, 
            parallelism: CRYPTO_CONFIG.PARALLELISM,
            iterations: CRYPTO_CONFIG.TIME_COST,
            memorySize: CRYPTO_CONFIG.MEMORY_COST,
            hashLength: CRYPTO_CONFIG.HASH_LENGTH,
            outputType: 'binary' 
        });

        const masterKeyMaterial = await window.crypto.subtle.importKey(
            "raw", mkBytes, "HKDF", false, ["deriveKey"]
        );

        this.kek = await window.crypto.subtle.deriveKey(
            {
                name: "HKDF",
                hash: "SHA-256",
                salt: new Uint8Array(0), 
                info: CRYPTO_CONFIG.KEK_INFO
            },
            masterKeyMaterial,
            { name: "AES-GCM", length: 256 },
            true,
            ["encrypt", "decrypt", "wrapKey", "unwrapKey"]
        );