lightnode-sdk 0.4.7 → 0.4.8

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/crypto.d.ts CHANGED
@@ -5,43 +5,50 @@
5
5
  * The format here MUST match the workers' Go implementation byte for byte,
6
6
  * which is what the `lcai-chat-v2` reference client also targets:
7
7
  * - ECDH P-256 key exchange.
8
- * - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
8
+ * - Raw shared secret X coordinate used DIRECTLY as the AES-256 key (no HKDF).
9
9
  * - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
10
10
  * - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
11
11
  *
12
- * Uses the Web Crypto API. Tries `globalThis.crypto` first (real browsers +
13
- * Node 19+ where it is global), then falls back to `node:crypto`'s
14
- * `webcrypto` export (Node 18 and StackBlitz's WebContainer, which exposes
15
- * the node: module but not the global). No hard dependency on Node, and no
16
- * polyfill in the browser bundle.
12
+ * Implementation note: this module used to be built on Web Crypto (subtle.*).
13
+ * That broke in environments where the runtime has a partial Web Crypto
14
+ * implementation - notably StackBlitz / Bolt WebContainer, where
15
+ * subtle.generateKey({name:"ECDH",namedCurve:"P-256"}) throws "Unsupported".
16
+ * The flow is now backed by @noble/curves (P-256) and @noble/ciphers (AES-GCM)
17
+ * which are pure-JS and identical across every runtime. The only Web Crypto
18
+ * surface left is getRandomValues, which is reliable everywhere (when missing
19
+ * we fall back to node:crypto).
17
20
  */
21
+ export interface EcdhKeyPair {
22
+ privateKey: Uint8Array;
23
+ publicKey: Uint8Array;
24
+ }
18
25
  /** A fresh 32-byte symmetric session key (random, never derived). */
19
26
  export declare function generateSessionKey(): Promise<Uint8Array>;
20
- /** Fresh ECDH P-256 keypair (extractable; we need to export the public key on the wire). */
21
- export declare function generateEcdhKeyPair(): Promise<CryptoKeyPair>;
22
- /** Export an ECDH public key to its raw uncompressed P-256 encoding (65 bytes). */
23
- export declare function exportPublicKey(key: CryptoKey): Promise<Uint8Array>;
24
- /** Import a raw uncompressed P-256 public key (65 bytes) into a CryptoKey. */
25
- export declare function importPublicKey(raw: Uint8Array): Promise<CryptoKey>;
27
+ /** Fresh ECDH P-256 keypair (32-byte private scalar, 65-byte uncompressed pub). */
28
+ export declare function generateEcdhKeyPair(): Promise<EcdhKeyPair>;
29
+ /** Identity passthrough kept for API back-compat. Public keys are already raw bytes. */
30
+ export declare function exportPublicKey(key: Uint8Array): Uint8Array;
31
+ /** Validate that raw is a well-formed uncompressed P-256 public key (65 bytes). */
32
+ export declare function importPublicKey(raw: Uint8Array): Uint8Array;
26
33
  /**
27
- * Derive a 32-byte shared secret from a local ECDH private key and a remote
28
- * public key. Returns the raw x-coordinate; deliberately no HKDF, matching the
34
+ * Derive a 32-byte shared secret (X coordinate of the shared point) from a
35
+ * local ECDH private key and a remote public key. No HKDF, matching the
29
36
  * protocol's `priv.ECDH(remotePub)` output exactly.
30
37
  */
31
- export declare function deriveSharedSecret(privateKey: CryptoKey, remotePublicKey: CryptoKey): Promise<Uint8Array>;
38
+ export declare function deriveSharedSecret(privateKey: Uint8Array, remotePublicKey: Uint8Array): Uint8Array;
32
39
  /** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
33
40
  export declare function encrypt(key: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array>;
34
41
  /** Decrypt AES-256-GCM. Input: nonce(12) || ciphertext || tag(16). */
