pal-explorer-cli 0.4.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.
Files changed (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, statSync, readdirSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ export function hashFile(filePath, algorithm = 'sha256') {
6
+ const content = readFileSync(filePath);
7
+ return createHash(algorithm).update(content).digest('hex');
8
+ }
9
+
10
+ export function buildManifest(dirPath, basePath = dirPath) {
11
+ const manifest = {};
12
+ const entries = readdirSync(dirPath, { withFileTypes: true });
13
+ for (const entry of entries) {
14
+ const fullPath = join(dirPath, entry.name);
15
+ const relPath = relative(basePath, fullPath).replace(/\\/g, '/');
16
+ if (entry.isDirectory()) {
17
+ Object.assign(manifest, buildManifest(fullPath, basePath));
18
+ } else if (entry.isFile()) {
19
+ const stat = statSync(fullPath);
20
+ manifest[relPath] = {
21
+ size: stat.size,
22
+ mtime: stat.mtimeMs,
23
+ hash: hashFile(fullPath),
24
+ };
25
+ }
26
+ }
27
+ return manifest;
28
+ }
29
+
30
+ export function diffManifests(localManifest, remoteManifest) {
31
+ const result = {
32
+ added: [],
33
+ modified: [],
34
+ deleted: [],
35
+ unchanged: [],
36
+ };
37
+
38
+ for (const [path, remote] of Object.entries(remoteManifest)) {
39
+ const local = localManifest[path];
40
+ if (!local) {
41
+ result.added.push({ path, ...remote });
42
+ } else if (local.hash !== remote.hash) {
43
+ result.modified.push({ path, localHash: local.hash, remoteHash: remote.hash, size: remote.size });
44
+ } else {
45
+ result.unchanged.push({ path, size: remote.size });
46
+ }
47
+ }
48
+
49
+ for (const path of Object.keys(localManifest)) {
50
+ if (!remoteManifest[path]) {
51
+ result.deleted.push({ path, ...localManifest[path] });
52
+ }
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ export function diffSummary(diff) {
59
+ const totalAdded = diff.added.reduce((s, f) => s + (f.size || 0), 0);
60
+ const totalModified = diff.modified.reduce((s, f) => s + (f.size || 0), 0);
61
+ return {
62
+ addedCount: diff.added.length,
63
+ modifiedCount: diff.modified.length,
64
+ deletedCount: diff.deleted.length,
65
+ unchangedCount: diff.unchanged.length,
66
+ transferBytes: totalAdded + totalModified,
67
+ skippedFiles: diff.unchanged.length + diff.deleted.length,
68
+ };
69
+ }
@@ -0,0 +1,119 @@
1
+ import config from '../utils/config.js';
2
+ import crypto from 'crypto';
3
+
4
+ export function getGroups() {
5
+ return config.get('groups') || [];
6
+ }
7
+
8
+ export function getGroup(idOrName) {
9
+ return getGroups().find(g => g.id === idOrName || g.name === idOrName);
10
+ }
11
+
12
+ export function createGroup(name, ownerPublicKey, options = {}) {
13
+ const groups = getGroups();
14
+ if (groups.find(g => g.name === name)) {
15
+ throw new Error(`Group '${name}' already exists`);
16
+ }
17
+ const group = {
18
+ id: crypto.randomUUID(),
19
+ name,
20
+ description: options.description || null,
21
+ visibility: options.visibility || 'private',
22
+ owner: ownerPublicKey || null,
23
+ members: [],
24
+ createdAt: new Date().toISOString()
25
+ };
26
+ groups.push(group);
27
+ config.set('groups', groups);
28
+ return group;
29
+ }
30
+
31
+ export function addMemberToGroup(groupId, pal) {
32
+ const groups = getGroups();
33
+ const group = groups.find(g => g.id === groupId || g.name === groupId);
34
+ if (!group) throw new Error('Group not found');
35
+ if (group.members.find(m => m.id === pal.id || (pal.publicKey && m.id === pal.publicKey))) throw new Error('Pal already in group');
36
+ group.members.push({
37
+ id: pal.id,
38
+ name: pal.name,
39
+ handle: pal.handle,
40
+ publicKey: pal.publicKey || pal.id,
41
+ role: 'member',
42
+ addedAt: new Date().toISOString()
43
+ });
44
+ config.set('groups', groups);
45
+ return group;
46
+ }
47
+
48
+ export function removeMemberFromGroup(groupId, palId) {
49
+ const groups = getGroups();
50
+ const group = groups.find(g => g.id === groupId || g.name === groupId);
51
+ if (!group) throw new Error('Group not found');
52
+ const before = group.members.length;
53
+ group.members = group.members.filter(m => m.id !== palId && m.name !== palId && m.handle !== palId);
54
+ if (group.members.length === before) throw new Error('Pal not found in group');
55
+ config.set('groups', groups);
56
+ return group;
57
+ }
58
+
59
+ export function updateGroup(idOrName, updates, requesterId) {
60
+ const groups = getGroups();
61
+ const group = groups.find(g => g.id === idOrName || g.name === idOrName);
62
+ if (!group) throw new Error('Group not found');
63
+ if (requesterId && group.owner && group.owner !== requesterId) {
64
+ throw new Error('Only the group owner can edit this group');
65
+ }
66
+ if (updates.name !== undefined && updates.name !== group.name) {
67
+ if (groups.find(g => g.name === updates.name && g.id !== group.id)) {
68
+ throw new Error(`Group '${updates.name}' already exists`);
69
+ }
70
+ group.name = updates.name;
71
+ }
72
+ if (updates.description !== undefined) group.description = updates.description;
73
+ if (updates.icon !== undefined) group.icon = updates.icon;
74
+ group.updatedAt = new Date().toISOString();
75
+ config.set('groups', groups);
76
+ return group;
77
+ }
78
+
79
+ export function deleteGroup(idOrName, requesterId) {
80
+ const group = getGroup(idOrName);
81
+ if (!group) throw new Error('Group not found');
82
+ if (requesterId && group.owner && group.owner !== requesterId) {
83
+ throw new Error('Only the group owner can delete this group');
84
+ }
85
+ const groups = getGroups().filter(g => g.id !== idOrName && g.name !== idOrName);
86
+ config.set('groups', groups);
87
+ }
88
+
89
+ export function getGroupMembers(idOrName) {
90
+ const group = getGroup(idOrName);
91
+ return group ? group.members : [];
92
+ }
93
+
94
+ export function getShareComments(shareId) {
95
+ const all = config.get('shareComments') || {};
96
+ return all[shareId] || [];
97
+ }
98
+
99
+ export function addShareComment(shareId, { authorHandle, authorName, text }) {
100
+ const all = config.get('shareComments') || {};
101
+ if (!all[shareId]) all[shareId] = [];
102
+ const comment = {
103
+ id: crypto.randomUUID(),
104
+ authorHandle,
105
+ authorName,
106
+ text,
107
+ createdAt: new Date().toISOString()
108
+ };
109
+ all[shareId].push(comment);
110
+ config.set('shareComments', all);
111
+ return comment;
112
+ }
113
+
114
+ export function deleteShareComment(shareId, commentId) {
115
+ const all = config.get('shareComments') || {};
116
+ if (!all[shareId]) return;
117
+ all[shareId] = all[shareId].filter(c => c.id !== commentId);
118
+ config.set('shareComments', all);
119
+ }
@@ -0,0 +1,340 @@
1
+ import sodium from 'sodium-native';
2
+ import crypto from 'crypto';
3
+ import os from 'os';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import keytar from 'keytar';
7
+ import * as bip39 from 'bip39';
8
+ import config from '../utils/config.js';
9
+
10
+ const KEYTAR_SERVICE = 'pal-explorer';
11
+ const KEYFILE_ALGORITHM = 'aes-256-gcm';
12
+
13
+ function getKeyFilePath() {
14
+ const dir = path.join(os.homedir(), '.palexplorer');
15
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ return path.join(dir, '.keys.json');
17
+ }
18
+
19
+ function deriveFileKey(pin, salt) {
20
+ return crypto.scryptSync(pin, salt, 32);
21
+ }
22
+
23
+ function encryptKeyData(data, pin) {
24
+ const salt = crypto.randomBytes(32);
25
+ const key = deriveFileKey(pin, salt);
26
+ const iv = crypto.randomBytes(16);
27
+ const cipher = crypto.createCipheriv(KEYFILE_ALGORITHM, key, iv);
28
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]);
29
+ const tag = cipher.getAuthTag();
30
+ return JSON.stringify({
31
+ encrypted: true,
32
+ v: 2,
33
+ salt: salt.toString('hex'),
34
+ iv: iv.toString('hex'),
35
+ tag: tag.toString('hex'),
36
+ data: encrypted.toString('hex'),
37
+ });
38
+ }
39
+
40
+ function decryptKeyData(raw, pin) {
41
+ const obj = JSON.parse(raw);
42
+ if (!obj.encrypted) return obj;
43
+ const salt = obj.salt ? Buffer.from(obj.salt, 'hex') : 'palexplorer-keyfile-salt-v1';
44
+ const key = deriveFileKey(pin, salt);
45
+ const decipher = crypto.createDecipheriv(KEYFILE_ALGORITHM, key, Buffer.from(obj.iv, 'hex'));
46
+ decipher.setAuthTag(Buffer.from(obj.tag, 'hex'));
47
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(obj.data, 'hex')), decipher.final()]);
48
+ return JSON.parse(decrypted.toString('utf8'));
49
+ }
50
+
51
+ let _keyfilePin = null;
52
+
53
+ export function setKeyfilePin(pin) {
54
+ _keyfilePin = pin;
55
+ }
56
+
57
+ function getMachineKey() {
58
+ return `pe:${os.hostname()}:${os.userInfo().username}:${process.platform}:${os.arch()}`;
59
+ }
60
+
61
+ function getEffectivePin() {
62
+ return _keyfilePin || getMachineKey();
63
+ }
64
+
65
+ function readKeyFile() {
66
+ try {
67
+ const p = getKeyFilePath();
68
+ if (!fs.existsSync(p)) return {};
69
+ const raw = fs.readFileSync(p, 'utf-8');
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed.encrypted) {
72
+ // Try user PIN first, then machine key
73
+ if (_keyfilePin) {
74
+ try { return decryptKeyData(raw, _keyfilePin); } catch {}
75
+ }
76
+ try { return decryptKeyData(raw, getMachineKey()); } catch {}
77
+ return {};
78
+ }
79
+ // Plaintext found — migrate to encrypted on next write
80
+ return parsed;
81
+ } catch { return {}; }
82
+ }
83
+
84
+ function writeKeyFile(keys) {
85
+ const p = getKeyFilePath();
86
+ // Always encrypt — use PIN if available, otherwise machine-derived key
87
+ fs.writeFileSync(p, encryptKeyData(keys, getEffectivePin()), { mode: 0o600 });
88
+ }
89
+
90
+ const useKeytar = process.platform === 'win32' || process.platform === 'darwin';
91
+
92
+ async function storeKey(publicKey, privateKey) {
93
+ if (useKeytar) {
94
+ try {
95
+ await keytar.setPassword(KEYTAR_SERVICE, publicKey, privateKey);
96
+ const verified = await keytar.getPassword(KEYTAR_SERVICE, publicKey);
97
+ if (verified !== privateKey) throw new Error('Keytar read-back verification failed');
98
+ return;
99
+ } catch {}
100
+ }
101
+ const keys = readKeyFile();
102
+ const hadPlaintext = Object.keys(keys).length > 0 && !_keyfilePin;
103
+ keys[publicKey] = privateKey;
104
+ writeKeyFile(keys); // Always encrypts now (PIN or machine key)
105
+ if (hadPlaintext) {
106
+ // Migrated plaintext → encrypted
107
+ }
108
+ }
109
+
110
+ async function retrieveKey(publicKey) {
111
+ if (useKeytar) {
112
+ try {
113
+ const key = await keytar.getPassword(KEYTAR_SERVICE, publicKey);
114
+ if (key) return key;
115
+ } catch {}
116
+ }
117
+ const keys = readKeyFile();
118
+ return keys[publicKey] || null;
119
+ }
120
+
121
+ let guestSession = null;
122
+
123
+ export async function guestLogin(mnemonic) {
124
+ if (!bip39.validateMnemonic(mnemonic)) {
125
+ throw new Error('Invalid mnemonic phrase');
126
+ }
127
+ const seed = Buffer.from(bip39.mnemonicToEntropy(mnemonic), 'hex');
128
+ const { publicKey, privateKey } = keypairFromSeed(seed);
129
+
130
+ guestSession = {
131
+ publicKey: publicKey.toString('hex'),
132
+ privateKey: privateKey.toString('hex'),
133
+ name: 'Guest',
134
+ handle: null,
135
+ isGuest: true,
136
+ };
137
+ return guestSession;
138
+ }
139
+
140
+ export function guestLogout() {
141
+ if (guestSession) {
142
+ try {
143
+ const pkBuf = Buffer.from(guestSession.privateKey, 'hex');
144
+ sodium.sodium_memzero(pkBuf);
145
+ } catch {}
146
+ guestSession = null;
147
+ }
148
+ }
149
+
150
+ export function isGuestSession() {
151
+ return guestSession !== null;
152
+ }
153
+
154
+ const makeDeviceId = () => {
155
+ if (typeof crypto.randomUUID === 'function') {
156
+ return crypto.randomUUID();
157
+ }
158
+ return crypto.randomBytes(16).toString('hex');
159
+ };
160
+
161
+ export function getDeviceInfo() {
162
+ const existing = config.get('device');
163
+ const defaultName = os.hostname() || 'pal-device';
164
+
165
+ if (!existing || typeof existing !== 'object') {
166
+ const created = {
167
+ id: makeDeviceId(),
168
+ name: defaultName,
169
+ createdAt: new Date().toISOString()
170
+ };
171
+ config.set('device', created);
172
+ return created;
173
+ }
174
+
175
+ let updated = false;
176
+ const normalized = { ...existing };
177
+ if (!normalized.id) {
178
+ normalized.id = makeDeviceId();
179
+ updated = true;
180
+ }
181
+ if (!normalized.name) {
182
+ normalized.name = defaultName;
183
+ updated = true;
184
+ }
185
+ if (!normalized.createdAt) {
186
+ normalized.createdAt = new Date().toISOString();
187
+ updated = true;
188
+ }
189
+
190
+ if (updated) {
191
+ config.set('device', normalized);
192
+ }
193
+
194
+ return normalized;
195
+ }
196
+
197
+ export function setDeviceName(name) {
198
+ const device = getDeviceInfo();
199
+ const next = {
200
+ ...device,
201
+ name: (name || '').trim() || device.name,
202
+ updatedAt: new Date().toISOString()
203
+ };
204
+ config.set('device', next);
205
+ return next;
206
+ }
207
+
208
+ export function setIdentityHandle(handle) {
209
+ const identity = config.get('identity');
210
+ if (!identity) return null;
211
+
212
+ const updated = {
213
+ ...identity,
214
+ handle,
215
+ updatedAt: new Date().toISOString()
216
+ };
217
+ config.set('identity', updated);
218
+ return updated;
219
+ }
220
+
221
+ export function updateProfile(fields) {
222
+ const identity = config.get('identity');
223
+ if (!identity) return null;
224
+
225
+ const allowed = ['avatar', 'bio', 'status', 'displayName'];
226
+ const updated = { ...identity };
227
+ for (const key of allowed) {
228
+ if (fields[key] !== undefined) updated[key] = fields[key];
229
+ }
230
+ updated.updatedAt = new Date().toISOString();
231
+ config.set('identity', updated);
232
+ return updated;
233
+ }
234
+
235
+ function keypairFromSeed(seed) {
236
+ const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
237
+ const privateKey = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
238
+ sodium.crypto_sign_seed_keypair(publicKey, privateKey, seed);
239
+ return { publicKey, privateKey };
240
+ }
241
+
242
+ export async function createIdentity(name, options = {}) {
243
+ const device = getDeviceInfo();
244
+ const existingIdentity = config.get('identity');
245
+
246
+ if (existingIdentity && !options.force) {
247
+ throw new Error('Identity already exists! Use --force to overwrite.');
248
+ }
249
+
250
+ // Generate mnemonic and derive keypair from seed
251
+ const mnemonic = bip39.generateMnemonic(256); // 24 words
252
+ const seed = Buffer.from(bip39.mnemonicToEntropy(mnemonic), 'hex');
253
+ const { publicKey, privateKey } = keypairFromSeed(seed);
254
+
255
+ const identity = {
256
+ name: name || 'Anonymous',
257
+ publicKey: publicKey.toString('hex'),
258
+ privateKey: privateKey.toString('hex'),
259
+ handle: existingIdentity?.handle || null,
260
+ deviceId: device.id,
261
+ deviceName: device.name,
262
+ createdAt: new Date().toISOString()
263
+ };
264
+
265
+ await storeKey(identity.publicKey, identity.privateKey);
266
+ const { privateKey: _pk, ...identityWithoutKey } = identity;
267
+ config.set('identity', identityWithoutKey);
268
+ return { ...identity, mnemonic };
269
+ }
270
+
271
+ export async function recoverIdentity(mnemonic, name) {
272
+ if (!bip39.validateMnemonic(mnemonic)) {
273
+ throw new Error('Invalid mnemonic phrase');
274
+ }
275
+
276
+ const device = getDeviceInfo();
277
+ const existing = config.get('identity');
278
+ const seed = Buffer.from(bip39.mnemonicToEntropy(mnemonic), 'hex');
279
+ const { publicKey, privateKey } = keypairFromSeed(seed);
280
+
281
+ const identity = {
282
+ name: name || existing?.name || 'Recovered',
283
+ publicKey: publicKey.toString('hex'),
284
+ privateKey: privateKey.toString('hex'),
285
+ handle: null,
286
+ deviceId: device.id,
287
+ deviceName: device.name,
288
+ createdAt: new Date().toISOString(),
289
+ recoveredAt: new Date().toISOString()
290
+ };
291
+
292
+ await storeKey(identity.publicKey, identity.privateKey);
293
+ const { privateKey: _pk, ...identityWithoutKey } = identity;
294
+ config.set('identity', identityWithoutKey);
295
+ return identity;
296
+ }
297
+
298
+ let _getIdentityInFlight = null;
299
+
300
+ export async function getIdentity() {
301
+ if (guestSession) {
302
+ const device = getDeviceInfo();
303
+ return { ...guestSession, deviceId: device.id, deviceName: device.name };
304
+ }
305
+
306
+ if (_getIdentityInFlight) return _getIdentityInFlight;
307
+ _getIdentityInFlight = _getIdentityImpl().finally(() => { _getIdentityInFlight = null; });
308
+ return _getIdentityInFlight;
309
+ }
310
+
311
+ async function _getIdentityImpl() {
312
+ const identity = config.get('identity');
313
+ if (!identity || !identity.publicKey) return null;
314
+
315
+ const device = getDeviceInfo();
316
+
317
+ // Migration: if privateKey still in config, move to secure store
318
+ if (identity.privateKey) {
319
+ await storeKey(identity.publicKey, identity.privateKey);
320
+ const { privateKey, ...rest } = identity;
321
+ config.set('identity', rest);
322
+ return { ...identity, deviceId: device.id, deviceName: device.name };
323
+ }
324
+
325
+ let privateKey;
326
+ try {
327
+ privateKey = await retrieveKey(identity.publicKey);
328
+ } catch {
329
+ privateKey = null;
330
+ }
331
+ const result = { ...identity, privateKey, deviceId: device.id, deviceName: device.name };
332
+ // Validate required fields to prevent returning incomplete identity
333
+ if (!result.publicKey) return null;
334
+ return result;
335
+ }
336
+
337
+ export async function getPublicKey() {
338
+ const identity = config.get('identity');
339
+ return identity ? identity.publicKey : null;
340
+ }
@@ -0,0 +1,126 @@
1
+ import { Bonjour } from 'bonjour-service';
2
+ import config from '../utils/config.js';
3
+ import { sendRequest } from './signalingServer.js';
4
+
5
+ let bonjour = null;
6
+ let service = null;
7
+ let browser = null;
8
+ const nearbyPeers = new Map();
9
+ let advertisedTxtExtras = {};
10
+ let verifyBeforeCaching = true;
11
+
12
+ // HIGH-1 fix: verify a discovered peer is running PAL/1.0 before caching
13
+ // Combined with MEDIUM-3 (cache by public key only) and CRITICAL-1 (authenticated share_list),
14
+ // this prevents blind cache poisoning from unauthenticated mDNS advertisers.
15
+ async function verifyPeerAlive(ip, port) {
16
+ try {
17
+ const resp = await sendRequest(ip, port, { type: 'status' }, 3000);
18
+ return resp.ok && resp.protocol === 'PAL/1.0';
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ // MEDIUM-3 fix: persist peer addresses keyed ONLY by public key
25
+ function persistPeerAddress(peer) {
26
+ try {
27
+ const cache = config.get('peerAddressCache') || {};
28
+ const key = peer.publicKey;
29
+ if (!key || !peer.ip || peer.ip === 'unknown') return;
30
+ cache[key] = {
31
+ ip: peer.ip,
32
+ port: 7474,
33
+ handle: peer.handle || null,
34
+ publicKey: peer.publicKey || null,
35
+ name: peer.name || null,
36
+ updatedAt: new Date().toISOString(),
37
+ };
38
+ // Do NOT cache by handle — handles are spoofable
39
+ config.set('peerAddressCache', cache);
40
+ } catch {}
41
+ }
42
+
43
+ export function getCachedPeerAddress(identifiers) {
44
+ const cache = config.get('peerAddressCache') || {};
45
+ // Only look up by public key (first valid hex key in identifiers)
46
+ for (const id of identifiers) {
47
+ if (id && /^[0-9a-f]{64}$/i.test(id) && cache[id]) return cache[id];
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export function startMdns(identity, opts = {}) {
53
+ if (bonjour) return;
54
+ if (!identity?.publicKey) return;
55
+ verifyBeforeCaching = opts.verifyPeers !== false;
56
+ const advertise = opts.advertise !== false;
57
+
58
+ advertisedTxtExtras = {};
59
+ if (opts.torrentPort) advertisedTxtExtras.tp = String(opts.torrentPort);
60
+ if (opts.webPort) advertisedTxtExtras.wp = String(opts.webPort);
61
+
62
+ bonjour = new Bonjour();
63
+
64
+ if (advertise) {
65
+ try {
66
+ service = bonjour.publish({
67
+ name: `palexplorer-${identity.handle || identity.publicKey.slice(0, 8)}`,
68
+ type: 'palexplorer',
69
+ port: 7474,
70
+ txt: {
71
+ pk: identity.publicKey,
72
+ h: identity.handle || '',
73
+ n: identity.name || '',
74
+ ...advertisedTxtExtras,
75
+ },
76
+ });
77
+ } catch {
78
+ // mDNS publish can fail if service name is already registered or port conflicts
79
+ service = null;
80
+ }
81
+ }
82
+
83
+ browser = bonjour.find({ type: 'palexplorer' }, async (svc) => {
84
+ const txt = svc.txt || {};
85
+ const pk = txt.pk;
86
+ if (!pk || !/^[0-9a-f]{64}$/i.test(pk)) return;
87
+ if (pk === identity.publicKey) return;
88
+ if (nearbyPeers.has(pk)) return;
89
+
90
+ const ip = (svc.addresses && svc.addresses[0]) || svc.host || 'unknown';
91
+ if (ip === 'unknown') return;
92
+
93
+ // HIGH-1 fix: verify the peer is running PAL/1.0 before caching
94
+ if (verifyBeforeCaching) {
95
+ const alive = await verifyPeerAlive(ip, 7474);
96
+ if (!alive) return;
97
+ }
98
+
99
+ const peer = {
100
+ publicKey: pk,
101
+ name: txt.n || 'Unknown',
102
+ handle: txt.h || null,
103
+ ip,
104
+ torrentPort: txt.tp ? parseInt(txt.tp, 10) : null,
105
+ webPort: txt.wp ? parseInt(txt.wp, 10) : null,
106
+ discoveredAt: new Date().toISOString(),
107
+ };
108
+ nearbyPeers.set(pk, peer);
109
+ persistPeerAddress(peer);
110
+ });
111
+ }
112
+
113
+ export function stopMdns() {
114
+ if (browser) { browser.stop(); browser = null; }
115
+ if (service) { service.stop(); service = null; }
116
+ if (bonjour) { bonjour.destroy(); bonjour = null; }
117
+ nearbyPeers.clear();
118
+ }
119
+
120
+ export function getNearbyPeers() {
121
+ return [...nearbyPeers.values()];
122
+ }
123
+
124
+ export function isRunning() {
125
+ return bonjour !== null;
126
+ }