Personal project · 01

Canopy

A password manager that trusts your device, not a server.

Local-firstPeer-to-peerNo serverNo account

Most password managers ask you to make a trade. You get sync across your phone, laptop and tablet — and in return your encrypted vault lives on somebody's server, guarded by a company's promise that they can't read it and won't be breached. Usually that promise holds. Sometimes it doesn't.

Canopy is built on a different bet: that your own devices are a better place to keep your secrets than anyone's cloud, and that they can talk to each other directly without a middleman. It's a local-first password manager that syncs peer-to-peer over your own Wi-Fi and Bluetooth, with no server, no account to sign up for, and no telemetry. The vault lives on your devices, and it moves between them the way a file moves when you AirDrop it — directly, when they're near each other.

This post is about what Canopy does, and — more interestingly — why it's built the way it is. Almost every decision here started as a constraint, and the design is what fell out of taking that constraint seriously.

The core bet: bind secrets to the device, not to the password

Start with the question every password manager has to answer: what happens when someone gets a copy of your encrypted vault? A backup leak, a stolen disk image, malware that copies a file, an over-eager sync folder — the ciphertext ends up somewhere it shouldn't.

The usual answer is "they still need your master password, and cracking a good one is infeasible." That's true, right up until the master password isn't good. A four-character password falls in milliseconds no matter how expensive your key-derivation function is; Argon2id can't save entropy that was never there.

An attacker has stolen your encrypted database and starts guessing offline.

Password only
p4ssKDF🔑

0 guesses

CRACKED · 3 ms

Canopy · password ⊕ device secret
1weak!KDF🔒 256-bitSecure Enclave · not in the stolen DB

0 guesses

SEALED · 2²⁵⁶ still to go

Even a one-character password is safe: the attacker can compute a key for every guess, but without the hardware secret they can never form the right one.

Canopy takes a different route. Every password-derived key is XOR-ed with a 32-byte secret that lives only in the device's hardware-backed keystore — the Secure Enclave on Apple devices, the Keystore/StrongBox on Android. That secret never touches disk and never appears in a backup. So an attacker holding the database is missing 256 bits of key material they cannot get at, and an offline guessing attack isn't just expensive — it's impossible, regardless of how weak the master password is.

This one decision reshapes everything downstream. Because safety comes from the device binding rather than from password complexity, Canopy imposes no strength policy at all— a one-character master password is accepted. That sounds reckless until you internalise the threat model: the password only defends against someone standing at your unlocked-adjacent device, and against that person a short PIN is exactly the ergonomics you want. The heavy lifting is done by hardware you already trust to hold your fingerprints and your bank app's keys.

The honest cost, stated plainly in the app: on platforms with no hardware-isolated keystore (desktop Linux), this protection is weaker, and Canopy says so rather than pretending otherwise.

One vault, many doors

A vault isn't one password. In Canopy it's a set of credentials, and they're deliberately indistinguishable from each other on disk. A master password, a PIN, a connect-the-dots pattern, a biometric key, a 24-word recovery phrase, and a duress "wipe" code all sit in identically-shaped rows. Which one a row is — its kind, its label, its privileges — lives inside its encrypted payload. An attacker reading the database cannot tell how many master passwords exist, whether biometrics are set up, or, critically, whether a duress code exists at all.

On disk — what an attacker sees
c1a4f7…🔑Master passwordPrimary
9e02b1…🔢PINQuick unlock
4d7ca8…PatternTablet
b83f10…👆BiometricFace / touch
2a6e9c…📜Recovery phrase24 words
f05b7d…DuressWipe code
Every row is the same shape. An attacker can't count how many master passwords exist, whether biometrics are set up, or — the whole point — whether a duress wipe code exists at all.

Two features fall out of this design.

Profiles — scoped access that's real, not cosmetic. There is no single "vault key" that opens everything. Instead each credential holds grants to specific folders, and reaching a folder implies reaching its subtree. A profile is just a credential with a name, an icon, and a scope. A "work" profile genuinely cannot decrypt your personal folders — not because a UI filter hides them, but because the keys aren't sealed to it. Anyone with the raw database file gets exactly the same boundary the UI shows. Scoping is cryptographic, not a checkbox.

Unlock as

opens everything

📁Personalunlocked🔒 sealed — no grant
🔐Bankingunlocked🔒 sealed — no grant
✉️Emailunlocked🔒 sealed — no grant
📁Workunlocked🔒 sealed — no grant
🗂️Client Aunlocked🔒 sealed — no grant
🖥️Infra keysunlocked🔒 sealed — no grant
📁Sharedunlocked🔒 sealed — no grant
👥Family planunlocked🔒 sealed — no grant
A "work" profile cannot decrypt your personal folders — the keys were never sealed to it. And the master can re-scope a profile using only its public key — widening or narrowing access without ever knowing that profile's PIN.

