Files
rclone/docs/plans/001-protondrive-tier1-workspace/final-review-crypto.md
T
2026-11-12 15:12:51 +01:00

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 (decryptContentKey checks the signature only if contentKeyPacketSig != ""), download.go:191-227 (contentKey), upload.go:312 (createRevision encrypts the new revision's blocks with c.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), verifyErr stays nil and nothing is logged, even without ignore_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 (every sync of 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's SignatureEmail to 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, return errSignatureMissing as verifyErr when the signature is empty, so contentKey refuses 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 to hostileContents and 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:204 unlockKey → 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 NodeKey is 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 produces verifyErr. So listing the folder runs the KDF.
  • Reproduced (scratch internal test): a node key locked with Argon2 memory 2^19 KiB and unlockKey called 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 or fatal 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().PrivateKey and 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-375 already 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-556 only logs hashKeyVerifyErr. targetFolder (nodes.go:964-988) checks hashKey != nil and the key's unverified, not the hash key's signature. createFolder, upload, rename, move and copy then compute nameHash(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, uploaded dir/tax-return-2026.pdf, and the stored Hash equalled 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 is keys.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 hashKeySignedSince exemption (nodes.go:551) is decided by the server-supplied CreateTime. A server that back-dates the folder silences even the ERROR line.
  • Fix: in targetFolder, refuse a folder whose hash key signature failed unless ignore_signature_errors is set, using the same message shape as an unverified key. The smallest way is to keep hashKeyVerifyErr on the node, or fold it into keys.unverified for 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) and nodes.go:1218-1224 (encryptForNewParent) reuse nameSessionKey(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 listed shown.txt with one ERROR. operations.Move to secret-new-name.txt succeeded, and the server decrypted the new stored name with its own session key: "secret-new-name.txt". Back-dating CreateTime before 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. Keep nameVerifyErr on the node to decide.

I3. Extended attributes whose signature is missing or invalid are used without ignore_signature_errors

  • nodes.go:557-580: the xattr verifyErr is only logged and n.X is kept. Spec 6.4 says ignore_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 as contentAuthor.
  • Anyone with the node's public key can write xattrs. Reproduced: unsigned xattrs claiming ModificationTime 2099-01-01. rclone logged one ERROR, Object.ModTime returned 2099, and the file read fine. Consequences: the server can set modtime, size and SHA1 at will. For example, rclone sync --update to the remote then skips every changed file the server has marked as newer, so a backup silently stops updating. Forged SHA1 or size gives false check results 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_errors is 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). publicKeys waiters get the fetching caller's ctx error (Task 4, account.go:288-303). setChildrenLocked overwrites a cached node without the changed-parent rule put applies, 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 second Fs). An xattr "Size": null reads as 0 and skips the block-size fallback (Task 17, crypto.go:848; recorded in the handoff). Move's anonymous detection uses SignatureEmail == "" (Task 9, nodes.go:1231; only reachable with the flag after checkVerified).
  • Closed: cache.put leaving 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: decryptNode inherits parent.unverified before its own check. createFolder copies the parent's. Uploads into an unverified folder read back through decryptNode. checkVerified gates rename, move and copy. Root of trust (bootstrap): share creator and root signer through acct.owns (ASCII-only lowering, emailKey), and own addresses are never looked up remotely.
  • decryptMessage decrypts 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) in fetchOnce). 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.RuneLen is 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/endFetch with change counts. add, invalidate (including an unknown parent, which bumps every listing in progress), move (both parents) and trash bump correctly.