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
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.
scroll the table sideways to see what the server receives →
| # | stays in your browser | sent to the server | ||||
|---|---|---|---|---|---|---|
| plaintext | block key | block name | ciphertext | wrapped key | upload | |
(full size)
(full size)
(full size)
(full size)
(full size)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
- 01 Keys from the password
- 02 Encrypting each chunk
- 03 Asking the server what it's missing
- 04 Rebuilding files in Rust
- 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.
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.
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.
@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.
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.
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));