@lightsparkdev/core 0.1.3

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,11 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ import LightsparkException from "../LightsparkException.js";
4
+
5
+ class LightsparkAuthException extends LightsparkException {
6
+ constructor(message: string, extraInfo?: any) {
7
+ super("AuthException", message, extraInfo);
8
+ }
9
+ }
10
+
11
+ export default LightsparkAuthException;
@@ -0,0 +1,16 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ import AuthProvider from "./AuthProvider.js";
4
+
5
+ export default class StubAuthProvider implements AuthProvider {
6
+ async addAuthHeaders(headers: any): Promise<any> {
7
+ return headers;
8
+ }
9
+ async isAuthorized(): Promise<boolean> {
10
+ return false;
11
+ }
12
+
13
+ addWsConnectionParams(params: any): any {
14
+ return params;
15
+ }
16
+ }
@@ -0,0 +1,5 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ export { default as AuthProvider } from "./AuthProvider.js";
4
+ export { default as LightsparkAuthException } from "./LightsparkAuthException.js";
5
+ export { default as StubAuthProvider } from "./StubAuthProvider.js";
@@ -0,0 +1,11 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ import LightsparkException from "../LightsparkException.js";
4
+
5
+ class LightsparkSigningException extends LightsparkException {
6
+ constructor(message: string, extraInfo?: any) {
7
+ super("SigningException", message, extraInfo);
8
+ }
9
+ }
10
+
11
+ export default LightsparkSigningException;
@@ -0,0 +1,46 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ import autoBind from "auto-bind";
4
+
5
+ import { b64decode } from "../utils/base64.js";
6
+ import { getCrypto } from "./crypto.js";
7
+
8
+ class NodeKeyCache {
9
+ private idToKey: Map<string, CryptoKey>;
10
+ constructor() {
11
+ this.idToKey = new Map();
12
+ autoBind(this);
13
+ }
14
+
15
+ public async loadKey(id: string, rawKey: string): Promise<CryptoKey | null> {
16
+ const decoded = b64decode(rawKey);
17
+ try {
18
+ const cryptoImpl = await getCrypto();
19
+ const key = await cryptoImpl.subtle.importKey(
20
+ "pkcs8",
21
+ decoded,
22
+ {
23
+ name: "RSA-PSS",
24
+ hash: "SHA-256",
25
+ },
26
+ true,
27
+ ["sign"]
28
+ );
29
+ this.idToKey.set(id, key);
30
+ return key;
31
+ } catch (e) {
32
+ console.log("Error importing key: ", e);
33
+ }
34
+ return null;
35
+ }
36
+
37
+ public getKey(id: string): CryptoKey | undefined {
38
+ return this.idToKey.get(id);
39
+ }
40
+
41
+ public hasKey(id: string): boolean {
42
+ return this.idToKey.has(id);
43
+ }
44
+ }
45
+
46
+ export default NodeKeyCache;
@@ -0,0 +1,258 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+ import LightsparkException from "../LightsparkException.js";
3
+
4
+ import { b64decode, b64encode } from "../utils/base64.js";
5
+
6
+ const ITERATIONS = 500000;
7
+
8
+ export function getCrypto() {
9
+ let cryptoImplPromise: Promise<typeof crypto>;
10
+ if (typeof crypto !== "undefined") {
11
+ cryptoImplPromise = Promise.resolve(crypto);
12
+ } else {
13
+ cryptoImplPromise = import("crypto").then((nodeCrypto) => {
14
+ return nodeCrypto as typeof crypto;
15
+ });
16
+ }
17
+ return cryptoImplPromise;
18
+ }
19
+
20
+ const getRandomValues = async (arr: Uint8Array): Promise<Uint8Array> => {
21
+ if (typeof crypto !== "undefined") {
22
+ return crypto.getRandomValues(arr);
23
+ } else {
24
+ const cryptoImpl = await getCrypto();
25
+ return cryptoImpl.getRandomValues(arr);
26
+ }
27
+ };
28
+
29
+ const getRandomValues32 = async (arr: Uint32Array): Promise<Uint32Array> => {
30
+ if (typeof crypto !== "undefined") {
31
+ return crypto.getRandomValues(arr);
32
+ } else {
33
+ const cryptoImpl = await getCrypto();
34
+ return cryptoImpl.getRandomValues(arr);
35
+ }
36
+ };
37
+
38
+ const deriveKey = async (
39
+ password: string,
40
+ salt: ArrayBuffer,
41
+ iterations: number,
42
+ algorithm: string,
43
+ bit_len: number
44
+ ): Promise<[CryptoKey, ArrayBuffer]> => {
45
+ const enc = new TextEncoder();
46
+ const cryptoImpl = await getCrypto();
47
+ const password_key = await cryptoImpl.subtle.importKey(
48
+ "raw",
49
+ enc.encode(password),
50
+ "PBKDF2",
51
+ false,
52
+ ["deriveBits", "deriveKey"]
53
+ );
54
+
55
+ const derived = await cryptoImpl.subtle.deriveBits(
56
+ {
57
+ name: "PBKDF2",
58
+ salt,
59
+ iterations: iterations,
60
+ hash: "SHA-256",
61
+ },
62
+ password_key,
63
+ bit_len
64
+ );
65
+
66
+ // Split the derived bytes into a 32 byte AES key and a 16 byte IV
67
+ const key = await cryptoImpl.subtle.importKey(
68
+ "raw",
69
+ derived.slice(0, 32),
70
+ { name: algorithm, length: 256 },
71
+ false,
72
+ ["encrypt", "decrypt"]
73
+ );
74
+
75
+ const iv = derived.slice(32);
76
+
77
+ return [key, iv];
78
+ };
79
+
80
+ export const encrypt = async (
81
+ plaintext: ArrayBuffer,
82
+ password: string,
83
+ salt?: Uint8Array
84
+ ): Promise<[string, string]> => {
85
+ if (!salt) {
86
+ salt = new Uint8Array(16);
87
+ getRandomValues(salt);
88
+ }
89
+
90
+ const [key, iv] = await deriveKey(password, salt, ITERATIONS, "AES-GCM", 352);
91
+ const cryptoImpl = await getCrypto();
92
+
93
+ const encrypted = new Uint8Array(
94
+ await cryptoImpl.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext)
95
+ );
96
+
97
+ const output = new Uint8Array(salt.byteLength + encrypted.byteLength);
98
+ output.set(salt);
99
+ output.set(encrypted, salt.byteLength);
100
+
101
+ const header = {
102
+ v: 4,
103
+ i: ITERATIONS,
104
+ };
105
+
106
+ return [JSON.stringify(header), b64encode(output)];
107
+ };
108
+
109
+ export const decrypt = async (
110
+ header_json: string,
111
+ ciphertext: string,
112
+ password: string
113
+ ): Promise<ArrayBuffer> => {
114
+ var decoded = b64decode(ciphertext);
115
+
116
+ var header;
117
+ if (header_json === "AES_256_CBC_PBKDF2_5000_SHA256") {
118
+ header = {
119
+ v: 0,
120
+ i: 5000,
121
+ };
122
+ // Strip "Salted__" prefix
123
+ decoded = decoded.slice(8);
124
+ } else {
125
+ header = JSON.parse(header_json);
126
+ }
127
+
128
+ if (header.v < 0 || header.v > 4) {
129
+ throw new LightsparkException(
130
+ "DecryptionError",
131
+ "Unknown version ".concat(header.v)
132
+ );
133
+ }
134
+
135
+ const cryptoImpl = await getCrypto();
136
+ const algorithm = header.v < 2 ? "AES-CBC" : "AES-GCM";
137
+ const bit_len = header.v < 4 ? 384 : 352;
138
+ const salt_len = header.v < 4 ? 8 : 16;
139
+
140
+ if (header.lsv === 2 || header.v === 3) {
141
+ const salt = decoded.slice(decoded.length - 8, decoded.length);
142
+ const nonce = decoded.slice(0, 12);
143
+ const cipherText = decoded.slice(12, decoded.length - 8);
144
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
145
+ const [key, _iv] = await deriveKey(
146
+ password,
147
+ salt,
148
+ header.i,
149
+ algorithm,
150
+ 256
151
+ );
152
+ return await cryptoImpl.subtle.decrypt(
153
+ { name: algorithm, iv: nonce.buffer },
154
+ key,
155
+ cipherText
156
+ );
157
+ } else {
158
+ const salt = decoded.slice(0, salt_len);
159
+ const encrypted = decoded.slice(salt_len);
160
+ const [key, iv] = await deriveKey(
161
+ password,
162
+ salt,
163
+ header.i,
164
+ algorithm,
165
+ bit_len
166
+ );
167
+ return await cryptoImpl.subtle.decrypt(
168
+ { name: algorithm, iv },
169
+ key,
170
+ encrypted
171
+ );
172
+ }
173
+ };
174
+
175
+ export async function decryptSecretWithNodePassword(
176
+ cipher: string,
177
+ encryptedSecret: string,
178
+ nodePassword: string
179
+ ): Promise<ArrayBuffer | null> {
180
+ let decryptedValue: ArrayBuffer | null = null;
181
+ try {
182
+ decryptedValue = await decrypt(cipher, encryptedSecret, nodePassword);
183
+ } catch (ex) {
184
+ // If the password is incorrect, we're likely to get UTF-8 decoding errors.
185
+ // Catch everything and we'll leave the value as the empty string.
186
+ console.error(ex);
187
+ }
188
+ return decryptedValue;
189
+ }
190
+
191
+ export function decode(arrBuff: ArrayBuffer): string {
192
+ const dec = new TextDecoder();
193
+ return dec.decode(arrBuff);
194
+ }
195
+
196
+ export const generateSigningKeyPair = async (): Promise<CryptoKeyPair> => {
197
+ const cryptoImpl = await getCrypto();
198
+ return await cryptoImpl.subtle.generateKey(
199
+ /*algorithm:*/ {
200
+ name: "RSA-PSS",
201
+ modulusLength: 4096,
202
+ publicExponent: new Uint8Array([1, 0, 1]),
203
+ hash: "SHA-256",
204
+ },
205
+ /*extractable*/ true,
206
+ /*keyUsages*/ ["sign", "verify"]
207
+ );
208
+ };
209
+
210
+ export const serializeSigningKey = async (
211
+ key: CryptoKey,
212
+ format: "pkcs8" | "spki"
213
+ ): Promise<ArrayBuffer> => {
214
+ const cryptoImpl = await getCrypto();
215
+ return await cryptoImpl.subtle.exportKey(/*format*/ format, /*key*/ key);
216
+ };
217
+
218
+ export const encryptWithNodeKey = async (
219
+ key: CryptoKey,
220
+ data: string
221
+ ): Promise<string> => {
222
+ const enc = new TextEncoder();
223
+ const encoded = enc.encode(data);
224
+ // @ts-ignore
225
+ const encrypted = await cryptoImpl.subtle.encrypt(
226
+ /*algorithm:*/ {
227
+ name: "RSA-OAEP",
228
+ },
229
+ /*key*/ key,
230
+ /*data*/ encoded
231
+ );
232
+ // @ts-ignore
233
+ return b64encode(encrypted);
234
+ };
235
+
236
+ export const loadNodeEncryptionKey = async (
237
+ rawPublicKey: string
238
+ ): Promise<CryptoKey> => {
239
+ const encoded = b64decode(rawPublicKey);
240
+ const cryptoImpl = await getCrypto();
241
+ return await cryptoImpl.subtle.importKey(
242
+ /*format*/ "spki",
243
+ /*keyData*/ encoded,
244
+ /*algorithm:*/ {
245
+ name: "RSA-OAEP",
246
+ hash: "SHA-256",
247
+ },
248
+ /*extractable*/ true,
249
+ /*keyUsages*/ ["encrypt"]
250
+ );
251
+ };
252
+
253
+ export const getNonce = async () => {
254
+ const nonceSt = await getRandomValues32(new Uint32Array(1));
255
+ return Number(nonceSt);
256
+ };
257
+
258
+ export { default as LightsparkSigningException } from "./LightsparkSigningException.js";
@@ -0,0 +1,5 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ export * from "./crypto.js";
4
+ export { default as LightsparkSigningException } from "./LightsparkSigningException.js";
5
+ export { default as NodeKeyCache } from "./NodeKeyCache.js";
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ export * from "./auth/index.js";
4
+ export * from "./crypto/index.js";
5
+ export { default as LightsparkException } from "./LightsparkException.js";
6
+ export * from "./requester/index.js";
7
+ export {
8
+ apiDomainForEnvironment,
9
+ default as ServerEnvironment,
10
+ } from "./ServerEnvironment.js";
11
+ export * from "./utils/index.js";
@@ -0,0 +1,17 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ type Query<T> = {
4
+ /** The string representation of the query payload for graphQL. **/
5
+ queryPayload: string;
6
+
7
+ /** The variables that will be passed to the query. **/
8
+ variables?: { [key: string]: any };
9
+
10
+ /** The function that will be called to construct the object from the response. **/
11
+ constructObject: (rawData: any) => T;
12
+
13
+ /** The id of the node that will be used to sign the query. **/
14
+ signingNodeId?: string;
15
+ };
16
+
17
+ export default Query;
@@ -0,0 +1,226 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ import autoBind from "auto-bind";
4
+ import dayjs from "dayjs";
5
+ import utc from "dayjs/plugin/utc.js";
6
+ import { Client as WsClient, createClient } from "graphql-ws";
7
+ import NodeWebSocket from "ws";
8
+ import { Observable } from "zen-observable-ts";
9
+
10
+ import Query from "./Query.js";
11
+
12
+ import AuthProvider from "../auth/AuthProvider.js";
13
+ import StubAuthProvider from "../auth/StubAuthProvider.js";
14
+ import {
15
+ getCrypto,
16
+ getNonce,
17
+ LightsparkSigningException,
18
+ } from "../crypto/crypto.js";
19
+ import NodeKeyCache from "../crypto/NodeKeyCache.js";
20
+ import LightsparkException from "../LightsparkException.js";
21
+ import { b64encode } from "../utils/base64.js";
22
+ import { isNode } from "../utils/environment.js";
23
+
24
+ const DEFAULT_BASE_URL = "api.lightspark.com";
25
+ export const LIGHTSPARK_BETA_HEADER_KEY = "X-Lightspark-Beta";
26
+ export const LIGHTSPARK_BETA_HEADER_VALUE =
27
+ "z2h0BBYxTA83cjW7fi8QwWtBPCzkQKiemcuhKY08LOo";
28
+ dayjs.extend(utc);
29
+
30
+ class Requester {
31
+ private readonly wsClient: WsClient;
32
+ constructor(
33
+ private readonly nodeKeyCache: NodeKeyCache,
34
+ private readonly schemaEndpoint: string,
35
+ private readonly authProvider: AuthProvider = new StubAuthProvider(),
36
+ private readonly baseUrl: string = DEFAULT_BASE_URL
37
+ ) {
38
+ let websocketImpl;
39
+ if (typeof WebSocket === "undefined" && typeof window === "undefined") {
40
+ websocketImpl = NodeWebSocket;
41
+ }
42
+ this.wsClient = createClient({
43
+ url: `wss://${this.baseUrl}/${this.schemaEndpoint}`,
44
+ connectionParams: authProvider.addWsConnectionParams({}),
45
+ webSocketImpl: websocketImpl,
46
+ });
47
+ autoBind(this);
48
+ }
49
+
50
+ public async executeQuery<T>(query: Query<T>): Promise<T | null> {
51
+ const data = await this.makeRawRequest(
52
+ query.queryPayload,
53
+ query.variables || {},
54
+ query.signingNodeId
55
+ );
56
+ return query.constructObject(data);
57
+ }
58
+
59
+ public subscribe(
60
+ queryPayload: string,
61
+ variables: { [key: string]: any } = {}
62
+ ): Observable<any> {
63
+ const operationNameRegex = /^\s*(query|mutation|subscription)\s+(\w+)/i;
64
+ const operationMatch = queryPayload.match(operationNameRegex);
65
+ if (!operationMatch || operationMatch.length < 3) {
66
+ throw new LightsparkException("InvalidQuery", "Invalid query payload");
67
+ }
68
+ const operationType = operationMatch[1];
69
+ if (operationType == "mutation") {
70
+ throw new LightsparkException(
71
+ "InvalidQuery",
72
+ "Mutation queries should call makeRawRequest instead"
73
+ );
74
+ }
75
+ // Undefined variables need to be null instead.
76
+ for (const key in variables) {
77
+ if (variables[key] === undefined) {
78
+ variables[key] = null;
79
+ }
80
+ }
81
+ const operation = operationMatch[2];
82
+ let bodyData = {
83
+ query: queryPayload,
84
+ variables,
85
+ operationName: operation,
86
+ };
87
+
88
+ return new Observable((observer) =>
89
+ this.wsClient.subscribe(bodyData, {
90
+ next: (data) => observer.next(data),
91
+ error: (err) => observer.error(err),
92
+ complete: () => observer.complete(),
93
+ })
94
+ );
95
+ }
96
+
97
+ public async makeRawRequest(
98
+ queryPayload: string,
99
+ variables: { [key: string]: any } = {},
100
+ signingNodeId: string | undefined = undefined
101
+ ): Promise<any | null> {
102
+ const operationNameRegex = /^\s*(query|mutation|subscription)\s+(\w+)/i;
103
+ const operationMatch = queryPayload.match(operationNameRegex);
104
+ if (!operationMatch || operationMatch.length < 3) {
105
+ throw new LightsparkException("InvalidQuery", "Invalid query payload");
106
+ }
107
+ const operationType = operationMatch[1];
108
+ if (operationType == "subscription") {
109
+ throw new LightsparkException(
110
+ "InvalidQuery",
111
+ "Subscription queries should call subscribe instead"
112
+ );
113
+ }
114
+ // Undefined variables need to be null instead.
115
+ for (const key in variables) {
116
+ if (variables[key] === undefined) {
117
+ variables[key] = null;
118
+ }
119
+ }
120
+ const operation = operationMatch[2];
121
+ let bodyData = {
122
+ query: queryPayload,
123
+ variables,
124
+ operationName: operation,
125
+ };
126
+ const browserUserAgent =
127
+ typeof navigator !== "undefined" ? navigator.userAgent : "";
128
+ const sdkUserAgent = this.getSdkUserAgent();
129
+ const headers = await this.authProvider.addAuthHeaders({
130
+ "Content-Type": "application/json",
131
+ [LIGHTSPARK_BETA_HEADER_KEY]: LIGHTSPARK_BETA_HEADER_VALUE,
132
+ "X-Lightspark-SDK": sdkUserAgent,
133
+ "User-Agent": browserUserAgent || sdkUserAgent,
134
+ });
135
+ bodyData = await this.addSigningDataIfNeeded(
136
+ bodyData,
137
+ headers,
138
+ signingNodeId
139
+ );
140
+
141
+ const response = await fetch(
142
+ `https://${this.baseUrl}/${this.schemaEndpoint}`,
143
+ {
144
+ method: "POST",
145
+ headers: headers,
146
+ body: JSON.stringify(bodyData),
147
+ }
148
+ );
149
+ if (!response.ok) {
150
+ throw new LightsparkException(
151
+ "RequestFailed",
152
+ `Request ${operation} failed. ${response.statusText}`
153
+ );
154
+ }
155
+ const responseJson = await response.json();
156
+ const data = responseJson.data;
157
+ if (!data) {
158
+ throw new LightsparkException(
159
+ "RequestFailed",
160
+ `Request ${operation} failed. ${JSON.stringify(responseJson.errors)}`
161
+ );
162
+ }
163
+ return data;
164
+ }
165
+
166
+ private getSdkUserAgent(): string {
167
+ const platform = isNode ? "NodeJS" : "Browser";
168
+ const platformVersion = isNode ? process.version : "";
169
+ // TODO(Jeremy): Figure out how to properly load this from the package.json. Using an import
170
+ // is breaking the streaming sats extension.
171
+ const sdkVersion = "1.0.4";
172
+ return `lightspark-js-sdk/${sdkVersion} ${platform}/${platformVersion}`;
173
+ }
174
+
175
+ private async addSigningDataIfNeeded(
176
+ queryPayload: { query: string; variables: any; operationName: string },
177
+ headers: { [key: string]: string },
178
+ signingNodeId: string | undefined
179
+ ): Promise<any> {
180
+ if (!signingNodeId) {
181
+ return queryPayload;
182
+ }
183
+
184
+ const query = queryPayload.query;
185
+ const variables = queryPayload.variables;
186
+ const operationName = queryPayload.operationName;
187
+
188
+ const nonce = await getNonce();
189
+ const expiration = dayjs.utc().add(1, "hour").format();
190
+
191
+ const payload = {
192
+ query,
193
+ variables,
194
+ operationName,
195
+ nonce,
196
+ expires_at: expiration,
197
+ };
198
+
199
+ const key = await this.nodeKeyCache.getKey(signingNodeId);
200
+ if (!key) {
201
+ throw new LightsparkSigningException(
202
+ "Missing node of encrypted_signing_private_key"
203
+ );
204
+ }
205
+
206
+ const encodedPayload = new TextEncoder().encode(JSON.stringify(payload));
207
+ const cryptoImpl = await getCrypto();
208
+ const signedPayload = await cryptoImpl.subtle.sign(
209
+ {
210
+ name: "RSA-PSS",
211
+ saltLength: 32,
212
+ },
213
+ key,
214
+ encodedPayload
215
+ );
216
+ const encodedSignedPayload = b64encode(signedPayload);
217
+
218
+ headers["X-Lightspark-Signing"] = JSON.stringify({
219
+ v: "1",
220
+ signature: encodedSignedPayload,
221
+ });
222
+ return payload;
223
+ }
224
+ }
225
+
226
+ export default Requester;
@@ -0,0 +1,4 @@
1
+ // Copyright ©, 2023-present, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ export { default as Query } from "./Query.js";
4
+ export { default as Requester } from "./Requester.js";
@@ -0,0 +1,15 @@
1
+ // Copyright ©, 2023, Lightspark Group, Inc. - All Rights Reserved
2
+
3
+ export const b64decode = (encoded: string): Uint8Array => {
4
+ return Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
5
+ };
6
+
7
+ export const urlsafe_b64decode = (encoded: string): Uint8Array => {
8
+ return b64decode(encoded.replace(/_/g, "/").replace(/-/g, "+"));
9
+ };
10
+
11
+ export const b64encode = (data: ArrayBuffer): string => {
12
+ return btoa(
13
+ String.fromCharCode.apply(null, Array.from(new Uint8Array(data)))
14
+ );
15
+ };