The neat trick that makes this usable: grants are sealed boxes — anonymous public-key encryption to a credential's public key. That means the master can widen or narrow another password's folder access without ever knowing that password. Re-scoping a profile doesn't require typing in the profile's PIN; holding the folder key and the profile's public key is enough. Without that, managing more than a couple of scoped passwords would mean re-entering every one of them, and the feature would collapse under its own friction.

Duress codes that actually erase. Set a special code, and typing it looks exactly like a normal unlock — same "opening" animation, same everything — and then quietly destroys the vault. Under the hood this isn't file deletion (flash storage makes deletion a hope, not a guarantee); it's crypto-erase. The code destroys the account secret in the keystore, and the ciphertext left on disk becomes permanently unopenable. A master-set duress code wipes everything and opens a decoy; a profile-set one deletes only that profile's own credentials. Because every credential row looks the same, an adversary forcing you to unlock can't tell the difference between your real code and your wipe code — which is the entire point.

Canopy

Enter your code

Bank · 88426·Rt2q
Email••••••
PassportM‑4471…
🔒account secret
in keystore
ciphertext on disk

An onlooker watches you type.

Both look identical up to "opening…". The wipe code isn't deleting a file (flash storage makes that a hope, not a guarantee) — it destroys the key, so the bytes left behind can never be opened again.

On top of that, individual folders and secrets can carry an extra lock: a separate PIN or pattern required to open just that item, even inside an already unlocked vault. And biometric unlock is built the way it should be — a random key lives in the keystoregated on a biometric check, and the vault's grants are wrapped under it. Unlocking asks the OS for that key, and the OS releases it only after a successful check. There's noif (faceMatched == true) branch to patch on a rooted device; the enforcement lives in the secure hardware, not in Canopy's code.

What the database doesn't say

Encrypting the values is table stakes. Canopy also hides the shape. The guiding rule for the storage schema is blunt: if a column isn't needed to find a row, it doesn't exist. A node on disk is {id, opaque blob} — no parent pointer, no name, no type, no timestamp. A folder's children — their ids, names, and keys — live inside the folder's own encrypted payload.

On disk · raw SQLite
c1a4f7…512 B
9e02b1…512 B
4d7ca8…512 B
b83f10…512 B
2a6e9c…512 B
f05b7d…512 B
77de30…512 B
0b19aa…512 B
flat · unordered · padded to a bucket
Decrypted · in memory
📁Personal
Bank · 8842
Gmail
📁Work
AWS root
VPN cert
📁Shared
Netflix
names, tree, depth, types — all rebuilt from the blobs
Everything an attacker learns:8 nodes3 credentialspadding bucket— no names, no structure, no depth, not even folder vs secret.

So the file reveals no folder names, no tree structure, no depth, not even which blobs are folders and which are secrets. Blobs are padded to size buckets, so their length can't be used to count fields. What an attacker with the raw database can learn is deliberately minimal: how many nodes exist, how many credentials exist, and each blob's padding bucket. Everything else is genuinely opaque.

The hard part: sync with no server

Getting one vault right is a solved problem. Keeping several devices in agreement — with no central source of truth, over flaky local radios, while everything stays encrypted — is where most of the engineering went.

📱 Phone
Bank · 88426·Rt2q
Email 2FAoff
Wi-Fi homesaved
💻 Laptop
Bank · 88426·Rt2q
Email 2FAoff
Wi-Fi homesaved

In sync — every field identical.

Concurrent edits to different fields both survive, ordered by a hybrid logical clock — and there's no relay in the middle, so nothing to breach, subpoena, or shut down.

No single source, so the merge has to be principled. Canopy treats the vault as a distributed data structure that must converge: any two devices that have seen the same set of edits end up identical, regardless of the order the edits arrived. Concurrent changes to different fields of the same secret both survive. Deletions propagate as tombstones rather than as an absence (because "I don't have this node" and "this node was deleted" must not look the same). Histories from every device are unioned. Ordering conflicts are settled per field by a hybrid logical clock — a timestamp that tracks causality, so the genuinely later edit wins even when two devices' wall clocks disagree.

Devices find each other and prove who they are. The first time you link a device, the two show a six-digit code derived from the handshake transcript. You compare them out loud. A machine-in-the-middle relaying the exchange necessarily produces two differenttranscripts, so the two screens show different numbers, and an honest user stops. After that one confirmation the peer is pinned by its public key, and every future sync is silent — no codes, no taps. Discovery is scoped to your own account by an opaque tag (a hash that reveals nothing secret), and before any data moves, both ends prove they hold the shared account secret without ever sending it.

