@harperfast/harper-pro 5.0.0-alpha.2 → 5.0.0-alpha.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.
Files changed (40) hide show
  1. package/core/CONTRIBUTING.md +2 -0
  2. package/core/package.json +2 -2
  3. package/core/resources/DatabaseTransaction.ts +1 -1
  4. package/core/resources/LMDBTransaction.ts +9 -4
  5. package/core/resources/databases.ts +1 -1
  6. package/core/unitTests/resources/permissions.test.js +7 -2
  7. package/core/unitTests/resources/txn-tracking.test.js +10 -4
  8. package/core/unitTests/resources/vectorIndex.test.js +1 -0
  9. package/dist/bin/harper.js +1 -1
  10. package/dist/bin/harper.js.map +1 -1
  11. package/dist/core/resources/DatabaseTransaction.js +1 -1
  12. package/dist/core/resources/DatabaseTransaction.js.map +1 -1
  13. package/dist/core/resources/LMDBTransaction.js +9 -5
  14. package/dist/core/resources/LMDBTransaction.js.map +1 -1
  15. package/dist/core/resources/databases.js +1 -1
  16. package/dist/core/resources/databases.js.map +1 -1
  17. package/dist/licensing/usageLicensing.js +246 -0
  18. package/dist/licensing/usageLicensing.js.map +1 -0
  19. package/dist/licensing/validation.js +149 -0
  20. package/dist/licensing/validation.js.map +1 -0
  21. package/dist/replication/replicator.js +5 -2
  22. package/dist/replication/replicator.js.map +1 -1
  23. package/dist/replication/setNode.js +0 -1
  24. package/dist/replication/setNode.js.map +1 -1
  25. package/dist/security/certificate.js +206 -6
  26. package/dist/security/certificate.js.map +1 -1
  27. package/dist/security/keyService.js +58 -0
  28. package/dist/security/keyService.js.map +1 -0
  29. package/dist/security/sshKeyOperations.js +343 -0
  30. package/dist/security/sshKeyOperations.js.map +1 -0
  31. package/licensing/usageLicensing.ts +262 -0
  32. package/licensing/validation.ts +191 -0
  33. package/npm-shrinkwrap.json +253 -253
  34. package/package.json +3 -2
  35. package/replication/replicator.ts +6 -2
  36. package/replication/setNode.ts +0 -1
  37. package/security/certificate.ts +259 -7
  38. package/security/keyService.ts +74 -0
  39. package/security/sshKeyOperations.ts +405 -0
  40. package/static/defaultConfig.yaml +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harperfast/harper-pro",
3
- "version": "5.0.0-alpha.2",
3
+ "version": "5.0.0-alpha.3",
4
4
  "description": "Harper is a distributed database, caching service, streaming broker, and application development platform focused on performance and ease of use.",