35
42
  export declare function decrypt(key: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array>;
36
43
  /**
37
- * Wrap (encrypt) a 32-byte session key for delivery to a remote party (e.g. the
38
- * worker), using a fresh ephemeral ECDH keypair.
44
+ * Wrap (encrypt) a 32-byte session key for delivery to a remote party
45
+ * (e.g. the worker), using a fresh ephemeral ECDH keypair.
39
46
  *
40
47
  * Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
41
48
  */
42
- export declare function encryptSessionKey(sessionKey: Uint8Array, remotePublicKey: CryptoKey): Promise<Uint8Array>;
49
+ export declare function encryptSessionKey(sessionKey: Uint8Array, remotePublicKey: Uint8Array): Promise<Uint8Array>;
43
50
  /** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
44
- export declare function decryptSessionKey(encWorkerKey: Uint8Array, privateKey: CryptoKey): Promise<Uint8Array>;
51
+ export declare function decryptSessionKey(encWorkerKey: Uint8Array, privateKey: Uint8Array): Promise<Uint8Array>;
45
52
  export declare function utf8ToBytes(s: string): Uint8Array;
46
53
  export declare function bytesToUtf8(b: Uint8Array): string;
47
54
  export declare function bytesToHex(b: Uint8Array): `0x${string}`;
package/dist/crypto.js CHANGED
@@ -5,114 +5,119 @@
5
5
  * The format here MUST match the workers' Go implementation byte for byte,
6
6
  * which is what the `lcai-chat-v2` reference client also targets:
7
7
  * - ECDH P-256 key exchange.
8
- * - Raw shared secret used DIRECTLY as the AES-256 key (no HKDF).
8
+ * - Raw shared secret X coordinate used DIRECTLY as the AES-256 key (no HKDF).
9
9
  * - AES-GCM ciphertext layout: nonce(12) || ciphertext || tag(16).
10
10
  * - Encrypted session key layout: ephemeralPub(65) || nonce(12) || ciphertext || tag(16).
11
11
  *
12
- * Uses the Web Crypto API. Tries `globalThis.crypto` first (real browsers +
13
- * Node 19+ where it is global), then falls back to `node:crypto`'s
14
- * `webcrypto` export (Node 18 and StackBlitz's WebContainer, which exposes
15
- * the node: module but not the global). No hard dependency on Node, and no
16
- * polyfill in the browser bundle.
12
+ * Implementation note: this module used to be built on Web Crypto (subtle.*).
13
+ * That broke in environments where the runtime has a partial Web Crypto
14
+ * implementation - notably StackBlitz / Bolt WebContainer, where
15
+ * subtle.generateKey({name:"ECDH",namedCurve:"P-256"}) throws "Unsupported".
16
+ * The flow is now backed by @noble/curves (P-256) and @noble/ciphers (AES-GCM)
17
+ * which are pure-JS and identical across every runtime. The only Web Crypto
18
+ * surface left is getRandomValues, which is reliable everywhere (when missing
19
+ * we fall back to node:crypto).
17
20
  */
21
+ import { p256 } from "@noble/curves/p256";
22
+ import { gcm } from "@noble/ciphers/aes";
18
23
  const AES_KEY_BYTES = 32;
19
24
  const GCM_NONCE_BYTES = 12;
20
- const P256_UNCOMPRESSED_KEY_BYTES = 65;
25
+ const GCM_TAG_BYTES = 16;
26
+ const P256_UNCOMPRESSED_KEY_BYTES = 65; // 0x04 || X(32) || Y(32)
21
27
  const SESSION_KEY_BYTES = 32;
22
- let resolvedCrypto = null;
23
- let resolvingCrypto = null;
24
- async function getCrypto() {
25
- if (resolvedCrypto)
26
- return resolvedCrypto;
27
- if (resolvingCrypto)
28
- return resolvingCrypto;
29
- resolvingCrypto = (async () => {
30
- const diag = [];
28
+ // Random source: try globalThis.crypto.getRandomValues (every modern browser
29
+ // and Node 19+ global), fall back to node:crypto.webcrypto.getRandomValues
30
+ // (Node 18, WebContainer). All algorithm-side crypto is pure JS, so this is
31
+ // the only Web-Crypto-flavored thing the SDK still needs.
32
+ let resolvedRng = null;
33
+ let resolvingRng = null;
34
+ async function getRng() {
35
+ if (resolvedRng)
36
+ return resolvedRng;
37
+ if (resolvingRng)
38
+ return resolvingRng;
39
+ resolvingRng = (async () => {
31
40
  const g = globalThis.crypto;
32
- diag.push(`globalThis.crypto=${g ? "present" : "missing"}`);
33
- if (g)
34
- diag.push(`globalThis.crypto.subtle=${g.subtle ? "present" : "missing"}`);
35
- if (g)
36
- diag.push(`globalThis.crypto.getRandomValues=${typeof g.getRandomValues}`);
37
- if (g?.subtle && typeof g.getRandomValues === "function") {
38
- const provider = { subtle: g.subtle, getRandomValues: g.getRandomValues.bind(g) };
39
- resolvedCrypto = provider;
40
- return provider;
41
+ if (g && typeof g.getRandomValues === "function") {
42
+ const bound = g.getRandomValues.bind(g);
43
+ resolvedRng = bound;
44
+ return bound;
41
45
  }
42
- // Node 18 + StackBlitz WebContainer: globalThis.crypto may be missing, but
43
- // `node:crypto` exposes the same Web Crypto API via `webcrypto`.
44
- let nodeCryptoError = null;
45
46
  try {
46
47
  const mod = (await import("node:crypto"));
47
- diag.push(`node:crypto=imported`);
48
48
  const wc = mod.webcrypto;
49
- diag.push(`node:crypto.webcrypto=${wc ? "present" : "missing"}`);
50
- if (wc)
51
- diag.push(`node:crypto.webcrypto.subtle=${wc.subtle ? "present" : "missing"}`);
52
- if (wc)
53
- diag.push(`node:crypto.webcrypto.getRandomValues=${typeof wc.getRandomValues}`);
54
- if (wc?.subtle && typeof wc.getRandomValues === "function") {
55
- const provider = { subtle: wc.subtle, getRandomValues: wc.getRandomValues.bind(wc) };
56
- resolvedCrypto = provider;
57
- return provider;
49
+ if (wc && typeof wc.getRandomValues === "function") {
50
+ const bound = wc.getRandomValues.bind(wc);
51
+ resolvedRng = bound;
52
+ return bound;
58
53
  }
59
54
  }
60
- catch (err) {
61
- nodeCryptoError = err.message;
62
- diag.push(`node:crypto=import threw: ${nodeCryptoError}`);
55
+ catch {
56
+ // browser bundle without a global crypto - fall through to the throw
63
57
  }
64
- throw new Error("Web Crypto unavailable. The SDK requires either globalThis.crypto (Node 19+ or any modern browser) " +
65
- "or node:crypto.webcrypto (Node 18, StackBlitz WebContainer). Diagnostic: " +
66
- diag.join("; "));
58
+ throw new Error("Secure random source unavailable: neither globalThis.crypto.getRandomValues nor " +
59
+ "node:crypto.webcrypto.getRandomValues was found. Requires Node 18+ or a modern browser.");
67
60
  })();
68
61
  try {
69
- return await resolvingCrypto;
62
+ return await resolvingRng;
70
63
  }
71
64
  finally {
72
- resolvingCrypto = null;
65
+ resolvingRng = null;
73
66
  }
74
67
  }
75
- async function subtle() {
76
- return (await getCrypto()).subtle;
77
- }
78
68
  async function randomBytes(n) {
79
69
  const buf = new Uint8Array(n);
80
- (await getCrypto()).getRandomValues(buf);
70
+ (await getRng())(buf);
81
71
  return buf;
82
72
  }
83
73
  /** A fresh 32-byte symmetric session key (random, never derived). */
84
74
  export function generateSessionKey() {
85
75
  return randomBytes(SESSION_KEY_BYTES);
86
76
  }
87
- /** Fresh ECDH P-256 keypair (extractable; we need to export the public key on the wire). */
77
+ /** Fresh ECDH P-256 keypair (32-byte private scalar, 65-byte uncompressed pub). */
88
78
  export async function generateEcdhKeyPair() {
89
- return (await subtle()).generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
90
- }
91
- /** Export an ECDH public key to its raw uncompressed P-256 encoding (65 bytes). */
92
- export async function exportPublicKey(key) {
93
- return new Uint8Array(await (await subtle()).exportKey("raw", key));
94
- }
95
- /** Import a raw uncompressed P-256 public key (65 bytes) into a CryptoKey. */
96
- export async function importPublicKey(raw) {
97
- return (await subtle()).importKey("raw", raw, { name: "ECDH", namedCurve: "P-256" }, true, []);
79
+ // Use our RNG (proven available) instead of noble's, so a missing
80
+ // globalThis.crypto fails loudly at one site rather than in noble's internals.
81
+ const privateKey = await randomBytes(32);
82
+ // P-256 private scalars must be < n; the chance of collision is astronomically
83
+ // small, but noble validates this when we call getPublicKey, so any bad draw
84
+ // throws (and we'd just retry - but it has never been hit in practice).
85
+ const publicKey = p256.getPublicKey(privateKey, false);
86
+ return { privateKey, publicKey };
87
+ }
88
+ /** Identity passthrough kept for API back-compat. Public keys are already raw bytes. */
89
+ export function exportPublicKey(key) {
90
+ return key;
91
+ }
92
+ /** Validate that raw is a well-formed uncompressed P-256 public key (65 bytes). */
93
+ export function importPublicKey(raw) {
94
+ if (raw.length !== P256_UNCOMPRESSED_KEY_BYTES || raw[0] !== 0x04) {
95
+ throw new Error(`expected uncompressed P-256 public key (${P256_UNCOMPRESSED_KEY_BYTES} bytes, leading 0x04)`);
96
+ }
97
+ // Round-trip through noble to confirm the point is on the curve. Throws if not.
98
+ p256.ProjectivePoint.fromHex(raw);
99
+ return raw;
98
100
  }
99
101
  /**
100
- * Derive a 32-byte shared secret from a local ECDH private key and a remote
101
- * public key. Returns the raw x-coordinate; deliberately no HKDF, matching the
102
+ * Derive a 32-byte shared secret (X coordinate of the shared point) from a
103
+ * local ECDH private key and a remote public key. No HKDF, matching the
102
104
  * protocol's `priv.ECDH(remotePub)` output exactly.
103
105
  */
104
- export async function deriveSharedSecret(privateKey, remotePublicKey) {
105
- return new Uint8Array(await (await subtle()).deriveBits({ name: "ECDH", public: remotePublicKey }, privateKey, 256));
106
+ export function deriveSharedSecret(privateKey, remotePublicKey) {
107
+ // noble returns 65 bytes uncompressed (0x04 || X || Y) when isCompressed=false.
108
+ // The protocol's shared secret is just X.
109
+ const sharedPoint = p256.getSharedSecret(privateKey, remotePublicKey, false);
110
+ return sharedPoint.slice(1, 1 + AES_KEY_BYTES);
106
111
  }
107
112
  /** Encrypt with AES-256-GCM. Output: nonce(12) || ciphertext || tag(16). */
108
113
  export async function encrypt(key, plaintext) {
109
114
  if (key.length !== AES_KEY_BYTES)
110
115
  throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
111
- const s = await subtle();
112
- const aesKey = await s.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
113
116
  const nonce = await randomBytes(GCM_NONCE_BYTES);
114
- const ctPlusTag = new Uint8Array(await s.encrypt({ name: "AES-GCM", iv: nonce }, aesKey, plaintext));
115
- const out = new Uint8Array(GCM_NONCE_BYTES + ctPlusTag.byteLength);
117
+ // noble's gcm returns plaintext.length + 16 (tag appended). Layout in our
118
+ // wire format is nonce || ct+tag.
119
+ const ctPlusTag = gcm(key, nonce).encrypt(plaintext);
120
+ const out = new Uint8Array(GCM_NONCE_BYTES + ctPlusTag.length);
116
121
  out.set(nonce, 0);
117
122
  out.set(ctPlusTag, GCM_NONCE_BYTES);
118
123
  return out;
@@ -121,17 +126,15 @@ export async function encrypt(key, plaintext) {
121
126
  export async function decrypt(key, ciphertext) {
122
127
  if (key.length !== AES_KEY_BYTES)
123
128
  throw new Error(`AES key must be ${AES_KEY_BYTES} bytes`);
124
- if (ciphertext.length < GCM_NONCE_BYTES + 16)
129
+ if (ciphertext.length < GCM_NONCE_BYTES + GCM_TAG_BYTES)
125
130
  throw new Error("ciphertext too short");
126
- const s = await subtle();
127
- const aesKey = await s.importKey("raw", key, "AES-GCM", false, ["decrypt"]);
128
131
  const nonce = ciphertext.slice(0, GCM_NONCE_BYTES);
129
132
  const body = ciphertext.slice(GCM_NONCE_BYTES);
130
- return new Uint8Array(await s.decrypt({ name: "AES-GCM", iv: nonce }, aesKey, body));
133
+ return gcm(key, nonce).decrypt(body);
131
134
  }
132
135
  /**
133
- * Wrap (encrypt) a 32-byte session key for delivery to a remote party (e.g. the
134
- * worker), using a fresh ephemeral ECDH keypair.
136
+ * Wrap (encrypt) a 32-byte session key for delivery to a remote party
137
+ * (e.g. the worker), using a fresh ephemeral ECDH keypair.
135
138
  *
136
139
  * Output: ephemeralPub(65) || nonce(12) || AES-GCM(sharedSecret, sessionKey)
137
140
  */
@@ -139,22 +142,21 @@ export async function encryptSessionKey(sessionKey, remotePublicKey) {
139
142
  if (sessionKey.length !== SESSION_KEY_BYTES)
140
143
  throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
141
144
  const ephemeral = await generateEcdhKeyPair();
142
- const shared = await deriveSharedSecret(ephemeral.privateKey, remotePublicKey);
145
+ const shared = deriveSharedSecret(ephemeral.privateKey, remotePublicKey);
143
146
  const ct = await encrypt(shared, sessionKey);
144
- const pub = await exportPublicKey(ephemeral.publicKey);
145
- const out = new Uint8Array(pub.length + ct.length);
146
- out.set(pub, 0);
147
- out.set(ct, pub.length);
147
+ const out = new Uint8Array(ephemeral.publicKey.length + ct.length);
148
+ out.set(ephemeral.publicKey, 0);
149
+ out.set(ct, ephemeral.publicKey.length);
148
150
  return out;
149
151
  }
150
152
  /** Unwrap a session key encrypted by `encryptSessionKey` using the local ECDH private key. */
151
153
  export async function decryptSessionKey(encWorkerKey, privateKey) {
152
- if (encWorkerKey.length < P256_UNCOMPRESSED_KEY_BYTES + GCM_NONCE_BYTES + 16) {
154
+ if (encWorkerKey.length < P256_UNCOMPRESSED_KEY_BYTES + GCM_NONCE_BYTES + GCM_TAG_BYTES) {
153
155
  throw new Error("encrypted session key too short");
154
156
  }
155
- const ephemeralPub = await importPublicKey(encWorkerKey.slice(0, P256_UNCOMPRESSED_KEY_BYTES));
157
+ const ephemeralPub = importPublicKey(encWorkerKey.slice(0, P256_UNCOMPRESSED_KEY_BYTES));
156
158
  const ct = encWorkerKey.slice(P256_UNCOMPRESSED_KEY_BYTES);
157
- const shared = await deriveSharedSecret(privateKey, ephemeralPub);
159
+ const shared = deriveSharedSecret(privateKey, ephemeralPub);
158
160
  const sk = await decrypt(shared, ct);
159
161
  if (sk.length !== SESSION_KEY_BYTES)
160
162
  throw new Error(`session key must be ${SESSION_KEY_BYTES} bytes`);
package/dist/index.d.ts CHANGED
@@ -66,7 +66,7 @@ export declare class LightNode {
66
66
  * (especially in registry-proxy environments like StackBlitz where lockfiles
67
67
  * may pin an older minor than the local install command suggests).
68
68
  */
69
- export declare const SDK_VERSION = "0.4.7";
69
+ export declare const SDK_VERSION = "0.4.8";
70
70
  export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost, GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto, runInference, runInferenceWithKey, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, isStalledWorker, };
71
71
  export type { BearerSource, GatewayClientOptions, SelectSessionResult, PrepareSessionResult, UploadBlobResult, SessionTokenResult } from "./gateway.js";
72
72
  export type { SessionPreparation, RunInferenceArgs, RunInferenceResult, RunInferenceWithKeyArgs } from "./inference.js";
package/dist/index.js CHANGED
@@ -96,7 +96,7 @@ export class LightNode {
96
96
  * (especially in registry-proxy environments like StackBlitz where lockfiles
97
97
  * may pin an older minor than the local install command suggests).
98
98
  */
99
- export const SDK_VERSION = "0.4.7";
99
+ export const SDK_VERSION = "0.4.8";
100
100
  export { NETWORKS, WORKER_REGISTRY, REGISTRY_TOPICS, aggregateModelStats, aggregateWorkerStats, networkAnalytics, modelStatsCsv, workerStatsCsv, workerJobsCsv, fromWei, computeModelId as modelId, estimateJobFee, JOB_REGISTRY_CONSUMER_ABI, consumerGatewayUrl, consumerGatewayHost,
101
101
  // v0.3 inference-submit surface (BETA - see README "Submitting inference").
102
102
  GatewayClient, GatewayHttpError, prepareSession, submitPrompt, decryptResponse, generateEcdhKeyPair, crypto,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Read-only TypeScript client for LightChain AI: workers, jobs, models, on-chain registration, and per-model network analytics. Independent, community-built (not an official LightChain package).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,6 +46,8 @@
46
46
  "url": "https://github.com/marinom2/lightnode/issues"
47
47
  },
48
48
  "dependencies": {
49
+ "@noble/ciphers": "^1.0.0",
50
+ "@noble/curves": "^1.0.0",
49
51
  "viem": ">=2"
50
52
  },
51
53
  "engines": {