sliftutils 1.5.5 → 1.6.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/index.d.ts CHANGED
@@ -71,7 +71,7 @@ declare module "sliftutils/misc/https/certs" {
71
71
  /// <reference types="node" />
72
72
  import * as forge from "node-forge";
73
73
  export declare const CA_NOT_FOUND_ERROR = "18aa7318-f88f-4d2d-b41f-3daf4a433827";
74
- export declare const identityStorageKey = "machineCA_12";
74
+ export declare const identityStorageKey = "machineCA_14";
75
75
  export type IdentityStorageType = {
76
76
  domain: string;
77
77
  certB64: string;
@@ -90,9 +90,9 @@ declare module "sliftutils/misc/https/certs" {
90
90
  keyPair: {
91
91
  publicKey: forge.Ed25519PublicKey;
92
92
  privateKey: forge.Ed25519PrivateKey;
93
- } | forge.pki.KeyPair;
93
+ };
94
94
  }): X509KeyPair;
95
- export declare function privateKeyToPem(buffer: forge.pki.PrivateKey | forge.Ed25519PrivateKey): string;
95
+ export declare function privateKeyToPem(key: forge.Ed25519PrivateKey): string;
96
96
  export declare function parseCert(PEMorDER: string | Buffer): forge.pki.Certificate;
97
97
  export declare function getPublicIdentifier(PEMorDER: string | Buffer): Buffer;
