# Nimbus Drive — Recovery Format Specification

**Version 1.** Public. Written to be implementable by someone with no access to
Nimbus Drive, its source code, its servers, or its authors.

---

## Why this document exists

Nimbus Drive stores your files as encrypted messages in a Telegram channel that
**you** own. The point of that design is that the files remain yours even if
Nimbus Drive stops existing.

That promise is only real if the format is documented. A recovery tool that
lives only on our website disappears with the company, which is precisely the
situation it was built for. So the format is published here, permanently, in
enough detail that a competent programmer — or anyone in the open-source
community — can write a working recovery tool from this page alone.

Everything below is standard, already-public cryptography. There is nothing
novel here, and that is deliberate: novel cryptography is a liability in a
format that has to keep working for decades.

## What you need to recover files

1. **The exported channel.** Telegram's own *Export chat history* feature, in
   Telegram Desktop. No Nimbus software and no API access is involved.
2. **The 12-word recovery phrase**, shown to the user once at setup.

That is the complete list. No account, no API key, no server, no network.

---

## 1. How a file is stored

A file goes through four steps on its way into the channel. Reverse them to get
the file back.

```
original bytes
   │
   ├─ 1. split into fixed-size PLAINTEXT chunks   (§2)
   │
   └─ for each chunk, in order:
         │
         ├─ 2. gzip                (per chunk, optional — see §3)
         │
         ├─ 3. AES-256-GCM         (per-file key, per-CHUNK random IV)
         │        the encrypted payload is:
         │        [version: 1][flags: 1][index: 4 BE][chunk bytes]
         │       ↓
         │    [IV: 12][Auth Tag: 16][Ciphertext: rest]
         │
         └─ 4. one Telegram message, attached as
               "<filename>.part<n>"  (n starts at 0)
```

**Every chunk is a complete, independently decryptable unit.** It carries its
own IV and its own auth tag, and it can be verified and opened without any other
chunk. That is what makes the format streamable and seekable: a reader can play
`part3` while `part4` is still downloading, and can jump straight to the chunk
holding a byte offset without touching the ones before it.

The 6-byte header **inside** the encrypted payload is authenticated by GCM along
with the data. Its `index` field binds each chunk to its position, so a chunk
that is moved, duplicated, or substituted from another file fails **at that
chunk** — before its bytes reach anyone.

> **Reading an older export.** Version 1 of this format encrypted the whole file
> as one GCM stream and sliced the *ciphertext*, putting a single
> `[IV][Tag]` at the front of `part0` only. The two are **not distinguishable by
> inspection** — for a single-chunk file they even begin identically — so a
> reader must take the format from the manifest (§8) and refuse what it does not
> recognise, rather than guess. Reading one as the other produces plausible
> garbage, not an error.

### 1.1 Encryption parameters

| | |
|---|---|
| Algorithm | **AES-256-GCM** |
| Key | **32 bytes**, random, unique per file |
| IV / nonce | **12 bytes**, random, unique per **chunk** |
| Auth tag | **16 bytes** (128-bit), per **chunk** |
| Additional authenticated data | none — the chunk header is inside the plaintext instead, which authenticates it identically and needs no AAD support from the reader's crypto library |

The key is per file; the IV is per chunk. A repeated IV under one key is the
catastrophic GCM failure, so each chunk draws a fresh random one — which also
means a chunk can be sealed with no reference to its neighbours, and a resumed
upload need not reproduce an earlier run's bytes.

