From 6f70ef201aadd8de188003521aef59c6a06401d9 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 10:36:24 +0200 Subject: [PATCH] docs: add update manifest schema Formal JSON schema for the release manifest, with canonical ordering rules so signatures stay stable. Includes a small synthetic example under docs/examples/. --- docs/examples/manifest-example.json | 52 ++++++++++++ docs/update-manifest.md | 124 ++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 docs/examples/manifest-example.json create mode 100644 docs/update-manifest.md diff --git a/docs/examples/manifest-example.json b/docs/examples/manifest-example.json new file mode 100644 index 00000000..02e1c964 --- /dev/null +++ b/docs/examples/manifest-example.json @@ -0,0 +1,52 @@ +{ + "version": "2026.04.14-1", + "created_at": "2026-04-14T14:00:00Z", + "previous": "2026.04.13-3", + "notes": "synthetic example showing the manifest structure. a real manifest covers tens of thousands of files.", + "launcher": { + "path": "Metin2Launcher.exe", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 15728640, + "platform": "windows" + }, + "files": [ + { + "path": "Metin2.exe", + "sha256": "2653e87ecd8ba305b70a96098788478e8b60c125ec83bcd1307486eafb5c5289", + "size": 27982848, + "platform": "windows" + }, + { + "path": "assets/root/serverinfo.py", + "sha256": "b564fef7e45326ff8ad1b188c49cd61d46af1b07de657689814526725919d5cb", + "size": 9231 + }, + { + "path": "config.exe", + "sha256": "a79c4e0daef43ce3a2666a92774fc6a369718177e456cb209fabe02ca640bcc2", + "size": 258048, + "platform": "windows" + }, + { + "path": "pack/item.pck", + "sha256": "7aa9d46724a921fecf5af14c10372d0d03922e92a4cace4b5c15c451416f36b7", + "size": 128547328 + }, + { + "path": "pack/locale.pck", + "sha256": "3b9dfe45317a14fcb70a138c1b9d57d984fe130e833c4341deaaff93d615ac67", + "size": 4587520 + }, + { + "path": "pack/metin2_patch_easter1.pck", + "sha256": "2dab6a06d317014782cbe417d68dd3365d1d8c7cc35daa465611ce2108547706", + "size": 12345600, + "required": false + }, + { + "path": "pack/uiscript.eix", + "sha256": "79bd367b31882e52dfa902f62112f5d43831673ed548ebbd530e2f86dfa77d14", + "size": 892416 + } + ] +} diff --git a/docs/update-manifest.md b/docs/update-manifest.md new file mode 100644 index 00000000..67e4fecc --- /dev/null +++ b/docs/update-manifest.md @@ -0,0 +1,124 @@ +# Update manifest — format specification + +The update manifest is a JSON document describing a single release of the Metin2 client. It lives at `https://updates.jakubkadlec.dev/manifest.json` alongside its Ed25519 signature at `manifest.json.sig`. + +See [update-manager.md](./update-manager.md) for the overall architecture this fits into. + +## Top-level schema + +```json +{ + "version": "2026.04.14-1", + "created_at": "2026-04-14T12:00:00Z", + "previous": "2026.04.13-3", + "launcher": { + "path": "Metin2Launcher.exe", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 15728640 + }, + "files": [ + { + "path": "Metin2.exe", + "sha256": "a1b2c3...", + "size": 27982848, + "platform": "windows", + "required": true + } + ] +} +``` + +### Required top-level fields + +| Field | Type | Description | +|---|---|---| +| `version` | string | Release version. Format: `YYYY.MM.DD-N` where `N` is the 1-indexed daily counter. Sortable, human-readable, allows multiple releases per day. | +| `created_at` | string | ISO 8601 timestamp in UTC with `Z` suffix. When the release was produced. | +| `launcher` | object | The launcher binary. See below. Special because of self-update handling. | +| `files` | array | The non-launcher files in the release. May be empty (launcher-only update). | + +### Optional top-level fields + +| Field | Type | Description | +|---|---|---| +| `previous` | string | `version` of the manifest this release replaces. Omit for the first release ever. Used for changelog display and future delta-patch strategies. | +| `notes` | string | Free-form release notes (Markdown). Displayed by the launcher in the changelog panel. | +| `min_launcher_version` | string | Refuse to apply this manifest if launcher's own version is older than this. Used when a manifest change requires a newer launcher. | + +## File entry schema + +```json +{ + "path": "pack/item.pck", + "sha256": "def456abc123...", + "size": 128000000, + "platform": "all", + "required": true +} +``` + +### Required file fields + +| Field | Type | Description | +|---|---|---| +| `path` | string | Path relative to the client root, using forward slashes. No `..` segments. | +| `sha256` | string | Lowercase hex sha256 of the file contents. | +| `size` | integer | File size in bytes. Used for the progress bar and to detect truncated downloads before hashing. | + +### Optional file fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `platform` | string | `"all"` | One of `"windows"`, `"linux"`, `"all"`. Launcher filters by its own platform. | +| `required` | boolean | `true` | If `false`, a failed download for this file does not block the game launch. | +| `executable` | boolean | `false` | On Unix-like systems, set the executable bit after applying. Ignored on Windows. | + +## Launcher entry + +The `launcher` top-level object has the same fields as a file entry, but is called out separately because the launcher is a privileged file: + +- The launcher replaces itself via **rename-before-replace**, not normal atomic move. +- The launcher is **always required**; if it fails to update, the launcher does not launch the game, to avoid a broken loop where the player is running a buggy launcher that can't fix itself. +- The launcher is **never** listed in the `files` array. + +## Signing + +`manifest.json.sig` is the raw Ed25519 signature over the literal bytes of `manifest.json`, in detached form. The public key is compiled into the launcher binary. Signing and verification use the standard Ed25519 algorithm (RFC 8032), no prehashing. + +Example verification in Python: + +```python +import json +from nacl.signing import VerifyKey + +with open("manifest.json", "rb") as f: + manifest_bytes = f.read() +with open("manifest.json.sig", "rb") as f: + sig = f.read() + +VerifyKey(bytes.fromhex(PUBLIC_KEY_HEX)).verify(manifest_bytes, sig) +``` + +In C# with `System.Security.Cryptography` (.NET 8+) or BouncyCastle. + +## Canonical JSON + +To keep signatures stable across trivial reformatting: + +- Top-level keys appear in the order `version, created_at, previous, notes, min_launcher_version, launcher, files`. +- Within the `files` array, entries are **sorted by `path`** lexicographically. +- Within a file object, keys appear in the order `path, sha256, size, platform, required, executable`. +- JSON is pretty-printed with **2-space indentation**, **LF line endings**, final newline. +- Strings use the shortest valid JSON escapes (no `\u00XX` for printable ASCII). + +`scripts/make-manifest.py` produces output in exactly this form. Do not re-serialize a manifest with a different JSON library before signing; the bytes must match. + +## Versioning rules + +- `version` strings are compared **as date + counter** (not as semver), via `(date, counter)` tuples. +- A launcher always replaces its own installed version with the one from the latest manifest, regardless of whether the manifest's `version` is newer than the launcher's own version. There is no "downgrade protection" for the launcher itself because the server is the source of truth. +- For the **client files** (not launcher), the launcher refuses to apply a manifest whose `version` is older than the locally-recorded `current-manifest.json` version. This prevents rollback attacks where a compromised CDN replays an old manifest to force players back onto an outdated client that had a known vulnerability. + +## Example + +See [examples/manifest-example.json](./examples/manifest-example.json) for a real manifest produced by `scripts/make-manifest.py` over the current dev client.