Jul 31, 2026 · 8 min read
A Backup You Can't Open: Eight Years of the Synology Cloud Sync Decryption Tool
A client backed up to a Synology NAS with Cloud Sync encryption — on macOS, where Synology ships no decryption tool. What started as one client's problem is now 136 stars, 36 forks, and four other people's commits.
- Open Source
- Python

A client of my company, Glimpse Networks, ran a sensible small-business backup: a Synology NAS syncing offsite to cloud storage, with Cloud Sync's encryption switched on. Every part of that is the right call. Encrypt the data before it leaves the building.
Then they needed a file back. The office runs on macOS.
Synology ships a Cloud Sync Decryption Tool for Windows and for Linux. It does not ship one for macOS, and it is closed source. So the encrypted files sat there — perfectly intact, completely inert. A backup that existed but could not be opened on any machine the client owned.
That is a worse position than having no backup at all, because it looks exactly like having one right up until the moment it matters.
Someone had already done the hard part
The genuinely difficult work — establishing what the encryption actually does — was already finished, by Marnix Klooster, in a project called synology-decrypt.
It's worth being precise about what that project is, because "reverse engineered" gets thrown around loosely. Marnix deliberately did not decompile Synology's closed-source tool. The algorithm was reconstructed from published material: page 9 of Synology's own Cloud Sync white paper, a knowledge base article, and a StackOverflow thread analysing the format. His README says so outright — he wanted an implementation he could rely on without doing anything that might be construed as illegal.
That distinction is not pedantry. It's the reason the work can be used, extended and shipped by other people, which is the entire premise of what follows.
What the project wasn't was reachable. It was a Python CLI. The person who needs it is a small-business owner who has just lost a file and wants it back — not someone who wants to install a Python toolchain first.
The missing half
So I built the other half. The tool I put together in 2018 is a GUI modelled deliberately on Synology's official Windows client — same shape, same fields, same workflow — so that anyone following Synology's own documentation would recognise it on sight. It runs on macOS and Linux, which is precisely the gap Synology left.
The lineage, for the record: Marnix's implementation, by way of neepl's fork, which is what I actually started from in 2018. The algorithm is theirs, and I want to be clear about that. What I added began as a front end — and then became eight years of maintaining something people actually depend on, which turned out to involve a fair amount of work in the decryption path itself. It's GPLv3, like the work it descends from.
What happened next
People showed up. I didn't ask them to, and that remains the part I find most striking about the whole thing.
- Sean Kelley taught it to skip files whose header is unparseable or empty instead of falling over, and to use every available core when decrypting a directory.
- Lukas Leppich improved raw decryption speed.
- Noel Miller added Linux support and worked out where the log file should actually live.
- Jörg Matysiak fixed and documented CLI usage, fixed running on Linux, and added support for passing multiple files at once.
Four people, none of whom I know, each of whom found a real problem in something I'd published and then fixed it. Between them they made the tool faster, more robust, and usable on a platform I wasn't targeting.
Not just the front end
Sean's change is also where this stops being a story about a GUI, because making decryption parallel turned a harmless shortcut in the core into a real bug.
Upstream keeps the running MD5 digest — the thing that verifies a decrypted file matches what was originally encrypted — as a function attribute:
decrypt_stream.md5_digestor = None # special kind of local variable...A function attribute is attached to the function object, so it is shared by every caller in the process. Decrypt one file at a time from a command line and that is harmless, which is exactly why it survived so long. Decrypt a directory across every available core — which is what this project gained in 2019 — and every concurrent call is writing into the same digestor. The integrity check quietly stops verifying anything meaningful, on the operation people are most likely to run: restore everything.
Here it's a real local, closed over by the handler that consumes it:
# NOTE: must be a real local (closed over via ``nonlocal``), not a
# function attribute. Function attributes are shared across all
# callers, which corrupts MD5 verification when ``decrypt_stream`` is
# invoked concurrently from multiple threads.
md5_digestor = NoneAs of this writing upstream still has the function attribute, and I don't think that's a criticism of it — nothing upstream runs concurrently, so it isn't a bug there. It became one the moment this project got faster.
The second change is the reason this tool can ship as a self-contained app at all. Upstream decompresses by spawning the external lz4 binary as a subprocess and pumping its stdout through a thread; its README still asks you to install liblz4-tool before anything will work. This project replaced that with in-process streaming decompression via lz4.frame.LZ4FrameDecompressor, behind the same interface the rest of the code already expected.
That removed a runtime dependency on a binary the user had to install, a subprocess, and a thread. It is also what finally retired InstallMeFirst.app — the AppleScript bundle that used to ship the lz4 C source tree inside the repository so the tool would have something to shell out to.
Then a set of smaller ones, all in the decryption path rather than the interface:
- Python 3 string handling in key derivation.
_openssl_kdfandsalted_hash_ofcoercestrtobyteswhere upstream assumes bytes — the difference between decrypting and aTypeError, depending on where the password arrived from. - Public-key decryption threaded through
decrypt_streamanddecrypt_file, which previously only accepted a password or a private key. - Format detection for the wrong kind of encrypted file — the eCryptfs guard described below. It lives in the core rather than the UI because it's a property of the file format, not of the interface.
- The 2020 core rework: migrating off the unmaintained
pycryptotopycryptodomex, and reworking key derivation so the iteration count follows from whether a salt is present instead of every caller passing the right number in.
Eight years of drift
Software left alone doesn't stay still, it just rots quietly. By early this year the project had accumulated most of a decade of it, and I rebuilt the foundations:
- Python 2 → 3.13;
pycrypto→pycryptodome→pycryptodomex; tk → ttk → tk/tcl 9. - An AppleScript bundle called
InstallMeFirst.app— which shipped the lz4 C source tree inside it, and which users had to run before the tool would work — deleted in favour of self-contained wheels. - Install scripts replaced with
uv:make sync && make runand you're going. - GUI and CLI decoupled, so the command-line path no longer drags a toolkit behind it.
- Universal builds for Apple Silicon and Intel, and a test suite running in CI on macOS and Linux — which the project had never had.
That last item is not housekeeping. Adding CI and tests is what surfaced the two bugs below, both of which had been shipping for years.
The build that lied about its architecture
Each release ships two zips, arm64 and x86_64. The x86_64 target created an x86_64 virtual environment, then ran uv sync. And .python-version pins cpython-3.13-macos-aarch64-none.
uv honours that pin over an existing environment: it discards a venv built from a different interpreter and rebuilds it from the pin. The line selecting x86_64 was therefore undone by the very next line. The build produced a native arm64 bundle, zipped it under the x86_64 name, and exited zero.
No error. Anywhere. V11's published x86_64 asset is affected; V10 predates the pin and is fine. Which means Intel Mac users who downloaded the file explicitly labelled for their machine got a binary that could not run on it.
The fix has two halves, and only one of them is the bug. Passing --python explicitly to every uv invocation corrects the behaviour. The other half is _assert_arch, a guard that inspects the linked binary and fails the build when it isn't the architecture the target asked for. The first half fixes this bug. The second is what makes the next one loud.
The flag that never did what it said
The usage string has described -p as "the file containing the decryption password" for as long as the flag has existed. main() used the value verbatim as the password.
So the documented invocation had never worked, in any version. It failed with an opaque invalid padding byte — which is what AES gives you when the key is wrong, and the key was wrong because it was the string /path/to/passwordfile rather than the password inside it. The example in the V11 release notes was affected.
There's a wrinkle that explains why the fix isn't simply "read the file in main()". The GUI calls main(["-p", password.get(), ...]) with the password straight out of its entry box. It browses for -k/-l key files, which is why those are read inside main(). The asymmetry follows the GUI rather than being an oversight — so the file read belongs one layer out, in the CLI entry point, where it can't break the GUI's contract.
The same commit strips a trailing newline, because an editor-added one isn't part of your password and otherwise fails in exactly the same opaque way. And it adds tests/test_cli.py — the first coverage the CLI layer had ever had, which is precisely how a documented flag stayed broken across two releases. It pins the documented behaviour, the newline tolerance, and main()'s literal-password contract, so a later tidy-up in the name of symmetry can't quietly break the GUI instead.
The wrong kind of encrypted
One more, this one from user reports rather than from code. Synology has two entirely unrelated encryption features: Cloud Sync encryption, and encrypted shared folders, which use eCryptfs. Different on-disk formats, no overlap in tooling, confusingly similar names. People kept feeding the tool the wrong one and getting a failure that explained nothing.
It now recognises the ECRYPTFS_FNEK_ENCRYPTED. filename prefix and says so plainly, with a link to the Synology article that covers the other case. A dozen lines of code, and I suspect it prevents more frustration than anything else on this page.
Where it is now
| Metric | Today |
|---|---|
| Stars / forks | 136 / 36 |
| Releases | 11, from V3 (2018) to V13 (July 2026) |
| Downloads of the prebuilt app | 710 |
| Issues and pull requests | 35 |
| Outside contributors | 4 |
| Size | ~950 lines of Python |
For comparison, the upstream project it descends from sits at 103 stars. That isn't a scoreboard — the algorithm is upstream's, and none of this exists without it. But it does say something about what it takes for a capability to actually reach people: the same decryption, plus a front door, a build that runs on their machine, and eight years of keeping it working.
What I'd take from it
Reachability is its own engineering problem. Marnix worked out what the encryption does, and that remains the hardest single piece of this. But the distance between a correct algorithm and something a small business can recover its data with turned out to be substantial: a GUI, certainly, but also decompression that doesn't require installing a C binary first, integrity verification that survives being run in parallel, and a build that produces the architecture it claims to. None of that is glamorous. All of it is the difference between a published implementation and a tool.
Both of this year's bugs reported success. A build that produced the wrong architecture and exited zero. A flag that had never once done what its own help text promised. It's the same shape as the media-library import I wrote about yesterday, and I've come to think it's the most expensive category of defect there is: not the crash, but the confident wrong answer. In both cases the fix that actually matters isn't the one-line correction — it's _assert_arch and test_cli.py, the checks that can fail.
It started as one client's problem. Not a project, not a portfolio piece — one small business that couldn't open its own backup. Eight years later four strangers have improved it and a few hundred people have downloaded it. I'd never have predicted that from the original ticket, and I don't think that's unusual: useful tools tend to come from someone with a specific problem rather than from someone looking for something to build.
Links
- anojht/synology-cloud-sync-decrypt-tool — the tool, with prebuilt macOS apps on the releases page for Apple Silicon and Intel.
- marnix/synology-decrypt — Marnix Klooster's original implementation of the algorithm, and the reason any of this is possible.
- neepl/synology-decrypt — the intermediate fork this project actually branched from in 2018.
Cite this post
Anojh Thayaparan, “A Backup You Can't Open: Eight Years of the Synology Cloud Sync Decryption Tool,” anojh.com, 2026. https://anojh.com/blog/synology-cloud-sync-decryption-tool-macos
© 2026 Anojh Thayaparan. Licensed under CC BY 4.0. You may quote, translate, or build on this post — including citing it as a source in an AI-generated answer — as long as you credit Anojh Thayaparan and link back to the original. Training or fine-tuning a model on it is not licensed.