5
5
  "keywords": [
6
6
  "database",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "replication/",
33
33
  "security/",
34
+ "licensing/",
34
35
  "static/",
35
36
  "core/",
36
37
  "dist/",
@@ -56,7 +57,7 @@
56
57
  "download-prebuilds": "node ./build-tools/download-prebuilds.js",
57
58
  "prebuild": "date",
58
59
  "format": "prettier .",
59
- "format:fix": "npm run format -- --write",
60
+ "format:write": "npm run format -- --write",
60
61
  "lint": "oxlint --deny-warnings .",
61
62
  "lint:fix": "npm run lint -- --fix",
62
63
  "lint:required": "oxlint --quiet .",
@@ -40,10 +40,14 @@ import { ServerError } from '../core/utility/errors/hdbError.js';
40
40
  import { isMainThread } from 'worker_threads';
41
41
  import type { Database } from 'lmdb';
42
42
  import { getHostnamesFromCertificate } from '../core/security/keys.js';
43
- import './setNode.ts'; // allow this to register operations
44
- import './clusterStatus.ts';
45
43
  import { clearThisNodeName } from '../core/server/nodeName';
46
44
 
45
+ // allow this to register operations
46
+ import './setNode.ts';
47
+ import './clusterStatus.ts';
48
+ import '../security/keyService.ts';
49
+ import '../security/sshKeyOperations.ts';
50
+
47
51
  let replicationDisabled;
48
52
  let nextId = 1; // for request ids
49
53
 
@@ -273,7 +273,6 @@ export async function removeNodeBack(req) {
273
273
  // delete the record
274
274
  await hdbNodes.delete(req.name);
275
275
  }
276
- console.error('registering operations');
277
276
 
278
277
  function reverseSubscription(subscription) {
279
278
  const { subscribe, publish } = subscription;
@@ -1,4 +1,10 @@
1
+ import Joi from 'joi';
2
+ import forge from 'node-forge';
3
+ import { access, constants, readFile, writeFile, unlink } from 'node:fs/promises';
1
4
  import { join } from 'node:path';
5
+ import { X509Certificate, createPrivateKey } from 'node:crypto';
6
+ import { validateBySchema } from '../core/validation/validationWrapper.js';
7
+ import { ClientError } from '../core/utility/errors/hdbError.js';
2
8
  import {
3
9
  getCertTable,
4
10
  getPrivateKeys,
@@ -8,19 +14,26 @@ import {
8
14
  certExtensions,
9
15
  CERT_ATTRIBUTES,
10
16
  getCommonName,
17
+ getPrimaryHostName,
18
+ setCertTable,
11
19
  } from '../core/security/keys.js';
12
20
  import env from '../core/utility/environment/environmentManager.js';
13
21
  import { LICENSE_KEY_DIR_NAME } from '../core/utility/hdbTerms.ts';
14
- import { existsSync, readFileSync } from 'node:fs';
15
- import forge from 'node-forge';
16
22
  import harperLogger from '../core/utility/logging/harper_logger.js';
17
- import { X509Certificate } from 'node:crypto';
18
23
  import { getThisNodeName } from '../core/server/nodeName.ts';
24
+ import { server } from '../core/server/Server.ts';
25
+ import { replicateOperation } from '../replication/replicator.ts';
26
+
19
27
  const { forComponent } = harperLogger;
20
28
  const logger = forComponent('certificate').conditional;
21
29
  const pki = forge.pki;
22
30
  const CERT_VALIDITY_DAYS = 3650;
23
31
 
32
+ const fileExists = async (path: string): Promise<boolean> =>
33
+ access(path, constants.F_OK)
34
+ .then(() => true)
35
+ .catch(() => false);
36
+
24
37
  export async function signCertificate(req) {
25
38
  const response = {};
26
39
  const hdbKeysDir = join(env.getHdbBasePath(), LICENSE_KEY_DIR_NAME);
@@ -29,7 +42,7 @@ export async function signCertificate(req) {
29
42
  let private_key;
30
43
  let cert_auth;
31
44
  const certificateTable = getCertTable();
32
- const privateKeys = getPrivateKeys();
45
+ const privateKeys: Map<string, string> = getPrivateKeys();
33
46
  // Search hdbCertificate for a non-HDB CA that also has a local private key
34
47
  for await (const cert of certificateTable.search([])) {
35
48
  if (cert.is_authority && !cert.details.issuer.includes('HarperDB-Certificate-Authority')) {
@@ -37,8 +50,8 @@ export async function signCertificate(req) {
37
50
  private_key = privateKeys.get(cert.private_key_name);
38
51
  cert_auth = cert;
39
52
  break;
40
- } else if (cert.private_key_name && existsSync(join(hdbKeysDir, cert.private_key_name))) {
41
- private_key = readFileSync(join(hdbKeysDir, cert.private_key_name));
53
+ } else if (cert.private_key_name && (await fileExists(join(hdbKeysDir, cert.private_key_name)))) {
54
+ private_key = await readFile(join(hdbKeysDir, cert.private_key_name));
42
55
  cert_auth = cert;
43
56
  break;
44
57
  }
@@ -94,6 +107,7 @@ export async function signCertificate(req) {
94
107
 
95
108
  return response;
96
109
  }
110
+
97
111
  export async function createCsr() {
98
112
  const rep = await getReplicationCert();
99
113
  const opsCert = pki.certificateFromPem(rep.options.cert);
@@ -135,7 +149,7 @@ export async function getReplicationCert() {
135
149
  const SNICallback = createTLSSelector('operations-api');
136
150
  const secureTarget = {
137
151
  secureContexts: null,
138
- setSecureContext: (_ctx) => {},
152
+ setSecureContext: () => {},
139
153
  };
140
154
  await SNICallback.initialize(secureTarget);
141
155
  const cert = secureTarget.secureContexts.get(getThisNodeName());
@@ -154,3 +168,241 @@ export async function getReplicationCertAuth() {
154
168
  const caName = repCert.issuer.match(/CN=(.*)/)?.[1];
155
169
  return getCertTable().get(caName);
156
170
  }
171
+
172
+ interface AddCertificateRequest {
173
+ name?: string;
174
+ certificate: string;
175
+ is_authority: boolean;
176
+ private_key?: string;
177
+ hosts?: string[];
178
+ uses?: string[];
179
+ ciphers?: string;
180
+ }
181
+
182
+ interface CertRecord {
183
+ name: string;
184
+ certificate: string;
185
+ is_authority: boolean;
186
+ hosts?: string[];
187
+ uses?: string[];
188
+ private_key_name?: string;
189
+ ciphers?: string;
190
+ }
191
+
192
+ /**
193
+ * Adds or updates a certificate in the hdbCertificate table.
194
+ *
195
+ * If `private_key` is provided, it will be written to disk (as `<name>.pem`) rather than
196
+ * stored in the table. If no `private_key` is provided, existing stored keys are searched
197
+ * for one that matches the certificate. Non-CA certificates require a matching private key
198
+ * to be either provided or already stored.
199
+ *
200
+ * If `name` is omitted, the primary hostname (CN) is extracted from the certificate itself.
201
+ *
202
+ * @param req.name - Primary key for the hdbCertificate record. Falls back to the certificate's CN if omitted.
203
+ * @param req.certificate - PEM-encoded certificate string to add or update.
204
+ * @param req.is_authority - Whether this certificate is a Certificate Authority (CA).
205
+ * CA certs do not require an associated private key, but can have one.
206
+ * @param req.private_key - Optional PEM-encoded private key. Written to disk and referenced
207
+ * by name in the table. If omitted, existing keys are checked for a match.
208
+ * @param req.hosts - Optional list of hostnames this certificate is valid for.
209
+ * @param req.uses - Optional list of use cases this certificate is assigned to.
210
+ * @param req.ciphers - Optional cipher suite string associated with this certificate.
211
+ * @throws {ClientError} If the certificate is not a CA and no matching private key is found.
212
+ * @throws {ClientError} If `name` is omitted and the CN cannot be extracted from the certificate.
213
+ * @returns A replication response with a `message` confirming the certificate name added.
214
+ */
215
+ async function addCertificate(req: AddCertificateRequest) {
216
+ const validation = validateBySchema(
217
+ req,
218
+ Joi.object({
219
+ name: Joi.string().optional(),
220
+ certificate: Joi.string().required(),
221
+ is_authority: Joi.boolean().required(),
222
+ private_key: Joi.string(),
223
+ hosts: Joi.array(),
224
+ uses: Joi.array(),
225
+ })
226
+ );
227
+ if (validation) throw new ClientError(validation.message);
228
+
229
+ const { name, certificate, private_key, is_authority } = req;
230
+ const x509Cert = new X509Certificate(certificate);
231
+
232
+ // Track whether we found a matching key among existing keys, and which one.
233
+ let matchingKeyFound: boolean = false;
234
+ let existingPrivateKeyName: string | undefined;
235
+ const privateKeys: Map<string, string> = getPrivateKeys();
236
+
237
+ if (private_key) {
238
+ // A key was provided — check if we already have it stored so we don't duplicate it.
239
+ for (const [keyName, key] of privateKeys) {
240
+ if (private_key === key) {
241
+ matchingKeyFound = true;
242
+ existingPrivateKeyName = keyName;
243
+ break;
244
+ }
245
+ }
246
+ } else {
247
+ // No key provided — search existing keys to see if one matches this cert.
248
+ for (const [keyName, key] of privateKeys) {
249
+ if (x509Cert.checkPrivateKey(createPrivateKey(key))) {
250
+ matchingKeyFound = true;
251
+ existingPrivateKeyName = keyName;
252
+ break;
253
+ }
254
+ }
255
+ }
256
+
257
+ // CA certs don't require a private key, but non-CA certs must have one either
258
+ // provided directly or already stored.
259
+ if (!is_authority && !private_key && !matchingKeyFound)
260
+ throw new ClientError('A suitable private key was not found for this certificate');
261
+
262
+ // If no name was provided, fall back to extracting the CN from the cert itself.
263
+ let certCn: string | undefined;
264
+ if (!name) {
265
+ try {
266
+ certCn = getPrimaryHostName(x509Cert);
267
+ } catch (err) {
268
+ logger.error?.(err);
269
+ }
270
+
271
+ if (certCn == null)
272
+ throw new ClientError('Error extracting certificate host name, please provide a name parameter');
273
+ }
274
+
275
+ const saniName: string = sanitizeName(name ?? certCn!);
276
+
277
+ // Only write the key to disk if it's new (not already stored).
278
+ if (private_key && !matchingKeyFound) {
279
+ await writeFile(join(env.getHdbBasePath(), LICENSE_KEY_DIR_NAME, saniName + '.pem'), private_key);
280
+ privateKeys.set(saniName, private_key);
281
+ }
282
+
283
+ const record: CertRecord = {
284
+ name: name ?? certCn!,
285
+ certificate,
286
+ is_authority,
287
+ hosts: req.hosts,
288
+ uses: req.uses,
289
+ };
290
+
291
+ // Attach private_key_name for non-CA certs, and for CA certs that have an associated key.
292
+ if (!is_authority || (is_authority && existingPrivateKeyName) || (is_authority && private_key)) {
293
+ record.private_key_name = existingPrivateKeyName ?? saniName + '.pem';
294
+ }
295
+
296
+ if (req.ciphers) record.ciphers = req.ciphers;
297
+
298
+ await setCertTable(record);
299
+ const response: { message: string } = await replicateOperation(req);
300
+ response.message = 'Successfully added certificate: ' + saniName;
301
+ return response;
302
+ }
303
+
304
+ /**
305
+ * Removes a certificate from the hdbCertificate table.
306
+ *
307
+ * If the certificate has an associated private key file, it will be deleted from disk —
308
+ * but only if no other certificates reference the same key.
309
+ *
310
+ * @param req.name - Name of the certificate to remove. Must match an existing record.
311
+ * @throws {ClientError} If no certificate with the given name is found.
312
+ * @returns A replication response with a `message` confirming the certificate name removed.
313
+ */
314
+ async function removeCertificate(req: { name: string }): Promise<{ message: string; replicated?: unknown[] }> {
315
+ const validation = validateBySchema(
316
+ req,
317
+ Joi.object({
318
+ name: Joi.string().required(),
319
+ })
320
+ );
321
+ if (validation) throw new ClientError(validation.message);
322
+
323
+ const { name } = req;
324
+ const certificateTable = getCertTable();
325
+ const certRecord: any = await certificateTable.get(name);
326
+ if (!certRecord) throw new ClientError(`${name} not found`);
327
+
328
+ const { private_key_name } = certRecord;
329
+ if (private_key_name) {
330
+ const matchingKeys = Array.from(
331
+ await certificateTable.search([{ attribute: 'private_key_name', value: private_key_name }])
332
+ );
333
+
334
+ // Only delete the key file if this is the only cert referencing it.
335
+ if (matchingKeys.length === 1 && matchingKeys[0].name === name) {
336
+ try {
337
+ logger.info?.('Removing private key named', private_key_name);
338
+ await unlink(join(env.getHdbBasePath(), LICENSE_KEY_DIR_NAME, private_key_name));
339
+ } catch (err) {
340
+ logger.error?.('Failed to remove private key file', private_key_name, err);
341
+ }
342
+ }
343
+ }
344
+
345
+ await certificateTable.delete(name);
346
+ const response: { message: string } = await replicateOperation(req);
347
+ response.message = `Successfully removed ${name}`;
348
+ return response;
349
+ }
350
+
351
+ /**
352
+ * List all the records in hdbCertificate table
353
+ * @returns {Promise<*[]>}
354
+ */
355
+ async function listCertificates() {
356
+ const certificateTable = getCertTable();
357
+ let response = [];
358
+ for await (const cert of certificateTable.search([])) {
359
+ response.push(cert);
360
+ }
361
+ return response;
362
+ }
363
+
364
+ /**
365
+ * Used to sanitize a cert common name or the 'name' param used in cert ops
366
+ * @param cn
367
+ * @returns {*}
368
+ */
369
+ function sanitizeName(cn: string): string {
370
+ return cn.replace(/[^a-z0-9.]/gi, '-');
371
+ }
372
+
373
+ // These will register the operations for the operations API. For now the method and schema are ignored,
374
+ // they are there for when build the REST interface for operations API
375
+ server.registerOperation?.({
376
+ name: 'add_certificate',
377
+ execute: addCertificate,
378
+ httpMethod: 'PUT',
379
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
380
+ });
381
+
382
+ server.registerOperation?.({
383
+ name: 'remove_certificate',
384
+ execute: removeCertificate,
385
+ httpMethod: 'DELETE',
386
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
387
+ });
388
+
389
+ server.registerOperation?.({
390
+ name: 'list_certificates',
391
+ execute: listCertificates,
392
+ httpMethod: 'GET',
393
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
394
+ });
395
+
396
+ server.registerOperation?.({
397
+ name: 'create_csr',
398
+ execute: createCsr,
399
+ httpMethod: 'POST',
400
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
401
+ });
402
+
403
+ server.registerOperation?.({
404
+ name: 'sign_certificate',
405
+ execute: signCertificate,
406
+ httpMethod: 'POST',
407
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
408
+ });
@@ -0,0 +1,74 @@
1
+ import Joi from 'joi';
2
+ import { validateBySchema } from '../core/validation/validationWrapper.js';
3
+ import { ClientError } from '../core/utility/errors/hdbError.js';
4
+ import { getPrivateKeys } from '../core/security/keys.js';
5
+ import { getJWTRSAKeys } from '../core/security/tokenAuthentication.ts';
6
+ import { server } from '../core/server/Server.ts';
7
+
8
+ type JwtKeyField = 'privateKey' | 'publicKey' | 'passphrase';
9
+
10
+ const jwtKeyMap: Record<string, JwtKeyField> = {
11
+ '.jwtPrivate': 'privateKey',
12
+ '.jwtPublic': 'publicKey',
13
+ '.jwtPass': 'passphrase',
14
+ };
15
+
16
+ interface KeyResolverRequest {
17
+ bypass_auth?: boolean;
18
+ name: string;
19
+ }
20
+
21
+ interface JWTRSAKeys {
22
+ publicKey: string;
23
+ privateKey: string;
24
+ passphrase: string;
25
+ }
26
+
27
+ /**
28
+ * Resolves a cryptographic key by name for use in replication or resource contexts.
29
+ *
30
+ * Supports JWT RSA keys (`.jwtPrivate`, `.jwtPublic`, `.jwtPass`) and arbitrary
31
+ * private keys managed by the key store.
32
+ *
33
+ * @param req - The request object. Must have `bypass_auth` set to `true` — direct
34
+ * calls from the operations API are not permitted.
35
+ * @param req.name - The name of the key to retrieve.
36
+ * @returns The resolved key material as a string.
37
+ */
38
+ async function keyResolver(req: KeyResolverRequest): Promise<string> {
39
+ // This is here to block this function from being called by operations API. It can be called by replication or a resource
40
+ if (req.bypass_auth !== true) throw new ClientError('Unauthorized', '401');
41
+
42
+ const validation = validateBySchema(
43
+ req,
44
+ Joi.object({
45
+ name: Joi.string().required(),
46
+ })
47
+ );
48
+ if (validation) throw new ClientError(validation.message);
49
+
50
+ const { name } = req;
51
+
52
+ // Handle JWT keys
53
+ const jwtField: JwtKeyField = jwtKeyMap[name];
54
+ if (jwtField) {
55
+ const jwt: JWTRSAKeys = await getJWTRSAKeys();
56
+ return jwt[jwtField];
57
+ }
58
+
59
+ // Handle private keys
60
+ const privateKeys = getPrivateKeys();
61
+ const privateKey = privateKeys.get(name);
62
+ if (privateKey) {
63
+ return privateKey;
64
+ }
65
+
66
+ throw new ClientError('Key not found');
67
+ }
68
+
69
+ server.registerOperation?.({
70
+ name: 'get_key',
71
+ execute: keyResolver,
72
+ httpMethod: 'GET',
73
+ parametersSchema: [{ name: 'hostname', in: 'path', schema: { type: 'string' } }],
74
+ });