athanasios papadopoulos

Closed source

SecureStore

Encrypted cloud storage where the server can't read your files and never sees your password.

period:
2025–2026
status:
in development
  • Python
  • Go
  • Rust
  • JavaScript
  • FastAPI
  • AES-256-GCM
  • Argon2id
  • HKDF
  • WebCrypto
  • PostgreSQL
  • Redis
  • MinIO
  • Firebase
  • gRPC
  • Docker
  • PyQt6
What the user sees, what the server can read, how signing in works, and the services behind it.

A research project I started on my own.

How it’s built

A FastAPI gateway sits in front of Python services for authentication and metadata, with PostgreSQL, Redis and MinIO behind them, all in Docker. The metadata service also stores and serves the encrypted blocks. Go services for upload and download are written but not wired in yet. The clients are a PyQt6 desktop app with a Rust sync engine, and a browser client.

How the encryption works

Files are encrypted on the device before upload, and each block is named by the hash of its ciphertext. That lets the server skip blocks it already has without being able to read them.

Interactive demo

Try the encryption in your browser

This runs SecureStore's client encryption in your browser, with the same algorithms and parameters, tested byte for byte against the Python client. Nothing you type leaves the page. Blocks here are 16 bytes, not 4 MB.

The PyQt desktop client: an unlocked vault with version history. (full size)
The PyQt desktop client: an unlocked vault with version history.
The desktop client's settings, open on the account page. (full size)
The desktop client's settings, open on the account page.
The browser client encrypting and uploading a file. (full size)
The browser client encrypting and uploading a file.
The browser client's Shared page, with the account menu open. (full size)
The browser client's Shared page, with the account menu open.
Creating a share link. Earlier links can be extended or revoked from the same dialogue. The key that opens the file sits after the # in the link, and browsers don't send that part to the server. (full size)
Creating a share link. Earlier links can be extended or revoked from the same dialogue. The key that opens the file sits after the # in the link, and browsers don't send that part to the server.

What’s tested and what isn’t

All 17 RBAC tests and both Rust block tests pass, and trash, restore, versioning and session revocation work end to end. The Go services aren’t wired in, and there are no benchmarks yet, so my report marks every performance claim as a hypothesis.

What I’d do next

Wire in the Go services over gRPC and build benchmarks, starting with how much upload the block scheme saves on small edits. Then measure what the server can learn from block sizes and timing.

How an upload works

  1. 01 Keys from the password
  2. 02 Encrypting each chunk
  3. 03 Asking the server what it's missing
  4. 04 Rebuilding files in Rust
  5. 05 The same keys in the browser

Keys from the password

The password never leaves the device. Argon2id (64 MiB, 3 passes, 4 lanes) turns it into a master key, which makes guessing slow even on GPUs. HKDF then derives two independent keys from it, with different labels: the key-encryption key, and a sign-in key that Firebase gets in place of the password. Neither can be reversed to get the master key back.

client_core.py, 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)
    # Sent to Firebase instead of the password; independent of the KEK (different HKDF label)
    self.auth_key_hex = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=None,
        info=CryptoPrimitives.AUTH_INFO,
    ).derive(self.master_key).hex()

Encrypting each chunk

Each 4 MB chunk is encrypted with AES-256-GCM under its own key, an HMAC of the chunk. The block is named by the SHA-256 of its ciphertext.

WhyThe IV is always zero, which is safe here. GCM breaks when one key and IV encrypt two different messages, but each key is made from its own chunk, so it only ever encrypts that chunk. Unchanged chunks get the same name and are never uploaded twice. The HMAC uses the user's key, so nobody else can test whether a user has a known file. The server can see when two of a user's blocks match.

client_core.py, chunk encryption (comments cut)
for i, chunk in enumerate(chunks):
    h = hmac.new(kek_to_use, chunk, hashlib.sha256)
    derived_key = h.digest()
            
    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
            
    encrypted_block = ciphertext_chunk + chunk_tag
            
    block_hash = hashlib.sha256(encrypted_block).hexdigest()
            
    encrypted_blocks.append((block_hash, encrypted_block))

Asking the server what it's missing

The client sends its block hashes and the server replies with the ones it's missing. It only sees ciphertext hashes and wrapped keys, and only checks the user's own blocks, so it can't reveal what other users store.

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))

Rebuilding files in Rust

The desktop sync engine is Rust, loaded by the PyQt app as a native module. It sorts downloaded blocks by position and joins them. Two unit tests cover it.

blocks.rs, reconstruct_file and 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 keys in the browser

The browser client uses hash-wasm for Argon2id and WebCrypto for HKDF, with the same parameters, so the same password gives the same keys.

web_client/crypto.js, loginAndDeriveKeys
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", "deriveBits"]
);

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"]
);

// Sent to Firebase instead of the password; independent of the KEK (different HKDF label)
const authBits = await window.crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: CRYPTO_CONFIG.AUTH_INFO },
    masterKeyMaterial,
    256
);
this.authKeyHex = this.bytesToHex(new Uint8Array(authBits));