mycomesh-consumer 0.1.53 → 0.2.1

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,54 @@
1
+ // V11 Consumer disputes: keep each request's plaintexts locally, reveal them as evidence on demand.
2
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJson } from "./secure-envelope.mjs";
5
+ import { encodeCall, encodeWords, hex, keccak, settlementKey } from "./eip712.mjs";
6
+
7
+ export const EVIDENCE_SCHEMA = "mycomesh.v11.evidence.v1";
8
+ const JOURNAL_RETENTION_MS = 3 * 24 * 3600 * 1000; // the dispute window is 24 hours
9
+
10
+ const journal = (dir) => join(dir, "requests");
11
+
12
+ export function recordRequest(dir, prepared, opened, relayUrl) {
13
+ const folder = journal(dir);
14
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
15
+ const key = settlementKey(prepared.authorization.key, prepared.authorization.request_id);
16
+ const path = join(folder, `${key}.json`);
17
+ writeFileSync(path, JSON.stringify({
18
+ settlement_key: key, relay_url: relayUrl, recorded_at: Math.floor(Date.now() / 1000),
19
+ signed_receipt: opened.receipt, request: Buffer.from(prepared.requestPlaintext).toString("base64"),
20
+ response: Buffer.from(opened.responsePlaintext).toString("base64"),
21
+ }), { mode: 0o600 });
22
+ chmodSync(path, 0o600);
23
+ for (const name of readdirSync(folder)) {
24
+ const file = join(folder, name);
25
+ if (Date.now() - statSync(file).mtimeMs > JOURNAL_RETENTION_MS) unlinkSync(file);
26
+ }
27
+ return key;
28
+ }
29
+
30
+ export function loadRequest(dir, which) {
31
+ const folder = journal(dir);
32
+ let name = `${String(which).toLowerCase()}.json`;
33
+ if (which === "last") {
34
+ const files = readdirSync(folder).filter((file) => file.endsWith(".json"));
35
+ if (!files.length) throw new Error("no recorded requests");
36
+ name = files.sort((a, b) => statSync(join(folder, b)).mtimeMs - statSync(join(folder, a)).mtimeMs)[0];
37
+ }
38
+ return JSON.parse(readFileSync(join(folder, name), "utf8"));
39
+ }
40
+
41
+ export function buildEvidence(record, { reasonCode, statement }) {
42
+ return {
43
+ schema: EVIDENCE_SCHEMA, settlement_key: record.settlement_key, signed_receipt: record.signed_receipt,
44
+ request: record.request, response: record.response,
45
+ allegation: { reason_code: String(reasonCode).slice(0, 64), statement: String(statement).slice(0, 4000) },
46
+ };
47
+ }
48
+
49
+ export const evidenceHash = (evidence) => hex(keccak(Buffer.from(canonicalJson(evidence), "utf8")));
50
+
51
+ export const reportId = (key, reporter, digest) =>
52
+ hex(keccak(encodeWords([["bytes32", key], ["address", reporter], ["bytes32", digest]])));
53
+
54
+ export const openDisputeCall = (key, digest) => encodeCall("openDispute(bytes32,bytes32)", [["bytes32", key], ["bytes32", digest]]);
package/src/eip712.mjs ADDED
@@ -0,0 +1,151 @@
1
+ // EVM primitives for the V11 Consumer; mirrors mycomesh/evm.py and mycomesh/settlement.py.
2
+ import { secp256k1 } from "@noble/curves/secp256k1";
3
+ import { keccak_256 } from "@noble/hashes/sha3.js";
4
+
5
+ export const DOMAIN_NAME = "MycoMesh Settlement";
6
+ export const DOMAIN_VERSION = "11";
7
+ export const MAX_AUTHORIZATION_TTL = 3 * 60 * 60;
8
+
9
+ const enc = new TextEncoder();
10
+ export const keccak = (bytes) => Buffer.from(keccak_256(bytes));
11
+ export const hex = (bytes) => `0x${Buffer.from(bytes).toString("hex")}`;
12
+ export const unhex = (value) => Buffer.from(String(value).replace(/^0x/, ""), "hex");
13
+ const typehash = (text) => keccak(enc.encode(text));
14
+
15
+ export const AUTHORIZATION_TYPEHASH = typehash(
16
+ "PaymentAuthorization(bytes32 requestId,bytes32 requestHash,address key,address providerSigner,"
17
+ + "address relaySigner,uint256 maxFee,uint64 issuedAt,uint64 executeBy,uint64 deadline)");
18
+ export const RECEIPT_TYPEHASH = typehash(
19
+ "UsageReceipt(bytes32 authorizationHash,bytes32 dispatchHash,bytes32 responseHash,uint256 inputTokens,"
20
+ + "uint256 outputTokens,uint256 actualFee)");
21
+ export const DISPATCH_TYPEHASH = typehash("RelayDispatch(bytes32 authorizationHash)");
22
+ export const PROVIDER_TRANSPORT_TYPEHASH = typehash(
23
+ "ProviderTransport(address providerSigner,bytes32 identityPublicKey,bytes32 transportKeyId,uint64 expiresAt)");
24
+ const DOMAIN_TYPEHASH = typehash("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
25
+
26
+ export function normalizeAddress(value) {
27
+ if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value)) throw new Error(`invalid address: ${value}`);
28
+ return value.toLowerCase();
29
+ }
30
+
31
+ export function word(kind, value) {
32
+ if (kind === "bytes32") {
33
+ if (typeof value !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(value)) throw new Error(`invalid bytes32: ${value}`);
34
+ return unhex(value);
35
+ }
36
+ if (kind === "address") return Buffer.concat([Buffer.alloc(12), unhex(normalizeAddress(value))]);
37
+ if (kind === "uint") {
38
+ const number = BigInt(value);
39
+ if (number < 0n || number >= 1n << 256n) throw new Error("uint out of range");
40
+ return Buffer.from(number.toString(16).padStart(64, "0"), "hex");
41
+ }
42
+ if (kind === "bool") return word("uint", value ? 1 : 0);
43
+ throw new Error(`unsupported word type ${kind}`);
44
+ }
45
+
46
+ export const encodeWords = (pairs) => Buffer.concat(pairs.map(([kind, value]) => word(kind, value)));
47
+
48
+ export function addressOf(privateKey) {
49
+ const pub = secp256k1.getPublicKey(unhex(privateKey), false);
50
+ return hex(keccak(pub.subarray(1)).subarray(12));
51
+ }
52
+
53
+ export function signDigest(privateKey, digest) {
54
+ const signature = secp256k1.sign(digest, unhex(privateKey), { lowS: true });
55
+ return hex(Buffer.concat([
56
+ Buffer.from(signature.r.toString(16).padStart(64, "0"), "hex"),
57
+ Buffer.from(signature.s.toString(16).padStart(64, "0"), "hex"),
58
+ Buffer.from([27 + signature.recovery]),
59
+ ]));
60
+ }
61
+
62
+ export function recoverAddress(digest, signatureHex) {
63
+ const raw = unhex(signatureHex);
64
+ if (raw.length !== 65) throw new Error("signature must be 65 bytes");
65
+ const v = raw[64] >= 27 ? raw[64] - 27 : raw[64];
66
+ const r = BigInt(hex(raw.subarray(0, 32)));
67
+ const s = BigInt(hex(raw.subarray(32, 64)));
68
+ if (s > secp256k1.CURVE.n / 2n || (v !== 0 && v !== 1)) throw new Error("signature is not canonical");
69
+ const point = new secp256k1.Signature(r, s).addRecoveryBit(v).recoverPublicKey(digest);
70
+ return hex(keccak(point.toRawBytes(false).subarray(1)).subarray(12));
71
+ }
72
+
73
+ export function domainSeparator(chainId, verifyingContract) {
74
+ return keccak(encodeWords([
75
+ ["bytes32", hex(DOMAIN_TYPEHASH)], ["bytes32", hex(keccak(enc.encode(DOMAIN_NAME)))],
76
+ ["bytes32", hex(keccak(enc.encode(DOMAIN_VERSION)))], ["uint", chainId], ["address", verifyingContract],
77
+ ]));
78
+ }
79
+
80
+ export const typedDigest = (deployment, structHash) =>
81
+ keccak(Buffer.concat([Buffer.from([0x19, 0x01]), domainSeparator(deployment.chainId, deployment.settlement), structHash]));
82
+
83
+ export function authorizationStructHash(a) {
84
+ return keccak(encodeWords([
85
+ ["bytes32", hex(AUTHORIZATION_TYPEHASH)], ["bytes32", a.request_id], ["bytes32", a.request_hash],
86
+ ["address", a.key], ["address", a.provider_signer], ["address", a.relay_signer], ["uint", a.max_fee],
87
+ ["uint", a.issued_at], ["uint", a.execute_by], ["uint", a.deadline],
88
+ ]));
89
+ }
90
+
91
+ export const dispatchStructHash = (authorizationHash) =>
92
+ keccak(encodeWords([["bytes32", hex(DISPATCH_TYPEHASH)], ["bytes32", hex(authorizationHash)]]));
93
+
94
+ export function receiptStructHash(r) {
95
+ return keccak(encodeWords([
96
+ ["bytes32", hex(RECEIPT_TYPEHASH)], ["bytes32", r.authorization_hash], ["bytes32", r.dispatch_hash],
97
+ ["bytes32", r.response_hash], ["uint", r.input_tokens], ["uint", r.output_tokens], ["uint", r.actual_fee],
98
+ ]));
99
+ }
100
+
101
+ export const requestIdFor = (key, nonce) => hex(keccak(encodeWords([["address", key], ["bytes32", nonce]])));
102
+ export const settlementKey = (key, requestId) => hex(keccak(encodeWords([["address", key], ["bytes32", requestId]])));
103
+
104
+ /** Every check the settlement contract makes that needs no chain state. */
105
+ export function verifySignedReceipt(signed, deployment) {
106
+ const a = signed.authorization;
107
+ const r = signed.receipt;
108
+ const authHash = authorizationStructHash(a);
109
+ if (r.authorization_hash !== hex(authHash) || r.dispatch_hash !== hex(dispatchStructHash(authHash))) {
110
+ throw new Error("receipt is not bound to its authorization");
111
+ }
112
+ if (!(BigInt(r.actual_fee) > 0n && BigInt(r.actual_fee) <= BigInt(a.max_fee))) throw new Error("receipt fee exceeds the authorization");
113
+ if (recoverAddress(typedDigest(deployment, authHash), signed.key_signature) !== a.key) throw new Error("bad consumer key signature");
114
+ if (recoverAddress(typedDigest(deployment, dispatchStructHash(authHash)), signed.relay_signature) !== a.relay_signer) {
115
+ throw new Error("bad relay dispatch signature");
116
+ }
117
+ if (recoverAddress(typedDigest(deployment, receiptStructHash(r)), signed.provider_signature) !== a.provider_signer) {
118
+ throw new Error("bad provider signature");
119
+ }
120
+ }
121
+
122
+ // ---------------- transactions (Consumer setup: approve, deposit, registerKey) ----------------
123
+
124
+ function rlp(value) {
125
+ if (Array.isArray(value)) return prefix(Buffer.concat(value.map(rlp)), 0xc0);
126
+ let bytes;
127
+ if (typeof value === "bigint" || typeof value === "number") {
128
+ const number = BigInt(value);
129
+ bytes = number === 0n ? Buffer.alloc(0) : Buffer.from(number.toString(16).padStart(Math.ceil(number.toString(16).length / 2) * 2, "0"), "hex");
130
+ } else bytes = Buffer.from(value);
131
+ if (bytes.length === 1 && bytes[0] < 0x80) return bytes;
132
+ return prefix(bytes, 0x80);
133
+ }
134
+
135
+ function prefix(payload, offset) {
136
+ if (payload.length <= 55) return Buffer.concat([Buffer.from([offset + payload.length]), payload]);
137
+ const lengthHex = payload.length.toString(16);
138
+ const length = Buffer.from(lengthHex.padStart(Math.ceil(lengthHex.length / 2) * 2, "0"), "hex");
139
+ return Buffer.concat([Buffer.from([offset + 55 + length.length]), length, payload]);
140
+ }
141
+
142
+ export function signLegacyTransaction(privateKey, { nonce, gasPrice, gasLimit, to, value = 0n, data, chainId }) {
143
+ const fields = [BigInt(nonce), BigInt(gasPrice), BigInt(gasLimit), to ? unhex(normalizeAddress(to)) : Buffer.alloc(0),
144
+ BigInt(value), unhex(data)];
145
+ const signature = secp256k1.sign(keccak(rlp([...fields, BigInt(chainId), 0n, 0n])), unhex(privateKey), { lowS: true });
146
+ return hex(rlp([...fields, BigInt(chainId) * 2n + 35n + BigInt(signature.recovery), signature.r, signature.s]));
147
+ }
148
+
149
+ export function encodeCall(signature, pairs) {
150
+ return hex(Buffer.concat([keccak(enc.encode(signature)).subarray(0, 4), encodeWords(pairs)]));
151
+ }
@@ -0,0 +1,90 @@
1
+ // V11 sealed request/response protocol; mirrors mycomesh/protocol.py and mycomesh/consumer.py.
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import {
4
+ PROVIDER_TRANSPORT_TYPEHASH, addressOf, authorizationStructHash, encodeWords, hex, keccak, normalizeAddress,
5
+ recoverAddress, requestIdFor, signDigest, typedDigest, verifySignedReceipt,
6
+ } from "./eip712.mjs";
7
+ import {
8
+ canonicalJson, generateIdentity, generateTransportKey, openFrame, sealFrame, verifyTransportKeyBinding,
9
+ } from "./secure-envelope.mjs";
10
+
11
+ export const REQUEST_SCHEMA = "mycomesh.v11.request.v1";
12
+ export const RESPONSE_SCHEMA = "mycomesh.v11.response.v1";
13
+ export const SEALED_REQUEST_PURPOSE = "mycomesh.v11.sealed-request";
14
+ export const SEALED_RESPONSE_PURPOSE = "mycomesh.v11.sealed-response";
15
+
16
+ export const sha256Hex = (bytes) => `0x${createHash("sha256").update(bytes).digest("hex")}`;
17
+
18
+ export function buildRequest({ endpoint, model, content, maxOutputTokens, replyKeyId, options = {} }) {
19
+ if (!["responses", "chat"].includes(endpoint)) throw new Error("unsupported endpoint");
20
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens < 1) throw new Error("invalid max_output_tokens");
21
+ const document = {
22
+ schema: REQUEST_SCHEMA, endpoint, model, [endpoint === "chat" ? "messages" : "input"]: content,
23
+ max_output_tokens: maxOutputTokens, options, reply_key_id: replyKeyId,
24
+ };
25
+ return Buffer.from(canonicalJson(document), "utf8");
26
+ }
27
+
28
+ /** Return the Provider signer a descriptor's own EVM attestation proves; a Relay cannot forge it. */
29
+ export function verifiedProvider(descriptor, deployment, now) {
30
+ const binding = verifyTransportKeyBinding(descriptor.transport_key, { now });
31
+ const attestation = descriptor.transport_attestation || {};
32
+ const signer = normalizeAddress(attestation.provider_signer);
33
+ if (!Number.isSafeInteger(attestation.expires_at) || attestation.expires_at <= now) throw new Error("transport attestation expired");
34
+ const structHash = keccak(encodeWords([
35
+ ["bytes32", hex(PROVIDER_TRANSPORT_TYPEHASH)], ["address", signer],
36
+ ["bytes32", `0x${binding.identityPublicKey}`], ["bytes32", `0x${binding.keyId.replace(/^x25519_/, "")}`],
37
+ ["uint", attestation.expires_at],
38
+ ]));
39
+ if (recoverAddress(typedDigest(deployment, structHash), attestation.signature) !== signer) {
40
+ throw new Error("transport attestation signature is invalid");
41
+ }
42
+ if (String(descriptor.provider_signer || "").toLowerCase() !== signer) throw new Error("descriptor signer differs from its attestation");
43
+ return { signer, binding: descriptor.transport_key };
44
+ }
45
+
46
+ export function prepareRequest({ descriptor, deployment, keyPrivate, relaySigner, endpoint, model, content,
47
+ maxOutputTokens, maxFee, options = {}, now = Math.floor(Date.now() / 1000) }) {
48
+ const { signer, binding } = verifiedProvider(descriptor, deployment, now);
49
+ const identity = generateIdentity();
50
+ const replyKey = generateTransportKey(identity, { lifetimeSeconds: 3600, now });
51
+ const plaintext = buildRequest({ endpoint, model, content, maxOutputTokens, replyKeyId: replyKey.binding.key_id, options });
52
+ const key = addressOf(keyPrivate);
53
+ const authorization = {
54
+ request_id: requestIdFor(key, hex(randomBytes(32))), request_hash: sha256Hex(plaintext), key,
55
+ provider_signer: signer, relay_signer: normalizeAddress(relaySigner), max_fee: maxFee,
56
+ issued_at: now, execute_by: now + 300, deadline: now + 7200,
57
+ };
58
+ const sealed = sealFrame(plaintext, {
59
+ sender: identity, recipientBinding: binding, expectedRecipientPeerId: binding.peer_id,
60
+ purpose: SEALED_REQUEST_PURPOSE, ttlSeconds: 300, now,
61
+ });
62
+ return {
63
+ authorization, replyKey, providerPeerId: binding.peer_id, requestPlaintext: plaintext,
64
+ payload: {
65
+ authorization,
66
+ key_signature: signDigest(keyPrivate, typedDigest(deployment, authorizationStructHash(authorization))),
67
+ sealed_request: sealed.toString("base64"),
68
+ reply_transport_key: replyKey.binding,
69
+ },
70
+ };
71
+ }
72
+
73
+ /** Decrypt the response and prove it is exactly what the Provider signed for. */
74
+ export function openResponse(prepared, result, deployment, now = Math.floor(Date.now() / 1000)) {
75
+ const signed = result.receipt;
76
+ if (canonicalJson(signed.authorization) !== canonicalJson(prepared.authorization)) {
77
+ throw new Error("receipt is for a different authorization");
78
+ }
79
+ verifySignedReceipt(signed, deployment);
80
+ const opened = openFrame(Buffer.from(result.sealed_response, "base64"), {
81
+ recipientKey: prepared.replyKey, expectedPurpose: SEALED_RESPONSE_PURPOSE, replaySet: new Set(),
82
+ expectedSenderPeerId: prepared.providerPeerId, now,
83
+ });
84
+ if (sha256Hex(opened.payload) !== signed.receipt.response_hash) throw new Error("response differs from the Provider-signed receipt");
85
+ const response = JSON.parse(opened.payload.toString("utf8"));
86
+ if (response.schema !== RESPONSE_SCHEMA || response.request_hash !== prepared.authorization.request_hash) {
87
+ throw new Error("response is not bound to this request");
88
+ }
89
+ return { response, receipt: signed, responsePlaintext: opened.payload };
90
+ }
@@ -0,0 +1,324 @@
1
+ // Byte-compatible port of gateway/secure_transport.py for the Consumer.
2
+ //
3
+ // A Consumer seals V10 request content directly to the Provider's transport
4
+ // key so a Relay only forwards ciphertext, and opens the Provider's sealed
5
+ // response with its own short-lived transport key. The formats, canonical
6
+ // JSON, signatures, key ids and KDF match the Python implementation exactly;
7
+ // tests/test_secure_envelope_interop.py checks both directions.
8
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
9
+ import { ed25519, x25519 } from "@noble/curves/ed25519";
10
+ import { hkdf } from "@noble/hashes/hkdf";
11
+ import { sha256 } from "@noble/hashes/sha2";
12
+
13
+ export const SECURE_ENVELOPE_VERSION = "mycomesh-secure-envelope-v1";
14
+ export const TRANSPORT_KEY_VERSION = "mycomesh-transport-key-v1";
15
+ export const TRANSPORT_KEY_PURPOSE = "mycomesh.transport.key_binding.v1";
16
+ export const ENVELOPE_SIGNATURE_PURPOSE = "mycomesh.transport.envelope.v1";
17
+ export const TRANSPORT_KEY_ALGORITHM = "X25519";
18
+ export const ENVELOPE_ALGORITHM = "X25519-HKDF-SHA256-CHACHA20POLY1305";
19
+
20
+ const MAX_PLAINTEXT_BYTES = 8 * 1024 * 1024;
21
+ const MAX_SECURE_FRAME_BYTES = 12 * 1024 * 1024;
22
+ const MAX_ENVELOPE_TTL_SECONDS = 5 * 60;
23
+ const MAX_TRANSPORT_KEY_LIFETIME_SECONDS = 30 * 24 * 60 * 60;
24
+ const MAX_CLOCK_SKEW_SECONDS = 30;
25
+ const NONCE_BYTES = 12;
26
+ const TAG_BYTES = 16;
27
+ const KDF_INFO = Buffer.concat([Buffer.from("mycomesh-secure-envelope-v1", "utf8"), Buffer.from([0]), Buffer.from("X25519-HKDF-SHA256-CHACHA20POLY1305", "utf8")]);
28
+ const HEX32 = /^[0-9a-f]{64}$/;
29
+ const HEX16 = /^[0-9a-f]{32}$/;
30
+ const HEX_NONCE = /^[0-9a-f]{24}$/;
31
+ const SIGNATURE = /^[0-9a-f]{128}$/;
32
+ const KEY_ID = /^x25519_[0-9a-f]{64}$/;
33
+ const PURPOSE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
34
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
35
+ const BINDING_FIELDS = ["version", "algorithm", "peer_id", "identity_public_key", "encryption_public_key",
36
+ "key_id", "not_before", "expires_at", "signature"];
37
+ const ENVELOPE_FIELDS = ["version", "algorithm", "message_id", "purpose", "sender_peer_id", "sender_public_key",
38
+ "recipient_peer_id", "recipient_public_key", "recipient_key_id", "ephemeral_public_key", "nonce",
39
+ "issued_at", "expires_at", "ciphertext", "signature"];
40
+ const BINDING_SIGNATURE_FIELDS = ["nonce", "public_key", "purpose", "timestamp", "signature"];
41
+ const ENVELOPE_SIGNATURE_FIELDS = [...BINDING_SIGNATURE_FIELDS, "audience"];
42
+
43
+ export class SecureEnvelopeError extends Error {}
44
+
45
+ // Python: json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
46
+ export function canonicalJson(value) {
47
+ if (value === null || typeof value !== "object") {
48
+ if (typeof value === "number" && !Number.isFinite(value)) throw new SecureEnvelopeError("canonical JSON rejects non-finite numbers");
49
+ return JSON.stringify(value);
50
+ }
51
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
52
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
53
+ }
54
+
55
+ const hex = (bytes) => Buffer.from(bytes).toString("hex");
56
+ const nowSeconds = (now) => (now === undefined ? Math.floor(Date.now() / 1000) : now);
57
+ const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
58
+
59
+ function fail(message) { throw new SecureEnvelopeError(message); }
60
+
61
+ function exactFields(value, fields, label) {
62
+ const keys = Object.keys(value).sort();
63
+ const expected = [...fields].sort();
64
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) fail(`${label} fields are invalid`);
65
+ }
66
+
67
+ function integer(value, label) {
68
+ if (!Number.isSafeInteger(value) || value < 0) fail(`${label} must be a non-negative integer`);
69
+ return value;
70
+ }
71
+
72
+ export function peerIdFromPublicKey(publicKeyHex) {
73
+ if (!HEX32.test(publicKeyHex)) fail("public key must be 32 bytes of lowercase hex");
74
+ return `peer_${createHash("sha256").update(Buffer.from(publicKeyHex, "hex")).digest("hex").slice(0, 24)}`;
75
+ }
76
+
77
+ export function generateIdentity() {
78
+ const privateKey = randomBytes(32);
79
+ const publicKey = hex(ed25519.getPublicKey(privateKey));
80
+ return { privateKey: privateKey.toString("hex"), publicKey, peerId: peerIdFromPublicKey(publicKey) };
81
+ }
82
+
83
+ function signatureMessage(document, signaturePayload) {
84
+ return Buffer.from(canonicalJson({ document, signature: signaturePayload }), "utf8");
85
+ }
86
+
87
+ export function signDocument(document, privateKeyHex, { purpose, timestamp, nonce, audience } = {}) {
88
+ if (Object.hasOwn(document, "signature")) fail("document is already signed");
89
+ const publicKey = hex(ed25519.getPublicKey(Buffer.from(privateKeyHex, "hex")));
90
+ const payload = { nonce: nonce || randomBytes(16).toString("hex"), public_key: publicKey, purpose, timestamp: nowSeconds(timestamp) };
91
+ if (audience) payload.audience = String(audience);
92
+ const signature = hex(ed25519.sign(signatureMessage(document, payload), Buffer.from(privateKeyHex, "hex")));
93
+ return { ...document, signature: { ...payload, signature } };
94
+ }
95
+
96
+ function verifyDocumentSignature(document, { purpose, audience }) {
97
+ const signature = document.signature;
98
+ if (!signature || typeof signature !== "object") fail("missing signature");
99
+ if (signature.purpose !== purpose) fail("bad signature purpose");
100
+ if (audience !== undefined && signature.audience !== audience) fail("bad signature audience");
101
+ const { signature: _unused, ...unsigned } = document;
102
+ const { signature: signatureHex, ...payload } = signature;
103
+ let valid = false;
104
+ try {
105
+ valid = ed25519.verify(Buffer.from(signatureHex, "hex"), signatureMessage(unsigned, payload), Buffer.from(signature.public_key, "hex"));
106
+ } catch { valid = false; }
107
+ if (!valid) fail("bad signature");
108
+ return unsigned;
109
+ }
110
+
111
+ function validateSignatureShape(signature, { fields, purpose, audience }) {
112
+ if (!signature || typeof signature !== "object") fail("missing secure transport signature");
113
+ exactFields(signature, fields, "secure transport signature");
114
+ if (typeof signature.nonce !== "string" || !HEX16.test(signature.nonce)) fail("secure transport signature nonce must be 16 bytes of lowercase hex");
115
+ if (typeof signature.public_key !== "string" || !HEX32.test(signature.public_key)) fail("secure transport signer public key is invalid");
116
+ if (signature.purpose !== purpose) fail("secure transport signature purpose mismatch");
117
+ integer(signature.timestamp, "secure transport signature timestamp");
118
+ if (audience !== undefined && signature.audience !== audience) fail("secure transport signature audience mismatch");
119
+ if (typeof signature.signature !== "string" || !SIGNATURE.test(signature.signature)) fail("secure transport signature must be 64 bytes of lowercase hex");
120
+ }
121
+
122
+ export function transportKeyId(publicKeyBytes) {
123
+ const digest = createHash("sha256").update(Buffer.concat([Buffer.from("mycomesh-transport-key-v1", "utf8"), Buffer.from([0]), Buffer.from(publicKeyBytes)])).digest("hex");
124
+ return `x25519_${digest}`;
125
+ }
126
+
127
+ export function generateTransportKey(identity, { lifetimeSeconds = 24 * 60 * 60, now } = {}) {
128
+ const current = nowSeconds(now);
129
+ if (!Number.isSafeInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > MAX_TRANSPORT_KEY_LIFETIME_SECONDS) {
130
+ fail("transport key lifetime is invalid");
131
+ }
132
+ const privateKey = randomBytes(32);
133
+ const publicKey = x25519.getPublicKey(privateKey);
134
+ const document = {
135
+ version: TRANSPORT_KEY_VERSION,
136
+ algorithm: TRANSPORT_KEY_ALGORITHM,
137
+ peer_id: identity.peerId,
138
+ identity_public_key: identity.publicKey,
139
+ encryption_public_key: hex(publicKey),
140
+ key_id: transportKeyId(publicKey),
141
+ not_before: current,
142
+ expires_at: current + lifetimeSeconds,
143
+ };
144
+ const binding = signDocument(document, identity.privateKey, { purpose: TRANSPORT_KEY_PURPOSE, timestamp: current });
145
+ return { binding, privateKey: privateKey.toString("hex") };
146
+ }
147
+
148
+ export function verifyTransportKeyBinding(binding, { expectedPeerId, expectedIdentityPublicKey, now } = {}) {
149
+ const current = nowSeconds(now);
150
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) fail("transport key binding must be an object");
151
+ exactFields(binding, BINDING_FIELDS, "transport key binding");
152
+ if (binding.version !== TRANSPORT_KEY_VERSION) fail("unsupported transport key binding version");
153
+ if (binding.algorithm !== TRANSPORT_KEY_ALGORITHM) fail("unsupported transport key algorithm");
154
+ const { peer_id: peerId, identity_public_key: identityPublicKey, encryption_public_key: encryptionPublicKey, key_id: keyId } = binding;
155
+ if (typeof peerId !== "string" || !peerId || peerId.length > 160) fail("transport peer_id is invalid");
156
+ if (typeof identityPublicKey !== "string" || !HEX32.test(identityPublicKey)) fail("transport identity public key is invalid");
157
+ if (typeof encryptionPublicKey !== "string" || !HEX32.test(encryptionPublicKey)) fail("transport encryption public key is invalid");
158
+ if (typeof keyId !== "string" || !KEY_ID.test(keyId)) fail("transport key_id is malformed");
159
+ if (keyId !== transportKeyId(Buffer.from(encryptionPublicKey, "hex"))) fail("transport key_id does not match encryption public key");
160
+ if (peerId !== peerIdFromPublicKey(identityPublicKey)) fail("transport peer_id does not match identity public key");
161
+ if (expectedPeerId !== undefined && peerId !== String(expectedPeerId)) fail("transport key binding peer_id mismatch");
162
+ if (expectedIdentityPublicKey !== undefined && identityPublicKey !== String(expectedIdentityPublicKey).toLowerCase()) {
163
+ fail("transport key binding identity public key mismatch");
164
+ }
165
+ const notBefore = integer(binding.not_before, "transport key not_before");
166
+ const expiresAt = integer(binding.expires_at, "transport key expires_at");
167
+ if (expiresAt <= notBefore) fail("transport key expiry must follow not_before");
168
+ if (expiresAt - notBefore > MAX_TRANSPORT_KEY_LIFETIME_SECONDS) fail("transport key lifetime exceeds the maximum");
169
+ const signature = binding.signature;
170
+ validateSignatureShape(signature, { fields: BINDING_SIGNATURE_FIELDS, purpose: TRANSPORT_KEY_PURPOSE });
171
+ if (signature.timestamp < notBefore - MAX_CLOCK_SKEW_SECONDS) fail("transport key was signed too early");
172
+ if (signature.timestamp > notBefore + MAX_CLOCK_SKEW_SECONDS) fail("transport key signature timestamp does not match not_before");
173
+ if (signature.timestamp > current + MAX_CLOCK_SKEW_SECONDS) fail("transport key signature timestamp is in the future");
174
+ if (notBefore > current + MAX_CLOCK_SKEW_SECONDS) fail("transport key is not active yet");
175
+ if (expiresAt <= current) fail("transport key has expired");
176
+ if (signature.public_key !== identityPublicKey) fail("transport key signer does not match identity public key");
177
+ verifyDocumentSignature(binding, { purpose: TRANSPORT_KEY_PURPOSE });
178
+ return { peerId, identityPublicKey, encryptionPublicKey, keyId, notBefore, expiresAt };
179
+ }
180
+
181
+ function contentKey(sharedSecret, aad) {
182
+ return Buffer.from(hkdf(sha256, sharedSecret, sha256(aad), KDF_INFO, 32));
183
+ }
184
+
185
+ function encodeFrame(envelope) {
186
+ const raw = Buffer.from(canonicalJson(envelope), "utf8");
187
+ if (raw.length + 4 > MAX_SECURE_FRAME_BYTES) fail(`secure frame exceeds ${MAX_SECURE_FRAME_BYTES} bytes`);
188
+ const prefix = Buffer.alloc(4);
189
+ prefix.writeUInt32BE(raw.length);
190
+ return Buffer.concat([prefix, raw]);
191
+ }
192
+
193
+ function decodeFrame(frame) {
194
+ if (!Buffer.isBuffer(frame)) fail("secure frame must be bytes");
195
+ if (frame.length < 4) fail("secure frame is truncated");
196
+ if (frame.length > MAX_SECURE_FRAME_BYTES) fail(`secure frame exceeds ${MAX_SECURE_FRAME_BYTES} bytes`);
197
+ const declared = frame.readUInt32BE(0);
198
+ if (declared === 0) fail("secure frame payload is empty");
199
+ if (declared !== frame.length - 4) fail("secure frame length mismatch");
200
+ const raw = frame.subarray(4);
201
+ let value;
202
+ try { value = JSON.parse(raw.toString("utf8")); } catch { fail("secure frame is not JSON"); }
203
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("secure frame payload must be an object");
204
+ if (!raw.equals(Buffer.from(canonicalJson(value), "utf8"))) fail("secure frame JSON is not canonical");
205
+ return value;
206
+ }
207
+
208
+ export function sealFrame(payload, { sender, recipientBinding, expectedRecipientPeerId, expectedRecipientPublicKey,
209
+ purpose, ttlSeconds = 60, now } = {}) {
210
+ const current = nowSeconds(now);
211
+ if (!Buffer.isBuffer(payload)) fail("secure envelope payload must be bytes");
212
+ if (payload.length > MAX_PLAINTEXT_BYTES) fail(`secure envelope plaintext exceeds ${MAX_PLAINTEXT_BYTES} bytes`);
213
+ if (typeof purpose !== "string" || !PURPOSE.test(purpose)) fail("secure envelope purpose is invalid");
214
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_ENVELOPE_TTL_SECONDS) fail("secure envelope TTL is invalid");
215
+ const recipient = verifyTransportKeyBinding(recipientBinding, {
216
+ expectedPeerId: expectedRecipientPeerId, expectedIdentityPublicKey: expectedRecipientPublicKey, now: current,
217
+ });
218
+ const expiresAt = current + ttlSeconds;
219
+ if (expiresAt > recipient.expiresAt) fail("secure envelope outlives the recipient transport key");
220
+ const ephemeralPrivate = randomBytes(32);
221
+ const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate);
222
+ const messageId = randomBytes(16).toString("hex");
223
+ const nonce = randomBytes(NONCE_BYTES);
224
+ const header = {
225
+ version: SECURE_ENVELOPE_VERSION,
226
+ algorithm: ENVELOPE_ALGORITHM,
227
+ message_id: messageId,
228
+ purpose,
229
+ sender_peer_id: sender.peerId,
230
+ sender_public_key: sender.publicKey,
231
+ recipient_peer_id: recipient.peerId,
232
+ recipient_public_key: recipient.identityPublicKey,
233
+ recipient_key_id: recipient.keyId,
234
+ ephemeral_public_key: hex(ephemeralPublic),
235
+ nonce: nonce.toString("hex"),
236
+ issued_at: current,
237
+ expires_at: expiresAt,
238
+ };
239
+ const aad = Buffer.from(canonicalJson(header), "utf8");
240
+ const shared = x25519.getSharedSecret(ephemeralPrivate, Buffer.from(recipient.encryptionPublicKey, "hex"));
241
+ const cipher = createCipheriv("chacha20-poly1305", contentKey(shared, aad), nonce, { authTagLength: TAG_BYTES });
242
+ cipher.setAAD(aad, { plaintextLength: payload.length });
243
+ const ciphertext = Buffer.concat([cipher.update(payload), cipher.final(), cipher.getAuthTag()]);
244
+ const envelope = { ...header, ciphertext: base64url(ciphertext) };
245
+ const signed = signDocument(envelope, sender.privateKey, {
246
+ purpose: ENVELOPE_SIGNATURE_PURPOSE, timestamp: current, nonce: messageId, audience: recipient.identityPublicKey,
247
+ });
248
+ return encodeFrame(signed);
249
+ }
250
+
251
+ export function sealJsonFrame(document, options) {
252
+ if (!document || typeof document !== "object" || Array.isArray(document)) fail("secure envelope JSON document must be an object");
253
+ return sealFrame(Buffer.from(canonicalJson(document), "utf8"), options);
254
+ }
255
+
256
+ // replaySet: an in-process Set; a Consumer opens each response frame once.
257
+ export function openFrame(frame, { recipientKey, expectedPurpose, replaySet, expectedSenderPeerId, expectedSenderPublicKey, now } = {}) {
258
+ const current = nowSeconds(now);
259
+ if (!(replaySet instanceof Set)) fail("a replay set is required");
260
+ const recipient = verifyTransportKeyBinding(recipientKey?.binding, { now: current });
261
+ if (hex(x25519.getPublicKey(Buffer.from(recipientKey.privateKey, "hex"))) !== recipient.encryptionPublicKey) {
262
+ fail("recipient private key does not match its binding");
263
+ }
264
+ const envelope = decodeFrame(frame);
265
+ exactFields(envelope, ENVELOPE_FIELDS, "secure envelope");
266
+ if (envelope.version !== SECURE_ENVELOPE_VERSION) fail("unsupported secure envelope version");
267
+ if (envelope.algorithm !== ENVELOPE_ALGORITHM) fail("unsupported secure envelope algorithm");
268
+ if (typeof envelope.message_id !== "string" || !HEX16.test(envelope.message_id)) fail("secure envelope message_id is invalid");
269
+ if (envelope.purpose !== expectedPurpose) fail("secure envelope purpose mismatch");
270
+ if (typeof envelope.sender_public_key !== "string" || !HEX32.test(envelope.sender_public_key)) fail("secure envelope sender public key is invalid");
271
+ if (envelope.sender_peer_id !== peerIdFromPublicKey(envelope.sender_public_key)) fail("secure envelope sender peer_id does not match public key");
272
+ if (expectedSenderPeerId !== undefined && envelope.sender_peer_id !== expectedSenderPeerId) fail("secure envelope sender peer_id mismatch");
273
+ if (expectedSenderPublicKey !== undefined && envelope.sender_public_key !== String(expectedSenderPublicKey).toLowerCase()) {
274
+ fail("secure envelope sender public key mismatch");
275
+ }
276
+ if (envelope.recipient_peer_id !== recipient.peerId) fail("secure envelope audience peer_id mismatch");
277
+ if (envelope.recipient_public_key !== recipient.identityPublicKey) fail("secure envelope audience public key mismatch");
278
+ if (envelope.recipient_key_id !== recipient.keyId) fail("secure envelope recipient key_id mismatch");
279
+ if (typeof envelope.ephemeral_public_key !== "string" || !HEX32.test(envelope.ephemeral_public_key)) fail("secure envelope ephemeral public key is invalid");
280
+ if (typeof envelope.nonce !== "string" || !HEX_NONCE.test(envelope.nonce)) fail("secure envelope nonce is invalid");
281
+ const issuedAt = integer(envelope.issued_at, "secure envelope issued_at");
282
+ const expiresAt = integer(envelope.expires_at, "secure envelope expires_at");
283
+ if (expiresAt <= issuedAt) fail("secure envelope expiry must follow issued_at");
284
+ if (expiresAt - issuedAt > MAX_ENVELOPE_TTL_SECONDS) fail("secure envelope TTL exceeds the maximum");
285
+ if (issuedAt > current + MAX_CLOCK_SKEW_SECONDS) fail("secure envelope was issued in the future");
286
+ if (expiresAt <= current) fail("secure envelope has expired");
287
+ if (issuedAt < recipient.notBefore - MAX_CLOCK_SKEW_SECONDS) fail("secure envelope predates the recipient transport key");
288
+ if (expiresAt > recipient.expiresAt) fail("secure envelope outlives the recipient transport key");
289
+ const signature = envelope.signature;
290
+ validateSignatureShape(signature, { fields: ENVELOPE_SIGNATURE_FIELDS, purpose: ENVELOPE_SIGNATURE_PURPOSE, audience: recipient.identityPublicKey });
291
+ if (signature.public_key !== envelope.sender_public_key) fail("secure envelope signer does not match sender public key");
292
+ if (signature.nonce !== envelope.message_id) fail("secure envelope signature nonce does not match message_id");
293
+ if (signature.timestamp !== issuedAt) fail("secure envelope signature timestamp does not match issued_at");
294
+ verifyDocumentSignature(envelope, { purpose: ENVELOPE_SIGNATURE_PURPOSE, audience: recipient.identityPublicKey });
295
+ if (typeof envelope.ciphertext !== "string" || !BASE64URL.test(envelope.ciphertext)) fail("secure envelope ciphertext must be canonical base64url");
296
+ const ciphertext = Buffer.from(envelope.ciphertext, "base64url");
297
+ if (base64url(ciphertext) !== envelope.ciphertext) fail("secure envelope ciphertext is not canonical base64url");
298
+ if (ciphertext.length < TAG_BYTES) fail("secure envelope ciphertext is too short");
299
+ if (ciphertext.length > MAX_PLAINTEXT_BYTES + TAG_BYTES) fail(`secure envelope plaintext exceeds ${MAX_PLAINTEXT_BYTES} bytes`);
300
+ const header = Object.fromEntries(ENVELOPE_FIELDS.filter((key) => key !== "ciphertext" && key !== "signature").map((key) => [key, envelope[key]]));
301
+ const aad = Buffer.from(canonicalJson(header), "utf8");
302
+ let plaintext;
303
+ try {
304
+ const shared = x25519.getSharedSecret(Buffer.from(recipientKey.privateKey, "hex"), Buffer.from(envelope.ephemeral_public_key, "hex"));
305
+ const decipher = createDecipheriv("chacha20-poly1305", contentKey(shared, aad), Buffer.from(envelope.nonce, "hex"), { authTagLength: TAG_BYTES });
306
+ decipher.setAAD(aad, { plaintextLength: ciphertext.length - TAG_BYTES });
307
+ decipher.setAuthTag(ciphertext.subarray(ciphertext.length - TAG_BYTES));
308
+ plaintext = Buffer.concat([decipher.update(ciphertext.subarray(0, ciphertext.length - TAG_BYTES)), decipher.final()]);
309
+ } catch {
310
+ fail("secure envelope authentication failed");
311
+ }
312
+ const replayKey = `${envelope.sender_public_key}:${recipient.keyId}:${envelope.message_id}`;
313
+ if (replaySet.has(replayKey)) fail("secure envelope was already accepted");
314
+ replaySet.add(replayKey);
315
+ return {
316
+ payload: plaintext,
317
+ messageId: envelope.message_id,
318
+ purpose: envelope.purpose,
319
+ senderPeerId: envelope.sender_peer_id,
320
+ senderPublicKey: envelope.sender_public_key,
321
+ issuedAt,
322
+ expiresAt,
323
+ };
324
+ }