@zcloak/ai-agent 1.0.41 → 1.0.43

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/dist/vetkey.js CHANGED
@@ -31,7 +31,7 @@ import { daemonLogPath } from './paths.js';
31
31
  import { join } from 'path';
32
32
  import { fileURLToPath } from 'url';
33
33
  import { createInterface } from 'readline';
34
- import { createHash } from 'crypto';
34
+ import { createHash, randomBytes } from 'crypto';
35
35
  import { Principal } from '@dfinity/principal';
36
36
  import { schnorr } from '@noble/curves/secp256k1';
37
37
  import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
@@ -133,7 +133,7 @@ function showHelp() {
133
133
  console.log(' grants-in List grants you received (as grantee)');
134
134
  console.log('');
135
135
  console.log('Encrypted Messaging:');
136
- console.log(' send-msg Encrypt + auto-deliver via zMail (IBE)');
136
+ console.log(' send-msg Encrypt + auto-deliver via zMail (Kind17 v2 by default)');
137
137
  console.log(' recv-msg Decrypt a received message via Mail daemon');
138
138
  console.log('');
139
139
  console.log('Options:');
@@ -154,6 +154,7 @@ function showHelp() {
154
154
  console.log(' --reply=<msg_id> Reply to a parent message (for send-msg)');
155
155
  console.log(' --data=<json> Encrypted message JSON envelope (for recv-msg)');
156
156
  console.log(' --msg-id=<id> Message ID to auto-fetch and decrypt (for recv-msg)');
157
+ console.log(' --kind17-version=<v> Mail content version: v2 (default) or v1');
157
158
  console.log(' --no-zmail Skip auto-POST to zMail (send-msg only)');
158
159
  console.log(' --zmail-url=<url> Override zMail server URL');
159
160
  }
@@ -439,7 +440,7 @@ async function cmdServe(session) {
439
440
  }
440
441
  log.info("Key derivation complete. Starting JSON-RPC daemon...");
441
442
  const { syncMailbox } = await import('./zmail.js');
442
- const { getOpenClawStatusContext, notifyOpenClawMainAgentOfNewMail, } = await import('./openclaw.js');
443
+ const { notifyOpenClawMainAgentOfNewMail } = await import('./openclaw.js');
443
444
  await runDaemonUds(activeKeyStore, mailKeyStore, principal, daemonId, [
444
445
  () => startPeriodicAsyncTask({
445
446
  name: 'zmail-sync',
@@ -447,12 +448,7 @@ async function cmdServe(session) {
447
448
  task: async () => {
448
449
  const summary = await syncMailbox(session, { fullSync: false, logProgress: false });
449
450
  if (summary.inbox_new > 0) {
450
- const channelContext = await getOpenClawStatusContext();
451
- if (!channelContext) {
452
- log.warn(`Daemon zMail sync: ${summary.inbox_new} new inbox, ${summary.sent_new} new sent (openclaw status unavailable)`);
453
- return;
454
- }
455
- const notified = await notifyOpenClawMainAgentOfNewMail(summary, channelContext);
451
+ const notified = await notifyOpenClawMainAgentOfNewMail(summary);
456
452
  if (notified) {
457
453
  log.info(`Daemon zMail sync: ${summary.inbox_new} new inbox, ${summary.sent_new} new sent (notified openclaw main)`);
458
454
  return;
@@ -1112,6 +1108,16 @@ function computeEnvelopeId(envelope) {
1112
1108
  ];
1113
1109
  return createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('hex');
1114
1110
  }
1111
+ function kind17VersionFlag(session) {
1112
+ const raw = session.args['kind17-version'];
1113
+ if (raw === undefined) {
1114
+ return 'v2';
1115
+ }
1116
+ if (raw !== 'v1' && raw !== 'v2') {
1117
+ throw new Error('Invalid --kind17-version value: expected "v1" or "v2"');
1118
+ }
1119
+ return raw;
1120
+ }
1115
1121
  /**
1116
1122
  * Extract the raw 32-byte secp256k1 private key from the session identity.
1117
1123
  * The Secp256k1KeyIdentity.toJSON() returns [publicKeyHex, privateKeyHex].
@@ -1135,6 +1141,33 @@ const SPKI_X_LENGTH = 64;
1135
1141
  export function schnorrPubkeyFromSpki(spkiHex) {
1136
1142
  return spkiHex.slice(SPKI_X_OFFSET, SPKI_X_OFFSET + SPKI_X_LENGTH);
1137
1143
  }
1144
+ function withSenderPubkeyTag(session, tags) {
1145
+ if (getTagValue(tags, 'from_pubkey')) {
1146
+ return tags;
1147
+ }
1148
+ const senderSpkiHex = Buffer.from(session.getIdentity().getPublicKey().toDer()).toString('hex');
1149
+ return [...tags, ['from_pubkey', senderSpkiHex]];
1150
+ }
1151
+ function canonicalSerializeEnvelopeCryptoContext(context) {
1152
+ return JSON.stringify(canonicalize({
1153
+ kind: context.kind,
1154
+ ai_id: context.ai_id,
1155
+ created_at: context.created_at,
1156
+ tags: context.tags,
1157
+ }));
1158
+ }
1159
+ function buildKind17V2BodyAad(context, payloadType) {
1160
+ return Buffer.from(`${canonicalSerializeEnvelopeCryptoContext(context)}|body|${payloadType}|v2`, 'utf8');
1161
+ }
1162
+ function splitCiphertextAndTag(raw) {
1163
+ if (raw.length < 16) {
1164
+ throw new Error('Invalid Kind17 v2 ciphertext: missing authentication tag');
1165
+ }
1166
+ return {
1167
+ ciphertext: raw.slice(0, raw.length - 16),
1168
+ tag: raw.slice(raw.length - 16),
1169
+ };
1170
+ }
1138
1171
  /**
1139
1172
  * Build, sign, and return a complete Kind17 envelope.
1140
1173
  *
@@ -1147,14 +1180,9 @@ export function schnorrPubkeyFromSpki(spkiHex) {
1147
1180
  * @param content - Encrypted content JSON string: {"v":1,"type":"text"|"file","ct":"<base64>"}
1148
1181
  * @returns Signed Kind17Envelope ready for JSON serialization
1149
1182
  */
1150
- function buildSignedEnvelope(session, tags, content) {
1183
+ function buildSignedEnvelope(session, tags, content, createdAt = Math.floor(Date.now() / 1000)) {
1151
1184
  const senderPrincipal = session.getPrincipal();
1152
- const createdAt = Math.floor(Date.now() / 1000);
1153
- // Include the sender's SPKI public key for receiver-side verification.
1154
- // This enables binding ai_id ↔ pubkey ↔ sig without registry lookups.
1155
- const senderIdentity = session.getIdentity();
1156
- const senderSpkiHex = Buffer.from(senderIdentity.getPublicKey().toDer()).toString('hex');
1157
- const allTags = [...tags, ['from_pubkey', senderSpkiHex]];
1185
+ const allTags = withSenderPubkeyTag(session, tags);
1158
1186
  // Compute the envelope ID from canonical serialization
1159
1187
  const partial = { kind: 17, ai_id: senderPrincipal, created_at: createdAt, tags: allTags, content };
1160
1188
  const id = computeEnvelopeId(partial);
@@ -1196,6 +1224,11 @@ async function cmdSendMsg(session) {
1196
1224
  throw new Error('--to=<AI-ID or principal> is required');
1197
1225
  }
1198
1226
  const { plaintext, payloadType, filename } = readMessageInput(text, file);
1227
+ const kind17Version = kind17VersionFlag(session);
1228
+ // Legacy Kind17 v1 generation is kept only for compatibility/debug comparison.
1229
+ // The default zMail backend is the V2 endpoint, which is expected to reject
1230
+ // newly sent v1 content with unsupported_content_version. Do not treat that
1231
+ // as a regression in the upgraded path — v1 is on the deprecation path.
1199
1232
  // Resolve recipient principal from AI-ID (readable) or raw principal text.
1200
1233
  let recipientPrincipal;
1201
1234
  const registryActor = await session.getAnonymousRegistryActor();
@@ -1239,28 +1272,51 @@ async function cmdSendMsg(session) {
1239
1272
  catch (e) {
1240
1273
  throw canisterCallError(`get_ibe_public_key failed: ${e instanceof Error ? e.message : String(e)}`, e);
1241
1274
  }
1242
- // IBE-encrypt the plaintext for the recipient's Mail identity
1243
- const ciphertext = cryptoOps.ibeEncrypt(dpkBytes, ibeIdentity, plaintext);
1244
- const contentBase64 = Buffer.from(ciphertext).toString('base64');
1245
- // Wrap ciphertext in the standardized message composition format per zmail-skill spec.
1246
- // Shape: {"v":1,"type":"text"|"file","ct":"<base64-ciphertext>"}
1247
- // The outer Kind17 envelope carries routing metadata; only the body is encrypted.
1248
- const content = JSON.stringify({ v: 1, type: payloadType, ct: contentBase64 });
1249
1275
  // Build tags: recipient + metadata
1250
- const tags = [
1276
+ const baseTags = [
1251
1277
  ['to', recipientPrincipal],
1252
- ['payload_type', payloadType],
1253
- ['ibe_id', ibeIdentity],
1254
1278
  ];
1279
+ if (kind17Version === 'v1') {
1280
+ baseTags.push(['payload_type', payloadType], ['ibe_id', ibeIdentity]);
1281
+ }
1255
1282
  if (filename) {
1256
- tags.push(['filename', filename]);
1283
+ baseTags.push(['filename', filename]);
1257
1284
  }
1258
1285
  // Include reply tag when responding to an existing message
1259
1286
  if (replyMsgId) {
1260
- tags.push(['reply', replyMsgId]);
1287
+ baseTags.push(['reply', replyMsgId]);
1261
1288
  }
1289
+ const createdAt = Math.floor(Date.now() / 1000);
1290
+ const envelopeTags = withSenderPubkeyTag(session, baseTags);
1291
+ const senderPrincipal = session.getPrincipal();
1292
+ const content = kind17Version === 'v1'
1293
+ ? buildKind17V1Content(dpkBytes, ibeIdentity, plaintext, payloadType)
1294
+ : buildKind17V2Content(dpkBytes, senderPrincipal, recipientPrincipal, plaintext, payloadType, {
1295
+ kind: 17,
1296
+ ai_id: senderPrincipal,
1297
+ created_at: createdAt,
1298
+ tags: envelopeTags,
1299
+ });
1300
+ log.debug('send-msg envelope prepared', {
1301
+ kind17Version,
1302
+ senderPrincipal,
1303
+ recipientPrincipal,
1304
+ payloadType,
1305
+ plaintextSize: plaintext.length,
1306
+ hasFilename: Boolean(filename),
1307
+ hasReply: Boolean(replyMsgId),
1308
+ contentType: typeof content,
1309
+ });
1262
1310
  // Build and sign the Kind17 envelope
1263
- const envelope = buildSignedEnvelope(session, tags, content);
1311
+ const envelope = buildSignedEnvelope(session, envelopeTags, content, createdAt);
1312
+ log.debug('send-msg envelope signed', {
1313
+ msgId: envelope.id,
1314
+ createdAt,
1315
+ tagKeys: envelope.tags.map(([key]) => key),
1316
+ contentVersion: typeof envelope.content === 'string'
1317
+ ? 'v1-string'
1318
+ : `v${String(envelope.content.v ?? 'unknown')}-object`,
1319
+ });
1264
1320
  // Output the envelope as JSON (always JSON for machine consumption)
1265
1321
  console.log(JSON.stringify(envelope));
1266
1322
  // Auto-POST to zMail if not explicitly disabled with --no-zmail.
@@ -1274,6 +1330,12 @@ async function cmdSendMsg(session) {
1274
1330
  : (zmailUrlEnv && zmailUrlEnv.length > 0)
1275
1331
  ? zmailUrlEnv.replace(/\/+$/, '')
1276
1332
  : (await import('./config.js')).default.zmail_url;
1333
+ log.debug('send-msg auto-deliver start', {
1334
+ zmailUrl,
1335
+ msgId: envelope.id,
1336
+ recipientPrincipal,
1337
+ kind17Version,
1338
+ });
1277
1339
  const { postEnvelopeToZmail } = await import('./zmail.js');
1278
1340
  const result = await postEnvelopeToZmail(zmailUrl, envelope);
1279
1341
  log.info(`zMail: delivered (msg_id=${result.msg_id}, to=${result.delivered_to})`);
@@ -1283,6 +1345,39 @@ async function cmdSendMsg(session) {
1283
1345
  }
1284
1346
  }
1285
1347
  }
1348
+ function buildKind17V1Content(dpkBytes, ibeIdentity, plaintext, payloadType) {
1349
+ // Deprecated compatibility format:
1350
+ // v1 wraps a single IBE ciphertext in a JSON string body. It is retained so
1351
+ // recv-msg can continue to read historical mail and so send-side output can
1352
+ // still be compared during migration work, but new delivery is expected to
1353
+ // move to Kind17 v2 only.
1354
+ const ciphertext = cryptoOps.ibeEncrypt(dpkBytes, ibeIdentity, plaintext);
1355
+ const content = {
1356
+ v: 1,
1357
+ type: payloadType,
1358
+ ct: Buffer.from(ciphertext).toString('base64'),
1359
+ };
1360
+ return JSON.stringify(content);
1361
+ }
1362
+ function buildKind17V2Content(dpkBytes, senderPrincipal, recipientPrincipal, plaintext, payloadType, context) {
1363
+ const bodyKey = randomBytes(32);
1364
+ const body = cryptoOps.aes256GcmEncryptRaw(bodyKey, plaintext, { aad: buildKind17V2BodyAad(context, payloadType) });
1365
+ const readers = [...new Set([senderPrincipal, recipientPrincipal])];
1366
+ const keys = {};
1367
+ for (const reader of readers) {
1368
+ const wrappedKeyCiphertext = cryptoOps.ibeEncrypt(dpkBytes, `${reader}:Mail`, bodyKey);
1369
+ keys[reader] = Buffer.from(wrappedKeyCiphertext).toString('base64');
1370
+ }
1371
+ return {
1372
+ v: 2,
1373
+ type: payloadType,
1374
+ alg: 'aes-256-gcm',
1375
+ key_alg: 'vetkey-ibe',
1376
+ ciphertext: Buffer.concat([body.ciphertext, body.tag]).toString('base64'),
1377
+ iv: body.iv.toString('base64'),
1378
+ keys,
1379
+ };
1380
+ }
1286
1381
  /**
1287
1382
  * recv-msg: Decrypt a received Kind17 encrypted message via the Mail daemon.
1288
1383
  *
@@ -1354,11 +1449,12 @@ async function cmdRecvMsg(session) {
1354
1449
  // Parse and validate the Kind17 envelope
1355
1450
  const envelope = parseKind17Envelope(dataStr);
1356
1451
  const principal = session.getPrincipal();
1357
- const derivationId = `${principal}:Mail`;
1358
- // Extract metadata from tags
1359
- const ibeId = getTagValue(envelope.tags, 'ibe_id') ?? derivationId;
1360
- const payloadType = (getTagValue(envelope.tags, 'payload_type') ?? 'text');
1361
- const filename = getTagValue(envelope.tags, 'filename');
1452
+ log.debug('recv-msg envelope parsed', {
1453
+ msgId: envelope.id,
1454
+ from: envelope.ai_id,
1455
+ createdAt: envelope.created_at,
1456
+ contentType: typeof envelope.content,
1457
+ });
1362
1458
  // Verify this envelope is addressed to us
1363
1459
  const recipients = envelope.tags.filter(t => t[0] === 'to').map(t => t[1]);
1364
1460
  if (!recipients.includes(principal)) {
@@ -1367,53 +1463,26 @@ async function cmdRecvMsg(session) {
1367
1463
  // Verify envelope integrity and sender authentication.
1368
1464
  // Returns true only if full Schnorr signature + principal binding was verified.
1369
1465
  const verifiedSender = verifyKind17Signature(envelope);
1370
- // Extract the IBE ciphertext from the content field.
1371
- // Content follows the zmail-skill message composition spec:
1372
- // {"v":1,"type":"text"|"file","ct":"<base64-ciphertext>"}
1373
- let ciphertextBase64;
1374
- try {
1375
- const parsed = JSON.parse(envelope.content);
1376
- if (!parsed || typeof parsed !== 'object' || parsed.v !== 1 || typeof parsed.ct !== 'string') {
1377
- throw new Error('Invalid message composition format: missing v=1 or ct field');
1378
- }
1379
- // Validate type field exists and matches one of the allowed payload types
1380
- if (parsed.type !== 'text' && parsed.type !== 'file') {
1381
- throw new Error(`Invalid message composition format: type must be "text" or "file", got "${String(parsed.type)}"`);
1382
- }
1383
- // Verify consistency between the composition type and the envelope payload_type tag
1384
- if (parsed.type !== payloadType) {
1385
- throw new Error(`Message composition type "${parsed.type}" does not match envelope payload_type tag "${payloadType}"`);
1386
- }
1387
- ciphertextBase64 = parsed.ct;
1388
- }
1389
- catch (e) {
1390
- if (e instanceof SyntaxError) {
1391
- throw new Error('Invalid message composition format: content is not valid JSON');
1392
- }
1393
- throw e;
1394
- }
1395
- const mailDerivationId = `${principal}:Mail`;
1466
+ const parsedContent = parseKind17MessageContent(envelope, principal);
1467
+ const payloadType = parsedContent.payloadType;
1468
+ const filename = parsedContent.filename;
1469
+ log.debug('recv-msg content parsed', {
1470
+ msgId: envelope.id,
1471
+ version: parsedContent.version,
1472
+ payloadType,
1473
+ hasFilename: Boolean(filename),
1474
+ verifiedSender,
1475
+ });
1396
1476
  await ensureDaemonReadyForRecvMsg(principal);
1397
- const sockPath = socketPath(principal);
1398
- const response = await sendRpcToSocket(sockPath, {
1399
- id: 1,
1400
- method: 'ibe-decrypt',
1401
- params: {
1402
- ibe_identity: ibeId,
1403
- ciphertext_base64: ciphertextBase64,
1404
- },
1477
+ const plaintextBytes = parsedContent.version === 'v1'
1478
+ ? await decryptKind17V1Content(principal, parsedContent)
1479
+ : await decryptKind17V2Content(principal, envelope, parsedContent);
1480
+ log.debug('recv-msg decrypt complete', {
1481
+ msgId: envelope.id,
1482
+ version: parsedContent.version,
1483
+ plaintextSize: plaintextBytes.length,
1484
+ outputMode: output ? 'file' : payloadType === 'file' ? 'auto-file' : 'stdout',
1405
1485
  });
1406
- if (response.error) {
1407
- throw new Error(`Daemon decryption failed: ${response.error}`);
1408
- }
1409
- const result = response.result;
1410
- if (!result || typeof result.data_base64 !== 'string' || typeof result.plaintext_size !== 'number') {
1411
- throw new Error('Daemon returned an invalid decrypt result');
1412
- }
1413
- const plaintextBytes = decodeBase64Strict(result.data_base64, 'daemon data_base64');
1414
- if (plaintextBytes.length !== result.plaintext_size) {
1415
- throw new Error(`Daemon returned mismatched plaintext size: expected ${result.plaintext_size}, got ${plaintextBytes.length}`);
1416
- }
1417
1486
  // Determine output target
1418
1487
  const shouldWriteFile = payloadType === 'file' || !!output;
1419
1488
  const resolvedOutput = shouldWriteFile
@@ -1430,7 +1499,7 @@ async function cmdRecvMsg(session) {
1430
1499
  payload_type: payloadType,
1431
1500
  filename,
1432
1501
  verified_sender: verifiedSender,
1433
- plaintext_size: result.plaintext_size,
1502
+ plaintext_size: plaintextBytes.length,
1434
1503
  timestamp: envelope.created_at,
1435
1504
  };
1436
1505
  if (resolvedOutput) {
@@ -1449,7 +1518,7 @@ async function cmdRecvMsg(session) {
1449
1518
  if (filename)
1450
1519
  console.log(` File Name: ${filename}`);
1451
1520
  console.log(` Time: ${new Date(envelope.created_at * 1000).toISOString()}`);
1452
- console.log(` Size: ${result.plaintext_size} bytes`);
1521
+ console.log(` Size: ${plaintextBytes.length} bytes`);
1453
1522
  console.log(` Output File: ${resolvedOutput}`);
1454
1523
  }
1455
1524
  else {
@@ -1459,11 +1528,167 @@ async function cmdRecvMsg(session) {
1459
1528
  console.log(` Verified: ${verifiedSender ? 'yes' : 'id-only'}`);
1460
1529
  console.log(` Payload Type: ${payloadType}`);
1461
1530
  console.log(` Time: ${new Date(envelope.created_at * 1000).toISOString()}`);
1462
- console.log(` Size: ${result.plaintext_size} bytes`);
1531
+ console.log(` Size: ${plaintextBytes.length} bytes`);
1463
1532
  console.log(' Content:');
1464
1533
  console.log(plaintextBytes.toString('utf-8'));
1465
1534
  }
1466
1535
  }
1536
+ function parseKind17MessageContent(envelope, readerPrincipal) {
1537
+ const filename = getTagValue(envelope.tags, 'filename');
1538
+ const legacyPayloadType = getTagValue(envelope.tags, 'payload_type');
1539
+ const legacyIbeIdentity = getTagValue(envelope.tags, 'ibe_id');
1540
+ if (typeof envelope.content === 'string') {
1541
+ log.debug('recv-msg parsing legacy string content', {
1542
+ msgId: envelope.id,
1543
+ readerPrincipal,
1544
+ contentLength: envelope.content.length,
1545
+ });
1546
+ let parsed;
1547
+ try {
1548
+ parsed = JSON.parse(envelope.content);
1549
+ }
1550
+ catch {
1551
+ throw new Error('Invalid message composition format: content is not valid JSON');
1552
+ }
1553
+ if (!parsed || typeof parsed !== 'object' || parsed.v !== 1 || typeof parsed.ct !== 'string') {
1554
+ throw new Error('Invalid message composition format: missing v=1 or ct field');
1555
+ }
1556
+ if (parsed.type !== 'text' && parsed.type !== 'file') {
1557
+ throw new Error(`Invalid message composition format: type must be "text" or "file", got "${String(parsed.type)}"`);
1558
+ }
1559
+ if (legacyPayloadType && parsed.type !== legacyPayloadType) {
1560
+ throw new Error(`Message composition type "${parsed.type}" does not match envelope payload_type tag "${legacyPayloadType}"`);
1561
+ }
1562
+ if (!legacyIbeIdentity) {
1563
+ throw new Error('Legacy Kind17 v1 message is missing required ibe_id tag');
1564
+ }
1565
+ return {
1566
+ version: 'v1',
1567
+ payloadType: parsed.type,
1568
+ ciphertextBase64: parsed.ct,
1569
+ ibeIdentity: legacyIbeIdentity,
1570
+ filename,
1571
+ };
1572
+ }
1573
+ const parsed = envelope.content;
1574
+ log.debug('recv-msg parsing structured object content', {
1575
+ msgId: envelope.id,
1576
+ readerPrincipal,
1577
+ contentVersion: parsed.v ?? null,
1578
+ });
1579
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1580
+ throw new Error('Invalid Kind17 content: expected a string or object');
1581
+ }
1582
+ if (parsed.v !== 2) {
1583
+ throw new Error(`Unsupported Kind17 content version: ${String(parsed.v)}`);
1584
+ }
1585
+ if (parsed.type !== 'text' && parsed.type !== 'file') {
1586
+ throw new Error(`Invalid Kind17 v2 content type: expected "text" or "file", got "${String(parsed.type)}"`);
1587
+ }
1588
+ if (parsed.alg !== 'aes-256-gcm') {
1589
+ throw new Error(`Unsupported Kind17 v2 body algorithm: ${String(parsed.alg)}`);
1590
+ }
1591
+ if (parsed.key_alg !== 'vetkey-ibe') {
1592
+ throw new Error(`Unsupported Kind17 v2 key algorithm: ${String(parsed.key_alg)}`);
1593
+ }
1594
+ if (typeof parsed.iv !== 'string' || typeof parsed.ciphertext !== 'string') {
1595
+ throw new Error('Invalid Kind17 v2 content: missing iv or ciphertext');
1596
+ }
1597
+ if (!parsed.keys || typeof parsed.keys !== 'object' || Array.isArray(parsed.keys)) {
1598
+ throw new Error('Invalid Kind17 v2 content: keys must be an object');
1599
+ }
1600
+ const wrappedKeyBase64 = parsed.keys[readerPrincipal];
1601
+ if (typeof wrappedKeyBase64 !== 'string' || wrappedKeyBase64.length === 0) {
1602
+ throw new Error(`Kind17 v2 content does not contain a wrapped key for reader "${readerPrincipal}"`);
1603
+ }
1604
+ return {
1605
+ version: 'v2',
1606
+ payloadType: parsed.type,
1607
+ content: parsed,
1608
+ wrappedKeyBase64,
1609
+ filename,
1610
+ };
1611
+ }
1612
+ async function decryptKind17V1Content(principal, parsed) {
1613
+ // Legacy receive path for historical inbox content. This stays read-only
1614
+ // compatible while v1 send support is being phased out.
1615
+ const response = await sendRpcToSocket(socketPath(principal), {
1616
+ id: 1,
1617
+ method: 'ibe-decrypt',
1618
+ params: {
1619
+ ibe_identity: parsed.ibeIdentity,
1620
+ ciphertext_base64: parsed.ciphertextBase64,
1621
+ },
1622
+ });
1623
+ if (response.error) {
1624
+ throw new Error(`Daemon decryption failed: ${response.error}`);
1625
+ }
1626
+ const result = response.result;
1627
+ if (!result || typeof result.data_base64 !== 'string' || typeof result.plaintext_size !== 'number') {
1628
+ throw new Error('Daemon returned an invalid decrypt result');
1629
+ }
1630
+ const plaintextBytes = decodeBase64Strict(result.data_base64, 'daemon data_base64');
1631
+ if (plaintextBytes.length !== result.plaintext_size) {
1632
+ throw new Error(`Daemon returned mismatched plaintext size: expected ${result.plaintext_size}, got ${plaintextBytes.length}`);
1633
+ }
1634
+ log.debug('recv-msg v1 decrypt result', {
1635
+ principal,
1636
+ plaintextSize: plaintextBytes.length,
1637
+ ibeIdentity: parsed.ibeIdentity,
1638
+ });
1639
+ return plaintextBytes;
1640
+ }
1641
+ async function decryptKind17V2Content(principal, envelope, parsed) {
1642
+ log.debug('recv-msg v2 wrapped-key decrypt start', {
1643
+ msgId: envelope.id,
1644
+ principal,
1645
+ });
1646
+ const wrappedKeyResponse = await sendRpcToSocket(socketPath(principal), {
1647
+ id: 1,
1648
+ method: 'ibe-decrypt',
1649
+ params: {
1650
+ ibe_identity: `${principal}:Mail`,
1651
+ ciphertext_base64: parsed.wrappedKeyBase64,
1652
+ },
1653
+ });
1654
+ if (wrappedKeyResponse.error) {
1655
+ throw new Error(`Daemon decryption failed: ${wrappedKeyResponse.error}`);
1656
+ }
1657
+ const wrappedKeyResult = wrappedKeyResponse.result;
1658
+ if (!wrappedKeyResult
1659
+ || typeof wrappedKeyResult.data_base64 !== 'string'
1660
+ || typeof wrappedKeyResult.plaintext_size !== 'number') {
1661
+ throw new Error('Daemon returned an invalid wrapped-key decrypt result');
1662
+ }
1663
+ const bodyKey = decodeBase64Strict(wrappedKeyResult.data_base64, 'daemon data_base64');
1664
+ if (bodyKey.length !== wrappedKeyResult.plaintext_size) {
1665
+ throw new Error(`Daemon returned mismatched wrapped-key size: expected ${wrappedKeyResult.plaintext_size}, got ${bodyKey.length}`);
1666
+ }
1667
+ log.debug('recv-msg v2 wrapped-key decrypt result', {
1668
+ msgId: envelope.id,
1669
+ principal,
1670
+ wrappedKeySize: bodyKey.length,
1671
+ });
1672
+ const iv = decodeBase64Strict(parsed.content.iv, 'content.iv');
1673
+ const rawCiphertext = decodeBase64Strict(parsed.content.ciphertext, 'content.ciphertext');
1674
+ const { ciphertext, tag } = splitCiphertextAndTag(rawCiphertext);
1675
+ const plaintext = cryptoOps.aes256GcmDecryptRaw(bodyKey, ciphertext, iv, tag, {
1676
+ aad: buildKind17V2BodyAad({
1677
+ kind: 17,
1678
+ ai_id: envelope.ai_id,
1679
+ created_at: envelope.created_at,
1680
+ tags: envelope.tags,
1681
+ }, parsed.payloadType),
1682
+ });
1683
+ log.debug('recv-msg v2 body decrypt result', {
1684
+ msgId: envelope.id,
1685
+ principal,
1686
+ ivSize: iv.length,
1687
+ ciphertextSize: ciphertext.length,
1688
+ plaintextSize: plaintext.length,
1689
+ });
1690
+ return plaintext;
1691
+ }
1467
1692
  // ── Message Input Parsing ────────────────────────────────────────────────────
1468
1693
  function readMessageInput(text, file) {
1469
1694
  if (text && file)
@@ -1526,8 +1751,10 @@ function parseKind17Envelope(dataStr) {
1526
1751
  if (!Array.isArray(e.tags)) {
1527
1752
  throw new Error('Invalid envelope: tags must be an array');
1528
1753
  }
1529
- if (typeof e.content !== 'string' || e.content.length === 0) {
1530
- throw new Error('Invalid envelope: content must be a non-empty string');
1754
+ const contentValid = ((typeof e.content === 'string' && e.content.length > 0)
1755
+ || (e.content !== null && typeof e.content === 'object' && !Array.isArray(e.content)));
1756
+ if (!contentValid) {
1757
+ throw new Error('Invalid envelope: content must be a non-empty string or object');
1531
1758
  }
1532
1759
  if (typeof e.sig !== 'string' || e.sig.length === 0) {
1533
1760
  throw new Error('Invalid envelope: sig must be a non-empty string');