nextcloud / nextcloud/desktop

Client writes the raw server-side-encryption container into the local file, truncated

Open
#10,773 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
3.9k
Forks
1k
Avg merge
1d 17h
Merged PRs (30d)
123

Description

## Summary

On a server with server-side encryption (master-key mode), the desktop client occasionally replaces a local file with the **raw encrypted container** from the server instead of the decrypted content, and that container is **truncated**. The local file becomes unusable. A conflicted copy carrying the correct content is written next to it, so the data is recoverable - but nothing indicates that the primary file is now garbage.

I have 12 specimens from 2 different days on 2 unrelated sync roots. The signature is exact and reproducible enough to be recognised, though I cannot trigger it on demand.

## What the damaged file looks like

The first 120 bytes are always identical:

```
HBEGIN:oc_encryption_module:OC_DEFAULT_MODULE:cipher:AES-256-CTR:signed:true:useLegacyFileKey:false:encoding:binary:HEND
```

followed by `-` padding to a full 8192-byte block. For files whose plaintext is smaller than one block, that is the entire file - header block only, **no ciphertext at all**. For a larger one I have (plaintext 11259 bytes) the second 8192-byte block does contain high-entropy data (all 256 byte values present), so the ciphertext is there, just incomplete.

## The size rule

In all 12 specimens the damaged file's size is exactly

```
ceil(plaintext_size / 8192) * 8192
```

| plaintext | damaged file | complete container would be |
|---|---|---|
| 230 | 8192 | 16384 |
| 395 | 8192 | 16384 |
| 652 | 8192 | 16384 |
| 837 | 8192 | 16384 |
| 1045 | 8192 | 16384 |
| 1109 | 8192 | 16384 |
| 1191 | 8192 | 16384 |
| 2210 | 8192 | 16384 |
| 2650 | 8192 | 16384 |
| 3518 | 8192 | 16384 |
| 4367 | 8192 | 16384 |
| 11259 | 16384 | 24576 |

A complete container is one header block plus `ceil(plaintext / 6072)` data blocks. Every specimen is **exactly one 8192-byte block short of that, and the missing one is the last**. That also explains the shape described above: for a plaintext below 6072 bytes the complete container is 2 blocks, one short leaves only the header block, which is why those files contain no ciphertext at all.

The written length looks like it was computed from the plaintext size and then applied to the ciphertext stream.

There is a second, independent symptom on the same instance that points at the same area. Over WebDAV, roughly 3300 files reported a `getcontentlength` that was too large, and downloading them aborted before the announced Content-Length with "bytes missing". The excess is an exact multiple of **2024** bytes in **1748 of 1749** files checked.

2024 is the difference between a base64-encoded block and its plaintext: base64 of 6072 bytes is `4 * ceil(6072/3)` = 8096 characters, and `8096 - 6072 = 2024`. The block layout is consistent with that - 8096 characters of payload plus 96 bytes of per-block overhead give the 8192-byte block.

Worth noting because it discriminates: had the index reported the **container** size instead of the plaintext size, the error would have been `8192 - 6072 = 2120` per block, not 2024. The measured value picks the base64 length, not the container length. Whether that shares a root cause with the truncation above I cannot say.

## Impact

The local file is silently destroyed. Nothing in the client's UI marks it. Two of my cases went unnoticed for 5 days.

Two consequences that made this worse than a single lost file:

- One of the damaged files was a backup script that mirrors a directory tree with `rsync --delete`. Had it run in that state, it would have overwritten good copies in the backup with containers and deleted what was there before.
- The sync root also contained a git repository. 8 loose objects were replaced by containers, which made the repository unreadable (`fatal: loose object ... is corrupt`). Git objects are immutable and written concurrently, so a sync root is a bad place for one - but the corruption was not a merge conflict, the objects were literally overwritten with encryption containers.

## Reproduction

I cannot trigger it deliberately. What I can say about the circumstances:

- It happens together with the client writing a `... (conflicted copy ) ...` file next to the original. The conflicted copy holds the **correct** content; the original becomes the container.
- All 12 specimens are small text files or git objects, all well under 100 KB.
- 2 events on 2 different days, in 2 sync roots that have nothing to do with each other.

If a conflict is a precondition, then the interesting question is why the conflict resolution path fetches the raw container rather than the decrypted stream.

## Related, but not duplicates

I searched before filing:

- **#1928 "Conflict: Server version downloaded, local copy renamed and not uploaded"** describes the code path this happens on - the client downloads the server version and renames the local one. What I am reporting is that on an encrypted instance, what gets written is the raw container rather than the decrypted stream, and truncated at that.
- The other conflict reports (#943, #2467, #4954) are about conflicts being raised too often, not about wrong content ending up in the file.
- `nextcloud/server` #54594 "Encryption logic broken" is a different defect - `fclose()` on a string in `Storage/Wrapper/Encryption.php`, hit when resuming an interrupted `decrypt-all`.
- The header format itself is expected and documented, so the file is a well-formed SSE container, not random damage.

One thing to get out of the way early: "just turn off server-side encryption" is not a cheap workaround here. `decrypt-all` has several open reports of leaving files encrypted or only partially decrypted (#16597, #18709, #54594).

## A note for anyone hitting this

The good content is in the conflicted copy, and for a versioned file also in the repository. A cheap detector is to check the first 24 bytes of each file for `HBEGIN:oc_encryption`; a full tree of 14000 files takes under a second that way.

## Environment

- Client 34.0.3 (`nextcloud-client 2:34.0.3-1`), Manjaro Linux, kernel 6.18.49
- Server Nextcloud 33.0.8, managed hosting
- Server-side encryption active, master-key mode (`useLegacyFileKey:false`)
- No end-to-end encryption configured
- No selective sync in the affected roots
- The root where most specimens appeared is a two-way sync of roughly 55000 files

Contributor guide

Open the contributing guide

Research direction

No source file, test, or entry point is named. Start by tracing the conflict-resolution path that downloads the server version, then inspect how server-side encryption streams and reported lengths are passed into local-file writes. Done means the conflicted-file scenario no longer replaces the primary file with a truncated raw container and the WebDAV length discrepancy is explained or covered by a regression test.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
desktop, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.