@quantsafe/mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MESSAGE_URL_PREFIX = exports.MESSAGE_URL_BASE = void 0;
4
+ exports.buildMessageUrl = buildMessageUrl;
5
+ exports.parseMessageUrl = parseMessageUrl;
6
+ const base64_1 = require("./base64");
7
+ const constants_1 = require("../lib/qsafe/constants");
8
+ exports.MESSAGE_URL_BASE = 'https://quantsafe.tech/m/#';
9
+ exports.MESSAGE_URL_PREFIX = 'v1.';
10
+ const PUBLIC_KEY_LENGTHS = {
11
+ mlkem: constants_1.ML_KEM_PK_LEN,
12
+ x25519: constants_1.X25519_PK_LEN,
13
+ mldsa: constants_1.ML_DSA_PK_LEN,
14
+ };
15
+ const SECRET_KEY_LENGTHS = {
16
+ mlkem: constants_1.ML_KEM_SK_LEN,
17
+ x25519: constants_1.X25519_SK_LEN,
18
+ mldsa: constants_1.ML_DSA_SK_LEN,
19
+ };
20
+ function malformed(segment) {
21
+ return new Error(`Malformed message URL: the ${segment} segment does not decode.`);
22
+ }
23
+ function serializeKey(obj) {
24
+ const out = {};
25
+ for (const k of Object.keys(obj)) {
26
+ out[k] = (0, base64_1.base64urlEncode)(obj[k]);
27
+ }
28
+ return JSON.stringify(out);
29
+ }
30
+ function encodeKey(key) {
31
+ return (0, base64_1.base64urlEncode)(new TextEncoder().encode(serializeKey(key)));
32
+ }
33
+ // A segment that decodes to the wrong shape would otherwise surface later as a parser or
34
+ // WebCrypto message that says nothing about the URL. Fields beyond the three are ignored.
35
+ function decodeKey(s, segment, lengths) {
36
+ let obj;
37
+ try {
38
+ obj = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode((0, base64_1.base64urlDecode)(s, segment)));
39
+ }
40
+ catch {
41
+ throw malformed(segment);
42
+ }
43
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj))
44
+ throw malformed(segment);
45
+ const fields = obj;
46
+ const out = {};
47
+ for (const [name, length] of Object.entries(lengths)) {
48
+ const value = fields[name];
49
+ if (typeof value !== 'string')
50
+ throw malformed(segment);
51
+ let bytes;
52
+ try {
53
+ bytes = (0, base64_1.base64urlDecode)(value, segment);
54
+ }
55
+ catch {
56
+ throw malformed(segment);
57
+ }
58
+ if (bytes.length !== length)
59
+ throw malformed(segment);
60
+ out[name] = bytes;
61
+ }
62
+ return out;
63
+ }
64
+ function buildMessageUrl(blob, signerPublic, embed) {
65
+ const parts = [exports.MESSAGE_URL_PREFIX + (0, base64_1.base64urlEncode)(blob), encodeKey(signerPublic)];
66
+ if (embed) {
67
+ parts.push(encodeKey(embed.sk));
68
+ parts.push(encodeKey(embed.pk));
69
+ }
70
+ return exports.MESSAGE_URL_BASE + parts.join('.');
71
+ }
72
+ function parseMessageUrl(url) {
73
+ if (typeof url !== 'string')
74
+ throw new Error('Malformed message URL: url must be a string.');
75
+ const hashIdx = url.indexOf('#');
76
+ if (hashIdx === -1)
77
+ throw new Error('Message URL missing fragment');
78
+ const frag = url.slice(hashIdx + 1);
79
+ if (!frag.startsWith(exports.MESSAGE_URL_PREFIX)) {
80
+ throw new Error('Unsupported message URL version');
81
+ }
82
+ const rest = frag.slice(exports.MESSAGE_URL_PREFIX.length);
83
+ const segs = rest.split('.');
84
+ if (segs.length !== 2 && segs.length !== 4) {
85
+ throw new Error('Malformed message URL');
86
+ }
87
+ let blob;
88
+ try {
89
+ blob = (0, base64_1.base64urlDecode)(segs[0], 'envelope');
90
+ }
91
+ catch {
92
+ throw malformed('envelope');
93
+ }
94
+ const signerPublic = decodeKey(segs[1], 'signer key', PUBLIC_KEY_LENGTHS);
95
+ if (segs.length === 2) {
96
+ return { blob, signerPublic };
97
+ }
98
+ const sk = decodeKey(segs[2], 'embedded secret key', SECRET_KEY_LENGTHS);
99
+ const pk = decodeKey(segs[3], 'embedded public key', PUBLIC_KEY_LENGTHS);
100
+ return { blob, signerPublic, ephemeralSecret: sk, ephemeralPublic: pk };
101
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const zod_1 = require("zod");
7
+ const tools_1 = require("./tools");
8
+ const tool_result_1 = require("./tool-result");
9
+ const KEYFILE_SCHEMA = zod_1.z.object({
10
+ v: zod_1.z.literal(1),
11
+ kdf: zod_1.z.literal('argon2id'),
12
+ kdfParams: zod_1.z.object({ t: zod_1.z.number(), m: zod_1.z.number(), p: zod_1.z.number() }),
13
+ salt: zod_1.z.string(),
14
+ nonce: zod_1.z.string(),
15
+ wrapped: zod_1.z.string(),
16
+ });
17
+ const SIG_HEX_RE = /^0x[0-9a-fA-F]{130}$/;
18
+ // 65-byte EOA signature (r||s||v), 0x-prefixed. Smart-contract wallet
19
+ // signatures are rejected: they are non-deterministic and cannot derive a
20
+ // stable keypair. This is root key material, pass it over local stdio only.
21
+ // The message is written as a JSON string so its line breaks are unambiguous: a signature over
22
+ // any other text recovers to another address and is refused.
23
+ const SIG_HEX = zod_1.z
24
+ .string()
25
+ .regex(SIG_HEX_RE, 'signature_hex must be a 65-byte EOA signature (0x + 130 hex chars)')
26
+ .describe(`65-byte EOA signature (0x + 130 hex chars) made with personal_sign (EIP-191) over this exact message, written here as a JSON string (lines joined by \\n, no trailing newline): ${JSON.stringify(tools_1.QSAFE_DERIVATION_MESSAGE)}. The browser app derives the same identity from the same signature. The signer is recovered and must equal the address passed with it. Pass the 65 bytes exactly as your wallet returned them: the identity is derived from those bytes, recovery byte (v) included, as in the browser app, so a signature rewritten from 0/1 to 27/28 (or back) derives a different identity. Root key material: local stdio only.`);
27
+ const ADDRESS = zod_1.z
28
+ .string()
29
+ .regex(/^0x[0-9a-fA-F]{40}$/, 'address must be an EVM address (0x + 40 hex chars)')
30
+ .describe('The EOA address that made the signature. Required with it.');
31
+ const SIG_HEX_REPEAT = zod_1.z
32
+ .string()
33
+ .regex(SIG_HEX_RE, 'signature_hex_repeat must be a 65-byte EOA signature (0x + 130 hex chars)')
34
+ .describe('Optional second signature of the same message by the same wallet. It must equal the first: MPC and threshold signers produce a different valid signature each time, and keys derived from them change between sessions. Pass it the first time a wallet is used.');
35
+ const KEY_SOURCE = {
36
+ keyfile_json: KEYFILE_SCHEMA.optional(),
37
+ passphrase: zod_1.z.string().optional(),
38
+ signature_hex: SIG_HEX.optional(),
39
+ address: ADDRESS.optional(),
40
+ signature_hex_repeat: SIG_HEX_REPEAT.optional(),
41
+ };
42
+ const SIGNER_KEY_SOURCE = {
43
+ signer_keyfile_json: KEYFILE_SCHEMA.optional(),
44
+ signer_passphrase: zod_1.z.string().optional(),
45
+ signer_signature_hex: SIG_HEX.optional(),
46
+ signer_address: ADDRESS.optional(),
47
+ signer_signature_hex_repeat: SIG_HEX_REPEAT.optional(),
48
+ };
49
+ const BASE64 = 'Standard base64 (A-Z, a-z, 0-9, +, / with = padding, no whitespace).';
50
+ const RECIPIENT_PUBKEY = `The recipient's hybrid public key. ${BASE64} It decodes to 3168 bytes: ML-KEM-768 (1184) || X25519 (32) || ML-DSA-65 (1952). It is the signer_pubkey_base64 that qsafe_sign_x402 or qsafe_encrypt_file returns when the recipient signs with its own keyfile or wallet signature: ask the recipient for that value.`;
51
+ // A signature over a v1 envelope covers only the envelope bytes: nothing about the signer is
52
+ // bound into the AEAD or the KEM, so a signature can be stripped and replaced.
53
+ const SIGNER_IS_NOT_AUTHOR = 'A verified signature shows which key signed this envelope, not who wrote its contents: anyone who sees an envelope can re-sign it with their own key.';
54
+ async function main() {
55
+ const server = new mcp_js_1.McpServer({ name: 'qsafe-mcp', version: '0.1.0' }, {
56
+ capabilities: {
57
+ tools: {},
58
+ },
59
+ });
60
+ server.registerTool('qsafe_encrypt_file', {
61
+ description: 'Encrypt bytes with the QuantSafe v1 hybrid PQC suite (ML-KEM-768 + X25519 + ML-DSA-65) to recipient_pubkey_base64. bytes_base64 must be standard base64 of the raw bytes; text or any other encoding is rejected. Returns a .qsafe blob, a Quantum Seal PNG (a 1x1 image whose text chunks carry the Seal fields, file_hash included; no rendered card) and signer_pubkey_base64; share signer_pubkey_base64 with the recipient, who needs it to decrypt. Sign as your wallet identity with signer_signature_hex + signer_address (personal_sign of the QuantSafe derivation message, quoted in the signer_signature_hex field description), or with signer_keyfile_json + signer_passphrase; omit every signer field for an anonymous ephemeral signer. A partial or doubled key source is rejected. Local stdio only.',
62
+ inputSchema: {
63
+ bytes_base64: zod_1.z.string().describe(`The bytes to encrypt. ${BASE64}`),
64
+ recipient_pubkey_base64: zod_1.z.string().describe(RECIPIENT_PUBKEY),
65
+ ...SIGNER_KEY_SOURCE,
66
+ },
67
+ }, async (args) => (0, tool_result_1.toolResult)(await (0, tools_1.qsafeEncryptFile)(args)));
68
+ server.registerTool('qsafe_decrypt_file', {
69
+ description: `Decrypt a .qsafe blob. Provide the recipient identity as keyfile_json + passphrase, or as signature_hex + address (personal_sign of the QuantSafe derivation message by that EOA). The ML-DSA-65 signature is always verified before decryption, and the blob does not carry its signer's key: pass the sender's signer_pubkey_base64 as expected_signer_pubkey_base64. It may be omitted only for a file you signed with the same key you decrypt with. ${SIGNER_IS_NOT_AUTHOR}`,
70
+ inputSchema: {
71
+ blob_base64: zod_1.z.string().describe(`The .qsafe envelope. ${BASE64}`),
72
+ ...KEY_SOURCE,
73
+ expected_signer_pubkey_base64: zod_1.z
74
+ .string()
75
+ .optional()
76
+ .describe("The sender's signer_pubkey_base64, as returned by qsafe_encrypt_file. Required unless the file was signed with the recipient's own key."),
77
+ },
78
+ }, async (args) => (0, tool_result_1.toolResult)(await (0, tools_1.qsafeDecryptFile)(args)));
79
+ server.registerTool('qsafe_send_message', {
80
+ description: 'Encrypt a UTF-8 message into a shareable URL. Returns url and mode. With recipient_pubkey_base64, only that key opens it (mode "recipient"). Without it, the URL carries its own decryption key and anyone holding the URL can read the message (mode "anyone_with_link"); an empty or malformed recipient_pubkey_base64 is rejected, never treated as omitted. Sign as your wallet identity with signer_signature_hex + signer_address, or with a keyfile, so a recipient who already knows your signer_pubkey_base64 can pin it; omit every signer field for an anonymous ephemeral signer. A partial or doubled key source is rejected.',
81
+ inputSchema: {
82
+ text: zod_1.z.string(),
83
+ recipient_pubkey_base64: zod_1.z
84
+ .string()
85
+ .optional()
86
+ .describe(`${RECIPIENT_PUBKEY} Omit it only for a URL that anyone holding it can read.`),
87
+ ...SIGNER_KEY_SOURCE,
88
+ },
89
+ }, async (args) => (0, tool_result_1.toolResult)(await (0, tools_1.qsafeSendMessage)(args)));
90
+ server.registerTool('qsafe_decrypt_message', {
91
+ description: `Decrypt a QuantSafe message URL and return its text and the key that signed it. URLs that carry their own key need no key source; recipient-mode URLs require keyfile_json + passphrase, or signature_hex + address. The URL names its own signer key, so the returned signer_pubkey_base64 is only the key the URL claims. Pass a key you already trust as expected_signer_pubkey_base64 and a URL signed by any other key is refused. ${SIGNER_IS_NOT_AUTHOR}`,
92
+ inputSchema: {
93
+ url: zod_1.z.string(),
94
+ ...KEY_SOURCE,
95
+ expected_signer_pubkey_base64: zod_1.z
96
+ .string()
97
+ .optional()
98
+ .describe('The signer_pubkey_base64 you expect, known from outside this URL. When given, a URL signed by any other key is refused. It confirms the signing key, not who wrote the text.'),
99
+ },
100
+ }, async (args) => (0, tool_result_1.toolResult)(await (0, tools_1.qsafeDecryptMessage)(args)));
101
+ server.registerTool('qsafe_verify_seal', {
102
+ description: 'Parse a Quantum Seal PNG and return the fields it carries (version, timestamp, algo, file_hash, optional x402 receipt fields, optional on-chain anchor fields). No Seal field is signed: every one is a claim anyone can write into a PNG. valid only means at least one Seal field was found; it checks nothing. This tool does not contact the chain. Pass the .qsafe envelope as blob_base64 to get file_hash_matches, true when sha256 of that envelope equals the Seal file_hash: the only check this tool makes. Who signed the envelope is checked when it is decrypted.',
103
+ inputSchema: {
104
+ png_base64: zod_1.z.string().describe(`The Seal PNG. ${BASE64}`),
105
+ blob_base64: zod_1.z
106
+ .string()
107
+ .optional()
108
+ .describe(`The .qsafe envelope the Seal is for. ${BASE64} When given, the result adds file_hash_matches.`),
109
+ },
110
+ }, async (args) => (0, tool_result_1.toolResult)((0, tools_1.qsafeVerifySeal)(args)));
111
+ server.registerTool('qsafe_sign_x402', {
112
+ description: 'Sign an x402 payload with ML-DSA-65 under context "qsafe-v1-x402". payload_json is a JSON object, not a JSON-encoded string. The signed bytes are the UTF-8 encoding of its canonical form: object keys sorted in JavaScript default string order at every level, no whitespace, arrays in order, strings, numbers, booleans and null as JSON.stringify writes them. Sign as your wallet identity with signature_hex + address, or with a keyfile. Local stdio only.',
113
+ inputSchema: {
114
+ payload_json: zod_1.z
115
+ .record(zod_1.z.string(), zod_1.z.unknown())
116
+ .describe('The x402 payload as a JSON object. A JSON-encoded string is rejected.'),
117
+ ...KEY_SOURCE,
118
+ },
119
+ }, async (args) => (0, tool_result_1.toolResult)(await (0, tools_1.qsafeSignX402)(args)));
120
+ const transport = new stdio_js_1.StdioServerTransport();
121
+ await server.connect(transport);
122
+ }
123
+ main().catch((err) => {
124
+ process.stderr.write(`qsafe-mcp fatal: ${err instanceof Error ? err.message : String(err)}\n`);
125
+ process.exit(1);
126
+ });
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toolResult = toolResult;
4
+ // Every tool catches its own failures into { error } so the agent still gets a
5
+ // parseable message; isError is what tells the client the call failed.
6
+ function toolResult(value) {
7
+ const failed = typeof value === 'object' && value !== null && 'error' in value;
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify(value),
13
+ },
14
+ ],
15
+ isError: failed,
16
+ };
17
+ }
@@ -0,0 +1,381 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildMinimalSealPng = exports.QSAFE_DERIVATION_MESSAGE = void 0;
4
+ exports.encodeHybridPublicKey = encodeHybridPublicKey;
5
+ exports.decodeHybridPublicKey = decodeHybridPublicKey;
6
+ exports.qsafeEncryptFile = qsafeEncryptFile;
7
+ exports.qsafeDecryptFile = qsafeDecryptFile;
8
+ exports.qsafeSendMessage = qsafeSendMessage;
9
+ exports.qsafeDecryptMessage = qsafeDecryptMessage;
10
+ exports.qsafeVerifySeal = qsafeVerifySeal;
11
+ exports.qsafeSignX402 = qsafeSignX402;
12
+ const sha2_1 = require("@noble/hashes/sha2");
13
+ const utils_1 = require("@noble/hashes/utils");
14
+ const base64_1 = require("./base64");
15
+ const canonical_json_1 = require("./canonical-json");
16
+ const png_text_1 = require("../lib/seal/png-text");
17
+ Object.defineProperty(exports, "buildMinimalSealPng", { enumerable: true, get: function () { return png_text_1.buildMinimalSealPng; } });
18
+ const constants_1 = require("../lib/seal/constants");
19
+ const message_url_1 = require("./message-url");
20
+ const wallet_identity_1 = require("./wallet-identity");
21
+ Object.defineProperty(exports, "QSAFE_DERIVATION_MESSAGE", { enumerable: true, get: function () { return wallet_identity_1.QSAFE_DERIVATION_MESSAGE; } });
22
+ const signer_determinism_1 = require("../lib/wallet/signer-determinism");
23
+ const file_1 = require("../lib/qsafe/file");
24
+ const index_1 = require("../lib/qsafe/index");
25
+ const constants_2 = require("../lib/qsafe/constants");
26
+ const MCP_FILE_NAME = 'mcp-file.bin';
27
+ const MCP_MESSAGE_FILENAME = 'mcp-message.txt';
28
+ function errorMessage(e) {
29
+ if (e instanceof Error)
30
+ return e.message;
31
+ return String(e);
32
+ }
33
+ // WebCrypto reports a failed AES-GCM tag check as an OperationError with a generic message. It
34
+ // only runs after the ML-DSA signature has verified, so the envelope is intact and the key
35
+ // opening it is the wrong one.
36
+ function isAeadFailure(e) {
37
+ return typeof e === 'object' && e !== null && e.name === 'OperationError';
38
+ }
39
+ const KEY_FIELD_NAMES = {
40
+ keyfile: 'keyfile_json',
41
+ passphrase: 'passphrase',
42
+ signature: 'signature_hex',
43
+ address: 'address',
44
+ repeat: 'signature_hex_repeat',
45
+ };
46
+ const SIGNER_FIELD_NAMES = {
47
+ keyfile: 'signer_keyfile_json',
48
+ passphrase: 'signer_passphrase',
49
+ signature: 'signer_signature_hex',
50
+ address: 'signer_address',
51
+ repeat: 'signer_signature_hex_repeat',
52
+ };
53
+ // A partial or doubled key source is a caller mistake, not a request for the
54
+ // fallback: on the signing tools the fallback is a random identity, which would
55
+ // hand back a valid-looking artifact signed by nobody the caller controls.
56
+ function selectKeySource(f, names) {
57
+ const { keyfile_json, passphrase, signature_hex, address, signature_hex_repeat } = f;
58
+ const walletGiven = signature_hex !== undefined || address !== undefined || signature_hex_repeat !== undefined;
59
+ const keyfileGiven = keyfile_json !== undefined || passphrase !== undefined;
60
+ if (walletGiven && keyfileGiven) {
61
+ throw new Error(`Conflicting key sources: supply ${names.signature} + ${names.address}, or ${names.keyfile} + ${names.passphrase}, not both.`);
62
+ }
63
+ if (walletGiven) {
64
+ if (signature_hex === undefined) {
65
+ const given = address !== undefined ? names.address : names.repeat;
66
+ throw new Error(`Incomplete key source: ${given} needs ${names.signature}.`);
67
+ }
68
+ if (address === undefined) {
69
+ throw new Error(`Incomplete key source: ${names.signature} needs ${names.address}, the EOA address that signed it.`);
70
+ }
71
+ return { kind: 'signature', signature_hex, address, repeat: signature_hex_repeat, names };
72
+ }
73
+ if (keyfile_json !== undefined && passphrase !== undefined) {
74
+ return { kind: 'keyfile', keyfile_json, passphrase };
75
+ }
76
+ if (keyfile_json !== undefined) {
77
+ throw new Error(`Incomplete key source: ${names.keyfile} needs ${names.passphrase}.`);
78
+ }
79
+ if (passphrase !== undefined) {
80
+ throw new Error(`Incomplete key source: ${names.passphrase} needs ${names.keyfile}.`);
81
+ }
82
+ return { kind: 'none' };
83
+ }
84
+ // The same guards the browser runs before it derives (constitution I): the EOA shape, then
85
+ // recovery of the signer of the fixed derivation message. Without recovery, any 65 bytes, a
86
+ // signature over other text or a 1-of-1 Safe owner signature included, would derive keys no
87
+ // browser session reproduces, and files from /app would later fail as if they were altered.
88
+ function sameRecoveryForm(signatureHex) {
89
+ const v = Number.parseInt(signatureHex.slice(130), 16);
90
+ return v === 0 || v === 1 ? `${signatureHex.slice(0, 130)}${(v + 27).toString(16)}` : signatureHex;
91
+ }
92
+ // Returns the bytes to derive from: the signature exactly as signed, as the browser derives it.
93
+ function walletDerivationSignature(source) {
94
+ const { names } = source;
95
+ try {
96
+ (0, index_1.assertEoaSignatureShape)(source.signature_hex);
97
+ }
98
+ catch {
99
+ throw new Error(`${names.signature} must be a 65-byte EOA signature (0x + 130 hex chars). Smart-contract wallet signatures are not supported.`);
100
+ }
101
+ if (!wallet_identity_1.EVM_ADDRESS_RE.test(source.address)) {
102
+ throw new Error(`${names.address} must be an EVM address (0x + 40 hex chars).`);
103
+ }
104
+ const signer = (0, wallet_identity_1.recoverPersonalSigner)(wallet_identity_1.QSAFE_DERIVATION_MESSAGE, source.signature_hex);
105
+ if (signer === null || signer !== source.address.toLowerCase()) {
106
+ throw new Error(`${names.signature} is not a personal_sign (EIP-191) signature by ${names.address} of the QuantSafe derivation message. Sign that exact text, given in the ${names.signature} field description, with the EOA at ${names.address}. Smart-contract wallet signatures are not supported.`);
107
+ }
108
+ const signature = (0, wallet_identity_1.canonicalRecoveryByte)(source.signature_hex);
109
+ // MPC and threshold signers draw a fresh nonce per signature: each one recovers to the
110
+ // address and still derives different keys, which would lock the owner out next session.
111
+ // The comparison ignores how v is written (0/1 or 27/28): that is an encoding, not a nonce.
112
+ if (source.repeat !== undefined &&
113
+ !(0, signer_determinism_1.signaturesMatch)(sameRecoveryForm(signature), sameRecoveryForm((0, wallet_identity_1.canonicalRecoveryByte)(source.repeat)))) {
114
+ throw new Error(`${names.signature} and ${names.repeat} differ: this signer does not sign deterministically (MPC and threshold signers often sign this way), so keys derived from it would change between sessions. Use an EOA with a local private key, or a key file.`);
115
+ }
116
+ return signature;
117
+ }
118
+ // The signature is root key material: only pass it across a local (stdio) MCP
119
+ // transport, never a network.
120
+ async function keypairFromSource(source) {
121
+ if (source.kind === 'signature') {
122
+ return (0, index_1.keysFromSeeds)((0, index_1.deriveSeedsFromWalletSignature)(walletDerivationSignature(source)));
123
+ }
124
+ return (0, index_1.unwrapKeyfile)(source.keyfile_json, source.passphrase);
125
+ }
126
+ async function requireKeypair(f) {
127
+ const source = selectKeySource(f, KEY_FIELD_NAMES);
128
+ if (source.kind === 'none') {
129
+ throw new Error('No key source: supply keyfile_json + passphrase, or signature_hex + address (an EOA signature of the QuantSafe derivation message).');
130
+ }
131
+ return keypairFromSource(source);
132
+ }
133
+ // Omitting every signer field is the only way to get an anonymous ephemeral signer.
134
+ async function signerKeypair(f) {
135
+ const source = selectKeySource(f, SIGNER_FIELD_NAMES);
136
+ if (source.kind === 'none')
137
+ return (0, index_1.keysFromSeeds)((0, index_1.deriveSeedsFromRandomness)());
138
+ return keypairFromSource(source);
139
+ }
140
+ function packHybridPublicKey(pk) {
141
+ const out = new Uint8Array(pk.mlkem.length + pk.x25519.length + pk.mldsa.length);
142
+ out.set(pk.mlkem, 0);
143
+ out.set(pk.x25519, pk.mlkem.length);
144
+ out.set(pk.mldsa, pk.mlkem.length + pk.x25519.length);
145
+ return out;
146
+ }
147
+ function unpackHybridPublicKey(bytes, label) {
148
+ const mlkemLen = 1184;
149
+ const x25519Len = 32;
150
+ const mldsaLen = 1952;
151
+ if (bytes.length !== mlkemLen + x25519Len + mldsaLen) {
152
+ throw new Error(`${label}: unexpected length`);
153
+ }
154
+ return {
155
+ mlkem: bytes.slice(0, mlkemLen),
156
+ x25519: bytes.slice(mlkemLen, mlkemLen + x25519Len),
157
+ mldsa: bytes.slice(mlkemLen + x25519Len),
158
+ };
159
+ }
160
+ function samePublicKey(a, b) {
161
+ const left = packHybridPublicKey(a);
162
+ const right = packHybridPublicKey(b);
163
+ return left.length === right.length && left.every((byte, i) => byte === right[i]);
164
+ }
165
+ function encodeHybridPublicKey(pk) {
166
+ return (0, base64_1.base64Encode)(packHybridPublicKey(pk));
167
+ }
168
+ // label names the offending field in the error, so an agent can tell a bad
169
+ // recipient key from a bad expected signer key.
170
+ function decodeHybridPublicKey(s, label = 'Recipient pubkey') {
171
+ return unpackHybridPublicKey((0, base64_1.base64Decode)(s, label), label);
172
+ }
173
+ async function qsafeEncryptFile(input) {
174
+ try {
175
+ if (!input.recipient_pubkey_base64) {
176
+ throw new Error('recipient_pubkey_base64 is required to produce a decryptable file');
177
+ }
178
+ const bytes = (0, base64_1.base64Decode)(input.bytes_base64, 'bytes_base64');
179
+ const signer = await signerKeypair({
180
+ keyfile_json: input.signer_keyfile_json,
181
+ passphrase: input.signer_passphrase,
182
+ signature_hex: input.signer_signature_hex,
183
+ address: input.signer_address,
184
+ signature_hex_repeat: input.signer_signature_hex_repeat,
185
+ });
186
+ const pub = decodeHybridPublicKey(input.recipient_pubkey_base64, 'recipient_pubkey_base64');
187
+ const blob = await (0, file_1.encryptFile)(bytes, MCP_FILE_NAME, pub, signer.secretKey);
188
+ // Same digest the web Seal carries: the anchor and /verify both key on sha256 of the envelope.
189
+ const seal = (0, png_text_1.buildMinimalSealPng)({
190
+ version: constants_1.SEAL_VERSION,
191
+ timestamp: new Date().toISOString(),
192
+ algo: constants_1.SEAL_ALGO,
193
+ file_hash: (0, utils_1.bytesToHex)((0, sha2_1.sha256)(blob)),
194
+ });
195
+ return {
196
+ blob_base64: (0, base64_1.base64Encode)(blob),
197
+ seal_png_base64: (0, base64_1.base64Encode)(seal),
198
+ signer_pubkey_base64: encodeHybridPublicKey(signer.publicKey),
199
+ };
200
+ }
201
+ catch (e) {
202
+ return { error: errorMessage(e) };
203
+ }
204
+ }
205
+ async function qsafeDecryptFile(input) {
206
+ try {
207
+ const blob = (0, base64_1.base64Decode)(input.blob_base64, 'blob_base64');
208
+ const kp = await requireKeypair({
209
+ keyfile_json: input.keyfile_json,
210
+ passphrase: input.passphrase,
211
+ signature_hex: input.signature_hex,
212
+ address: input.address,
213
+ signature_hex_repeat: input.signature_hex_repeat,
214
+ });
215
+ // The .qsafe container does not carry its signer's key, so the signature can
216
+ // only be checked against a key the caller supplies. Without one, the only
217
+ // key on hand is the recipient's own, which fits self-signed files only.
218
+ const signerB64 = input.expected_signer_pubkey_base64;
219
+ const signerGiven = signerB64 !== undefined;
220
+ const expectedSigner = signerGiven
221
+ ? decodeHybridPublicKey(signerB64, 'expected_signer_pubkey_base64')
222
+ : kp.publicKey;
223
+ let decrypted;
224
+ try {
225
+ decrypted = await (0, file_1.decryptFile)(blob, kp.secretKey, expectedSigner);
226
+ }
227
+ catch (e) {
228
+ if (isAeadFailure(e)) {
229
+ throw new Error('The file is not encrypted to this key source (keyfile_json + passphrase, or signature_hex + address): its signature verified, but this key cannot open it.');
230
+ }
231
+ if (!(e instanceof index_1.SignatureError))
232
+ throw e;
233
+ throw new Error(signerGiven
234
+ ? 'Signature verification failed: the file was not signed by expected_signer_pubkey_base64, or it was altered.'
235
+ : "Signature verification failed against the recipient's own key: the file was signed by a different key, or altered. Pass the sender's signer_pubkey_base64 (returned by qsafe_encrypt_file) as expected_signer_pubkey_base64.");
236
+ }
237
+ return {
238
+ plaintext_base64: (0, base64_1.base64Encode)(decrypted.fileBytes),
239
+ signer_pubkey_base64: encodeHybridPublicKey(expectedSigner),
240
+ filename: decrypted.filename,
241
+ };
242
+ }
243
+ catch (e) {
244
+ return { error: errorMessage(e) };
245
+ }
246
+ }
247
+ async function qsafeSendMessage(input) {
248
+ try {
249
+ if (typeof input.text !== 'string') {
250
+ throw new Error('text must be a string');
251
+ }
252
+ const textBytes = new TextEncoder().encode(input.text);
253
+ const signer = await signerKeypair({
254
+ keyfile_json: input.signer_keyfile_json,
255
+ passphrase: input.signer_passphrase,
256
+ signature_hex: input.signer_signature_hex,
257
+ address: input.signer_address,
258
+ signature_hex_repeat: input.signer_signature_hex_repeat,
259
+ });
260
+ let recipientPub;
261
+ let embed;
262
+ // Only an omitted field selects the link mode. An empty or broken key is a caller mistake,
263
+ // and falling back would publish the text to anyone who sees the URL.
264
+ if (input.recipient_pubkey_base64 === undefined) {
265
+ const eph = (0, index_1.keysFromSeeds)((0, index_1.deriveSeedsFromRandomness)());
266
+ recipientPub = eph.publicKey;
267
+ embed = { sk: eph.secretKey, pk: eph.publicKey };
268
+ }
269
+ else {
270
+ if (input.recipient_pubkey_base64 === '') {
271
+ throw new Error('recipient_pubkey_base64 is empty. Pass the recipient key, or omit the field for a URL anyone holding it can read.');
272
+ }
273
+ recipientPub = decodeHybridPublicKey(input.recipient_pubkey_base64, 'recipient_pubkey_base64');
274
+ }
275
+ const blob = await (0, file_1.encryptFile)(textBytes, MCP_MESSAGE_FILENAME, recipientPub, signer.secretKey);
276
+ return {
277
+ url: (0, message_url_1.buildMessageUrl)(blob, signer.publicKey, embed),
278
+ mode: embed ? 'anyone_with_link' : 'recipient',
279
+ };
280
+ }
281
+ catch (e) {
282
+ return { error: errorMessage(e) };
283
+ }
284
+ }
285
+ async function qsafeDecryptMessage(input) {
286
+ try {
287
+ const source = selectKeySource({
288
+ keyfile_json: input.keyfile_json,
289
+ passphrase: input.passphrase,
290
+ signature_hex: input.signature_hex,
291
+ address: input.address,
292
+ signature_hex_repeat: input.signature_hex_repeat,
293
+ }, KEY_FIELD_NAMES);
294
+ const expectedSigner = input.expected_signer_pubkey_base64 === undefined
295
+ ? undefined
296
+ : decodeHybridPublicKey(input.expected_signer_pubkey_base64, 'expected_signer_pubkey_base64');
297
+ const parts = (0, message_url_1.parseMessageUrl)(input.url);
298
+ // The URL names its own signer key, so a valid signature alone proves only that whoever
299
+ // built the URL holds that key. A pin to a key known out of band confirms which key signed
300
+ // the envelope, not who wrote the text: v1 binds no signer into the AEAD or the KEM, so
301
+ // anyone who sees the URL can strip the signature and re-sign the same envelope.
302
+ if (expectedSigner && !samePublicKey(expectedSigner, parts.signerPublic)) {
303
+ throw new Error('The message URL is signed by a different key than expected_signer_pubkey_base64.');
304
+ }
305
+ let kp;
306
+ let linkMode = false;
307
+ if (parts.ephemeralSecret && parts.ephemeralPublic) {
308
+ kp = { secretKey: parts.ephemeralSecret, publicKey: parts.ephemeralPublic };
309
+ linkMode = true;
310
+ }
311
+ else {
312
+ if (source.kind === 'none') {
313
+ throw new Error('Recipient-mode URL requires keyfile_json + passphrase, or signature_hex + address');
314
+ }
315
+ kp = await keypairFromSource(source);
316
+ }
317
+ let decrypted;
318
+ try {
319
+ decrypted = await (0, file_1.decryptFile)(parts.blob, kp.secretKey, parts.signerPublic);
320
+ }
321
+ catch (e) {
322
+ if (e instanceof index_1.SignatureError) {
323
+ throw new Error('Signature verification failed: the message URL was altered.');
324
+ }
325
+ if (!isAeadFailure(e))
326
+ throw e;
327
+ throw new Error(linkMode
328
+ ? 'The key carried in the message URL does not open it: the URL was altered.'
329
+ : 'The message is not encrypted to this key source (keyfile_json + passphrase, or signature_hex + address).');
330
+ }
331
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(decrypted.fileBytes);
332
+ return { text, signer_pubkey_base64: encodeHybridPublicKey(parts.signerPublic) };
333
+ }
334
+ catch (e) {
335
+ return { error: errorMessage(e) };
336
+ }
337
+ }
338
+ // No Seal field is signed, so valid can only mean a Seal field is present (the parser leaves
339
+ // anchor-only images out; here they count). The envelope digest is the one claim checkable
340
+ // offline, and only against the envelope itself.
341
+ function qsafeVerifySeal(input) {
342
+ try {
343
+ const png = (0, base64_1.base64Decode)(input.png_base64, 'png_base64');
344
+ const blob = input.blob_base64 === undefined ? undefined : (0, base64_1.base64Decode)(input.blob_base64, 'blob_base64');
345
+ const { fields } = (0, png_text_1.parseSealTextChunks)(png);
346
+ const out = { valid: Object.keys(fields).length > 0, metadata: fields };
347
+ if (blob !== undefined) {
348
+ out.file_hash_matches = fields.file_hash?.toLowerCase() === (0, utils_1.bytesToHex)((0, sha2_1.sha256)(blob));
349
+ }
350
+ return out;
351
+ }
352
+ catch (e) {
353
+ return { error: errorMessage(e) };
354
+ }
355
+ }
356
+ async function qsafeSignX402(input) {
357
+ try {
358
+ const kp = await requireKeypair({
359
+ keyfile_json: input.keyfile_json,
360
+ passphrase: input.passphrase,
361
+ signature_hex: input.signature_hex,
362
+ address: input.address,
363
+ signature_hex_repeat: input.signature_hex_repeat,
364
+ });
365
+ // A JSON-encoded string would canonicalize to a quoted string literal, a
366
+ // different byte string from the object any verifier rebuilds.
367
+ if (!(0, canonical_json_1.isJsonObject)(input.payload_json)) {
368
+ throw new Error('payload_json must be a JSON object. Pass the parsed object, not a JSON-encoded string, array or scalar.');
369
+ }
370
+ const canonical = (0, canonical_json_1.canonicalize)(input.payload_json);
371
+ const msg = new TextEncoder().encode(canonical);
372
+ const sig = (0, index_1.sign)(kp.secretKey.mldsa, msg, constants_2.X402_CONTEXT);
373
+ return {
374
+ signature_base64: (0, base64_1.base64Encode)(sig),
375
+ signer_pubkey_base64: encodeHybridPublicKey(kp.publicKey),
376
+ };
377
+ }
378
+ catch (e) {
379
+ return { error: errorMessage(e) };
380
+ }
381
+ }