@bhooai/nexus-crypto 2.0.13 → 2.0.15
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/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/license.ts +141 -0
- package/tests/license.test.ts +97 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/license.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License core — one master key, per-package derived keys.
|
|
3
|
+
*
|
|
4
|
+
* Flow: the user pastes a master key (`nxl_…`, issued by nexus.bhooai.com)
|
|
5
|
+
* once via `nexus license add`. Every package derives its own key from that
|
|
6
|
+
* single master secret with HKDF-SHA256 (salt `bhooai-nexus/v1`, info =
|
|
7
|
+
* package name), and gated features call `requireLicense()` which throws a
|
|
8
|
+
* "add license first" error when no master key is present.
|
|
9
|
+
*
|
|
10
|
+
* Master key lookup order: `NEXUS_LICENSE_KEY` env → `~/.nexus/license.json`
|
|
11
|
+
* → `./.nexus-license.json` (project-local).
|
|
12
|
+
*/
|
|
13
|
+
import { hkdfSync, timingSafeEqual } from 'node:crypto';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
export const LICENSE_ENV_VAR = 'NEXUS_LICENSE_KEY';
|
|
19
|
+
export const LICENSE_FILE_NAME = 'license.json';
|
|
20
|
+
export const LICENSE_DIR_NAME = '.nexus';
|
|
21
|
+
export const PROJECT_LICENSE_FILE = '.nexus-license.json';
|
|
22
|
+
export const HKDF_SALT = 'bhooai-nexus/v1';
|
|
23
|
+
export const HKDF_LENGTH = 32;
|
|
24
|
+
|
|
25
|
+
/** Machine-readable license failure. Packages surface this at gated entry points. */
|
|
26
|
+
export class LicenseError extends Error {
|
|
27
|
+
readonly code = 'LICENSE_REQUIRED';
|
|
28
|
+
constructor(
|
|
29
|
+
packageName: string,
|
|
30
|
+
readonly hint = `No license found — run \`nexus license add <key>\` (get one at https://nexus.bhooai.com/account) and retry.`,
|
|
31
|
+
) {
|
|
32
|
+
super(`[${packageName}] ${hint}`);
|
|
33
|
+
this.name = 'LicenseError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StoredLicense {
|
|
38
|
+
key: string;
|
|
39
|
+
addedAt: string;
|
|
40
|
+
server?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function licenseFileCandidates(projectDir?: string): string[] {
|
|
44
|
+
const files: string[] = [];
|
|
45
|
+
if (projectDir) files.push(join(projectDir, PROJECT_LICENSE_FILE));
|
|
46
|
+
try {
|
|
47
|
+
files.push(join(homedir(), LICENSE_DIR_NAME, LICENSE_FILE_NAME));
|
|
48
|
+
} catch {
|
|
49
|
+
// homedir() can throw in restricted environments — env + project file still work
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read the stored master key file, or null when missing/unparseable. */
|
|
55
|
+
export function readLicenseFile(path: string): StoredLicense | null {
|
|
56
|
+
try {
|
|
57
|
+
if (!existsSync(path)) return null;
|
|
58
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<StoredLicense>;
|
|
59
|
+
if (typeof raw.key !== 'string' || !raw.key) return null;
|
|
60
|
+
return { key: raw.key, addedAt: typeof raw.addedAt === 'string' ? raw.addedAt : '', server: raw.server };
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the master license key without touching the network.
|
|
68
|
+
* Returns null when no key is configured anywhere.
|
|
69
|
+
*/
|
|
70
|
+
export function loadMasterKey(opts: { projectDir?: string; env?: NodeJS.ProcessEnv } = {}): string | null {
|
|
71
|
+
const env = opts.env ?? process.env;
|
|
72
|
+
const fromEnv = (env[LICENSE_ENV_VAR] ?? '').trim();
|
|
73
|
+
if (fromEnv) return fromEnv;
|
|
74
|
+
for (const file of licenseFileCandidates(opts.projectDir)) {
|
|
75
|
+
const stored = readLicenseFile(file);
|
|
76
|
+
if (stored) return stored.key;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Persist a master key to `~/.nexus/license.json` (mode 0600). */
|
|
82
|
+
export function saveMasterKey(key: string, opts: { server?: string } = {}): string {
|
|
83
|
+
const dir = join(homedir(), LICENSE_DIR_NAME);
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
const path = join(dir, LICENSE_FILE_NAME);
|
|
86
|
+
writeFileSync(
|
|
87
|
+
path,
|
|
88
|
+
JSON.stringify({ key, addedAt: new Date().toISOString(), ...(opts.server ? { server: opts.server } : {}) }, null, 2),
|
|
89
|
+
'utf8',
|
|
90
|
+
);
|
|
91
|
+
try {
|
|
92
|
+
chmodSync(path, 0o600);
|
|
93
|
+
} catch {
|
|
94
|
+
// Windows ACLs — best effort
|
|
95
|
+
}
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Remove the user-wide license file. Returns true when a file was removed. */
|
|
100
|
+
export function removeMasterKey(): boolean {
|
|
101
|
+
try {
|
|
102
|
+
const path = join(homedir(), LICENSE_DIR_NAME, LICENSE_FILE_NAME);
|
|
103
|
+
if (!existsSync(path)) return false;
|
|
104
|
+
rmSync(path);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Derive a package-specific key from the master secret (HKDF-SHA256).
|
|
113
|
+
* Deterministic and one-way: packages never see each other's keys or the master.
|
|
114
|
+
*/
|
|
115
|
+
export function derivePackageKey(masterKey: string, packageName: string): string {
|
|
116
|
+
if (!masterKey) throw new LicenseError(packageName);
|
|
117
|
+
return Buffer.from(hkdfSync('sha256', masterKey, HKDF_SALT, packageName, HKDF_LENGTH)).toString('hex');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fingerprint for display (first 12 chars) — safe to log, never the key. */
|
|
121
|
+
export function keyFingerprint(key: string): string {
|
|
122
|
+
return key.slice(0, 12);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Constant-time master-key comparison (for tests/tools, not for secret storage). */
|
|
126
|
+
export function keysEqual(a: string, b: string): boolean {
|
|
127
|
+
const ab = Buffer.from(a);
|
|
128
|
+
const bb = Buffer.from(b);
|
|
129
|
+
if (ab.length !== bb.length) return false;
|
|
130
|
+
return timingSafeEqual(ab, bb);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Gate for licensed features. Returns the derived package key, or throws a
|
|
135
|
+
* "add license first" LicenseError when no master key is configured.
|
|
136
|
+
*/
|
|
137
|
+
export function requireLicense(packageName: string, opts: { projectDir?: string } = {}): string {
|
|
138
|
+
const master = loadMasterKey({ projectDir: opts.projectDir });
|
|
139
|
+
if (!master) throw new LicenseError(packageName);
|
|
140
|
+
return derivePackageKey(master, packageName);
|
|
141
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hkdfSync } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
derivePackageKey,
|
|
5
|
+
requireLicense,
|
|
6
|
+
loadMasterKey,
|
|
7
|
+
keyFingerprint,
|
|
8
|
+
keysEqual,
|
|
9
|
+
LicenseError,
|
|
10
|
+
LICENSE_ENV_VAR,
|
|
11
|
+
} from '../src/license.js';
|
|
12
|
+
|
|
13
|
+
describe('hkdf primitive (RFC 5869 test case 1, SHA-256)', () => {
|
|
14
|
+
it('matches the RFC vector', () => {
|
|
15
|
+
const ikm = Buffer.alloc(22, 0x0b);
|
|
16
|
+
const salt = Buffer.from('000102030405060708090a0b0c', 'hex');
|
|
17
|
+
const info = Buffer.from('f0f1f2f3f4f5f6f7f8f9', 'hex');
|
|
18
|
+
const okm = Buffer.from(hkdfSync('sha256', ikm, salt, info, 42)).toString('hex');
|
|
19
|
+
expect(okm).toBe(
|
|
20
|
+
'3cb25f25faacd57a90434f64d0362f2a' + '2d2d0a90cf1a5a4c5db02d56ecc4c5bf' + '34007208d5b887185865',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('derivePackageKey', () => {
|
|
26
|
+
const master = 'nxl_test_master_key_001';
|
|
27
|
+
|
|
28
|
+
it('is deterministic', () => {
|
|
29
|
+
expect(derivePackageKey(master, 'nexus-payments')).toBe(derivePackageKey(master, 'nexus-payments'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('isolates packages (32-byte hex each)', () => {
|
|
33
|
+
const a = derivePackageKey(master, 'nexus-payments');
|
|
34
|
+
const b = derivePackageKey(master, 'nexus-ai-client');
|
|
35
|
+
expect(a).toHaveLength(64);
|
|
36
|
+
expect(b).toHaveLength(64);
|
|
37
|
+
expect(a).not.toBe(b);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws LicenseError on empty master', () => {
|
|
41
|
+
expect(() => derivePackageKey('', 'nexus-payments')).toThrowError(LicenseError);
|
|
42
|
+
try {
|
|
43
|
+
derivePackageKey('', 'nexus-payments');
|
|
44
|
+
} catch (e) {
|
|
45
|
+
expect((e as Error).message).toContain('nexus license add');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('loadMasterKey / requireLicense', () => {
|
|
51
|
+
it('prefers the env var', () => {
|
|
52
|
+
expect(loadMasterKey({ env: { [LICENSE_ENV_VAR]: 'nxl_from_env' } as NodeJS.ProcessEnv })).toBe('nxl_from_env');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null when nothing is configured', () => {
|
|
56
|
+
expect(
|
|
57
|
+
loadMasterKey({ env: {} as NodeJS.ProcessEnv, projectDir: '/nonexistent-dir-xyz' }),
|
|
58
|
+
).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('requireLicense throws the add-first error without a key', () => {
|
|
62
|
+
const prev = process.env[LICENSE_ENV_VAR];
|
|
63
|
+
delete process.env[LICENSE_ENV_VAR];
|
|
64
|
+
try {
|
|
65
|
+
expect(() => requireLicense('nexus-payments', { projectDir: '/nonexistent-dir-xyz' })).toThrowError(
|
|
66
|
+
/nexus license add/,
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
if (prev !== undefined) process.env[LICENSE_ENV_VAR] = prev;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('requireLicense derives from the env key', () => {
|
|
74
|
+
const prev = process.env[LICENSE_ENV_VAR];
|
|
75
|
+
process.env[LICENSE_ENV_VAR] = 'nxl_env_master';
|
|
76
|
+
try {
|
|
77
|
+
expect(requireLicense('nexus-payments')).toBe(derivePackageKey('nxl_env_master', 'nexus-payments'));
|
|
78
|
+
} finally {
|
|
79
|
+
if (prev !== undefined) process.env[LICENSE_ENV_VAR] = prev;
|
|
80
|
+
else delete process.env[LICENSE_ENV_VAR];
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('helpers', () => {
|
|
86
|
+
it('fingerprints without leaking the key', () => {
|
|
87
|
+
const fp = keyFingerprint('nxl_abcdefghijklmnop');
|
|
88
|
+
expect(fp).toHaveLength(12);
|
|
89
|
+
expect('nxl_abcdefghijklmnop').toContain(fp);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('compares in constant time', () => {
|
|
93
|
+
expect(keysEqual('abc', 'abc')).toBe(true);
|
|
94
|
+
expect(keysEqual('abc', 'abd')).toBe(false);
|
|
95
|
+
expect(keysEqual('abc', 'abcd')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|