# FORMAT-WEB-v1 — Sealby Web `.sealed` file format, version 1 **Status: NORMATIVE.** This document is the sole byte-exact authority for the Sealby **web** seal/open tool in the Toolbox (`sealby.app/tools/seal-a-file`, one page with Seal and Open modes). It is **independent of the iOS app's formats**: nothing in `FORMAT.md` (app v1) or `FORMAT-v2.md` (app v2) applies here, and nothing here applies to them. The web format shares no bytes, no key material, and no derivations with the app. Scope (per `WEB-PRODUCT-CONCEPT.md` §0, owner-approved 2026-08-16): one file, sealed entirely client-side in the browser, under a single-use random 256-bit key, using WebCrypto AES-256-GCM only. No passphrase mode, no KDF, no asymmetric crypto, no backend. Known-answer vectors for this document live in `vectors-web/` and are verified by `website/tests/seal-format.test.mjs`. An implementation is conformant only if it reproduces those vectors byte-for-byte **and** rejects every negative case listed in §9. Conventions: all multi-byte integers are **big-endian**. "u32be"/"u64be" mean unsigned 32/64-bit big-endian. `‖` is byte concatenation. 1 MiB = 1,048,576 bytes. --- ## 1. Registries ### 1.1 `format_version` (1 byte) | Value | Meaning | |---|---| | `0x01` | This document | | any other | **Reject.** UI message: the file was made with a newer Sealby — update/reload the page. Never attempt partial parsing. | Version 1 has no minor-version concept: any change to the bytes defined here is a new `format_version`. ### 1.2 `alg_id` (1 byte) | Value | Algorithm | Notes | |---|---|---| | `0x01` | AES-256-GCM | 256-bit key, 96-bit nonce, 128-bit tag. WebCrypto `AES-GCM`. The only entry in v1. | | any other | — | **Reject** (same "newer Sealby" handling as an unknown version). | ## 2. Key - The file key is **32 bytes from `crypto.getRandomValues`**, generated fresh for every seal. It is **single-use**: one seal, one key. It is never derived from anything, never reused, never stored by any Sealby system. - No other source of randomness is permitted anywhere in this format. ### 2.1 Key string encoding The key is shown to the user exactly once, as: ``` key_string = base64url_nopad( key(32) ‖ check(3) ) check = SHA-256(key)[0..2] // first 3 bytes ``` - `base64url_nopad` is RFC 4648 §5 (alphabet `A–Z a–z 0–9 - _`), **no** padding. 35 bytes encode to **exactly 47 characters**. - Decoding MUST reject: length ≠ 47, any character outside the alphabet, and a checksum mismatch after decoding (recompute `SHA-256(key)[0..2]` and compare). A typo therefore fails with a clear "check the key for typos" error instead of producing garbage or a misleading "corrupt file" error. - The checksum is an integrity aid against typos only; it has no security function. (A 2⁻²⁴ false-accept on a random typo then still fails GCM authentication.) - The share link is `https://sealby.app/tools/seal-a-file#` — the key rides in the URL **fragment only**, which browsers never transmit to any server; the page opens in its Open mode with the key filled in. This URL is part of the format's compatibility promise: it must keep opening every v1 `.sealed` file for as long as the format is supported. ## 3. Container layout ``` blob = header(16) ‖ frame_0 ‖ frame_1 ‖ … ‖ frame_{N-1} ``` There are **no trailing bytes**: after the last frame, the file MUST end. Anything else is a structure error. ### 3.1 Header (16 bytes, plaintext) | Offset | Size | Field | Value | |---|---|---|---| | 0 | 8 | `magic` | ASCII `SEALBY-W` = `53 45 41 4C 42 59 2D 57` | | 8 | 1 | `format_version` | `0x01` | | 9 | 1 | `alg_id` | `0x01` | | 10 | 4 | `seal_salt` | 4 random bytes (`crypto.getRandomValues`), fresh per seal | | 14 | 2 | `reserved` | `0x00 0x00`. Writers MUST write zero; readers MUST reject non-zero. | A wrong `magic` is rejected as "not a Sealby sealed file" (distinct from the unknown-version message). ### 3.2 Frames Each chunk is stored as one frame: ``` frame_i = ct_len_i (u32be) ‖ ct_i ``` `ct_i` is the AES-GCM output for chunk *i*: ciphertext followed by the 16-byte tag (WebCrypto's native layout). `ct_len_i` is the byte length of `ct_i` (plaintext length + 16). Readers MUST reject: - a truncated frame (fewer than 4 length bytes, or fewer than `ct_len` bytes remaining), - `ct_len < 16` (no room for the tag), - `ct_len_0 > 65,552` (manifest plaintext is capped at 64 KiB), - `ct_len_i > 1,048,592` for `i ≥ 1` (data plaintext is capped at 1 MiB), - bytes remaining after the last frame. The frame count **N** obtained by scanning to end-of-file is the value `total_chunks` used in every AAD (§5). Removing, adding, or truncating a frame therefore changes `total_chunks` and causes **every** chunk — including the manifest — to fail authentication. ## 4. Chunking - **Chunk 0 is the manifest** (§6). It is always present. `N ≥ 1`. - **Chunks 1 … N−1 are file data**: the plaintext file split into consecutive 1 MiB pieces. Every data chunk except the last is exactly 1 MiB; the last is 1 … 1 MiB bytes. A zero-byte file has no data chunks. - Therefore `N = 1 + ceil(size / 1 MiB)` (with `N = 1` when `size = 0`). Readers MUST verify this equality against the manifest `size` and reject on mismatch. - During stage-2 decryption, readers MUST additionally verify that every non-final data chunk decrypts to exactly 1 MiB and that the total decrypted length equals the manifest `size`. ### 4.1 Size cap Maximum plaintext file size: **104,857,600 bytes (100 MiB, presented as "100 MB" in UI copy)**. Sealing a larger file is refused before any crypto runs. Readers MUST reject a manifest whose `size` exceeds the cap before decrypting any data chunk. (The cap exists for in-memory processing on mobile Safari; if device testing forces it lower, this constant changes in code and in UI copy — the format itself carries no cap field.) ## 5. Nonces and AAD For chunk index *i* (`0 ≤ i < N`, u64): ``` nonce_i (12 bytes) = seal_salt(4) ‖ i (u64be) aad_i (32 bytes) = header(16) ‖ i (u64be) ‖ N (u64be) ``` - The nonce is the locked rule **32-bit random seal salt ‖ 64-bit chunk counter**. Within one blob every chunk index is distinct, so no nonce ever repeats under the blob's key — this is asserted by an automated test, not just by argument. Across blobs the key itself is fresh per seal (§2), so a `(key, nonce)` pair can never repeat; the random salt is defense-in-depth on top of that, not the uniqueness mechanism. - The AAD binds the **entire header** (hence `format_version`, `alg_id`, and `seal_salt`), the chunk's own index, and the total chunk count. In consequence, all of the following MUST fail GCM authentication, and the test suite proves each one: ciphertext tampering, header tampering, chunk reordering (swap), chunk relocation (a chunk moved or duplicated at another index), truncation (frame removed or cut short), and splicing a chunk into a different blob (different key/salt). ## 6. Manifest (chunk 0) Plaintext of chunk 0 is UTF-8 JSON. Writers MUST produce **exactly**: ``` {"name":,"size":,"type":} ``` - Keys in exactly this order, no whitespace, standard `JSON.stringify` string escaping — i.e. the output of `JSON.stringify({name, size, type})`. Byte determinism here is what makes whole-blob known-answer vectors possible. - `name` — string: the original filename (base name only, no path). Readers MUST treat it as untrusted display data: strip path separators and control characters before display or use as a download name. - `size` — non-negative integer: exact plaintext byte length. Bound by §4.1; also bound (`≤ 2^53 − 1`) by JSON number interop. - `type` — string: the file's MIME type as reported by the browser, possibly `""` when unknown. Advisory display data only; the danger warning (§8) is computed from `name`'s extension, never from `type` alone. - Readers MUST reject chunk 0 that is not valid JSON, lacks any of the three fields, or carries wrong types, and MUST then run the §4 count check. - Unknown extra fields: **reject** in v1 (writers can't produce them; their presence means the blob was not written by a conformant v1 writer). ## 7. Sealing procedure (normative summary) 1. Refuse files over the §4.1 cap. 2. `key ← getRandomValues(32)`, `seal_salt ← getRandomValues(4)`. 3. Build the header (§3.1) and manifest plaintext (§6). 4. `N = 1 + ceil(size / 1 MiB)` (1 if size = 0). 5. Encrypt chunk 0 (manifest) and chunks 1…N−1 (data) with AES-256-GCM, `nonce_i`/`aad_i` per §5. 6. Emit `header ‖ frames`. Suggested output filename: original full name + `.sealed` (e.g. `report.pdf` → `report.pdf.sealed`). 7. Show `key_string` (§2.1) and the fragment link once. The key exists only in page memory; it is never logged or written to any storage. ## 8. Opening procedure — two-stage ceremony (normative) **Stage 1 — manifest only.** Parse and validate the header (§3.1: magic, version, alg, reserved) and the frame structure (§3.2). Decode and checksum-verify the key string (§2.1). Decrypt **only chunk 0**; validate §6 and the §4 count/size checks. Display `name`, `size`, `type`, plus a prominent warning when the name matches the dangerous-type policy (§8.1). Nothing is written to disk and no data chunk is decrypted in stage 1. **Stage 2 — only after an explicit user click.** Decrypt chunks 1…N−1 in order, enforce the §4 length checks, assemble the plaintext, and offer it as a download named from the sanitized manifest `name`. The decrypted content is **never auto-opened and never rendered** by the page — download only. Any authentication failure at either stage aborts the whole operation; there is no partial output, ever. ### 8.1 Dangerous-type warning (UI policy, tested) The warning triggers on the final filename **extension** (case-insensitive) of the manifest `name`, against a list maintained in code (`website/src/lib/seal/classify.js`) covering at minimum: executables and installers (`exe msi msix bat cmd com scr pif hta cpl jar apk app dmg pkg deb rpm iso img lnk url reg gadget`), scripts (`js mjs jse vbs vbe wsf wsh ps1 psm1 sh bash zsh command py rb pl php`), macro-enabled Office (`docm xlsm pptm dotm xltm ppsm sldm xlam ppam`), and web-executable content (`html htm xhtml shtml svg mht mhtml`). A **double extension** whose final extension is dangerous (e.g. `invoice.pdf.exe`) additionally warns that the file is disguised as the inner type. This list is advisory UI policy — it may grow without a format change — but the entries above are locked by tests. ## 9. Negative-test obligations A conformant implementation ships automated tests proving rejection of each: | Case | Expected failure | |---|---| | Wrong magic | not-a-sealed-file error | | `format_version ≠ 0x01` | "newer Sealby" error, no partial parse | | Unknown `alg_id` | "newer Sealby" error | | Non-zero `reserved` | reject | | Tampered header byte (salt, version, alg) | every chunk fails auth / reject | | Tampered ciphertext or tag (manifest or data chunk) | auth failure | | Truncated blob (partial frame, or whole final frame removed) | structure error or auth failure on every chunk | | Trailing bytes after last frame | structure error | | Two data chunks swapped | auth failure | | Chunk relocated / duplicated at another index | auth failure | | Chunk spliced in from another blob | auth failure | | Manifest `size` inconsistent with frame count | reject | | Non-final data chunk shorter than 1 MiB | reject | | Key string: wrong length, bad character, checksum mismatch | key-format error (before any decryption) | | Correct-format but wrong key | auth failure | | Nonce uniqueness | test asserts all N nonces of a multi-chunk blob are distinct | ## 10. Known-answer vectors `vectors-web/kats-web-v1.json` commits fixed `(key, seal_salt, manifest fields, plaintext)` → the exact full blob bytes (hex) for small cases, and the exact blob SHA-256 for a multi-chunk case whose plaintext is derived deterministically (derivation defined in `vectors-web/README.md`). The same key strings' §2.1 encodings are committed alongside. CI fails if any byte drifts. ## 11. What this format deliberately does not have No filenames outside the encrypted manifest, no timestamps, no identifiers of any Sealby account or device (none exist), no compression, no padding or size hiding (the blob length reveals the file length — stated honestly on the how-it-works page), no multi-file archives, no passphrase mode, no key wrapping. Each of these is a format-version bump if it ever arrives.