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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "10.6.0",
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": {
@@ -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
- // Encrypt channel name with a key derived from the channel (will be re-encrypted with shared key)
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, // Sent in plaintext for now — will be encrypted once shared key exists
192
+ name: name,
177
193
  creatorFingerprint: identity.fingerprint,
178
194
  creatorPublicKey: identity.publicKey,
179
195
  creatorDisplayName: identity.displayName,
180
- ttlHours: 0, // permanent until deleted
196
+ ttlHours: 0,
181
197
  maxMessages: 5000,
182
198
  });
183
199
 
184
200
  if (result.error) { fail(result.error); return; }
185
201
 
186
- // Save channel locally
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}${result.id}${NC}`);
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 ${result.id}${NC}`);
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 === inviteCode)) {
217
- setActiveChannel(inviteCode);
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/${inviteCode}/join`, {
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/${inviteCode}`);
257
+ const chInfo = await apiGet(`/channels/${channelId}`);
232
258
 
233
259
  channels.forEach(c => c.active = false);
234
260
  channels.push({
235
- id: inviteCode,
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 || inviteCode} (${result.members} members)`);
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-wide shared key: derived from channel ID + member's private key salt
259
- // All members who know the channel ID can derive the same key
260
- // This is the "invite code IS the key" model — simple, secure for the use case
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 same for all members who know the channel ID
304
- const channelKey = crypto.createHash('sha256')
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
 
@@ -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 messages client-side using channel key
359
- const channelKey = crypto.createHash('sha256').update('alexandria-channel-key-v1').update(chId).digest();
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 the same channel key used by CLI
387
- const channelKey = crypto.createHash('sha256').update('alexandria-channel-key-v1').update(body.channelId).digest();
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 the message
2118
- const channelKey = crypto.createHash('sha256').update('alexandria-channel-key-v1').update(channelId).digest();
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.6.0';
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
 
@@ -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
- // Reload to get server timestamp
1770
- setTimeout(collabLoadMessages,500);
1772
+ // Message will arrive via WebSocket — no need to reload
1771
1773
  });
1772
1774
  }
1773
1775