98
98
  export declare const sign: (keyPair: {
@@ -101,8 +101,10 @@ declare module "sliftutils/misc/https/certs" {
101
101
  export declare function verify(cert: string, signature: string, data: unknown): void;
102
102
  export declare function validateCACert(domain: string, cert: string | Buffer): void;
103
103
  export declare function validateCertificate(domain: string, cert: Buffer | string, issuerCert: Buffer | string): void;
104
- export declare function generateKeyPair(): forge.pki.rsa.KeyPair;
105
- export declare function generateRSAKeyPair(): forge.pki.rsa.KeyPair;
104
+ export declare function generateKeyPair(): {
105
+ publicKey: forge.Ed25519PublicKey;
106
+ privateKey: forge.Ed25519PrivateKey;
107
+ };
106
108
  export declare function generateTestCA(domain: string): X509KeyPair;
107
109
  export declare function createCertFromCA(config: {
108
110
  CAKeyPair: X509KeyPair;
@@ -129,11 +131,6 @@ declare module "sliftutils/misc/https/certs" {
129
131
  publicKey: Buffer;
130
132
  }): boolean;
131
133
  export declare function getThreadKeyCert(domain: string): X509KeyPair;
132
- export declare const createTestBrowserKeyCert: {
133
- (): Promise<X509KeyPair>;
134
- reset(): void;
135
- set(newValue: Promise<X509KeyPair>): void;
136
- };
137
134
  export declare function getOwnNodeId(): string;
138
135
  export declare function getOwnNodeIdAllowUndefined(): string;
139
136
 
@@ -153,6 +150,34 @@ declare module "sliftutils/misc/https/dns" {
153
150
  export declare function setRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
154
151
  /** Keeps existing records */
155
152
  export declare function addRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
153
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
154
+ export declare function setCloudflareCredentials(config: {
155
+ key?: string;
156
+ path?: string;
157
+ }): void;
158
+
159
+ }
160
+
161
+ declare module "sliftutils/misc/https/hostServer" {
162
+ export type HostServerConfig = {
163
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
164
+ domain: string;
165
+ port: number;
166
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
167
+ cloudflareApiToken?: string;
168
+ cloudflareApiTokenPath?: string;
169
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
170
+ setDNSRecord?: boolean;
171
+ publicIp?: string;
172
+ allowHostnames?: string[];
173
+ };
174
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
175
+ export declare function hostServer(config: HostServerConfig): Promise<string>;
176
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
177
+ export declare function getFreshHTTPSCert(domain: string): Promise<{
178
+ key: string;
179
+ cert: string;
180
+ }>;
156
181
 
157
182
  }
158
183
 
@@ -201,10 +226,14 @@ declare module "sliftutils/misc/https/node-forge-ed25519" {
201
226
  declare module "node-forge" {
202
227
  declare type Ed25519PublicKey = {
203
228
  publicKeyBytes: Buffer;
204
- } & Buffer;
229
+ keyType: string;
230
+ verify(message: string | Buffer, signature: string): boolean;
231
+ };
205
232
  declare type Ed25519PrivateKey = {
206
233
  privateKeyBytes: Buffer;
207
- } & Buffer;
234
+ keyType: string;
235
+ sign(message: string | Buffer): string;
236
+ };
208
237
  class ed25519 {
209
238
  static generateKeyPair(): { publicKey: Ed25519PublicKey, privateKey: Ed25519PrivateKey };
210
239
  static privateKeyToPem(key: Ed25519PrivateKey): string;
@@ -213,6 +242,7 @@ declare module "sliftutils/misc/https/node-forge-ed25519" {
213
242
  static publicKeyFromPem(pem: string): Ed25519PublicKey;
214
243
  }
215
244
  }
245
+
216
246
  }
217
247
 
218
248
  declare module "sliftutils/misc/https/persistentLocalStorage" {
@@ -4,7 +4,7 @@
4
4
  /// <reference types="node" />
5
5
  import * as forge from "node-forge";
6
6
  export declare const CA_NOT_FOUND_ERROR = "18aa7318-f88f-4d2d-b41f-3daf4a433827";
7
- export declare const identityStorageKey = "machineCA_12";
7
+ export declare const identityStorageKey = "machineCA_14";
8
8
  export type IdentityStorageType = {
9
9
  domain: string;
10
10
  certB64: string;
@@ -23,9 +23,9 @@ export declare function createX509(config: {
23
23
  keyPair: {
24
24
  publicKey: forge.Ed25519PublicKey;
25
25
  privateKey: forge.Ed25519PrivateKey;
26
- } | forge.pki.KeyPair;
26
+ };
27
27
  }): X509KeyPair;
28
- export declare function privateKeyToPem(buffer: forge.pki.PrivateKey | forge.Ed25519PrivateKey): string;
28
+ export declare function privateKeyToPem(key: forge.Ed25519PrivateKey): string;
29
29
  export declare function parseCert(PEMorDER: string | Buffer): forge.pki.Certificate;
30
30
  export declare function getPublicIdentifier(PEMorDER: string | Buffer): Buffer;
31
31
  export declare const sign: (keyPair: {
@@ -34,8 +34,10 @@ export declare const sign: (keyPair: {
34
34
  export declare function verify(cert: string, signature: string, data: unknown): void;
35
35
  export declare function validateCACert(domain: string, cert: string | Buffer): void;
36
36
  export declare function validateCertificate(domain: string, cert: Buffer | string, issuerCert: Buffer | string): void;
37
- export declare function generateKeyPair(): forge.pki.rsa.KeyPair;
38
- export declare function generateRSAKeyPair(): forge.pki.rsa.KeyPair;
37
+ export declare function generateKeyPair(): {
38
+ publicKey: forge.Ed25519PublicKey;
39
+ privateKey: forge.Ed25519PrivateKey;
40
+ };
39
41
  export declare function generateTestCA(domain: string): X509KeyPair;
40
42
  export declare function createCertFromCA(config: {
41
43
  CAKeyPair: X509KeyPair;
@@ -62,10 +64,5 @@ export declare function verifyMachineIdForPublicKey(config: {
62
64
  publicKey: Buffer;
63
65
  }): boolean;
64
66
  export declare function getThreadKeyCert(domain: string): X509KeyPair;
65
- export declare const createTestBrowserKeyCert: {
66
- (): Promise<X509KeyPair>;
67
- reset(): void;
68
- set(newValue: Promise<X509KeyPair>): void;
69
- };
70
67
  export declare function getOwnNodeId(): string;
71
68
  export declare function getOwnNodeIdAllowUndefined(): string;
@@ -2,6 +2,9 @@
2
2
 
3
3
  module.allowclient = true;
4
4
 
5
+ // NOTE: We can't use crypto.subtle (non-extractable CryptoKeys) for our keys, because subtle is asynchronous, and everything here (key generation, cert creation, signing) must be available synchronously. The only real benefit of subtle is that a cross-site scripting attack can't exfiltrate the key. Which, while nice, is of minor benefit, as the cross-site script can already do quite a bit of damage anyway with access. And if that happens, the first thing the user is probably going to do is reset all their credentials, which solves the case of the key being exfiltrated anyway (by telling the server to stop trusting all identities).
6
+ // NOTE: We are just not going to support HTTPS browser certs. Our code is purely for identification, and so it only supports ED25519.
7
+
5
8
  // https://www.rfc-editor.org/rfc/rfc5280#page-42
6
9
 
7
10
  import { setFlag } from "socket-function/require/compileFlags";
@@ -27,7 +30,7 @@ const timeInDay = 1000 * 60 * 60 * 24;
27
30
 
28
31
  export const CA_NOT_FOUND_ERROR = "18aa7318-f88f-4d2d-b41f-3daf4a433827";
29
32
 
30
- export const identityStorageKey = "machineCA_12";
33
+ export const identityStorageKey = "machineCA_14";
31
34
  export type IdentityStorageType = { domain: string; certB64: string; keyB64: string };
32
35
 
33
36
  function getIdentityStore(domain: string) {
@@ -52,19 +55,16 @@ export function createX509(
52
55
  keyPair: {
53
56
  publicKey: forge.Ed25519PublicKey;
54
57
  privateKey: forge.Ed25519PrivateKey;
55
- } | forge.pki.KeyPair;
58
+ };
56
59
  }
57
60
  ): X509KeyPair {
58
61
  return measureBlock(function createX509() {
59
62
  let { domain, issuer, lifeSpan, keyPair } = config;
60
63
 
61
64
  let certObj = forge.pki.createCertificate();
62
- certObj.publicKey = keyPair.publicKey;
65
+ certObj.publicKey = keyPair.publicKey as unknown as forge.pki.PublicKey;
63
66
  certObj.serialNumber = "01";
64
- // Give it 5 minutes before now. If we give it too much time, it can look like the cert is really
65
- // old, which will trigger various processes to try to get a fresher one (as if it lasts for
66
- // 1 hour, but we set notBefore to 1 month ago, it looks 1 month old, and so almost expired,
67
- // when it isn't...)
67
+ // Give it 5 minutes before now. If we give it too much time, it can look like the cert is really old, which will trigger various processes to try to get a fresher one (as if it lasts for 1 hour, but we set notBefore to 1 month ago, it looks 1 month old, and so almost expired, when it isn't...)
68
68
  certObj.validity.notBefore = new Date(Date.now() - 1000 * 60 * 5);
69
69
  certObj.validity.notAfter = new Date(Date.now() + lifeSpan);
70
70
 
@@ -94,15 +94,10 @@ export function createX509(
94
94
  { type: 2, value: domain },
95
95
  { type: 2, value: "*." + domain },
96
96
  { type: 2, value: localHostDomain },
97
- // NOTE: No longer allow 127.0.0.1, to make this more secure. We might enable this
98
- // behavior behind a flag, for development.
99
- //{ type: 7, ip: "127.0.0.1" }
97
+ // NOTE: No longer allow 127.0.0.1 ({ type: 7, ip: "127.0.0.1" }), to make this more secure. We might enable this behavior behind a flag, for development.
100
98
  ]
101
99
  },
102
- // NOTE: nameConstraints require our forked node-forge:
103
- // "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
104
- // Chrome ignores them (https://bugs.chromium.org/p/chromium/issues/detail?id=1072083),
105
- // but our own validation (validateCACert/validateCertificate) enforces them.
100
+ // NOTE: nameConstraints require our forked node-forge ("node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6"). Chrome ignores them (https://bugs.chromium.org/p/chromium/issues/detail?id=1072083), but our own validation (validateCACert/validateCertificate) enforces them.
106
101
  {
107
102
  name: "nameConstraints",
108
103
  permittedSubtrees: [
@@ -116,9 +111,9 @@ export function createX509(
116
111
 
117
112
  measureBlock(function sign() {
118
113
  if (issuer === "self") {
119
- certObj.sign(keyPair.privateKey as any, forge.md.sha256.create());
114
+ certObj.sign(keyPair.privateKey as any);
120
115
  } else {
121
- certObj.sign(privateKeyFromPem(issuer.key.toString()) as any, forge.md.sha256.create());
116
+ certObj.sign(forge.ed25519.privateKeyFromPem(issuer.key.toString()) as any);
122
117
  }
123
118
  });
124
119
 
@@ -131,75 +126,37 @@ export function createX509(
131
126
  });
132
127
  });
133
128
  }
134
- export function privateKeyToPem(buffer: forge.pki.PrivateKey | forge.Ed25519PrivateKey) {
135
- if ("privateKeyBytes" in buffer) {
136
- return forge.ed25519.privateKeyToPem(buffer);
137
- }
138
- return forge.pki.privateKeyToPem(buffer);
139
- }
140
- function privateKeyFromPem(pem: string) {
141
- // We want to guess the type correctly, as caught exceptions make debugging annoying
142
- if (pem.length < 200) {
143
- try {
144
- return forge.ed25519.privateKeyFromPem(pem);
145
- } catch { }
146
- }
147
- try {
148
- return forge.pki.privateKeyFromPem(pem);
149
- } catch {
150
- return forge.ed25519.privateKeyFromPem(pem);
151
- }
152
- }
153
- function publicKeyFromCert(cert: string) {
154
- return parseCert(cert).publicKey;
129
+ export function privateKeyToPem(key: forge.Ed25519PrivateKey) {
130
+ return forge.ed25519.privateKeyToPem(key);
155
131
  }
156
132
  export function parseCert(PEMorDER: string | Buffer) {
157
133
  return forge.pki.certificateFromPem(normalizeCertToPEM(PEMorDER));
158
134
  }
159
135
 
160
- // Gets a unique value to represent the public key
161
- export function getPublicIdentifier(PEMorDER: string | Buffer): Buffer {
162
- let obj = parseCert(PEMorDER);
163
- let publicKey = obj.publicKey;
164
- if ("publicKeyBytes" in publicKey) {
165
- return Buffer.from(publicKey.publicKeyBytes as any);
136
+ function getED25519PublicKey(certParsed: forge.pki.Certificate): forge.Ed25519PublicKey {
137
+ let publicKey = certParsed.publicKey;
138
+ if (!("publicKeyBytes" in publicKey)) {
139
+ throw new Error(`Only ED25519 certificates are supported, the certificate public key is not ED25519 (subject: ${certParsed.subject.getField("CN")?.value})`);
166
140
  }
167
- return Buffer.from(new Uint32Array((publicKey as any).n.data).buffer);
141
+ return publicKey as unknown as forge.Ed25519PublicKey;
168
142
  }
169
143
 
170
- function isED25519(key: string | Buffer) {
171
- return key.length < 256;
144
+ // Gets a unique value to represent the public key
145
+ export function getPublicIdentifier(PEMorDER: string | Buffer): Buffer {
146
+ return Buffer.from(getED25519PublicKey(parseCert(PEMorDER)).publicKeyBytes);
172
147
  }
173
148
 
174
- // EQUIVALENT TO: `crypto.createSign("SHA256").update(JSON.stringify(payload)).sign(keyCert.key, "binary")`
175
149
  export const sign = measureWrap(function sign(keyPair: { key: string | Buffer }, data: unknown): string {
176
150
  let dataStr = JSON.stringify(data);
177
- if (isED25519(keyPair.key)) {
178
- let privateKey = (forge.pki.ed25519 as any).privateKeyFromPem(keyPair.key.toString());
179
- return privateKey.sign(dataStr);
180
- } else {
181
- let privateKey = forge.pki.privateKeyFromPem(keyPair.key.toString());
182
- const md = forge.md.sha256.create();
183
- md.update(dataStr);
184
- return privateKey.sign(md);
185
- }
151
+ let privateKey = forge.ed25519.privateKeyFromPem(keyPair.key.toString());
152
+ return privateKey.sign(dataStr);
186
153
  });
187
154
 
188
155
  export function verify(cert: string, signature: string, data: unknown) {
189
156
  let certObj = parseCert(cert);
190
- let publicKey = certObj.publicKey;
157
+ let publicKey = getED25519PublicKey(certObj);
191
158
  let dataStr = JSON.stringify(data);
192
- let verified: boolean;
193
- if ("publicKeyBytes" in publicKey) {
194
- // ed25519 verifies the raw message (it hashes internally)
195
- verified = (publicKey as unknown as { verify(message: string, signature: string): boolean }).verify(dataStr, signature);
196
- } else {
197
- // RSA verifies against the digest, matching the digest sign() creates
198
- const md = forge.md.sha256.create();
199
- md.update(dataStr);
200
- verified = (publicKey as forge.pki.rsa.PublicKey).verify(md.digest().bytes(), signature);
201
- }
202
- if (!verified) {
159
+ if (!publicKey.verify(dataStr, signature)) {
203
160
  throw new Error(`Signature verification failed. Signature: ${JSON.stringify(signature)} | Data: ${ellipsize(dataStr, 1024)}`);
204
161
  }
205
162
  }
@@ -212,8 +169,7 @@ function normalizeCertToPEM(PEMorDER: string | Buffer): string {
212
169
  return "-----BEGIN CERTIFICATE-----\n" + PEMorDER + "\n-----END CERTIFICATE-----";
213
170
  }
214
171
 
215
- // Base32 (RFC 4648, lowercase, unpadded), as domain names are case-insensitive,
216
- // which rules out base64/hex-with-case
172
+ // Base32 (RFC 4648, lowercase, unpadded), as domain names are case-insensitive, which rules out base64/hex-with-case
217
173
  const base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567";
218
174
  function encodeBase32(bytes: Buffer): string {
219
175
  let result = "";
@@ -233,14 +189,12 @@ function encodeBase32(bytes: Buffer): string {
233
189
  return result;
234
190
  }
235
191
 
236
- function getDomainPartFromPublicKey(publicKey: { publicKeyBytes: Buffer } | forge.pki.KeyPair["publicKey"] | Buffer) {
192
+ function getDomainPartFromPublicKey(publicKey: { publicKeyBytes: Buffer } | Buffer) {
237
193
  let bytes: Buffer;
238
194
  if ("publicKeyBytes" in publicKey) {
239
195
  bytes = publicKey.publicKeyBytes;
240
- } else if (publicKey instanceof Buffer) {
241
- bytes = publicKey;
242
196
  } else {
243
- bytes = Buffer.from(new Uint32Array((publicKey as any).n.data).buffer);
197
+ bytes = publicKey;
244
198
  }
245
199
  return "b" + encodeBase32(Buffer.from(sha265.sha256.array(Buffer.from(bytes)))).slice(0, 20);
246
200
  }
@@ -255,9 +209,7 @@ export function validateCACert(domain: string, cert: string | Buffer) {
255
209
 
256
210
  let rootDomainParsed = [domainParts.shift(), domainParts.shift()].reverse().join(".");
257
211
  if (rootDomainParsed !== domain) {
258
- // This is important, as our trust store contains more then just OUR certificates,
259
- // so if we allow any domains then real domains can impersonate anyone! It has to
260
- // be one OUR domain to be trusted!
212
+ // This is important, as our trust store contains more then just OUR certificates, so if we allow any domains then real domains can impersonate anyone! It has to be OUR domain to be trusted!
261
213
  throw new Error(`Certificate root domain should be ${domain}, but is ${rootDomainParsed}`);
262
214
  }
263
215
  // TODO: Maybe just skip if it isn't a hash string?
@@ -266,7 +218,7 @@ export function validateCACert(domain: string, cert: string | Buffer) {
266
218
  }
267
219
 
268
220
  let certExpectedPublicKeyPart = (domainParts.shift() || "").split("-").slice(-1)[0];
269
- let certActualPublicKeyPart = getDomainPartFromPublicKey(certParsed.publicKey);
221
+ let certActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(certParsed));
270
222
  if (certExpectedPublicKeyPart !== certActualPublicKeyPart) {
271
223
  throw new Error(`Certificate public key in the url is ${certExpectedPublicKeyPart}, but in the cert is ${certActualPublicKeyPart}`);
272
224
  }
@@ -281,8 +233,7 @@ export function validateCACert(domain: string, cert: string | Buffer) {
281
233
  throw new Error(`Certificate must have nameConstraints.permittedSubtrees`);
282
234
  }
283
235
  let subtreeValues = subtrees.map((x: any) => x.value);
284
- // Ignore localhostDomain, as it can always safely be allowed (the same machine
285
- // is always allowed).
236
+ // Ignore localhostDomain, as it can always safely be allowed (the same machine is always allowed).
286
237
  subtreeValues = subtreeValues.filter((x: string) => x !== localhostDomain);
287
238
  if (subtreeValues.length !== 1 || subtreeValues[0] !== subject) {
288
239
  throw new Error(`Certificate must have a single constrained domain (had ${JSON.stringify(subtreeValues)})`);
@@ -312,14 +263,14 @@ export function validateCertificate(domain: string, cert: Buffer | string, issue
312
263
  let issuerCertParsed = parseCert(issuerCert);
313
264
 
314
265
  let issuerExpectedPublicKeyPart = domainParts.shift() || "";
315
- let issuerActualPublicKeyPart = getDomainPartFromPublicKey(issuerCertParsed.publicKey);
266
+ let issuerActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(issuerCertParsed));
316
267
  if (issuerExpectedPublicKeyPart !== issuerActualPublicKeyPart) {
317
268
  throw new Error(`Issuer public key in the url is ${issuerExpectedPublicKeyPart}, but in the cert is ${issuerActualPublicKeyPart}`);
318
269
  }
319
270
 
320
271
  // Take the last part
321
272
  let certExpectedPublicKeyPart = domainParts.shift() || "";
322
- let certActualPublicKeyPart = getDomainPartFromPublicKey(certParsed.publicKey);
273
+ let certActualPublicKeyPart = getDomainPartFromPublicKey(getED25519PublicKey(certParsed));
323
274
  if (certExpectedPublicKeyPart !== certActualPublicKeyPart) {
324
275
  throw new Error(`Certificate public key in the url is ${certExpectedPublicKeyPart}, but in the cert is ${certActualPublicKeyPart}`);
325
276
  }
@@ -334,8 +285,7 @@ export function validateCertificate(domain: string, cert: Buffer | string, issue
334
285
  throw new Error(`CA must have nameConstraints.permittedSubtrees`);
335
286
  }
336
287
  let subtreeValues = subtrees.map((x: any) => x.value);
337
- // Ignore localhostDomain, as it can always safely be allowed (the same machine
338
- // is always allowed).
288
+ // Ignore localhostDomain, as it can always safely be allowed (the same machine is always allowed).
339
289
  subtreeValues = subtreeValues.filter((x: string) => x !== localhostDomain);
340
290
  if (subtreeValues.length !== 1) {
341
291
  throw new Error(`CA must have a single constrained domain (had ${JSON.stringify(subtreeValues)})`);
@@ -367,16 +317,7 @@ function validateAltNames(certParsed: forge.pki.Certificate, subject: string) {
367
317
  !(
368
318
  x === subject
369
319
  || x.endsWith("." + subject)
370
- // Commented out, because... it is so easy to publish a 127.0.0.1 A record,
371
- // and even to generate a real cert, so, we should jsut do that, and keep it secure.
372
- // If we need this for development we can put it behind a flag, so non-development
373
- // instances are still secure.
374
- // // Also allow 127.0.0.1, for local testing, for now?
375
- // // - This might be insecure if these are trusted by the browser,
376
- // // as then anyone that stores any cookies in this ip can have
377
- // // the cookies stolen. But... if this is just cross-server...
378
- // // I don't see how this could cause a security vulnerability.
379
- // || x === Buffer.from([127, 0, 0, 1]).toString()
320
+ // NOTE: We don't allow 127.0.0.1 (|| x === Buffer.from([127, 0, 0, 1]).toString()), because it is so easy to publish a 127.0.0.1 A record, and even to generate a real cert, so we should just do that, and keep it secure. If we need this for development we can put it behind a flag, so non-development instances are still secure.
380
321
  )
381
322
  )
382
323
  ) {
@@ -385,29 +326,10 @@ function validateAltNames(certParsed: forge.pki.Certificate, subject: string) {
385
326
  }
386
327
 
387
328
 
388
- // NOTE: We can't use crypto.subtle (non-extractable CryptoKeys) for our keys, because subtle
389
- // is asynchronous, and everything here (key generation, cert creation, signing) must be
390
- // available synchronously. The only real benefit of subtle is that a cross-site scripting
391
- // attack can't exfiltrate the key. Which, while nice, is of minor benefit, as the cross-site
392
- // script can already do quite a bit of damage anyway with access. And if that happens, the
393
- // first thing the user is probably going to do is reset all their credentials, which solves
394
- // the case of the key being exfiltrated anyway (by telling the server to stop trusting
395
- // all identities).
396
329
  export function generateKeyPair() {
397
330
  return measureBlock(function generateKeyPair() {
398
- // NOTE: We use ED25519 because it can generated keys about 10X faster, WHICH, is still slow
399
- // (~6ms on my machine). So we DEFINITELY don't want it to be 10X slower!
400
- // NOTE: ED25519 doens't have great support in browsers, but we shouldn't need self signed certificates
401
- // in the browser anyway.
402
- // - https://security.stackexchange.com/a/236943/282367
403
- //let keyPair = forge.ed25519.generateKeyPair();
404
- let keyPair = forge.pki.rsa.generateKeyPair();
405
- return keyPair;
406
- });
407
- }
408
- export function generateRSAKeyPair() {
409
- return measureBlock(function generateKeyPair() {
410
- return forge.pki.rsa.generateKeyPair();
331
+ // NOTE: We use ED25519 because it can generate keys about 10X faster than RSA (which is still slow, ~6ms on my machine, so we DEFINITELY don't want it to be 10X slower!) - https://security.stackexchange.com/a/236943/282367
332
+ return forge.ed25519.generateKeyPair();
411
333
  });
412
334
  }
413
335
 
@@ -415,10 +337,6 @@ export function generateTestCA(domain: string) {
415
337
  const keyPair = generateKeyPair();
416
338
  let caPublicKeyPart = getDomainPartFromPublicKey(keyPair.publicKey);
417
339
  let fullDomain = `${caPublicKeyPart}.${domain}`;
418
- if (!isNode()) {
419
- fullDomain = `${caPublicKeyPart}.${domain}`;
420
- }
421
-
422
340
  return createX509({ domain: fullDomain, issuer: "self", keyPair, lifeSpan: timeInDay * 365 * 20 });
423
341
  }
424
342
 
@@ -449,12 +367,7 @@ let identityCA = cache((domain: string) => lazy((): X509KeyPair => {
449
367
  return result;
450
368
  }));
451
369
 
452
- // IMPORTANT! We do not embed any debug info in this domain. If we did, it would be useful,
453
- // but... potentally a security vulnerability, as if the debug info (such as a prefix)
454
- // is used to identify what a certificate is for, it would be easy for an attack to
455
- // forge this (as the debug info won't be secured). So it is much better to keep
456
- // the certificate opaque, and then require any metadata to be actually vetted
457
- // (and hopefully stored in a UI, showing IP, time, etc).
370
+ // IMPORTANT! We do not embed any debug info in this domain. If we did, it would be useful, but... potentally a security vulnerability, as if the debug info (such as a prefix) is used to identify what a certificate is for, it would be easy for an attack to forge this (as the debug info won't be secured). So it is much better to keep the certificate opaque, and then require any metadata to be actually vetted (and hopefully stored in a UI, showing IP, time, etc).
458
371
  export function createCertFromCA(config: {
459
372
  CAKeyPair: X509KeyPair;
460
373
  }): X509KeyPair {
@@ -488,11 +401,7 @@ export function decodeNodeId(nodeId: string, domain: string, allowMissingThreadI
488
401
  return undefined;
489
402
  }
490
403
  let parts = locationObj.address.split(".");
491
- // NOTE: We have to only allow localhost domains on our own domain, as the underlying domain
492
- // gets stripped when we're looking at the machineId. So if we allowed localhost domains on
493
- // other domains, a server could trick us into connecting to it, and then once the connection
494
- // is established, it could talk back and we would think it has a localhost machineId, which
495
- // is implicitly trusted, which would then give it access to everything.
404
+ // NOTE: We have to only allow localhost domains on our own domain, as the underlying domain gets stripped when we're looking at the machineId. So if we allowed localhost domains on other domains, a server could trick us into connecting to it, and then once the connection is established, it could talk back and we would think it has a localhost machineId, which is implicitly trusted, which would then give it access to everything.
496
405
  if (locationObj.address === `127-0-0-1.${domain}` && nodeId.includes(":")) {
497
406
  return {
498
407
  threadId: "",
@@ -542,9 +451,7 @@ export async function setIdentityCARaw(domain: string, json: string) {
542
451
  resetAllNodeCallFactories();
543
452
  }
544
453
 
545
- // NOTE: The identity CA is available synchronously (storage is fs/localStorage, both
546
- // synchronous), so this only exists for backwards compatibility with startup code
547
- // that awaits it.
454
+ // NOTE: The identity CA is available synchronously (storage is fs/localStorage, both synchronous), so this only exists for backwards compatibility with startup code that awaits it.
548
455
  export async function loadIdentityCA(domain: string) {
549
456
  identityCA(domain)();
550
457
  }
@@ -552,8 +459,7 @@ export function getIdentityCA(domain: string): X509KeyPair {
552
459
  return identityCA(domain)();
553
460
  }
554
461
 
555
- // TODO: Replace this with a database, so it is easy for us to trust CAs
556
- // cross machine, and even have multiple users, etc, etc.
462
+ // TODO: Replace this with a database, so it is easy for us to trust CAs cross machine, and even have multiple users, etc, etc.
557
463
  export function getIdentityCAPromise(domain: string): X509KeyPair {
558
464
  return identityCA(domain)();
559
465
  }
@@ -578,11 +484,8 @@ export function verifyMachineIdForPublicKey(config: {
578
484
  }
579
485
 
580
486
  // NOTE: We don't have a cache per CA, as... the CA should be set first
581
- // TODO: Maybe throw if they try to change the CA after they generate any certificates?
582
- // TODO: Regenerate certificates after enough time (as thread certs should be relatively short lived,
583
- // so it is plausible for them to expire)
584
- // - We will also need to provide a callback so that users of the cert can update the cert they
585
- // are using as well.
487
+ // TODO: Maybe throw if they try to change the CA after they generate any certificates?
488
+ // TODO: Regenerate certificates after enough time (as thread certs should be relatively short lived, so it is plausible for them to expire). We will also need to provide a callback so that users of the cert can update the cert they are using as well.
586
489
  export function getThreadKeyCert(domain: string) {
587
490
  return getThreadKeyCertBase(domain)();
588
491
  }
@@ -591,11 +494,6 @@ const getThreadKeyCertBase = cache((domain: string) => lazy(() => {
591
494
  return createCertFromCA({ CAKeyPair: ca });
592
495
  }));
593
496
 
594
- export const createTestBrowserKeyCert = lazy(async () => {
595
- let keyPair = generateRSAKeyPair();
596
- return await createX509({ domain: "test", issuer: "self", keyPair, lifeSpan: timeInDay * 365 * 20 });
597
- });
598
-
599
497
  export function getOwnNodeId(): string {
600
498
  let nodeId = SocketFunction.mountedNodeId;
601
499
  if (!nodeId) {
@@ -11,3 +11,8 @@ export declare function deleteRecord(type: string, key: string, value: string):
11
11
  export declare function setRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
12
12
  /** Keeps existing records */
13
13
  export declare function addRecord(type: string, key: string, value: string, proxied?: "proxied"): Promise<void>;
14
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
15
+ export declare function setCloudflareCredentials(config: {
16
+ key?: string;
17
+ path?: string;
18
+ }): void;
package/misc/https/dns.ts CHANGED
@@ -114,16 +114,32 @@ export async function addRecord(type: string, key: string, value: string, proxie
114
114
  }
115
115
 
116
116
 
117
- const getCloudflareCreds = lazy(async (): Promise<{ key: string; }> => {
117
+ let credsOverride: { key: string } | undefined;
118
+ /** Provide Cloudflare credentials directly (an API token, or a path to a file containing one), instead of relying on ./cloudflare.json */
119
+ export function setCloudflareCredentials(config: { key?: string; path?: string }) {
120
+ let key = config.key;
121
+ if (!key && config.path) {
122
+ key = fs.readFileSync(config.path, "utf8").trim();
123
+ }
124
+ if (!key) {
125
+ throw new Error(`Must provide either key or path in setCloudflareCredentials, received ${JSON.stringify(Object.keys(config))}`);
126
+ }
127
+ credsOverride = { key };
128
+ }
129
+
130
+ const getCloudflareCredsFromFile = lazy(async (): Promise<{ key: string; }> => {
118
131
  const path = "cloudflare.json";
119
132
  if (!fs.existsSync(path)) {
120
- throw new Error(`Must add cloudflare.json file to root of project.`);
133
+ throw new Error(`Must add cloudflare.json file to root of project (or call setCloudflareCredentials).`);
121
134
  }
122
135
  let creds = JSON.parse(fs.readFileSync(path, "utf8")) as { key: string; };
123
136
  return {
124
137
  key: creds.key,
125
138
  };
126
139
  });
140
+ async function getCloudflareCreds() {
141
+ return credsOverride || await getCloudflareCredsFromFile();
142
+ }
127
143
 
128
144
  async function cloudflareGETCall<T>(path: string, params?: { [key: string]: string }): Promise<T> {
129
145
  let url = new URL(`https://api.cloudflare.com/client/v4` + path);
@@ -0,0 +1,19 @@
1
+ export type HostServerConfig = {
2
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
3
+ domain: string;
4
+ port: number;
5
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
6
+ cloudflareApiToken?: string;
7
+ cloudflareApiTokenPath?: string;
8
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
9
+ setDNSRecord?: boolean;
10
+ publicIp?: string;
11
+ allowHostnames?: string[];
12
+ };
13
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
14
+ export declare function hostServer(config: HostServerConfig): Promise<string>;
15
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
16
+ export declare function getFreshHTTPSCert(domain: string): Promise<{
17
+ key: string;
18
+ cert: string;
19
+ }>;
@@ -0,0 +1,132 @@
1
+ import os from "os";
2
+ import fs from "fs";
3
+ import { SocketFunction } from "socket-function/SocketFunction";
4
+ import { timeInMinute } from "socket-function/src/misc";
5
+ import { delay } from "socket-function/src/batching";
6
+ import { getExternalIP } from "socket-function/src/networking";
7
+ import { magenta } from "socket-function/src/formatting/logColors";
8
+ import { getThreadKeyCert, loadIdentityCA } from "./certs";
9
+ import { generateCert, getAccountKey, parseCert } from "./httpsCerts";
10
+ import { setCloudflareCredentials, setRecord } from "./dns";
11
+
12
+ // Renew somewhere randomly between 40% and 60% of the way through the cert lifetime. The random threshold staggers parallel processes on the same machine, so usually one process renews early, and the others see the renewed cert on disk (we re-read the disk before every renewal check) and never renew themselves.
13
+ const RENEW_THRESHOLD_MIN = 0.4;
14
+ const RENEW_THRESHOLD_MAX = 0.6;
15
+ const CERT_CHECK_INTERVAL = timeInMinute * 15;
16
+
17
+ // One threshold per process, so a process is consistently early or late relative to its siblings
18
+ const renewThreshold = RENEW_THRESHOLD_MIN + Math.random() * (RENEW_THRESHOLD_MAX - RENEW_THRESHOLD_MIN);
19
+
20
+ export type HostServerConfig = {
21
+ /** Full domain to host on (e.g. "testsite.example.com"). The HTTPS cert is created for this domain and *.domain, so using a subdomain never touches the root domain (beyond its _acme-challenge TXT record). */
22
+ domain: string;
23
+ port: number;
24
+ /** Cloudflare API token (or a path to a file containing one). If neither is given, ./cloudflare.json is used. */
25
+ cloudflareApiToken?: string;
26
+ cloudflareApiTokenPath?: string;
27
+ /** Creates an unproxied A record pointing domain at this machine (publicIp, or our detected external IP) */
28
+ setDNSRecord?: boolean;
29
+ publicIp?: string;
30
+ allowHostnames?: string[];
31
+ };
32
+
33
+ /** Hosts a SocketFunction server on a real domain, with an automatically created and renewed Let's Encrypt HTTPS certificate (cached in the home folder, shared between processes on the machine). Expose your controllers (and any RequireController setup) before calling this. Returns the mounted nodeId. */
34
+ export async function hostServer(config: HostServerConfig): Promise<string> {
35
+ let { domain, port } = config;
36
+ if (config.cloudflareApiToken || config.cloudflareApiTokenPath) {
37
+ setCloudflareCredentials({ key: config.cloudflareApiToken, path: config.cloudflareApiTokenPath });
38
+ }
39
+ // The identity CA always lives on the root domain (nodeIds are threadHash.machineHash.root.tld)
40
+ let rootDomain = domain.split(".").slice(-2).join(".");
41
+ await loadIdentityCA(rootDomain);
42
+
43
+ if (config.setDNSRecord) {
44
+ let ip = config.publicIp || await getExternalIP();
45
+ await setRecord("A", domain, ip);
46
+ }
47
+
48
+ let keyCert = await getFreshHTTPSCert(domain);
49
+ let certListeners: ((value: { key: string; cert: string }) => void)[] = [];
50
+ void runCertRenewalLoop(domain, newKeyCert => {
51
+ keyCert = newKeyCert;
52
+ for (let listener of certListeners) {
53
+ listener(newKeyCert);
54
+ }
55
+ });
56
+
57
+ let nodeId = await SocketFunction.mount({
58
+ public: true,
59
+ port,
60
+ ...getThreadKeyCert(rootDomain),
61
+ SNICerts: {
62
+ [domain]: callback => {
63
+ callback(keyCert);
64
+ certListeners.push(callback);
65
+ },
66
+ },
67
+ allowHostnames: config.allowHostnames,
68
+ });
69
+ console.log(magenta(`Hosting https://${domain}:${port} (nodeId ${nodeId})`));
70
+ return nodeId;
71
+ }
72
+
73
+ function getCertDiskPath(domain: string) {
74
+ return os.homedir() + `/httpscert_${domain}.json`;
75
+ }
76
+ function readCertFromDisk(domain: string): { key: string; cert: string } | undefined {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(getCertDiskPath(domain), "utf8")) as { key: string; cert: string };
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ function getRenewTime(certPem: string, threshold: number) {
84
+ let certObj = parseCert(certPem);
85
+ let start = +new Date(certObj.validity.notBefore);
86
+ let end = +new Date(certObj.validity.notAfter);
87
+ return start + (end - start) * threshold;
88
+ }
89
+
90
+ /** Returns the cached HTTPS cert for the domain, creating/renewing it first if it is past this process's renewal threshold. Reads the disk cache on every call, so a renewal done by a parallel process is picked up instead of renewing again. */
91
+ export async function getFreshHTTPSCert(domain: string): Promise<{ key: string; cert: string }> {
92
+ let keyCert = readCertFromDisk(domain);
93
+ if (keyCert && getRenewTime(keyCert.cert, renewThreshold) > Date.now()) {
94
+ return keyCert;
95
+ }
96
+ if (keyCert) {
97
+ console.log(magenta(`HTTPS cert for ${domain} is past ${(renewThreshold * 100).toFixed(0)}% of its lifetime, renewing`));
98
+ } else {
99
+ console.log(magenta(`No HTTPS cert on disk for ${domain}, creating one`));
100
+ }
101
+ let accountKey = await getAccountKey(domain);
102
+ try {
103
+ keyCert = await generateCert({ accountKey, domain, altDomains: ["*." + domain] });
104
+ } catch (e) {
105
+ if (String(e).includes("authorization must be pending")) {
106
+ // Another process is mid-renewal. Wait for it to finish, then re-check the disk.
107
+ console.log(`Certificate authorization is pending in another process, waiting 2 minutes`);
108
+ await delay(timeInMinute * 2);
109
+ return await getFreshHTTPSCert(domain);
110
+ }
111
+ throw e;
112
+ }
113
+ fs.writeFileSync(getCertDiskPath(domain), JSON.stringify({ key: keyCert.key, cert: keyCert.cert }));
114
+ return { key: keyCert.key, cert: keyCert.cert };
115
+ }
116
+
117
+ async function runCertRenewalLoop(domain: string, onNewCert: (keyCert: { key: string; cert: string }) => void) {
118
+ let lastCert = readCertFromDisk(domain)?.cert;
119
+ while (true) {
120
+ await delay(CERT_CHECK_INTERVAL);
121
+ try {
122
+ let keyCert = await getFreshHTTPSCert(domain);
123
+ if (keyCert.cert !== lastCert) {
124
+ lastCert = keyCert.cert;
125
+ console.log(magenta(`HTTPS cert for ${domain} updated, applying to running server`));
126
+ onNewCert(keyCert);
127
+ }
128
+ } catch (e) {
129
+ console.error(`Failed to check/renew HTTPS cert for ${domain}`, e);
130
+ }
131
+ }
132
+ }
@@ -3,10 +3,14 @@
3
3
  declare module "node-forge" {
4
4
  declare type Ed25519PublicKey = {
5
5
  publicKeyBytes: Buffer;
6
- } & Buffer;
6
+ keyType: string;
7
+ verify(message: string | Buffer, signature: string): boolean;
8
+ };
7
9
  declare type Ed25519PrivateKey = {
8
10
  privateKeyBytes: Buffer;
9
- } & Buffer;
11
+ keyType: string;
12
+ sign(message: string | Buffer): string;
13
+ };
10
14
  class ed25519 {
11
15
  static generateKeyPair(): { publicKey: Ed25519PublicKey, privateKey: Ed25519PrivateKey };
12
16
  static privateKeyToPem(key: Ed25519PrivateKey): string;
@@ -14,4 +18,4 @@ declare module "node-forge" {
14
18
  static publicKeyToPem(key: Ed25519PublicKey): string;
15
19
  static publicKeyFromPem(pem: string): Ed25519PublicKey;
16
20
  }
17
- }
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.5.5",
3
+ "version": "1.6.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1,3 +1,5 @@
1
+ module.allowclient = true;
2
+
1
3
  import * as preact from "preact";
2
4
  import { observable, Reaction } from "mobx";
3
5
  import { measureBlock } from "socket-function/src/profiling/measure";
@@ -0,0 +1,46 @@
1
+ module.allowclient = true;
2
+
3
+ import preact from "preact";
4
+ import { observable } from "mobx";
5
+ import { observer } from "../render-utils/observer";
6
+ import { isNode } from "socket-function/src/misc";
7
+ import { css } from "typesafecss";
8
+ import { SocketFunction } from "socket-function/SocketFunction";
9
+ import { ServerInfoController } from "./serverInfoController";
10
+
11
+ @observer
12
+ class TestPage extends preact.Component {
13
+ synced = observable({
14
+ serverOSName: "",
15
+ error: "",
16
+ });
17
+
18
+ componentDidMount() {
19
+ void (async () => {
20
+ try {
21
+ let nodeId = SocketFunction.connect({ address: location.hostname, port: +location.port || 443 });
22
+ this.synced.serverOSName = await ServerInfoController.nodes[nodeId].getServerOSName();
23
+ } catch (e) {
24
+ this.synced.error = String(e);
25
+ }
26
+ })();
27
+ }
28
+
29
+ render() {
30
+ return <div className={css.vbox(8).pad2(16)}>
31
+ <div>sliftutils hostServer test page</div>
32
+ <div>
33
+ {this.synced.error && `Error: ${this.synced.error}`
34
+ || this.synced.serverOSName && `Server OS (via SocketFunction): ${this.synced.serverOSName}`
35
+ || "Asking the server for its OS name..."}
36
+ </div>
37
+ </div>;
38
+ }
39
+ }
40
+
41
+ async function main() {
42
+ if (isNode()) return;
43
+ preact.render(<TestPage />, document.body);
44
+ }
45
+
46
+ main().catch(console.error);
@@ -0,0 +1,41 @@
1
+ process.env.NODE_ENV = "production";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+ import { RequireController } from "socket-function/require/RequireController";
6
+ import { hostServer } from "../misc/https/hostServer";
7
+ import { ServerInfoController } from "./serverInfoController";
8
+ // Import browser code, so it is allowed to be required by the client
9
+ import "./browser";
10
+
11
+ const DOMAIN = "testsite.vidgridweb.com";
12
+ const PORT = 4443;
13
+
14
+ process.on("unhandledRejection", (error) => {
15
+ console.error("Unhandled promise rejection:", error);
16
+ });
17
+ process.on("uncaughtException", (error) => {
18
+ console.error("Uncaught exception:", error);
19
+ });
20
+
21
+ async function main() {
22
+ RequireController.allowAllNodeModules();
23
+ SocketFunction.expose(RequireController);
24
+ SocketFunction.expose(ServerInfoController);
25
+ SocketFunction.setDefaultHTTPCall(RequireController, "requireHTML", {
26
+ requireCalls: ["./testsite/browser.tsx"],
27
+ });
28
+ RequireController.addStaticRoot(path.resolve("."));
29
+
30
+ await hostServer({
31
+ domain: DOMAIN,
32
+ port: PORT,
33
+ cloudflareApiTokenPath: os.homedir() + "/vidgridweb.com.key",
34
+ setDNSRecord: true,
35
+ });
36
+ }
37
+
38
+ main().catch(e => {
39
+ console.error(e);
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,19 @@
1
+ module.allowclient = true;
2
+
3
+ import os from "os";
4
+ import { SocketFunction } from "socket-function/SocketFunction";
5
+
6
+ class ServerInfoControllerBase {
7
+ // Innocuous server-only information, to prove the browser is really talking to the server
8
+ async getServerOSName(): Promise<string> {
9
+ return `${os.type()} ${os.release()} (${os.platform()})`;
10
+ }
11
+ }
12
+
13
+ export const ServerInfoController = SocketFunction.register(
14
+ "ServerInfoController-7f3b1c2a",
15
+ new ServerInfoControllerBase(),
16
+ () => ({
17
+ getServerOSName: {},
18
+ })
19
+ );