Files
metin-launcher/docs/m2pack-integration.md
Jan Nedbal 1790502b58 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>
2026-04-14 21:12:08 +02:00

5.3 KiB

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.