docs: document m2pack launcher integration

adds docs/m2pack-integration.md covering the signature boundary,
runtime key env-var delivery, telemetry opt-in, backward compatibility
and expected on-disk layout. README gains a short "Release formats"
section pointing at the new doc, and CHANGELOG tracks the [Unreleased]
entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 21:12:08 +02:00
parent 0e95171e50
commit 1790502b58
3 changed files with 187 additions and 0 deletions

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
All notable changes to `metin-launcher` are tracked here. Format loosely
follows Keep a Changelog; dates are Europe/Prague.
## [Unreleased]
### Added
- Release format dispatch. The manifest now carries an optional top-level
`format` field (defaults to `legacy-json-blob`) and the orchestrator
resolves an `IReleaseFormat` strategy via `ReleaseFormatFactory`.
- `M2PackFormat`: new release format that lists `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher never opens or decrypts `.m2p`
archives; it only places them next to the client root and loads the
runtime key after apply.
- `RuntimeKey` model + `IRuntimeKeyDelivery` strategy. `EnvVarKeyDelivery`
is the MVP implementation and forwards `M2PACK_MASTER_KEY_HEX`,
`M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` to the child process scoped
to `ProcessStartInfo.Environment` only. `SharedMemoryKeyDelivery` is
documented as a stub and throws until the Windows receiver lands.
- `GameProcess.BuildStartInfo` now accepts an optional `RuntimeKey?` and
forwards it through the env-var delivery.
- `ClientAppliedReporter`: opt-in best-effort telemetry ping that fires
once after a successful apply with a 5-second cap. Disabled by default
(`LauncherConfig.TelemetryUrlTemplate == ""`). Failures are always
swallowed and logged as warnings.
- `docs/m2pack-integration.md` documenting the signature boundary, runtime
key delivery, telemetry, backward compatibility and file layout.
- ~60 new tests across `RuntimeKeyTests`, `EnvVarDeliveryTests`,
`LegacyJsonBlobFormatTests`, `M2PackFormatTests`, `ReleaseFormatFactoryTests`,
`ClientAppliedReporterTests`, `UpdateOrchestratorFormatDispatchTests` and
extended `GameProcessTests`. Total suite is ~92 tests.
### Changed
- `UpdateOrchestrator` dispatches through `ReleaseFormatFactory` after
signature verification. The legacy flow is preserved byte-for-byte for
manifests without a `format` field; the only visible difference is that
the complete log line now reads `format=legacy-json-blob`.
- `UpdateOrchestrator.Result` gained `Format` and `RuntimeKey` slots so the
headless entry point can forward the runtime key into `GameProcess.Launch`.
### Security
- A signed manifest carrying an unknown `format` value is refused outright
rather than silently falling back to legacy, preventing a downgrade
attack vector in the event of a signing key compromise.
- Env vars produced by `EnvVarKeyDelivery` are scoped to the spawned
child's environment only. The launcher never mutates its own process
environment, so other processes on the machine and later code in the
launcher itself cannot read the key.

View File

@@ -125,6 +125,30 @@ tests/Metin2Launcher.Tests/
game assets (~4 GB) are handled by our own patcher code.
- **xunit / xunit.runner.visualstudio** — tests.
## Release formats
The launcher dispatches on a top-level `format` field in the signed manifest.
Two strategies exist today:
- `legacy-json-blob` (default when `format` is absent) — individual files
listed in `files[]`, downloaded by sha256 and atomically replaced inside
the client root.
- `m2pack` — the `files[]` entries are `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher **never** opens or decrypts
`.m2p` archives; it just places them next to the client root and forwards
the runtime key to `Metin2.exe` via environment variables
(`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`, `M2PACK_KEY_ID`).
A manifest whose `format` value is signed but unknown is refused outright —
the orchestrator falls back to offline mode rather than silently downgrading.
See `docs/m2pack-integration.md` for the full threat model and file layout.
An opt-in "client applied this release" telemetry ping is available via
`LauncherConfig.TelemetryUrlTemplate`. It is empty by default, which short-
circuits the reporter so no network call is made. When set, the reporter
fires a single bounded (5s) best-effort POST after a successful apply and
swallows any failure.
## Signing key
`LauncherConfig.PublicKeyHex` is the Ed25519 public key that every manifest

114
docs/m2pack-integration.md Normal file
View File

@@ -0,0 +1,114 @@
# m2pack integration
Notes on how `metin-launcher` handles the m2pack release format. Written when the
launcher-side support landed on `claude/m2pack-launcher`; the client-side receiver
for the runtime key is tracked separately.
## Scope
The launcher's job in an m2pack release is narrowly defined:
1. Fetch the signed `manifest.json` and its `.sig` exactly as before.
2. Verify Ed25519 signature against the compiled-in public key, exactly as before.
3. Read the new top-level `format` field (falling back to `legacy-json-blob`).
4. Dispatch through `ReleaseFormatFactory.Resolve(...)` to an `IReleaseFormat`.
5. Diff, download and atomically apply the file entries listed in the manifest.
For m2pack these are `.m2p` pack archives plus a `runtime-key.json` sidecar.
6. **Never** open, decrypt or decompress a `.m2p` archive. Integrity of the
archive contents is the client's problem, gated on the runtime key.
7. Load `runtime-key.json` from the client root and forward it to `Metin2.exe`
via environment variables (`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`,
`M2PACK_KEY_ID`), scoped to the child process only.
Anything beyond step 7 — parsing the archive, selecting pack files, decrypting
blocks, validating per-file signatures — lives in the client. The launcher is
deliberately dumb about `.m2p`.
## Signature boundary
The Ed25519 signature stays on the top-level `manifest.json`. `.m2p` files are
each a single `files[]` entry with a `sha256`, exactly like any other blob —
that's enough to detect tampering or incomplete downloads, and tamper-resistance
of the content-addressed URL is inherited from the signed manifest.
The `runtime-key.json` sidecar is listed in the manifest in the same way, so
its integrity is also covered by the manifest signature. We do **not** sign the
runtime key independently.
## Runtime key delivery
Two delivery strategies are defined behind `IRuntimeKeyDelivery`:
- `EnvVarKeyDelivery` — MVP, used on every platform. Sets
`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` on the
child `ProcessStartInfo.Environment`. The launcher never sets these on its
own process environment, so unrelated processes can't snoop them and the
values don't leak via `Environment.GetEnvironmentVariable` calls elsewhere
in the launcher.
- `SharedMemoryKeyDelivery` — Windows stub. Not implemented. See the class
docs for the planned approach (named `MemoryMappedFile` with a random
suffix, write-then-zero lifecycle, child reads via mapping name passed in
env). Left as a stub because the client-side receiver hasn't landed.
Threat model note: env vars are readable by any process that shares the
parent's user on Linux (`/proc/$pid/environ`, gated by `ptrace_scope` and by
file mode). That's identical to the current legacy flow in terms of secret
handling — the legacy release also trusts the local user — and the shared
memory strategy is planned for a future hardening pass, not as an MVP
requirement.
## Telemetry
`ClientAppliedReporter` is wired but disabled by default: `LauncherConfig.TelemetryUrlTemplate`
is empty. When set to a non-empty URL template (supporting `{format}` and
`{version}` placeholders) the reporter fires a single bounded POST after a
successful apply, with a hard 5-second timeout and warning-level logging on
failure. Failures never block the launch flow and are never retried.
The payload is intentionally small: `format`, `version`, `timestamp`. Nothing
personally identifying.
## Backward compatibility
Existing installs pointing at a manifest without a `format` field continue to
parse exactly as before: `Manifest.EffectiveFormat` defaults to
`legacy-json-blob` and `LegacyJsonBlobFormat` is a near-verbatim lift of the
old inline behaviour. The only visible difference is that the orchestrator's
log line now reads `format=legacy-json-blob`.
Signed manifests that ship an unknown `format` value are refused outright —
`ReleaseFormatFactory.Resolve` throws and the orchestrator reports
`OfflineFallback`. This prevents a future attacker who compromises the signing
key from downgrading clients via a fabricated `format: "no-verify"` field.
## File layout on disk
```
client/
├── Metin2.exe
├── runtime-key.json ← written from the manifest, loaded by M2PackFormat
├── pack/
│ ├── item.m2p ← staged/placed by the launcher, never opened
│ ├── mob.m2p
│ └── ...
└── .updates/
├── launcher.log
├── current-manifest.json
└── staging/ ← per-file resume, same as legacy
```
## Testing
Run `dotnet test -c Release` — the relevant suites are:
- `RuntimeKeyTests` — happy path, validation, load-from-disk
- `EnvVarDeliveryTests` — scoped env mutation, no current-process leak
- `LegacyJsonBlobFormatTests` / `M2PackFormatTests` / `ReleaseFormatFactoryTests`
- `ClientAppliedReporterTests` — disabled-by-default, success, 500, timeout,
connection refused
- `UpdateOrchestratorFormatDispatchTests` — signature failure path, offline
fallback when manifest fetch fails, result carries `Format` + `RuntimeKey`
- Extended `GameProcessTests` asserting the runtime key gets forwarded through
`BuildStartInfo` and does NOT leak into the launcher's own environment.
Total suite after this change is ~92 tests.