Note that most crypto libraries (including WebCrypto's `crypto.subtle.decrypt`)
expect the auth tag **appended to the ciphertext**, whereas this format stores
it **before** the ciphertext. Reassemble as `ciphertext || tag` before calling
such an API. Getting this wrong produces a tag-verification failure, which is
indistinguishable from a wrong key — a common and confusing first bug.

## 2. Chunking

The **plaintext** is split into fixed-size chunks, and each is sealed
separately. **Chunk size is not part of the format — never assume one.**
Reassemble strictly by the chunk index recorded in the manifest, which starts at
0 and is contiguous. Only the final chunk may be shorter.

The manifest records the plaintext stride as `chunkPlaintextBytes`, which is
what makes seeking arithmetic rather than a lookup:

```
chunk holding byte N  =  floor(N / chunkPlaintextBytes)
```

Per-chunk compression does not disturb this. Compression changes what a chunk
*weighs on the wire*; the stride describes where it *starts in the file*.

For context, writers at the time of publication cut video and audio into 4 MB
chunks, so a player can start sooner, and other files into chunks between 10 MB
and a ceiling just under 20 MB, by connection speed. The ceiling is a transport
limitation rather than a format rule, and all of these may change without the
format changing. An implementation that hardcodes a size will eventually be
wrong.

Each chunk is uploaded as a separate Telegram message with the attachment name
`<filename>.part<n>`. A single-chunk file still uses `.part0`.

**Do not rely on filenames to identify chunks.** Two different files in the
same channel may share a name, in which case their parts share a name too
(`notes.txt.part0` twice). Telegram Desktop's JSON export writes a
`result.json` mapping each message id to its exported file path; the manifest
records the message id of every chunk. Use that mapping. Where it is
unavailable and names are ambiguous, **refuse rather than guess** — a wrong
chunk decrypts to a valid-looking failure, or worse, to somebody else's file.

## 3. Compression

Each chunk may be gzip-compressed **independently**, and whether it was is
recorded in that chunk's own sealed header — bit 0 of the `flags` byte. There is
no per-file compression flag to read, and none to get wrong.

**Never infer compression from the MIME type, the file extension, or another
chunk.** The uploader attempts gzip only for likely-compressible MIME types and
then keeps the result *only if that chunk actually came out smaller*, so a
single file routinely contains both compressed and uncompressed chunks — an
archive with a text header and compressed body, for instance. A reader that
makes one decision for the whole file will be wrong about part of it.

(The manifest's `compression` field is a format-1 leftover. For format 2 it is
always `"none"` and must not be consulted.)

## 4. The key hierarchy

```
        12-word recovery phrase
                 │  Argon2id (§6)
                 ▼
          phrase wrapping key ──unwraps──► MK (Master Key, 32 bytes)
                                            │
                                            │ unwraps
                                            ▼
                                   per-file key (32 bytes)
                                            │
                                            ▼
                                     the file's bytes
```

Per-file keys are **wrapped** under MK, not derived from it. Each file keeps
the random key it was encrypted with, and the manifest carries that key sealed
under MK. (This is why adopting the format never requires re-encrypting or
re-uploading anything.)

MK itself is wrapped independently several ways by the app; **only the
phrase-wrapped copy matters for offline recovery**, and it is stored in the
channel itself, inside the manifest. Recovery therefore depends on nothing that
Nimbus Drive controls.

## 5. The recovery phrase

Standard **BIP39**, English wordlist, **12 words** = 128 bits of entropy plus a
4-bit checksum.

- Input is normalised by trimming, lowercasing, and collapsing all runs of
  whitespace. The wordlist is entirely lowercase ASCII.
- Each word is its 0-based index in the wordlist, as 11 bits, big-endian,
  concatenated: 12 × 11 = 132 bits.
- The first 128 bits are the entropy; the last 4 are the checksum.
- The checksum must equal the **first 4 bits of `SHA-256(entropy)`**.

**Feed the 128-bit entropy into the KDF — not the word string, and not BIP39's
own PBKDF2 seed derivation.** This format does not use BIP39 seeds or
passphrases; it only borrows the mnemonic encoding.

Validate the checksum **before** running the KDF. It costs microseconds and
turns a mistyped word into a specific, actionable message instead of an
indistinguishable "wrong phrase" a second later.

## 6. Key derivation

| | |
|---|---|
| Algorithm | **Argon2id** |
| Version | **0x13** (v1.3) |
| Memory | **m = 65536 KiB (64 MiB)** |
| Iterations | **t = 3** |
| Parallelism | **p = 4** |
| Salt | **16 bytes**, stored in the manifest |
| Output | **32 bytes** |
| Secret / associated data | none |

These are [RFC 9106](https://www.rfc-editor.org/info/rfc9106/)'s SECOND
RECOMMENDED parameters verbatim.

The parameters are also recorded in the manifest (`kdf`) rather than only here,
so a tool can read what to run instead of assuming. **If a manifest names an
algorithm your implementation does not support, stop.** Do not substitute a
default: silently deriving a different key surfaces as "wrong phrase" for a
phrase that is correct, sending someone hunting for a transcription error that
does not exist.

## 7. The wrapped-secret envelope

Every wrapped key in the manifest — MK and per-file keys alike — uses one
layout, base64-encoded:

```
base64( [IV: 12 bytes][Auth Tag: 16 bytes][Ciphertext] )
```

which is the same layout as file bodies (§1). Decrypt with AES-256-GCM. A
failure here means the wrapping key is wrong, or the data was altered; GCM
admits no third possibility.

## 8. The manifest

One JSON document in the channel, attached as **`nimbus-manifest.json`**,
normally pinned. It is republished when the library changes, and only the most
recent one is kept.

```jsonc
{
  "v": 1,                    // format version; refuse anything higher
  "generatedAt": 1786370000000,
  "kdf": {                   // PLAINTEXT — needed before any secret exists
    "alg": "argon2id",
    "m": 65536, "t": 3, "p": 4,
    "saltB64": "…"           // 16 bytes
  },
  "mkWrappedB64": "…",       // MK, wrapped under the phrase key (§7)
  "bodyB64": "…"             // AES-256-GCM( gzip( JSON(body) ), MK )
}
```

The header is deliberately readable without any secret: a recovery tool needs
the KDF parameters and salt *before* it can derive anything at all. The body is
encrypted because it costs nothing and keeps names and folder structure out of
Telegram's view.

`bodyB64` uses the §7 envelope, and once unwrapped is **gzip-compressed JSON**:

```jsonc
{
  "folders": [
    { "path": "/Photos", "parentPath": "/", "name": "Photos", "createdAt": 1784759055939 }
  ],
  "files": [
    {
      "name": "holiday.mp4",
      "parentPath": "/",
      "mimeType": "video/mp4",
      "formatVersion": 2,          // REQUIRED. Absent means 1. See §1 and §11.
      "chunkPlaintextBytes": 20971485, // the seeking stride (§2). Format 2 only.
      "compression": "none",       // format-1 leftover; ignore for format 2 (§3)
      "sizeBytes": 26214400,       // ORIGINAL size, before compression
      "sha256": "…",               // ORIGINAL file hash, for the final check
      "fkWrappedB64": "…",         // this file's key, wrapped under MK (§7)
      "encIvB64": "",              // format-1 only; format 2 has no file-level IV
      "chunks": [
        { "index": 0, "messageId": 12, "sha256": "…" }  // sha256 of the SEALED chunk
      ],
      "createdAt": 1784759055939   // OPTIONAL; when the file was first stored
    }
  ],
  "deletedFiles": [              // OPTIONAL; deletions, not needed for recovery
    { "firstMessageId": 8, "deletedAt": 1784760000000 }
  ],
  "deletedFolders": [            // OPTIONAL; likewise
    { "path": "/Old", "deletedAt": 1784760000000 }
  ],
  "scope": {                     // OPTIONAL; not needed for recovery
    "scopeId": "telegram:1000000001",
    "providerId": "telegram_1",
    "channelId": "1000000001",
    "channelTitle": "NimbusStorage",
    "accountId": "200000002",      // the Telegram user id
    "accountLabel": "+1…0000"       // a masked phone number
  }
}
```

`scope` says which channel and account the library was stored in. A recovery
reads the channel it was given and does not need it; it exists so an app can
restore the library to the right account. It is inside the encrypted body, and
it identifies the Telegram account, so treat a decrypted body as personal data.
Manifests written before it was added have no `scope`; treat that as unknown.

`deletedFiles` and `deletedFolders` let the app's phones agree on deletions:
one phone deletes a file, and the others delete their copy of its record
instead of describing it again. A file is named by the message id of its first
chunk. **A recovery ignores both.** The files they name are not in `files`, and
their chunks have been deleted from the channel or soon will be. Manifests
written before 2026-10-19 have neither.

Two hashes appear, and confusing them is the easiest mistake to make:

- `chunks[].sha256` — of the **sealed** chunk, exactly as stored, `[IV][Tag]`
  and all. Checked *before* decryption.
- `files[].sha256` — of the **original** file, after every chunk is opened and
  joined. Checked *last*.

`formatVersion` is the field a reader must not skip. Nothing in the stored bytes
reveals which layout they use, so a manifest that omits it means format 1 — and
a reader that assumes otherwise will decrypt format-1 data into garbage that
fails no check until the final hash.

## 9. The recovery algorithm

```
 1. Read nimbus-manifest.json → the plaintext header
 2. Refuse if "v" is greater than the version you implement
 3. Validate the 12-word BIP39 checksum          → fail fast, before any KDF
 4. phrase → 128-bit entropy
 5. phraseKey = Argon2id(entropy, salt, m=65536, t=3, p=4, out=32)
 6. MK        = AES-256-GCM-decrypt(mkWrappedB64, phraseKey)
        ↳ a tag failure here means WRONG PHRASE — no other outcome is possible
 7. body      = gunzip(AES-256-GCM-decrypt(bodyB64, MK))
 8. For each file you want:
      a. read files[].formatVersion — refuse what you do not implement (§1)
      b. FK = AES-256-GCM-decrypt(fkWrappedB64, MK)
      c. locate its chunks by messageId (§2)
      d. for each chunk, in index order:
           i.   verify its SHA-256      → stop on mismatch, naming the chunk
           ii.  split off [IV 12][Tag 16]; decrypt the remainder with FK
           iii. check the sealed header: version == 2, and index == the
                position you are reading  → a mismatch means these parts are
                rearranged or from another export; refuse
           iv.  if flags bit 0 is set, gunzip THIS chunk    (never guessed — §3)
           v.   append
      e. verify SHA-256 against files[].sha256
      f. only now hand the bytes back
```

Steps 8d.i, 8d.ii, 8d.iii and 8e are four independent integrity checks: the
chunk arrived intact, its ciphertext was not altered, it belongs at the position
it is being read from, and the reconstructed file matches what was originally
stored. Do not skip any of them, and do not deliver bytes that failed one.

Step 8d.iii is the one format 1 could not perform. There, a chunk swapped for
another authentic chunk was invisible until the whole file had been decrypted —
if it surfaced at all. Here it fails at the chunk, before any of its bytes are
used.

**A reader may stop at any chunk and still have delivered only verified bytes.**
That is what makes streaming safe, and it is a property of this step order
rather than of any particular player: nothing is appended until it has passed
8d.i–8d.iv.

## 10. Test vectors

Any implementation should reproduce all of these before being trusted.

### BIP39

| Entropy (hex) | Phrase |
|---|---|
| `00000000000000000000000000000000` | `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about` |
| `00112233445566778899aabbccddeeff` | `abandon math mimic master filter design carbon crystal rookie group knife young` |

Both are published BIP39 vectors, not Nimbus-specific ones. The final word
differs from `abandon` only because of the checksum bits — an implementation
that dropped the checksum would produce `abandon` and pass a naive round-trip
test.

### Argon2id — this format's parameters

```
entropy  = 00000000000000000000000000000000        (the all-"abandon" phrase)
salt     = 42424242424242424242424242424242        (16 bytes of 0x42)
m = 65536, t = 3, p = 4, version = 0x13, output = 32 bytes

result   = 3192bbad49829adc54293f9d6c62ac3e7e6e160605caf98815abccf1848a9140
```

### Argon2id — RFC 9106 §5.3

Reproducing this proves the Argon2id underneath is the real algorithm, not
merely self-consistent. It uses a secret and associated data, which this format
never does, and a much smaller memory cost.

```
password = 01 × 32,  salt = 02 × 16,  secret = 03 × 8,  associated data = 04 × 12
m = 32, t = 3, p = 4, version = 0x13, output = 32 bytes

tag      = 0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659
```

## 11. Versioning

`"v"` in the manifest is the format version. **Refuse a manifest whose version
is higher than you implement** rather than reading it partially: a half-understood
manifest that silently drops files presents as "some of your files are missing"
during the one operation where the user has nothing else to compare against.

Fields may be added within a version. Ignore fields you do not recognise.

**Two versions travel independently, and conflating them is a real hazard:**

| | what it versions | where it lives |
|---|---|---|
| `v` | the manifest document | top of the manifest |
| `files[].formatVersion` | how one file's chunks are laid out | per file |

A single manifest can describe files of more than one storage format, so
`formatVersion` is read per file and never inferred from `v`. Refuse a file
whose `formatVersion` you do not implement, say plainly that the *tool* is the
problem and not the file, and carry on with the rest of the library — one
unreadable file must not stop a recovery.

Storage format 2 (per-chunk sealing) is the current writer. Format 1 —
whole-file GCM sliced into chunks — is documented above so an older export can
be recognised and refused clearly.

## 12. Known limitations, stated honestly

- **Chunk attachment names leak file names to Telegram.** `<name>.part<n>` is
  visible to Telegram as a message attachment name. The file *contents* are
  encrypted end-to-end and the manifest body is encrypted, but the names are
  not private today.
- **The manifest describes what the app believed when it was written.** A file
  whose chunks were deleted directly in Telegram may still be listed. The
  export is the ground truth; report such files as unrecoverable rather than
  failing the whole recovery.
- **A format-1 file cannot be decrypted incrementally in a browser.** WebCrypto
  offers no incremental GCM, and format 1 is one GCM stream over the whole
  file, so a browser tool holds the whole file in memory. Format 2 seals every
  chunk on its own, so any reader can open one chunk at a time; only the final
  whole-file SHA-256 has to see every byte. WebCrypto's digest is one-shot too,
  so a browser reader that wants to stream needs its own incremental SHA-256.

## 13. What this document does not cover

The Nimbus Drive **application** — its interface, its Telegram integration, its
sync behaviour, its branding — is not described here and is not open source.
This specification covers exactly one thing: **how to get your files back**.
That is the part that has to outlive us.