Link over
📱 Phone000000
😈 relay
💻 Laptop000000

Compare the two codes out loud.

You only do this once per device. After the numbers match, the peer is pinned by its public key and every future sync is silent — no codes, no taps.

It only sends what changed. Each node has a content fingerprint, and a single top-level hash lets two settled devices confirm "nothing to do" in two tiny messages instead of shipping their whole manifests back and forth. Node sealing is deterministic — the encryption nonce is derived from the content — so an unchanged secret produces byte-identical ciphertext and simply stops being re-transferred. A device that has nothing new stays quiet and lets the device thatdoes come to it.

Removing a device is a real revocation, not a hopeful request. Take a device off the account and it's cryptographically cut off: a signed revocation (verified against an account authority whose public half every device carries and whose private half only a master can wield) propagates to the others, and the sync key rotates to the survivors. The removed device is refused by every peer from then on — and, when it next tries to connect, it's handed its own signed revocation, verifies it, and wipes its copy of the vault.

🛡 Account authority · only a master can sign✎ revoke Tablet — signed
📱 Phone🔑 sync key k1on account
💻 Laptop🔑 sync key k1on account
🖥️ Tablet🔑 sync key k1on account

Three devices, one shared sync key.

It's not a hopeful "please forget me." The removed device is refused by every peer, and the next time it connects it's handed its own revocation, verifies it, and wipes its copy.

And it's stubborn about staying connected. Local radios are fickle — Wi-Fi drops, Bluetooth toggles, you walk onto a different network. Canopy watches for all of it. Turning a radio back on, joining a new network, making an edit, foregrounding the app, or tapping "sync now" all trigger a fresh look-out over both transports at once. The goal is that two of your devices, awake on the same network, simply are in sync, without you ever thinking about it.

Notably, all of this is local only. Two of your devices sync when they can see each other. There's no relay in the middle, which means there's also nothing in the middle to breach, subpoena, or shut down — and, honestly, it means sync-from-anywhere isn't a feature yet. That's the trade, made on purpose.

Engineering choices worth calling out

A few decisions shaped the codebase as much as the crypto shaped the product.

The security core is pure Dart, behind a swappable interface. Every cryptographic primitive Canopy needs sits behind one narrow interface, with a pure-Dart default implementation. The payoff is that the entire security core — the key hierarchy, grants, the merge engine, duress, recovery — runs under a plain test runner with no device, no emulator, and no keystore. Security logic you can't exhaustively test is security logic you're guessing about. A faster libsodium-backed backend can be dropped in for release builds without touching a single call site.

SQLite, directly, no ORM. The schema is small, hand-written, and the whole point iswhat it omits. An ORM would want to model relationships Canopy deliberately doesn't store — parent links, names, types — and would quietly reintroduce the very columns the structure-hiding design exists to avoid.

Contrast is a test, not a guideline. Accessibility isn't left to good intentions; colour-contrast ratios are asserted in the test suite, so a theme tweak that drops legibility below the bar fails the build rather than shipping.

An on-device test harness for the parts tests can't reach. Radios and timing across two real phones are exactly what unit tests can't cover. So there's a debug "test kit": one tap runs the whole merge and removal engine on the actual device against throwaway vaults, and a two-device mode runs a live sync — over Wi-Fi or Bluetooth, discovered automatically — including a soak test of hundreds of random operations shared across both devices, ending in a convergence check shown on each. The claims the code makes about converging get checked on the hardware they'll actually run on.

What Canopy deliberately doesn't do

A security document that claims to stop everything is useless for deciding anything, so Canopy's is kept honest — and so is this post.

Canopy does not defend against malware running inside an unlocked session on your device; at that point the plaintext is in memory and the game is over the same as it is for any app. It doesn't (yet) sync over the internet — only across your local network — so keeping a phone and a laptop in step means they need to share a network sometimes. Desktop Linux gets weaker key protection than mobile, because it lacks a hardware-isolated keystore, and the app tells you so. And some platform features are simply refused when they can't be made honest — Android's face unlock, for instance, isn't offered, because on most Android hardware it isn't backed by the same secure-hardware guarantees as fingerprint unlock, and offering it would imply a protection that isn't there.

That last one is the through-line for the whole project. Canopy would rather do fewer things and mean them than paper over a gap with a feature that only looks like security. Your secrets live on your devices, the math that protects them is bound to hardware you already trust, and when two of your devices are near each other they quietly agree on the truth — no server, no account, no one else in the loop.

separate branches · joined at the top

That's the canopy: a lot of separate branches, joined at the top, holding everything up between them.

← All personal projects