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>
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:
- Fetch the signed
manifest.jsonand its.sigexactly as before. - Verify Ed25519 signature against the compiled-in public key, exactly as before.
- Read the new top-level
formatfield (falling back tolegacy-json-blob). - Dispatch through
ReleaseFormatFactory.Resolve(...)to anIReleaseFormat. - Diff, download and atomically apply the file entries listed in the manifest.
For m2pack these are
.m2ppack archives plus aruntime-key.jsonsidecar. - Never open, decrypt or decompress a
.m2parchive. Integrity of the archive contents is the client's problem, gated on the runtime key. - Load
runtime-key.jsonfrom the client root and forward it toMetin2.exevia 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. SetsM2PACK_MASTER_KEY_HEX,M2PACK_SIGN_PUBKEY_HEXandM2PACK_KEY_IDon the childProcessStartInfo.Environment. The launcher never sets these on its own process environment, so unrelated processes can't snoop them and the values don't leak viaEnvironment.GetEnvironmentVariablecalls elsewhere in the launcher.SharedMemoryKeyDelivery— Windows stub. Not implemented. See the class docs for the planned approach (namedMemoryMappedFilewith 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-diskEnvVarDeliveryTests— scoped env mutation, no current-process leakLegacyJsonBlobFormatTests/M2PackFormatTests/ReleaseFormatFactoryTestsClientAppliedReporterTests— disabled-by-default, success, 500, timeout, connection refusedUpdateOrchestratorFormatDispatchTests— signature failure path, offline fallback when manifest fetch fails, result carriesFormat+RuntimeKey- Extended
GameProcessTestsasserting the runtime key gets forwarded throughBuildStartInfoand does NOT leak into the launcher's own environment.
Total suite after this change is ~92 tests.