@calimero-network/mero-js 19.21.0 → 19.23.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.
- package/dist/admin-api/admin-types.d.ts +23 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +359 -138
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +359 -138
- package/dist/index.mjs.map +4 -4
- package/dist/sealed/index.d.ts +1 -1
- package/dist/sealed/index.js +1 -1
- package/dist/sealed/noise.d.ts +44 -0
- package/dist/sealed/noise.js +137 -0
- package/dist/sealed/sealed.d.ts +60 -40
- package/dist/sealed/sealed.js +270 -126
- package/package.json +1 -1
package/dist/sealed/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
|
|
1
|
+
export { HANDSHAKE_PATH, SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
|
|
2
2
|
export type { SealedFetchOptions, VerifyTransportQuote } from './sealed.js';
|
package/dist/sealed/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
|
|
1
|
+
export { HANDSHAKE_PATH, SEALED_CONTENT_TYPE, SEALED_PATH, SealedTransportError, StaleTransportKeyError, createSealedFetch, fetchAttestedTransportKey, transportKeyBinding, } from './sealed.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Noise NK handshake (`Noise_NK_25519_AESGCM_SHA256`) that opens a sealed
|
|
3
|
+
* session, written against WebCrypto from the Noise specification
|
|
4
|
+
* (revision 34, http://www.noiseprotocol.org/noise.html). The node runs the
|
|
5
|
+
* other side with `snow`; core's published vectors check the two against each
|
|
6
|
+
* other.
|
|
7
|
+
*
|
|
8
|
+
* NK: the initiator knows the responder's static key in advance — here the
|
|
9
|
+
* transport key the node's quote commits to — and sends nothing but an
|
|
10
|
+
* ephemeral key before both ephemerals are mixed in, so everything after the
|
|
11
|
+
* handshake has forward secrecy.
|
|
12
|
+
*/
|
|
13
|
+
/** Noise's `SymmetricState`: the chaining key, the handshake hash and the cipher key. */
|
|
14
|
+
export declare class SymmetricState {
|
|
15
|
+
private ck;
|
|
16
|
+
private h;
|
|
17
|
+
private k;
|
|
18
|
+
private n;
|
|
19
|
+
private constructor();
|
|
20
|
+
static initialize(): Promise<SymmetricState>;
|
|
21
|
+
mixHash(data: Uint8Array): Promise<void>;
|
|
22
|
+
mixKey(inputKeyMaterial: Uint8Array): Promise<void>;
|
|
23
|
+
encryptAndHash(plaintext: Uint8Array): Promise<Uint8Array>;
|
|
24
|
+
decryptAndHash(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
25
|
+
/** The initiator-to-responder key, then the responder-to-initiator key. */
|
|
26
|
+
split(): Promise<[Uint8Array, Uint8Array]>;
|
|
27
|
+
}
|
|
28
|
+
export interface X25519KeyPair {
|
|
29
|
+
privateKey: CryptoKey;
|
|
30
|
+
publicKey: Uint8Array;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start an NK handshake to `remoteStatic`. `fixedEphemeral` pins the
|
|
34
|
+
* ephemeral secret, for the published test vectors only; leave it out.
|
|
35
|
+
*/
|
|
36
|
+
export declare function initiate(remoteStatic: Uint8Array, prologue: Uint8Array, fixedEphemeral?: Uint8Array): Promise<{
|
|
37
|
+
message1: Uint8Array;
|
|
38
|
+
finish(message2: Uint8Array): Promise<{
|
|
39
|
+
payload: Uint8Array;
|
|
40
|
+
split: [Uint8Array, Uint8Array];
|
|
41
|
+
}>;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function x25519KeyPair(secret?: Uint8Array): Promise<X25519KeyPair>;
|
|
44
|
+
export declare function dh(privateKey: CryptoKey, publicKey: Uint8Array): Promise<Uint8Array>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Noise NK handshake (`Noise_NK_25519_AESGCM_SHA256`) that opens a sealed
|
|
3
|
+
* session, written against WebCrypto from the Noise specification
|
|
4
|
+
* (revision 34, http://www.noiseprotocol.org/noise.html). The node runs the
|
|
5
|
+
* other side with `snow`; core's published vectors check the two against each
|
|
6
|
+
* other.
|
|
7
|
+
*
|
|
8
|
+
* NK: the initiator knows the responder's static key in advance — here the
|
|
9
|
+
* transport key the node's quote commits to — and sends nothing but an
|
|
10
|
+
* ephemeral key before both ephemerals are mixed in, so everything after the
|
|
11
|
+
* handshake has forward secrecy.
|
|
12
|
+
*/
|
|
13
|
+
import { concat } from '../crypto/internal.js';
|
|
14
|
+
const PROTOCOL_NAME = 'Noise_NK_25519_AESGCM_SHA256';
|
|
15
|
+
const DH_LEN = 32;
|
|
16
|
+
const TAG_LEN = 16;
|
|
17
|
+
/** PKCS#8 wrapping of a raw X25519 private key (RFC 8410). */
|
|
18
|
+
const PKCS8_X25519_PREFIX = new Uint8Array([
|
|
19
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04,
|
|
20
|
+
0x22, 0x04, 0x20,
|
|
21
|
+
]);
|
|
22
|
+
/** Noise's `SymmetricState`: the chaining key, the handshake hash and the cipher key. */
|
|
23
|
+
export class SymmetricState {
|
|
24
|
+
constructor(h) {
|
|
25
|
+
this.k = null;
|
|
26
|
+
this.n = 0;
|
|
27
|
+
this.ck = h;
|
|
28
|
+
this.h = h;
|
|
29
|
+
}
|
|
30
|
+
static async initialize() {
|
|
31
|
+
// A name of 32 bytes or fewer is used as the hash, zero-padded.
|
|
32
|
+
const name = new TextEncoder().encode(PROTOCOL_NAME);
|
|
33
|
+
const h = new Uint8Array(32);
|
|
34
|
+
h.set(name);
|
|
35
|
+
return new SymmetricState(h);
|
|
36
|
+
}
|
|
37
|
+
async mixHash(data) {
|
|
38
|
+
this.h = await sha256(concat(this.h, data));
|
|
39
|
+
}
|
|
40
|
+
async mixKey(inputKeyMaterial) {
|
|
41
|
+
const [ck, key] = await hkdf2(this.ck, inputKeyMaterial);
|
|
42
|
+
this.ck = ck;
|
|
43
|
+
this.k = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
44
|
+
this.n = 0;
|
|
45
|
+
}
|
|
46
|
+
async encryptAndHash(plaintext) {
|
|
47
|
+
if (!this.k)
|
|
48
|
+
throw new Error('Noise: no cipher key yet');
|
|
49
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: noiseNonce(this.n), additionalData: this.h }, this.k, plaintext));
|
|
50
|
+
this.n += 1;
|
|
51
|
+
await this.mixHash(ciphertext);
|
|
52
|
+
return ciphertext;
|
|
53
|
+
}
|
|
54
|
+
async decryptAndHash(ciphertext) {
|
|
55
|
+
if (!this.k)
|
|
56
|
+
throw new Error('Noise: no cipher key yet');
|
|
57
|
+
const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: noiseNonce(this.n), additionalData: this.h }, this.k, ciphertext));
|
|
58
|
+
this.n += 1;
|
|
59
|
+
await this.mixHash(ciphertext);
|
|
60
|
+
return plaintext;
|
|
61
|
+
}
|
|
62
|
+
/** The initiator-to-responder key, then the responder-to-initiator key. */
|
|
63
|
+
split() {
|
|
64
|
+
return hkdf2(this.ck, new Uint8Array());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Start an NK handshake to `remoteStatic`. `fixedEphemeral` pins the
|
|
69
|
+
* ephemeral secret, for the published test vectors only; leave it out.
|
|
70
|
+
*/
|
|
71
|
+
export async function initiate(remoteStatic, prologue, fixedEphemeral) {
|
|
72
|
+
const state = await SymmetricState.initialize();
|
|
73
|
+
await state.mixHash(prologue);
|
|
74
|
+
// <- s (known in advance)
|
|
75
|
+
await state.mixHash(remoteStatic);
|
|
76
|
+
// -> e, es
|
|
77
|
+
const ephemeral = await x25519KeyPair(fixedEphemeral);
|
|
78
|
+
await state.mixHash(ephemeral.publicKey);
|
|
79
|
+
await state.mixKey(await dh(ephemeral.privateKey, remoteStatic));
|
|
80
|
+
const message1 = concat(ephemeral.publicKey, await state.encryptAndHash(new Uint8Array()));
|
|
81
|
+
return {
|
|
82
|
+
message1,
|
|
83
|
+
async finish(message2) {
|
|
84
|
+
if (message2.length < DH_LEN + TAG_LEN)
|
|
85
|
+
throw new Error('Noise: message 2 is too short');
|
|
86
|
+
// <- e, ee
|
|
87
|
+
const remoteEphemeral = message2.slice(0, DH_LEN);
|
|
88
|
+
await state.mixHash(remoteEphemeral);
|
|
89
|
+
await state.mixKey(await dh(ephemeral.privateKey, remoteEphemeral));
|
|
90
|
+
const payload = await state.decryptAndHash(message2.slice(DH_LEN));
|
|
91
|
+
return { payload, split: await state.split() };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export async function x25519KeyPair(secret) {
|
|
96
|
+
if (!secret) {
|
|
97
|
+
const pair = (await crypto.subtle.generateKey({ name: 'X25519' }, true, [
|
|
98
|
+
'deriveBits',
|
|
99
|
+
]));
|
|
100
|
+
const publicKey = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
101
|
+
return { privateKey: pair.privateKey, publicKey };
|
|
102
|
+
}
|
|
103
|
+
const privateKey = await crypto.subtle.importKey('pkcs8', concat(PKCS8_X25519_PREFIX, secret), { name: 'X25519' }, true, ['deriveBits']);
|
|
104
|
+
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
|
105
|
+
return { privateKey, publicKey: base64UrlDecode(jwk.x) };
|
|
106
|
+
}
|
|
107
|
+
export async function dh(privateKey, publicKey) {
|
|
108
|
+
const peer = await crypto.subtle.importKey('raw', publicKey, { name: 'X25519' }, false, []);
|
|
109
|
+
return new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: peer }, privateKey, 256));
|
|
110
|
+
}
|
|
111
|
+
/** Noise's AESGCM nonce: 32 zero bits, then the counter as a big-endian u64. */
|
|
112
|
+
function noiseNonce(n) {
|
|
113
|
+
const nonce = new Uint8Array(12);
|
|
114
|
+
new DataView(nonce.buffer).setBigUint64(4, BigInt(n), false);
|
|
115
|
+
return nonce;
|
|
116
|
+
}
|
|
117
|
+
async function sha256(data) {
|
|
118
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
|
|
119
|
+
}
|
|
120
|
+
async function hmacSha256(key, data) {
|
|
121
|
+
const hmacKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
122
|
+
'sign',
|
|
123
|
+
]);
|
|
124
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', hmacKey, data));
|
|
125
|
+
}
|
|
126
|
+
/** Noise's `HKDF` with two outputs. */
|
|
127
|
+
async function hkdf2(chainingKey, inputKeyMaterial) {
|
|
128
|
+
const temp = await hmacSha256(chainingKey, inputKeyMaterial);
|
|
129
|
+
const first = await hmacSha256(temp, new Uint8Array([1]));
|
|
130
|
+
const second = await hmacSha256(temp, concat(first, new Uint8Array([2])));
|
|
131
|
+
return [first, second];
|
|
132
|
+
}
|
|
133
|
+
function base64UrlDecode(value) {
|
|
134
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
135
|
+
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
|
|
136
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
137
|
+
}
|
package/dist/sealed/sealed.d.ts
CHANGED
|
@@ -1,39 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sealed transport: requests encrypted to a TEE node's attested key.
|
|
2
|
+
* Sealed transport: requests encrypted end to end to a TEE node's attested key.
|
|
3
3
|
*
|
|
4
4
|
* A node speaks plain HTTP and any TLS in front of it ends outside the enclave,
|
|
5
5
|
* at whatever proxy or relay the operator runs, so everything crossing that hop
|
|
6
6
|
* (the bearer token, JSON-RPC arguments, results) is readable there. Sealing
|
|
7
|
-
* encrypts
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* encrypts it so that only the attested TD can read it, and only the TD can
|
|
8
|
+
* produce a response the client accepts.
|
|
9
|
+
*
|
|
10
|
+
* The client opens a session with a Noise NK handshake to the X25519 transport
|
|
11
|
+
* key the node's quote commits to ({@link fetchAttestedTransportKey}). The
|
|
12
|
+
* transport key only authenticates the node: the session keys come from both
|
|
13
|
+
* sides' ephemeral keys, so once a session is gone (an hour at most) what was
|
|
14
|
+
* sent in it cannot be opened, not even with the transport key. Each request
|
|
15
|
+
* is sealed under the session; each response streams back in sealed frames, so
|
|
16
|
+
* server-sent events are sealed too. WebSockets cannot be, and the node
|
|
17
|
+
* refuses one inside the envelope with a 501.
|
|
10
18
|
*
|
|
11
19
|
* The node's side, and the wire format this implements byte for byte, are in
|
|
12
20
|
* core's `crates/server/src/sealed.rs`:
|
|
13
21
|
*
|
|
14
22
|
* ```text
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* resp key
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* handshake Noise_NK_25519_AESGCM_SHA256, prologue "calimero/sealed-http/v2"
|
|
24
|
+
* request POST /sealed/v2/handshake 0x02 || transport_pk || Noise message 1
|
|
25
|
+
* response 0x02 || Noise message 2 (payload = session_id(16) || lifetime secs, u32 BE)
|
|
26
|
+
* keys (req key, resp key) = Split()
|
|
27
|
+
* exchange POST /sealed/v2
|
|
28
|
+
* header 0x02 || session_id(16) || request_id (u64 BE; from 1, each used once)
|
|
29
|
+
* request header || AES-256-GCM(req key, nonce = request_id || 0u32, aad = header, inner)
|
|
30
|
+
* response frames, each u32 BE length || AES-256-GCM(resp key,
|
|
31
|
+
* nonce = request_id || frame index (u32 BE, from 0), aad = header, kind || data)
|
|
32
|
+
* kind 0 = head (JSON), 1 = body bytes, 2 = end; no end frame = cut short
|
|
33
|
+
* inner u32 BE head length || head (JSON) || body
|
|
24
34
|
* ```
|
|
25
35
|
*
|
|
26
|
-
* Use it by
|
|
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.
|
|
36
|
+
* Use it by handing {@link createSealedFetch} to `MeroJsConfig.fetch`.
|
|
30
37
|
*/
|
|
31
38
|
import type { TeeAttestRequest, TeeAttestResponseData } from '../admin-api/admin-types.js';
|
|
39
|
+
import { initiate } from './noise.js';
|
|
40
|
+
/** Where a session is opened, relative to the node's base URL. */
|
|
41
|
+
export declare const HANDSHAKE_PATH = "/sealed/v2/handshake";
|
|
32
42
|
/** Where sealed requests are posted, relative to the node's base URL. */
|
|
33
|
-
export declare const SEALED_PATH = "/sealed/
|
|
34
|
-
/** Content type of
|
|
43
|
+
export declare const SEALED_PATH = "/sealed/v2";
|
|
44
|
+
/** Content type of every sealed body. */
|
|
35
45
|
export declare const SEALED_CONTENT_TYPE = "application/vnd.calimero.sealed";
|
|
36
|
-
/** The node no longer holds the key this client
|
|
46
|
+
/** The node no longer holds the transport key this client attested (it restarted). */
|
|
37
47
|
export declare class StaleTransportKeyError extends Error {
|
|
38
48
|
name: string;
|
|
39
49
|
constructor();
|
|
@@ -84,43 +94,53 @@ export declare function fetchAttestedTransportKey(admin: {
|
|
|
84
94
|
export interface SealedFetchOptions {
|
|
85
95
|
/** The node's base URL, as given to `MeroJsConfig.baseUrl`. */
|
|
86
96
|
baseUrl: string;
|
|
87
|
-
/**
|
|
88
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The node's attested transport key, or a function that attests and returns
|
|
99
|
+
* it — typically `() => fetchAttestedTransportKey(admin, verify)`. Given a
|
|
100
|
+
* function, a restarted node is handled: the key is attested again, through
|
|
101
|
+
* your verifier, and the request retried once. Given bytes, a restart
|
|
102
|
+
* surfaces as {@link StaleTransportKeyError}.
|
|
103
|
+
*/
|
|
104
|
+
transportPublicKey: Uint8Array | (() => Promise<Uint8Array>);
|
|
89
105
|
/** Fetch that carries the sealed envelope. Defaults to global `fetch`. */
|
|
90
106
|
fetch?: typeof fetch;
|
|
91
107
|
}
|
|
92
108
|
/**
|
|
93
|
-
* A `fetch` that seals every request to the node's transport key.
|
|
94
|
-
* `MeroJsConfig.fetch`.
|
|
109
|
+
* A `fetch` that seals every request to the node's attested transport key.
|
|
110
|
+
* Pass it as `MeroJsConfig.fetch`.
|
|
95
111
|
*
|
|
96
112
|
* A request for any URL outside `baseUrl` is refused rather than sent in the
|
|
97
113
|
* clear: falling back to plaintext would silently undo the point of sealing.
|
|
98
114
|
*/
|
|
99
115
|
export declare function createSealedFetch(options: SealedFetchOptions): typeof fetch;
|
|
116
|
+
export interface Session {
|
|
117
|
+
requestKey: CryptoKey;
|
|
118
|
+
responseKey: CryptoKey;
|
|
119
|
+
nextRequestId: number;
|
|
120
|
+
renewAt: number;
|
|
121
|
+
header(requestId: number): Uint8Array;
|
|
122
|
+
}
|
|
123
|
+
/** Finish the handshake with the node's reply. Exported for the published vectors. */
|
|
124
|
+
export declare function sessionFrom(noise: Awaited<ReturnType<typeof initiate>>, reply: Uint8Array): Promise<Session>;
|
|
125
|
+
/** Seal one request under the session's next request id. */
|
|
126
|
+
export declare function sealRequest(session: Session, head: RequestHead, body: Uint8Array): Promise<{
|
|
127
|
+
requestId: number;
|
|
128
|
+
header: Uint8Array;
|
|
129
|
+
envelope: Uint8Array;
|
|
130
|
+
}>;
|
|
100
131
|
export interface RequestHead {
|
|
101
132
|
method: string;
|
|
102
133
|
path: string;
|
|
103
134
|
headers: Array<[string, string]>;
|
|
104
|
-
ts: number;
|
|
105
135
|
}
|
|
106
136
|
export interface ResponseHead {
|
|
107
137
|
status: number;
|
|
108
138
|
headers: Array<[string, string]>;
|
|
109
139
|
}
|
|
110
|
-
export interface SealedExchange {
|
|
111
|
-
envelope: Uint8Array;
|
|
112
|
-
open(sealed: Uint8Array): Promise<{
|
|
113
|
-
head: ResponseHead;
|
|
114
|
-
body: Uint8Array;
|
|
115
|
-
}>;
|
|
116
|
-
}
|
|
117
140
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
141
|
+
* Open a sealed response as it streams: wait for its head, then hand back a
|
|
142
|
+
* `Response` whose body opens each frame as it arrives. A frame that does not
|
|
143
|
+
* open, arrives out of order, or a stream that stops before its end frame
|
|
144
|
+
* errors the body rather than passing anything unauthenticated on.
|
|
122
145
|
*/
|
|
123
|
-
export declare function
|
|
124
|
-
clientSecret: Uint8Array;
|
|
125
|
-
nonce: Uint8Array;
|
|
126
|
-
}): Promise<SealedExchange>;
|
|
146
|
+
export declare function openResponse(response: Response, responseKey: CryptoKey, header: Uint8Array, requestId: number): Promise<Response>;
|