Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
Final review: cryptography, node tree, caching (trust model)
Reviewer area: backend/protondrive/crypto.go, nodes.go, cache.go, the parts of
api/types.go they use, and the parts of download.go, upload.go and account.go
they feed into (contentKey, createRevision, unlockKey, publicKeys). HEAD 29ad26944.
SDK reference: ~/.cache/protondrive-sdk-ref @ 05c9115 (read only).
All Critical and Important findings were reproduced against the fake server in a
scratch worktree (now removed). The probes used small scratch-only helpers in
protontest that model what a hostile server can do with only public keys:
encrypt to a node's public key, and drop or replace signatures.
Counts: Critical 2, Important 3, Minor 8.
Critical
C1. An unsigned content key packet is accepted. Updates are then encrypted with a key the server chose, and ranged reads return content the server made up
crypto.go:614-620(decryptContentKeychecks the signature onlyif contentKeyPacketSig != ""),download.go:191-227(contentKey),upload.go:312(createRevisionencrypts the new revision's blocks withc.contentKey(n)).- Anyone who has the node's public key can make a content key packet: generate a
session key and encrypt it to that key. The server stores the armored node key, so it
has the public key. If the server sends
ContentKeyPacketSignature: ""(or null),verifyErrstays nil and nothing is logged, even withoutignore_signature_errors. - Confidentiality break (reproduced). A file with a verified node key: the server
swaps in its own content key packet and blanks the signature.
Object.Update(everysyncof a changed file) goes ahead without an error or a log line and encrypts the user's new content with the server's session key. The probe then decrypted the stored block of the new revision with the server's key and got"the user's new secret content"back. - Integrity break (reproduced). The server also re-encrypts block 1 under its key
(the server lists the hash, and the size matches the recorded block size). A
ranged read (
RangeOption{Start:1}, as mounts and multi-thread copies make) returned"ORGED BY SERVER"with no error. A full read failed on the manifest. But a server that also renames the revision'sSignatureEmailto a foreign address and serves keys for it (the known protocol limit) can sign a manifest over its own block hashes, so full reads can be forged too. docs/TODO.md says a ranged read can be fed "validly encrypted blocks of the same file swapped or from an older revision". This is worse: the content can be anything the server likes. - The SDK also skips verification when the signature is absent
(
driveCrypto.decryptAndVerifySessionKey,cryptoService.ts:240-249). But rulings 18/19 and spec section 7 put the server in the threat model and are stricter than the SDK on the node key. A key the server can mint is the same hole one level down. - Fix: in
decryptContentKey, returnerrSignatureMissingasverifyErrwhen the signature is empty, socontentKeyrefuses without the flag. If legacy unsigned content keys exist, allow the exemption only by a creation date, the way names and hash keys do, and say that the date is server-supplied. Add a hostile case tohostileContentsand a write case ("Update of a file whose content key is unsigned is refused").
C2. Node keys from the server are unlocked with whatever Argon2 parameters they carry, up to a 2 TiB allocation
crypto.go:431(decryptNodeKeys→account.go:204unlockKey→crypto.Key.Unlock). go-crypto accepts an Argon2 S2K with memory exponent up to 31 (s2k.go:431), which is 2^31 KiB, and runs the KDF before it can tell that the passphrase is wrong.- The armored
NodeKeyis server data. For nodes in a folder the user has shared for editing, or in a public upload link folder, it is written by other people's clients. A valid encrypted passphrase is easy to supply: copy any node's, or encrypt any string to the parent's public key. The passphrase signature is checked only after the unlock and only producesverifyErr. So listing the folder runs the KDF. - Reproduced (scratch internal test): a node key locked with Argon2 memory 2^19 KiB
and
unlockKeycalled with a wrong passphrase took 0.47 s and allocated 512 MiB before it returned "error in unlocking key". At exponent 24 to 31 that is 16 GiB to 2 TiB: an OOM kill orfatal error: runtime: out of memory, which nothing can recover. Every listing that includes the node crashes the process (rclone mount, sync).passes(up to 255) multiplies the CPU cost on top. - Not in docs/TODO.md. The decompression-bomb entries cover message sizes, not key
unlocking. The same function unlocks address keys in
account.go, which is outside this area but has the same cause. - Fix: before
Unlock, walk the private key packets (key.GetEntity().PrivateKeyand subkeys,PrivateKey.S2KParams/s2k.Params.Mode()or by parsing the packet). Refuse a node key whose S2K is not iterated-and-salted.crypto.go:374-375already notes that Argon2-locked node keys break the official web app, so no genuine node key uses Argon2. Make it an unreadable node. Optionally bound the RSA modulus size too.
Important
I1. A hash key whose signature fails or is missing is accepted and used for writes, so the server can test guesses at the names written
nodes.go:550-556only logshashKeyVerifyErr.targetFolder(nodes.go:964-988) checkshashKey != niland the key'sunverified, not the hash key's signature.createFolder, upload, rename, move and copy then computenameHash(name, parent.hashKey).- Reproduced: the server replaced folder
dir's hash key with an unsigned one of its own, encrypted to the folder's public key. rclone logged one ERROR, uploadeddir/tax-return-2026.pdf, and the storedHashequalled HMAC(server key, "tax-return-2026.pdf"). The server can now test names against a dictionary, or confirm guesses, for everything rclone writes into that folder. Name hashes exist so that it can't. - The SDK folds hash-key and folder-xattr author failures into the node's
keyAuthor(cryptoService.ts:265-281,finalKeyAuthor). Under rulings 18/21, rclone's equivalent iskeys.unverified, but rclone folds in only the passphrase signature. This is a defect the rulings didn't foresee, not a re-litigation of them. - The
hashKeySignedSinceexemption (nodes.go:551) is decided by the server-suppliedCreateTime. A server that back-dates the folder silences even the ERROR line. - Fix: in
targetFolder, refuse a folder whose hash key signature failed unlessignore_signature_errorsis set, using the same message shape as an unverified key. The smallest way is to keephashKeyVerifyErron the node, or fold it intokeys.unverifiedfor folders. Document that the date exemption depends on a server value.
I2. Renaming or moving a node whose name signature failed reuses a session key the server can know, so the server reads the new name
nodes.go:1149-1157(rename) andnodes.go:1218-1224(encryptForNewParent) reusenameSessionKey(n.link.Link.Name, ...). The name signature failure is only logged (nodes.go:505-516, docs/TODO.md "a name whose signature fails is listed").- Reproduced: the server replaced file
real.txt's encrypted name with"shown.txt"under a session key it generated, unsigned. rclone listedshown.txtwith one ERROR.operations.Movetosecret-new-name.txtsucceeded, and the server decrypted the new stored name with its own session key:"secret-new-name.txt". Back-datingCreateTimebefore 2021 removes the ERROR line too (nameSignedSince). - docs/TODO.md records only that names can be swapped and shown, not that they leak on rename or move. This is a confidentiality loss, not just mislabelling.
- Fix: reuse the old name's session key only when the name's signature verified.
Otherwise use a new session key (
encryptName(newName, nil, ...)), or refuse without the flag. KeepnameVerifyErron the node to decide.
I3. Extended attributes whose signature is missing or invalid are used without ignore_signature_errors
nodes.go:557-580: the xattrverifyErris only logged andn.Xis kept. Spec 6.4 saysignore_signature_errors"downgrades content and authorship signature failures to a logged ERROR", so without it they should fail. The SDK treats a revision's xattr signature ascontentAuthor.- Anyone with the node's public key can write xattrs. Reproduced: unsigned xattrs
claiming
ModificationTime2099-01-01. rclone logged one ERROR,Object.ModTimereturned 2099, and the file read fine. Consequences: the server can set modtime, size and SHA1 at will. For example,rclone sync --updateto the remote then skips every changed file the server has marked as newer, so a backup silently stops updating. Forged SHA1 or size gives falsecheckresults and failed copies. Integrity of the content itself holds: block sizes are checked on read. - Not in docs/TODO.md (only names are recorded).
- Fix: when the xattr signature fails and
ignore_signature_errorsis not set, drop the attributes (n.X = nil, as for undecryptable ones). The file then shows the encrypted size and the server's modtime, and the log says why.
Minor
M1. A node fetched while it is being changed can overwrite the newer cached node and stay cached
nodes.go:724 cache.put(n) after fetchLinks. cache.go:52-63 has no
per-node generation, unlike the per-folder change count of ruling 27. Goroutine A
starts fetchNode(X) (cache miss) and receives revision R1. Goroutine B commits
R2 and calls cache.add(X@R2). A then calls put(X@R1), which wins. With no
expiry, reads through the cache serve R1's (still stored, obsolete) content until
a re-list, and the next Update fails on a stale CurrentRevisionID. The window is
narrow: concurrent ops on one object in one process, e.g. a mount. Fix: give
put a token from before the fetch (a global or per-ID change count, bumped by
invalidate and add), as startFetch/endFetch do.
M2. Unbounded per-client maps
client.folderKeys (every folder ever seen, private keys and passphrases, kept
whether or not caching is on; Task 8 minor, still present), the node cache (no
size bound or expiry), client.reported (one entry per node ID that logged a
problem), and account.foreign (one key ring per distinct foreign email a server
names). A long mount or a hostile server grows them without limit. close()
still doesn't clear the private keys of file nodes held by the cache (Task 8).
M3. listChildren loops forever when the server keeps answering More with the same AnchorID
nodes.go:837-857. No progress check: a buggy or hostile server keeps rclone
requesting until ctx ends, with seen and all growing if the IDs change. Fix:
stop with an error when a page adds no new ID or repeats the anchor.
M4. Root-of-trust and signature date exemptions use server-supplied times
nameSignedSince, hashKeySignedSince (nodes.go:514, :551) and
contentKeyFallbackUntil (download.go:204) read Link.CreateTime, which the
server controls. Only the first two waive a check. The SDK does the same. Worth a
comment and a docs/TODO.md line, as it feeds I1 and I2.
M5. decryptBlock is used only by tests
crypto.go:684-696. Production uses blockDecrypter. reencryptPassphrase
(crypto.go:448-450) is a one-line alias of encryptPassphrase. Maintainers will
ask for both to go: move decryptBlock to a test file and call
encryptPassphrase directly.
M6. truncateName can cut a UTF-8 sequence
nodes.go:599-605 slices bytes at 64, so log lines can carry invalid UTF-8. Cut
on a rune boundary.
M7. Earlier deferred minors re-checked
- Still present: the node passphrase is not checked as valid UTF-8 (Task 5,
crypto.go:429).publicKeyswaiters get the fetching caller's ctx error (Task 4,account.go:288-303).setChildrenLockedoverwrites a cached node without the changed-parent ruleputapplies, so after an external move the old folder's cached listing can still return it (Task 9,cache.go:100-107; only matters with milestone M5 events or a secondFs). An xattr"Size": nullreads as 0 and skips the block-size fallback (Task 17,crypto.go:848; recorded in the handoff). Move's anonymous detection usesSignatureEmail == ""(Task 9,nodes.go:1231; only reachable with the flag aftercheckVerified). - Closed:
cache.putleaving a moved node in its old parent's listing (Task 8), and a negative xattr Size with block sizes (Task 17; the fallback now sums the block sizes). The root name not being validated (Task 8) has no effect: it is never used as a path.
M8. publicKeyRing is rebuilt on every own-address lookup
account.go:278-279. Each decryptNode of a node signed by the user calls
ToPublic on every address key (up to three lookups per node). Cache the public
ring on the address. Performance only.
Checked and found sound
- Transitive distrust:
decryptNodeinheritsparent.unverifiedbefore its own check.createFoldercopies the parent's. Uploads into an unverified folder read back throughdecryptNode.checkVerifiedgates rename, move and copy. Root of trust (bootstrap): share creator and root signer throughacct.owns(ASCII-only lowering,emailKey), and own addresses are never looked up remotely. decryptMessagedecrypts with the session key alone, so holders of decKR can't pass as signers. The intended-recipient check is sound. Decompression is bounded for metadata (maxSmallMessageSize,maxXAttrsSize) and for blocks (LimitReader(maxBlockSize+1)infetchOnce). Integrity checks (block hash, sizes, block count, re-listed hash) do not depend on the flag.- Name validation on read and write: empty,
.,..,/, NUL, UTF-8, and 255 UTF-16 units (utf16.RuneLenis never -1 on valid UTF-8). - Parent chains: cycle detection over
below, the depth cap of 2048, and a parentless non-root refused ("not in the drive"). - Listing cache (ruling 27):
startFetch/endFetchwith change counts.add,invalidate(including an unknown parent, which bumps every listing in progress),move(both parents) andtrashbump correctly.