From cc1e4af651e9e2a32702975989b485937f932e61 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 12:26:22 +0200 Subject: [PATCH] Add runtime key export and launcher contract --- README.md | 15 +++++++ docs/client-integration.md | 4 ++ docs/launcher-contract.md | 72 +++++++++++++++++++++++++++++++ docs/mcp.md | 10 +++++ mcp_server.mjs | 28 +++++++++++++ mcp_server.py | 24 +++++++++++ src/cli.cpp | 86 +++++++++++++++++++++++++++++++++++++- 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 docs/launcher-contract.md diff --git a/README.md b/README.md index 5cd23e6..852f673 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ with. - `m2pack verify` - `m2pack extract` - `m2pack export-client-config` +- `m2pack export-runtime-key` ## MCP Server @@ -63,6 +64,7 @@ Exposed tools: - `pack_verify` - `pack_extract` - `pack_export_client_config` +- `pack_export_runtime_key` - `pack_binary_info` Smoke test: @@ -152,6 +154,18 @@ Export a client config header for `m2dev-client-src/src/PackLib/M2PackKeys.h`: --output /path/to/m2dev-client-src/src/PackLib/M2PackKeys.h ``` +Export a runtime key payload for a launcher or CI handoff: + +```bash +./build/m2pack export-runtime-key \ + --key keys/master.key \ + --public-key keys/signing.pub \ + --key-id 1 \ + --format json \ + --output out/runtime-key.json \ + --json +``` + Diff a source tree against an archive: ```bash @@ -172,3 +186,4 @@ Diff a source tree against an archive: See [docs/format.md](docs/format.md) and [docs/client-integration.md](docs/client-integration.md). For Codex and Claude Code MCP setup, see [docs/agent-setup.md](docs/agent-setup.md). +For the runtime key payload contract, see [docs/launcher-contract.md](docs/launcher-contract.md). diff --git a/docs/client-integration.md b/docs/client-integration.md index 70acb65..59292dc 100644 --- a/docs/client-integration.md +++ b/docs/client-integration.md @@ -31,6 +31,7 @@ Generate it from the release key material with: m2pack export-client-config \ --key keys/master.key \ --public-key keys/signing.pub \ + --key-id 1 \ --output /path/to/m2dev-client-src/src/PackLib/M2PackKeys.h ``` @@ -71,6 +72,7 @@ Supported inputs, highest priority last: --m2pack-key-hex <64-hex-master-key> --m2pack-pubkey-hex <64-hex-public-key> --m2pack-key-map +--m2pack-key-id ``` ### Environment @@ -79,6 +81,7 @@ Supported inputs, highest priority last: M2PACK_MASTER_KEY_HEX M2PACK_SIGN_PUBKEY_HEX M2PACK_KEY_MAP +M2PACK_KEY_ID ``` ### Shared memory @@ -96,6 +99,7 @@ struct M2PackSharedKeys { char magic[8]; // "M2KEYS1\0" uint32_t version; // 1 uint32_t flags; // reserved + uint32_t key_id; // runtime master key slot uint8_t master_key[32]; uint8_t sign_public_key[32]; }; diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md new file mode 100644 index 0000000..10b81e3 --- /dev/null +++ b/docs/launcher-contract.md @@ -0,0 +1,72 @@ +# Launcher contract + +`m2pack` can export a runtime key payload for the Windows client loader. + +That payload is meant for a launcher, bootstrapper, or CI handoff step that +delivers the active release key material at runtime. + +## Command + +```bash +./build/m2pack export-runtime-key \ + --key keys/master.key \ + --public-key keys/signing.pub \ + --key-id 1 \ + --format json \ + --output out/runtime-key.json \ + --json +``` + +Options: + +- `--key` +- `--public-key` +- `--key-id` optional, defaults to `1` +- `--format json|blob` optional, defaults to `json` +- `--output` + +## JSON format + +Use this for CI, scripts, and launcher preprocessing: + +```json +{ + "version": 1, + "mapping_name": "Local\\M2PackSharedKeys", + "key_id": 1, + "master_key_hex": "<64 hex chars>", + "sign_public_key_hex": "<64 hex chars>" +} +``` + +## Binary format + +Use this when a launcher wants to write the exact shared-memory payload expected +by the client: + +```c +struct M2PackSharedKeys { + char magic[8]; // "M2KEYS1\0" + uint32_t version; // 1 + uint32_t flags; // reserved + uint32_t key_id; // runtime master key slot + uint8_t master_key[32]; + uint8_t sign_public_key[32]; +}; +``` + +The client currently expects: + +- `magic = "M2KEYS1\0"` +- `version = 1` +- `flags = 0` +- `key_id` matching the archive header `key_id` + +## Recommended flow + +1. Linux CI builds `.m2p` with `m2pack build --key-id `. +2. Linux CI exports `M2PackKeys.h` with `m2pack export-client-config`. +3. Linux CI exports a runtime key payload with `m2pack export-runtime-key`. +4. The Windows launcher creates `Local\\M2PackSharedKeys`. +5. The launcher writes the blob and starts the client with `--m2pack-key-map`. +6. The client rejects `.m2p` loading if the runtime key is missing or the `key_id` does not match. diff --git a/docs/mcp.md b/docs/mcp.md index 6f0015c..d23724d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -83,6 +83,16 @@ Inputs: - `output_header` - `key_id` optional +### `pack_export_runtime_key` + +Inputs: + +- `key_file` +- `public_key_file` +- `output_file` +- `key_id` optional +- `format` optional, `json` or `blob` + ### `pack_binary_info` No input. Returns the active `m2pack` binary path. diff --git a/mcp_server.mjs b/mcp_server.mjs index 475a8ef..b7b22c7 100644 --- a/mcp_server.mjs +++ b/mcp_server.mjs @@ -185,6 +185,34 @@ server.tool( ), ); +server.tool( + "pack_export_runtime_key", + "Generate a launcher runtime key payload in json or blob form.", + { + key_file: z.string(), + public_key_file: z.string(), + output_file: z.string(), + key_id: z.number().int().positive().optional(), + format: z.enum(["json", "blob"]).optional(), + }, + async ({ key_file, public_key_file, output_file, key_id, format }) => + toolResult( + runCli([ + "export-runtime-key", + "--key", + key_file, + "--public-key", + public_key_file, + "--key-id", + String(key_id ?? 1), + "--format", + format ?? "json", + "--output", + output_file, + ]), + ), +); + server.tool( "pack_binary_info", "Report which m2pack binary the server will execute.", diff --git a/mcp_server.py b/mcp_server.py index eeb2de5..ff6f8bb 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -158,6 +158,30 @@ def pack_export_client_config( ) +@mcp.tool() +def pack_export_runtime_key( + key_file: str, + public_key_file: str, + output_file: str, + key_id: int = 1, + format: str = "json", +) -> dict[str, Any]: + """Generate a launcher runtime key payload in json or blob form.""" + return _run_cli( + "export-runtime-key", + "--key", + key_file, + "--public-key", + public_key_file, + "--key-id", + str(key_id), + "--format", + format, + "--output", + output_file, + ) + + @mcp.tool() def pack_binary_info() -> dict[str, Any]: """Report which m2pack binary the server will execute.""" diff --git a/src/cli.cpp b/src/cli.cpp index d6b6962..73d7d3f 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -120,6 +120,18 @@ struct SnapshotEntry using SnapshotMap = std::map; +#pragma pack(push, 1) +struct LauncherSharedKeysBlob +{ + char magic[8]; + std::uint32_t version; + std::uint32_t flags; + std::uint32_t key_id; + std::uint8_t master_key[kAeadKeySize]; + std::uint8_t sign_public_key[crypto_sign_PUBLICKEYBYTES]; +}; +#pragma pack(pop) + SnapshotMap snapshot_from_directory(const std::filesystem::path& root) { SnapshotMap snapshot; @@ -260,7 +272,8 @@ void print_usage() << " list --archive [--json]\n" << " verify --archive [--public-key ] [--key ] [--json]\n" << " extract --archive --output --key [--json]\n" - << " export-client-config --key --public-key --output [--json]\n"; + << " export-runtime-key --key --public-key --output [--key-id ] [--format json|blob] [--json]\n" + << " export-client-config --key --public-key --output [--key-id ] [--json]\n"; } void command_keygen(const ParsedArgs& args) @@ -495,6 +508,72 @@ void command_export_client_config(const ParsedArgs& args) std::cout << "Wrote client config to " << output_path << "\n"; } +void command_export_runtime_key(const ParsedArgs& args) +{ + const auto master = load_hex_file(require_option(args, "key")); + const auto public_key = load_hex_file(require_option(args, "public-key")); + const auto output_path = require_option(args, "output"); + const auto format = optional_option(args, "format").value_or("json"); + const auto key_id = load_key_id(args); + + if (master.size() != kAeadKeySize) + { + fail("Master key must be 32 bytes"); + } + + if (public_key.size() != crypto_sign_PUBLICKEYBYTES) + { + fail("Signing public key has invalid size"); + } + + if (format == "json") + { + std::ostringstream out; + out + << "{\n" + << " \"version\": 1,\n" + << " \"mapping_name\": \"Local\\\\M2PackSharedKeys\",\n" + << " \"key_id\": " << key_id << ",\n" + << " \"master_key_hex\": \"" << to_hex(master) << "\",\n" + << " \"sign_public_key_hex\": \"" << to_hex(public_key) << "\"\n" + << "}\n"; + const auto text = out.str(); + write_file(output_path, std::vector(text.begin(), text.end())); + } + else if (format == "blob") + { + LauncherSharedKeysBlob blob {}; + std::memcpy(blob.magic, "M2KEYS1", 8); + blob.version = 1; + blob.flags = 0; + blob.key_id = key_id; + std::memcpy(blob.master_key, master.data(), master.size()); + std::memcpy(blob.sign_public_key, public_key.data(), public_key.size()); + + std::vector bytes(sizeof(blob)); + std::memcpy(bytes.data(), &blob, sizeof(blob)); + write_file(output_path, bytes); + } + else + { + fail("Unsupported format for export-runtime-key: " + format); + } + + if (args.json) + { + std::cout + << "{" + << "\"ok\":true," + << "\"output\":\"" << json_escape(output_path) << "\"," + << "\"format\":\"" << json_escape(format) << "\"," + << "\"key_id\":" << key_id + << "}\n"; + return; + } + + std::cout << "Wrote runtime key payload to " << output_path << "\n"; +} + } // namespace int run_cli(int argc, char** argv) @@ -544,6 +623,11 @@ int run_cli(int argc, char** argv) command_export_client_config(args); return 0; } + if (args.command == "export-runtime-key") + { + command_export_runtime_key(args); + return 0; + } if (args.command == "help" || args.command == "--help" || args.command == "-h") { print_usage();