nothumanallowed 10.6.0 → 10.7.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/package.json +1 -1
- package/src/commands/collab.mjs +49 -33
- package/src/commands/ui.mjs +13 -6
- package/src/constants.mjs +1 -1
- package/src/services/web-ui.mjs +9 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.7.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 53 tools. Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, GitHub, Notion, Slack, voice chat, 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/collab.mjs
CHANGED
|
@@ -92,6 +92,19 @@ function decrypt(ciphertextB64, nonceB64, key) {
|
|
|
92
92
|
return decipher.update(encrypted) + decipher.final('utf-8');
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Derive channel encryption key from channel ID + secret.
|
|
97
|
+
* The secret is part of the invite code and NEVER sent to the server.
|
|
98
|
+
* Without the secret, the server cannot decrypt messages.
|
|
99
|
+
*/
|
|
100
|
+
function deriveChannelKey(channelId, secret) {
|
|
101
|
+
return crypto.createHash('sha256')
|
|
102
|
+
.update('alexandria-e2e-key-v2')
|
|
103
|
+
.update(channelId)
|
|
104
|
+
.update(secret || '') // legacy channels without secret use empty string
|
|
105
|
+
.digest();
|
|
106
|
+
}
|
|
107
|
+
|
|
95
108
|
// ── Identity Management ───────────────────────────────────────────────────
|
|
96
109
|
|
|
97
110
|
function loadIdentity() {
|
|
@@ -171,23 +184,30 @@ async function cmdCreate(args) {
|
|
|
171
184
|
const name = args.join(' ') || 'Unnamed Channel';
|
|
172
185
|
const identity = getOrCreateIdentity();
|
|
173
186
|
|
|
174
|
-
//
|
|
187
|
+
// Generate a channel secret — this NEVER goes to the server
|
|
188
|
+
// The invite code is channelId:secret — only people with the invite can decrypt
|
|
189
|
+
const channelSecret = crypto.randomBytes(16).toString('hex');
|
|
190
|
+
|
|
175
191
|
const result = await apiPost('/channels', {
|
|
176
|
-
name: name,
|
|
192
|
+
name: name,
|
|
177
193
|
creatorFingerprint: identity.fingerprint,
|
|
178
194
|
creatorPublicKey: identity.publicKey,
|
|
179
195
|
creatorDisplayName: identity.displayName,
|
|
180
|
-
ttlHours: 0,
|
|
196
|
+
ttlHours: 0,
|
|
181
197
|
maxMessages: 5000,
|
|
182
198
|
});
|
|
183
199
|
|
|
184
200
|
if (result.error) { fail(result.error); return; }
|
|
185
201
|
|
|
186
|
-
//
|
|
202
|
+
// The invite code includes the secret — server only knows the ID, not the secret
|
|
203
|
+
const inviteCode = `${result.id}:${channelSecret}`;
|
|
204
|
+
|
|
205
|
+
// Save channel locally WITH the secret
|
|
187
206
|
const channels = loadChannels();
|
|
188
207
|
channels.forEach(c => c.active = false);
|
|
189
208
|
channels.push({
|
|
190
209
|
id: result.id,
|
|
210
|
+
secret: channelSecret, // stored locally, NEVER sent to server
|
|
191
211
|
name,
|
|
192
212
|
createdAt: result.createdAt,
|
|
193
213
|
active: true,
|
|
@@ -198,10 +218,11 @@ async function cmdCreate(args) {
|
|
|
198
218
|
console.log('');
|
|
199
219
|
ok(`Channel created: ${name}`);
|
|
200
220
|
console.log('');
|
|
201
|
-
console.log(` ${BOLD}Invite Code:${NC} ${G}${
|
|
221
|
+
console.log(` ${BOLD}Invite Code:${NC} ${G}${inviteCode}${NC}`);
|
|
202
222
|
console.log('');
|
|
203
223
|
console.log(` ${D}Share this code securely with collaborators.${NC}`);
|
|
204
|
-
console.log(` ${D}They join with: ${C}nha collab join ${
|
|
224
|
+
console.log(` ${D}They join with: ${C}nha collab join ${inviteCode}${NC}`);
|
|
225
|
+
console.log(` ${R}The server CANNOT read your messages — the secret stays with you.${NC}`);
|
|
205
226
|
console.log('');
|
|
206
227
|
}
|
|
207
228
|
|
|
@@ -209,17 +230,22 @@ async function cmdJoin(args) {
|
|
|
209
230
|
const inviteCode = args[0];
|
|
210
231
|
if (!inviteCode) { fail('Usage: nha collab join <invite-code>'); return; }
|
|
211
232
|
|
|
233
|
+
// Parse invite code: channelId:secret or just channelId (legacy)
|
|
234
|
+
const parts = inviteCode.split(':');
|
|
235
|
+
const channelId = parts[0];
|
|
236
|
+
const channelSecret = parts[1] || ''; // empty = legacy channel (less secure)
|
|
237
|
+
|
|
212
238
|
const identity = getOrCreateIdentity();
|
|
213
239
|
|
|
214
240
|
// Check if already joined
|
|
215
241
|
const channels = loadChannels();
|
|
216
|
-
if (channels.find(c => c.id ===
|
|
217
|
-
setActiveChannel(
|
|
242
|
+
if (channels.find(c => c.id === channelId)) {
|
|
243
|
+
setActiveChannel(channelId);
|
|
218
244
|
ok(`Already a member. Switched to this channel.`);
|
|
219
245
|
return;
|
|
220
246
|
}
|
|
221
247
|
|
|
222
|
-
const result = await apiPost(`/channels/${
|
|
248
|
+
const result = await apiPost(`/channels/${channelId}/join`, {
|
|
223
249
|
fingerprint: identity.fingerprint,
|
|
224
250
|
publicKey: identity.publicKey,
|
|
225
251
|
displayName: identity.displayName,
|
|
@@ -228,11 +254,12 @@ async function cmdJoin(args) {
|
|
|
228
254
|
if (result.error) { fail(result.error); return; }
|
|
229
255
|
|
|
230
256
|
// Get channel info
|
|
231
|
-
const chInfo = await apiGet(`/channels/${
|
|
257
|
+
const chInfo = await apiGet(`/channels/${channelId}`);
|
|
232
258
|
|
|
233
259
|
channels.forEach(c => c.active = false);
|
|
234
260
|
channels.push({
|
|
235
|
-
id:
|
|
261
|
+
id: channelId,
|
|
262
|
+
secret: channelSecret, // stored locally, never sent to server
|
|
236
263
|
name: chInfo.name || 'Unknown',
|
|
237
264
|
createdAt: chInfo.createdAt,
|
|
238
265
|
active: true,
|
|
@@ -240,7 +267,12 @@ async function cmdJoin(args) {
|
|
|
240
267
|
});
|
|
241
268
|
saveChannels(channels);
|
|
242
269
|
|
|
243
|
-
ok(`Joined channel: ${chInfo.name ||
|
|
270
|
+
ok(`Joined channel: ${chInfo.name || channelId} (${result.members} members)`);
|
|
271
|
+
if (channelSecret) {
|
|
272
|
+
info('E2E encryption active — server cannot read your messages.');
|
|
273
|
+
} else {
|
|
274
|
+
warn('Legacy invite code (no secret). Messages use channel-ID-based key.');
|
|
275
|
+
}
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
async function cmdSend(args) {
|
|
@@ -255,22 +287,9 @@ async function cmdSend(args) {
|
|
|
255
287
|
const chInfo = await apiGet(`/channels/${channel.id}`);
|
|
256
288
|
if (chInfo.error) { fail(chInfo.error); return; }
|
|
257
289
|
|
|
258
|
-
// Channel
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
const sharedKey = crypto.createHash('sha256')
|
|
262
|
-
.update(channel.id)
|
|
263
|
-
.update('alexandria-e2e-v1')
|
|
264
|
-
.update(Buffer.from(identity.privateKey, 'base64').subarray(0, 16)) // salt with private key fragment for uniqueness
|
|
265
|
-
.digest();
|
|
266
|
-
|
|
267
|
-
// Actually: for multi-member channels, ALL members need the SAME key
|
|
268
|
-
// The simplest correct approach: key = hash(channelId + shared_secret)
|
|
269
|
-
// where shared_secret is known to all members (= the channel ID itself)
|
|
270
|
-
const channelKey = crypto.createHash('sha256')
|
|
271
|
-
.update('alexandria-channel-key-v1')
|
|
272
|
-
.update(channel.id)
|
|
273
|
-
.digest();
|
|
290
|
+
// Channel key: derived from channel ID + secret (secret NEVER sent to server)
|
|
291
|
+
// Only people with the invite code (id:secret) can derive this key
|
|
292
|
+
const channelKey = deriveChannelKey(channel.id, channel.secret);
|
|
274
293
|
|
|
275
294
|
const { nonce, ciphertext } = encrypt(message, channelKey);
|
|
276
295
|
|
|
@@ -300,11 +319,8 @@ async function cmdRead(args) {
|
|
|
300
319
|
return;
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
// Derive channel key
|
|
304
|
-
const channelKey =
|
|
305
|
-
.update('alexandria-channel-key-v1')
|
|
306
|
-
.update(channel.id)
|
|
307
|
-
.digest();
|
|
322
|
+
// Derive channel key from ID + secret
|
|
323
|
+
const channelKey = deriveChannelKey(channel.id, channel.secret);
|
|
308
324
|
|
|
309
325
|
console.log(`\n ${BOLD}${channel.name}${NC} ${D}(${result.messages.length} messages)${NC}\n`);
|
|
310
326
|
|
package/src/commands/ui.mjs
CHANGED
|
@@ -355,8 +355,11 @@ export async function cmdUI(args) {
|
|
|
355
355
|
if (!chId) { sendJSON(res, 400, { error: 'channelId required' }); return; }
|
|
356
356
|
const r = await fetch(ALEX_API + '/channels/' + chId + '/messages?fp=' + identity.fingerprint);
|
|
357
357
|
const data = await r.json();
|
|
358
|
-
// Decrypt
|
|
359
|
-
const
|
|
358
|
+
// Decrypt using channel key (ID + secret from local file)
|
|
359
|
+
const chFile2 = path.join(collabDir, 'channels.json');
|
|
360
|
+
let chSecret = '';
|
|
361
|
+
try { const chs = JSON.parse(fs.readFileSync(chFile2, 'utf-8')); const found = chs.find(c => c.id === chId); chSecret = found?.secret || ''; } catch {}
|
|
362
|
+
const channelKey = crypto.createHash('sha256').update('alexandria-e2e-key-v2').update(chId).update(chSecret).digest();
|
|
360
363
|
if (data.messages) {
|
|
361
364
|
for (const msg of data.messages) {
|
|
362
365
|
if (msg.type === 'system' || !msg.ciphertext || !msg.nonce) continue;
|
|
@@ -383,8 +386,10 @@ export async function cmdUI(args) {
|
|
|
383
386
|
|
|
384
387
|
if (collabAction === 'send' && method === 'POST') {
|
|
385
388
|
const body = await parseBody(req);
|
|
386
|
-
// Encrypt with
|
|
387
|
-
|
|
389
|
+
// Encrypt with channel key (ID + secret)
|
|
390
|
+
let sendSecret = '';
|
|
391
|
+
try { const chs3 = JSON.parse(fs.readFileSync(path.join(collabDir, 'channels.json'), 'utf-8')); sendSecret = chs3.find(c => c.id === body.channelId)?.secret || ''; } catch {}
|
|
392
|
+
const channelKey = crypto.createHash('sha256').update('alexandria-e2e-key-v2').update(body.channelId).update(sendSecret).digest();
|
|
388
393
|
const nonce = crypto.randomBytes(12);
|
|
389
394
|
const cipher = crypto.createCipheriv('aes-256-gcm', channelKey, nonce);
|
|
390
395
|
const encrypted = Buffer.concat([cipher.update(body.message, 'utf-8'), cipher.final()]);
|
|
@@ -2114,8 +2119,10 @@ export async function cmdUI(args) {
|
|
|
2114
2119
|
try {
|
|
2115
2120
|
const msg = JSON.parse(data.toString());
|
|
2116
2121
|
if (msg.type === 'new_message' && msg.message) {
|
|
2117
|
-
// Decrypt
|
|
2118
|
-
|
|
2122
|
+
// Decrypt using channel key with secret
|
|
2123
|
+
let wsSecret = '';
|
|
2124
|
+
try { const chs4 = JSON.parse(fs.readFileSync(path.join(collabDir, 'channels.json'), 'utf-8')); wsSecret = chs4.find(c => c.id === channelId)?.secret || ''; } catch {}
|
|
2125
|
+
const channelKey = crypto.createHash('sha256').update('alexandria-e2e-key-v2').update(channelId).update(wsSecret).digest();
|
|
2119
2126
|
let content = '[encrypted]';
|
|
2120
2127
|
try {
|
|
2121
2128
|
const nonce = Buffer.from(msg.message.nonce, 'base64');
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '10.
|
|
8
|
+
export const VERSION = '10.7.0';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -1744,16 +1744,22 @@ function collabSelect(id){
|
|
|
1744
1744
|
|
|
1745
1745
|
function collabLoadMessages(){
|
|
1746
1746
|
if(!collabActiveChannel)return;
|
|
1747
|
-
// Reset unread when viewing
|
|
1748
1747
|
collabUnreadCount=0;
|
|
1749
1748
|
updateCollabBadge();
|
|
1750
1749
|
apiGet('/api/collab/messages?channelId='+collabActiveChannel).then(function(r){
|
|
1750
|
+
if(r&&r.error){
|
|
1751
|
+
var el=document.getElementById('collabMessages');
|
|
1752
|
+
if(el)el.innerHTML='<div style="text-align:center;color:var(--red);padding:20px;font-size:11px">'+esc(r.error)+'<br><span style="color:var(--dim);font-size:10px">This channel may have expired or the server was restarted.</span></div>';
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1751
1755
|
if(!r||!r.messages)return;
|
|
1752
1756
|
collabMessages=r.messages;
|
|
1753
|
-
// Update last count for this channel
|
|
1754
1757
|
var ch=collabChannels.find(function(c){return c.id===collabActiveChannel});
|
|
1755
1758
|
if(ch)ch._lastCount=r.messages.length;
|
|
1756
1759
|
renderCollabMessages();
|
|
1760
|
+
}).catch(function(e){
|
|
1761
|
+
var el=document.getElementById('collabMessages');
|
|
1762
|
+
if(el)el.innerHTML='<div style="text-align:center;color:var(--red);padding:20px;font-size:11px">Connection error</div>';
|
|
1757
1763
|
});
|
|
1758
1764
|
}
|
|
1759
1765
|
|
|
@@ -1761,13 +1767,9 @@ function collabSend(){
|
|
|
1761
1767
|
var inp=document.getElementById('collabInput');if(!inp)return;
|
|
1762
1768
|
var msg=inp.value.trim();if(!msg||!collabActiveChannel)return;
|
|
1763
1769
|
inp.value='';
|
|
1764
|
-
// Optimistic: show message immediately
|
|
1765
|
-
collabMessages.push({senderName:'You',timestamp:new Date().toISOString(),content:msg,type:'text'});
|
|
1766
|
-
renderCollabMessages();
|
|
1767
1770
|
apiPost('/api/collab/send',{channelId:collabActiveChannel,message:msg}).then(function(r){
|
|
1768
1771
|
if(r.error){alert(r.error);return;}
|
|
1769
|
-
//
|
|
1770
|
-
setTimeout(collabLoadMessages,500);
|
|
1772
|
+
// Message will arrive via WebSocket — no need to reload
|
|
1771
1773
|
});
|
|
1772
1774
|
}
|
|
1773
1775
|
|