@calimero-network/mero-js 19.20.0 → 19.22.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,2 @@
1
+ export { SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
2
+ export type { SealedFetchOptions, VerifyTransportQuote } from './sealed.js';
@@ -0,0 +1 @@
1
+ export { SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Sealed transport: requests encrypted to a TEE node's attested key.
3
+ *
4
+ * A node speaks plain HTTP and any TLS in front of it ends outside the enclave,
5
+ * at whatever proxy or relay the operator runs, so everything crossing that hop
6
+ * (the bearer token, JSON-RPC arguments, results) is readable there. Sealing
7
+ * encrypts each request to an X25519 key the node's quote commits to, so only
8
+ * the attested TD can read it, and only the TD can produce a response the
9
+ * client accepts.
10
+ *
11
+ * The node's side, and the wire format this implements byte for byte, are in
12
+ * core's `crates/server/src/sealed.rs`:
13
+ *
14
+ * ```text
15
+ * shared = X25519(secret, peer public) all-zero result refused
16
+ * prk = HKDF-SHA256-Extract(salt = transport_pk || client_pk, ikm = shared)
17
+ * req key = HKDF-Expand(prk, "calimero/sealed-http/v1/request", 32)
18
+ * resp key = HKDF-Expand(prk, "calimero/sealed-http/v1/response", 32)
19
+ * request = 0x01 || transport_pk || client_pk || nonce(12)
20
+ * || AES-256-GCM(req key, nonce, aad = the 77 bytes before it, inner request)
21
+ * response = 0x01 || nonce(12)
22
+ * || AES-256-GCM(resp key, nonce, aad = 0x01 || transport_pk || client_pk, inner response)
23
+ * inner = u32 BE head length || head (JSON) || body
24
+ * ```
25
+ *
26
+ * Use it by attesting once ({@link fetchAttestedTransportKey}) and handing
27
+ * {@link createSealedFetch} to `MeroJsConfig.fetch`. Server-sent events and
28
+ * WebSockets are streams and cannot be sealed; the node refuses a sealed SSE
29
+ * request with a 501 rather than hang.
30
+ */
31
+ import type { TeeAttestRequest, TeeAttestResponseData } from '../admin-api/admin-types.js';
32
+ /** Where sealed requests are posted, relative to the node's base URL. */
33
+ export declare const SEALED_PATH = "/sealed/v1";
34
+ /** Content type of both sealed bodies. */
35
+ export declare const SEALED_CONTENT_TYPE = "application/vnd.calimero.sealed";
36
+ /** The node no longer holds the key this client sealed to (it restarted). */
37
+ export declare class StaleTransportKeyError extends Error {
38
+ name: string;
39
+ constructor();
40
+ }
41
+ /** The node refused the envelope itself, before anything inside it ran. */
42
+ export declare class SealedTransportError extends Error {
43
+ status: number;
44
+ code: string | undefined;
45
+ name: string;
46
+ constructor(status: number, code: string | undefined, message: string);
47
+ }
48
+ /**
49
+ * What the attest endpoint puts in report data bytes `32..64` when asked to
50
+ * bind its transport key. `inner` is what would have been there otherwise: the
51
+ * expected application hash, or 32 zero bytes when none was asked for.
52
+ * Mirrors core's `attest_transport_binding`.
53
+ */
54
+ export declare function transportKeyBinding(inner: Uint8Array, transportKey: Uint8Array): Promise<Uint8Array>;
55
+ export interface VerifyTransportQuote {
56
+ /**
57
+ * Verify `quoteB64` with a verifier you trust — never the node being
58
+ * attested — and check that its report data is `nonce || reportDataSuffix`
59
+ * (both hex). `AdminApiClient.teeVerifyQuote` on a node of your own does both
60
+ * when given `expectedApplicationHash: reportDataSuffix`. Resolve true only
61
+ * when the quote is genuine, its measurements are ones you accept, and the
62
+ * report data matches.
63
+ */
64
+ (args: {
65
+ quoteB64: string;
66
+ nonce: string;
67
+ reportDataSuffix: string;
68
+ }): Promise<boolean>;
69
+ }
70
+ /**
71
+ * Ask a node for its transport key and accept it only if a quote you verified
72
+ * commits to it.
73
+ *
74
+ * `applicationHash` (hex), when given, is the bytecode hash you expect the
75
+ * attested application to have; the node is asked to bind that application,
76
+ * and the quote only verifies if it runs that exact bytecode.
77
+ */
78
+ export declare function fetchAttestedTransportKey(admin: {
79
+ teeAttest(request: TeeAttestRequest): Promise<TeeAttestResponseData>;
80
+ }, verify: VerifyTransportQuote, options?: {
81
+ applicationId?: string;
82
+ applicationHash?: string;
83
+ }): Promise<Uint8Array>;
84
+ export interface SealedFetchOptions {
85
+ /** The node's base URL, as given to `MeroJsConfig.baseUrl`. */
86
+ baseUrl: string;
87
+ /** The node's transport key, from {@link fetchAttestedTransportKey}. */
88
+ transportPublicKey: Uint8Array;
89
+ /** Fetch that carries the sealed envelope. Defaults to global `fetch`. */
90
+ fetch?: typeof fetch;
91
+ }
92
+ /**
93
+ * A `fetch` that seals every request to the node's transport key. Pass it as
94
+ * `MeroJsConfig.fetch`.
95
+ *
96
+ * A request for any URL outside `baseUrl` is refused rather than sent in the
97
+ * clear: falling back to plaintext would silently undo the point of sealing.
98
+ */
99
+ export declare function createSealedFetch(options: SealedFetchOptions): typeof fetch;
100
+ export interface RequestHead {
101
+ method: string;
102
+ path: string;
103
+ headers: Array<[string, string]>;
104
+ ts: number;
105
+ }
106
+ export interface ResponseHead {
107
+ status: number;
108
+ headers: Array<[string, string]>;
109
+ }
110
+ export interface SealedExchange {
111
+ envelope: Uint8Array;
112
+ open(sealed: Uint8Array): Promise<{
113
+ head: ResponseHead;
114
+ body: Uint8Array;
115
+ }>;
116
+ }
117
+ /**
118
+ * Seal one request, keeping what is needed to open its response.
119
+ *
120
+ * `fixed` pins the client's one-time secret and the nonce, for the published
121
+ * test vectors only; leave it out.
122
+ */
123
+ export declare function sealRequest(transportKey: Uint8Array, head: RequestHead, body: Uint8Array, fixed?: {
124
+ clientSecret: Uint8Array;
125
+ nonce: Uint8Array;
126
+ }): Promise<SealedExchange>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Sealed transport: requests encrypted to a TEE node's attested key.
3
+ *
4
+ * A node speaks plain HTTP and any TLS in front of it ends outside the enclave,
5
+ * at whatever proxy or relay the operator runs, so everything crossing that hop
6
+ * (the bearer token, JSON-RPC arguments, results) is readable there. Sealing
7
+ * encrypts each request to an X25519 key the node's quote commits to, so only
8
+ * the attested TD can read it, and only the TD can produce a response the
9
+ * client accepts.
10
+ *
11
+ * The node's side, and the wire format this implements byte for byte, are in
12
+ * core's `crates/server/src/sealed.rs`:
13
+ *
14
+ * ```text
15
+ * shared = X25519(secret, peer public) all-zero result refused
16
+ * prk = HKDF-SHA256-Extract(salt = transport_pk || client_pk, ikm = shared)
17
+ * req key = HKDF-Expand(prk, "calimero/sealed-http/v1/request", 32)
18
+ * resp key = HKDF-Expand(prk, "calimero/sealed-http/v1/response", 32)
19
+ * request = 0x01 || transport_pk || client_pk || nonce(12)
20
+ * || AES-256-GCM(req key, nonce, aad = the 77 bytes before it, inner request)
21
+ * response = 0x01 || nonce(12)
22
+ * || AES-256-GCM(resp key, nonce, aad = 0x01 || transport_pk || client_pk, inner response)
23
+ * inner = u32 BE head length || head (JSON) || body
24
+ * ```
25
+ *
26
+ * Use it by attesting once ({@link fetchAttestedTransportKey}) and handing
27
+ * {@link createSealedFetch} to `MeroJsConfig.fetch`. Server-sent events and
28
+ * WebSockets are streams and cannot be sealed; the node refuses a sealed SSE
29
+ * request with a 501 rather than hang.
30
+ */
31
+ import { concat, fromHex, hex } from '../crypto/internal.js';
32
+ /** Where sealed requests are posted, relative to the node's base URL. */
33
+ export const SEALED_PATH = '/sealed/v1';
34
+ /** Content type of both sealed bodies. */
35
+ export const SEALED_CONTENT_TYPE = 'application/vnd.calimero.sealed';
36
+ const VERSION = 1;
37
+ const NONCE_LEN = 12;
38
+ const REQUEST_DOMAIN = 'calimero/sealed-http/v1/request';
39
+ const RESPONSE_DOMAIN = 'calimero/sealed-http/v1/response';
40
+ const TRANSPORT_BINDING_DOMAIN = 'calimero.tee-attest.transport-key.v1';
41
+ /** PKCS#8 wrapping of a raw X25519 private key (RFC 8410). */
42
+ const PKCS8_X25519_PREFIX = new Uint8Array([
43
+ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04,
44
+ 0x22, 0x04, 0x20,
45
+ ]);
46
+ const utf8 = new TextEncoder();
47
+ /** The node no longer holds the key this client sealed to (it restarted). */
48
+ export class StaleTransportKeyError extends Error {
49
+ constructor() {
50
+ super('The node no longer holds the transport key this request was sealed to; attest again for the current one');
51
+ this.name = 'StaleTransportKeyError';
52
+ }
53
+ }
54
+ /** The node refused the envelope itself, before anything inside it ran. */
55
+ export class SealedTransportError extends Error {
56
+ constructor(status, code, message) {
57
+ super(message);
58
+ this.status = status;
59
+ this.code = code;
60
+ this.name = 'SealedTransportError';
61
+ }
62
+ }
63
+ /**
64
+ * What the attest endpoint puts in report data bytes `32..64` when asked to
65
+ * bind its transport key. `inner` is what would have been there otherwise: the
66
+ * expected application hash, or 32 zero bytes when none was asked for.
67
+ * Mirrors core's `attest_transport_binding`.
68
+ */
69
+ export async function transportKeyBinding(inner, transportKey) {
70
+ const digest = await crypto.subtle.digest('SHA-256', concat(utf8.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey));
71
+ return new Uint8Array(digest);
72
+ }
73
+ /**
74
+ * Ask a node for its transport key and accept it only if a quote you verified
75
+ * commits to it.
76
+ *
77
+ * `applicationHash` (hex), when given, is the bytecode hash you expect the
78
+ * attested application to have; the node is asked to bind that application,
79
+ * and the quote only verifies if it runs that exact bytecode.
80
+ */
81
+ export async function fetchAttestedTransportKey(admin, verify, options = {}) {
82
+ if ((options.applicationId === undefined) !== (options.applicationHash === undefined)) {
83
+ throw new Error('applicationId and applicationHash are given together or not at all');
84
+ }
85
+ const nonce = hex(crypto.getRandomValues(new Uint8Array(32)));
86
+ const attested = await admin.teeAttest({
87
+ nonce,
88
+ applicationId: options.applicationId,
89
+ bindTransportKey: true,
90
+ });
91
+ if (!attested.transportPublicKey) {
92
+ throw new Error('The node did not report a transport key; it predates sealed transport');
93
+ }
94
+ const transportKey = fromHex(attested.transportPublicKey, 'transportPublicKey', 32);
95
+ const inner = options.applicationHash
96
+ ? fromHex(options.applicationHash, 'applicationHash', 32)
97
+ : new Uint8Array(32);
98
+ const reportDataSuffix = hex(await transportKeyBinding(inner, transportKey));
99
+ if (!(await verify({ quoteB64: attested.quoteB64, nonce, reportDataSuffix }))) {
100
+ throw new Error('The attestation did not verify, so its transport key is not trusted');
101
+ }
102
+ return transportKey;
103
+ }
104
+ /**
105
+ * A `fetch` that seals every request to the node's transport key. Pass it as
106
+ * `MeroJsConfig.fetch`.
107
+ *
108
+ * A request for any URL outside `baseUrl` is refused rather than sent in the
109
+ * clear: falling back to plaintext would silently undo the point of sealing.
110
+ */
111
+ export function createSealedFetch(options) {
112
+ const base = new URL(options.baseUrl);
113
+ const basePath = base.pathname.replace(/\/+$/, '');
114
+ const sealedUrl = `${base.origin}${basePath}${SEALED_PATH}`;
115
+ const transportKey = options.transportPublicKey;
116
+ if (transportKey.length !== 32) {
117
+ throw new Error('transportPublicKey must be 32 bytes');
118
+ }
119
+ const baseFetch = options.fetch ?? ((input, init) => fetch(input, init));
120
+ return async (input, init) => {
121
+ const request = new Request(input, init);
122
+ const url = new URL(request.url);
123
+ if (url.origin !== base.origin || !url.pathname.startsWith(`${basePath}/`)) {
124
+ throw new Error(`Refusing to send ${url.href} outside the sealed node at ${options.baseUrl}`);
125
+ }
126
+ const headers = [];
127
+ request.headers.forEach((value, name) => headers.push([name, value]));
128
+ const head = {
129
+ method: request.method,
130
+ path: url.pathname.slice(basePath.length) + url.search,
131
+ headers,
132
+ ts: Math.floor(Date.now() / 1000),
133
+ };
134
+ const body = new Uint8Array(await request.arrayBuffer());
135
+ const exchange = await sealRequest(transportKey, head, body);
136
+ const response = await baseFetch(sealedUrl, {
137
+ method: 'POST',
138
+ headers: { 'content-type': SEALED_CONTENT_TYPE },
139
+ body: exchange.envelope,
140
+ signal: request.signal,
141
+ });
142
+ if (response.headers.get('content-type') !== SEALED_CONTENT_TYPE) {
143
+ throw await envelopeRefusal(response);
144
+ }
145
+ const opened = await exchange.open(new Uint8Array(await response.arrayBuffer()));
146
+ const nullBody = [101, 204, 205, 304].includes(opened.head.status);
147
+ return new Response(nullBody ? null : opened.body, {
148
+ status: opened.head.status,
149
+ headers: opened.head.headers,
150
+ });
151
+ };
152
+ }
153
+ async function envelopeRefusal(response) {
154
+ let code;
155
+ let message = `Sealed request refused: HTTP ${response.status}`;
156
+ try {
157
+ const body = (await response.json());
158
+ code = body?.error?.code;
159
+ if (body?.error?.message)
160
+ message = `Sealed request refused: ${body.error.message}`;
161
+ }
162
+ catch {
163
+ // Not the node's JSON refusal — a proxy's page, most likely. The status is
164
+ // all that can honestly be reported.
165
+ }
166
+ if (code === 'stale_transport_key')
167
+ return new StaleTransportKeyError();
168
+ return new SealedTransportError(response.status, code, message);
169
+ }
170
+ /**
171
+ * Seal one request, keeping what is needed to open its response.
172
+ *
173
+ * `fixed` pins the client's one-time secret and the nonce, for the published
174
+ * test vectors only; leave it out.
175
+ */
176
+ export async function sealRequest(transportKey, head, body, fixed) {
177
+ const { privateKey, publicKey } = await clientKeyPair(fixed?.clientSecret);
178
+ const transport = await crypto.subtle.importKey('raw', transportKey, { name: 'X25519' }, false, []);
179
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: transport }, privateKey, 256));
180
+ // A low-order point yields an all-zero secret that anybody can compute.
181
+ if (shared.every((byte) => byte === 0)) {
182
+ throw new Error('The transport key is a low-order point');
183
+ }
184
+ const salt = concat(transportKey, publicKey);
185
+ const requestKey = await exchangeKey(shared, salt, REQUEST_DOMAIN, 'encrypt');
186
+ const responseKey = await exchangeKey(shared, salt, RESPONSE_DOMAIN, 'decrypt');
187
+ const nonce = fixed?.nonce ?? crypto.getRandomValues(new Uint8Array(NONCE_LEN));
188
+ const header = concat(new Uint8Array([VERSION]), transportKey, publicKey, nonce);
189
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: header }, requestKey, joinInner(head, body));
190
+ const responseAad = concat(new Uint8Array([VERSION]), salt);
191
+ return {
192
+ envelope: concat(header, new Uint8Array(ciphertext)),
193
+ async open(sealed) {
194
+ if (sealed.length < 1 + NONCE_LEN + 16 || sealed[0] !== VERSION) {
195
+ throw new Error('The sealed response is malformed');
196
+ }
197
+ let plaintext;
198
+ try {
199
+ plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(1, 1 + NONCE_LEN), additionalData: responseAad }, responseKey, sealed.slice(1 + NONCE_LEN)));
200
+ }
201
+ catch {
202
+ throw new Error('The sealed response did not open: it was not sealed by the attested node');
203
+ }
204
+ const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);
205
+ const len = view.getUint32(0, false);
206
+ const head = JSON.parse(new TextDecoder().decode(plaintext.slice(4, 4 + len)));
207
+ return { head, body: plaintext.slice(4 + len) };
208
+ },
209
+ };
210
+ }
211
+ function joinInner(head, body) {
212
+ const encoded = utf8.encode(JSON.stringify(head));
213
+ const len = new Uint8Array(4);
214
+ new DataView(len.buffer).setUint32(0, encoded.length, false);
215
+ return concat(len, encoded, body);
216
+ }
217
+ async function exchangeKey(shared, salt, domain, usage) {
218
+ const ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
219
+ return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt, info: utf8.encode(domain) }, ikm, { name: 'AES-GCM', length: 256 }, false, [usage]);
220
+ }
221
+ async function clientKeyPair(secret) {
222
+ if (!secret) {
223
+ const pair = (await crypto.subtle.generateKey({ name: 'X25519' }, true, [
224
+ 'deriveBits',
225
+ ]));
226
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
227
+ return { privateKey: pair.privateKey, publicKey };
228
+ }
229
+ const privateKey = await crypto.subtle.importKey('pkcs8', concat(PKCS8_X25519_PREFIX, secret), { name: 'X25519' }, true, ['deriveBits']);
230
+ const jwk = await crypto.subtle.exportKey('jwk', privateKey);
231
+ return { privateKey, publicKey: base64UrlDecode(jwk.x) };
232
+ }
233
+ function base64UrlDecode(value) {
234
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
235
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
236
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "19.20.0",
3
+ "version": "19.22.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {