@zcloak/ai-agent 1.0.18 → 1.0.20

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
@@ -23,17 +23,31 @@
23
23
  *
24
24
  * Usage: zcloak-ai vetkey <sub-command> [options]
25
25
  */
26
- import { readFileSync, statSync, writeFileSync } from 'fs';
27
- import { basename } from 'path';
26
+ import { readFileSync, statSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from 'fs';
27
+ import { basename, dirname } from 'path';
28
28
  import { createConnection } from 'net';
29
+ import { spawn } from 'child_process';
30
+ import { homedir } from 'os';
31
+ import { join } from 'path';
32
+ import { fileURLToPath } from 'url';
29
33
  import { createInterface } from 'readline';
30
- import { createPublicKey, createVerify } from 'crypto';
34
+ import { createHash } from 'crypto';
31
35
  import { Principal } from '@dfinity/principal';
36
+ import { schnorr } from '@noble/curves/secp256k1';
37
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
32
38
  import * as cryptoOps from './crypto.js';
33
39
  import { KeyStore } from './key-store.js';
34
40
  import { runDaemonUds, runDaemonStdio } from './serve.js';
35
- import { findRunningDaemon } from './daemon.js';
41
+ import { findRunningDaemon, isDaemonAlive, socketPath } from './daemon.js';
36
42
  import { canisterCallError } from './error.js';
43
+ /**
44
+ * Absolute path to cli.js (the CLI entry script).
45
+ * Used by spawnDaemonBackground() to spawn daemon child processes via
46
+ * `process.execPath` (the current Node binary) + this script path, so that
47
+ * daemon spawning works regardless of how the CLI was invoked (global install,
48
+ * npx, node dist/cli.js, etc.).
49
+ */
50
+ const CLI_ENTRY_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'cli.js');
37
51
  // ============================================================================
38
52
  // Module Entry Point
39
53
  // ============================================================================
@@ -113,7 +127,7 @@ function showHelp() {
113
127
  console.log(' grants-in List grants you received (as grantee)');
114
128
  console.log('');
115
129
  console.log('Encrypted Messaging:');
116
- console.log(' send-msg Encrypt a message for a recipient (IBE)');
130
+ console.log(' send-msg Encrypt + auto-deliver via zMail (IBE)');
117
131
  console.log(' recv-msg Decrypt a received message via Mail daemon');
118
132
  console.log('');
119
133
  console.log('Options:');
@@ -133,6 +147,8 @@ function showHelp() {
133
147
  console.log(' --grant-id=<id> Grant ID (for revoke)');
134
148
  console.log(' --to=<AI-ID|principal> Recipient AI-ID or principal (for send-msg)');
135
149
  console.log(' --data=<json> Encrypted message JSON envelope (for recv-msg)');
150
+ console.log(' --no-zmail Skip auto-POST to zMail (send-msg only)');
151
+ console.log(' --zmail-url=<url> Override zMail server URL');
136
152
  }
137
153
  // ============================================================================
138
154
  // Command Implementations
@@ -475,6 +491,134 @@ async function cmdStatus(session) {
475
491
  }
476
492
  }
477
493
  // ============================================================================
494
+ // Daemon Auto-Start
495
+ // ============================================================================
496
+ /** Maximum time to wait for the daemon to become ready (ms) */
497
+ const DAEMON_READY_TIMEOUT_MS = 30000;
498
+ /** Polling interval when waiting for daemon socket to appear (ms) */
499
+ const DAEMON_POLL_INTERVAL_MS = 500;
500
+ /** Key names for the two standard daemons that should always be kept alive */
501
+ const STANDARD_DAEMON_KEY_NAMES = ['default', 'Mail'];
502
+ /**
503
+ * Spawn a daemon process in the background for the given key name.
504
+ *
505
+ * The child process is fully detached (survives parent exit) with stderr
506
+ * redirected to a log file under ~/.vetkey-tool/.
507
+ *
508
+ * @param pemPath - Path to the identity PEM file
509
+ * @param keyName - Daemon key name (e.g. "default", "Mail")
510
+ * @returns The child process PID (or undefined if spawn failed)
511
+ */
512
+ function spawnDaemonBackground(pemPath, keyName) {
513
+ const logDir = join(homedir(), '.vetkey-tool');
514
+ const logPath = join(logDir, `${keyName.toLowerCase()}-daemon.log`);
515
+ try {
516
+ mkdirSync(logDir, { recursive: true });
517
+ }
518
+ catch {
519
+ // Best effort — directory may already exist
520
+ }
521
+ let logFd;
522
+ try {
523
+ logFd = openSync(logPath, 'a');
524
+ }
525
+ catch {
526
+ return undefined;
527
+ }
528
+ try {
529
+ // Use process.execPath (current Node binary) + CLI_ENTRY_SCRIPT (absolute
530
+ // path to cli.js) instead of 'zcloak-ai'. This ensures daemon spawning
531
+ // works regardless of how the CLI was invoked (global install, npx, or
532
+ // direct `node dist/cli.js`), avoiding ENOENT when 'zcloak-ai' is not on PATH.
533
+ const child = spawn(process.execPath, [
534
+ CLI_ENTRY_SCRIPT,
535
+ 'vetkey', 'serve', `--key-name=${keyName}`, `--identity=${pemPath}`,
536
+ ], {
537
+ detached: true,
538
+ stdio: ['ignore', 'ignore', logFd],
539
+ });
540
+ // Must listen for 'error' to prevent unhandled exceptions from crashing
541
+ // the parent process.
542
+ child.on('error', () => { });
543
+ child.unref();
544
+ closeSync(logFd);
545
+ return child.pid;
546
+ }
547
+ catch {
548
+ closeSync(logFd);
549
+ return undefined;
550
+ }
551
+ }
552
+ /**
553
+ * Ensure a daemon with the given key name is running for the session's principal.
554
+ *
555
+ * If the daemon is already alive, returns the socket path immediately.
556
+ * Otherwise spawns a background `zcloak-ai vetkey serve` process and
557
+ * polls until the socket file appears or the timeout is reached.
558
+ *
559
+ * @param session - CLI session (provides identity / PEM path)
560
+ * @param keyName - Daemon key name (e.g. "default", "Mail")
561
+ * @returns Socket path of the running daemon
562
+ * @throws Error if the daemon fails to start within the timeout
563
+ */
564
+ async function ensureDaemon(session, keyName) {
565
+ const principal = session.getPrincipal();
566
+ const derivationId = `${principal}:${keyName}`;
567
+ // Already running? Return immediately.
568
+ if (isDaemonAlive(derivationId)) {
569
+ return socketPath(derivationId);
570
+ }
571
+ console.error(`[zcloak-ai] ${keyName} daemon is not running. Starting it automatically...`);
572
+ const pid = spawnDaemonBackground(session.getPemPath(), keyName);
573
+ console.error(`[zcloak-ai] ${keyName} daemon spawned (PID: ${pid ?? 'unknown'}). Waiting for ready...`);
574
+ // Poll for the socket file to appear (daemon writes PID + creates socket on ready)
575
+ const sock = socketPath(derivationId);
576
+ const logPath = join(homedir(), '.vetkey-tool', `${keyName.toLowerCase()}-daemon.log`);
577
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
578
+ while (Date.now() < deadline) {
579
+ await new Promise((resolve) => setTimeout(resolve, DAEMON_POLL_INTERVAL_MS));
580
+ if (isDaemonAlive(derivationId) && existsSync(sock)) {
581
+ console.error(`[zcloak-ai] ${keyName} daemon is ready. Socket: ${sock}`);
582
+ return sock;
583
+ }
584
+ }
585
+ throw new Error(`${keyName} daemon failed to start within ${DAEMON_READY_TIMEOUT_MS / 1000}s. ` +
586
+ `Check the log at ${logPath} for details.`);
587
+ }
588
+ /**
589
+ * Background daemon health check — fire-and-forget.
590
+ *
591
+ * Called by cli.ts after Session creation to keep both standard daemons
592
+ * ("default" and "Mail") alive. If a daemon is dead, spawns it in the
593
+ * background WITHOUT waiting for it to be ready (non-blocking).
594
+ *
595
+ * Prerequisites:
596
+ * - The PEM file must exist (user has already created an identity)
597
+ * - If PEM doesn't exist, silently skips (no identity = no daemon possible)
598
+ *
599
+ * All errors are silently swallowed — this is a best-effort health check
600
+ * and must never block or fail the main command.
601
+ *
602
+ * @param pemPath - Path to the identity PEM file
603
+ * @param principal - The principal ID derived from the PEM
604
+ */
605
+ export function ensureDaemonsBackground(pemPath, principal) {
606
+ for (const keyName of STANDARD_DAEMON_KEY_NAMES) {
607
+ const derivationId = `${principal}:${keyName}`;
608
+ try {
609
+ if (!isDaemonAlive(derivationId)) {
610
+ const pid = spawnDaemonBackground(pemPath, keyName);
611
+ if (pid) {
612
+ console.error(`[zcloak-ai] ${keyName} daemon was not running — auto-started (PID: ${pid})`);
613
+ }
614
+ }
615
+ }
616
+ catch {
617
+ // Silently ignore — daemon health check must never block the main command
618
+ }
619
+ }
620
+ }
621
+ // ============================================================================
478
622
  // Helper Functions
479
623
  // ============================================================================
480
624
  /**
@@ -805,18 +949,109 @@ function printGrants(grants, direction) {
805
949
  // ============================================================================
806
950
  /** Maximum plaintext payload for encrypted messages (64 KB) */
807
951
  const MAX_MSG_PAYLOAD = 64 * 1024;
808
- /** Domain separator used for sender signature over the message envelope */
809
- const MAIL_SIGNATURE_DOMAIN = 'zcloak-mail-envelope';
952
+ // ── Kind17 Envelope Helpers ──────────────────────────────────────────────────
953
+ /**
954
+ * NFC-normalize text and convert Windows line endings to Unix.
955
+ * Matches zMail's canonicalization.
956
+ */
957
+ function normalizeText(value) {
958
+ return value.replace(/\r\n?/g, '\n').normalize('NFC');
959
+ }
960
+ /**
961
+ * Deep-canonicalize a value: sort object keys alphabetically, NFC-normalize strings.
962
+ * Produces deterministic JSON for ID computation.
963
+ */
964
+ function canonicalize(value) {
965
+ if (typeof value === 'string')
966
+ return normalizeText(value);
967
+ if (Array.isArray(value))
968
+ return value.map((item) => canonicalize(item));
969
+ if (value !== null && typeof value === 'object') {
970
+ const out = {};
971
+ for (const [key, nested] of Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
972
+ out[normalizeText(key)] = canonicalize(nested);
973
+ }
974
+ return out;
975
+ }
976
+ return value;
977
+ }
810
978
  /**
811
- * send-msg: Encrypt a message for a recipient using IBE.
979
+ * Compute the Kind17 envelope ID: SHA-256 of canonical serialization.
980
+ * Format: [0, ai_id, created_at, 17, tags, content]
981
+ */
982
+ function computeEnvelopeId(envelope) {
983
+ const payload = [
984
+ 0,
985
+ normalizeText(envelope.ai_id),
986
+ envelope.created_at,
987
+ 17,
988
+ canonicalize(envelope.tags),
989
+ canonicalize(envelope.content),
990
+ ];
991
+ return createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('hex');
992
+ }
993
+ /**
994
+ * Extract the raw 32-byte secp256k1 private key from the session identity.
995
+ * The Secp256k1KeyIdentity.toJSON() returns [publicKeyHex, privateKeyHex].
996
+ */
997
+ export function extractPrivateKeyHex(session) {
998
+ const identity = session.getIdentity();
999
+ const json = identity.toJSON();
1000
+ return json[1];
1001
+ }
1002
+ /**
1003
+ * DER prefix for an uncompressed secp256k1 SPKI public key (23 bytes).
1004
+ * SPKI layout: prefix(23) || 04(1) || x(32) || y(32) = 88 bytes total.
1005
+ * Hex offset of x-coordinate: 48 (23*2 + 2), length: 64 (32*2).
1006
+ */
1007
+ const SPKI_X_OFFSET = 48;
1008
+ const SPKI_X_LENGTH = 64;
1009
+ /**
1010
+ * Extract the BIP-340 Schnorr public key (x-only, 32 bytes hex) from an SPKI hex string.
1011
+ * The x-coordinate sits at a fixed offset in the uncompressed secp256k1 SPKI DER structure.
1012
+ */
1013
+ export function schnorrPubkeyFromSpki(spkiHex) {
1014
+ return spkiHex.slice(SPKI_X_OFFSET, SPKI_X_OFFSET + SPKI_X_LENGTH);
1015
+ }
1016
+ /**
1017
+ * Build, sign, and return a complete Kind17 envelope.
1018
+ *
1019
+ * The sender's SPKI public key is included as a ["from_pubkey", spkiHex] tag
1020
+ * to enable self-contained sender verification: receivers can derive both the
1021
+ * ICP principal (to bind ai_id) and the Schnorr pubkey (to verify sig) from it.
1022
+ *
1023
+ * @param session - CLI session (provides identity for Schnorr signing)
1024
+ * @param tags - Envelope tags (must include at least one ["to", ...])
1025
+ * @param content - Encrypted content string (base64 IBE ciphertext)
1026
+ * @returns Signed Kind17Envelope ready for JSON serialization
1027
+ */
1028
+ function buildSignedEnvelope(session, tags, content) {
1029
+ const senderPrincipal = session.getPrincipal();
1030
+ const createdAt = Math.floor(Date.now() / 1000);
1031
+ // Include the sender's SPKI public key for receiver-side verification.
1032
+ // This enables binding ai_id ↔ pubkey ↔ sig without registry lookups.
1033
+ const senderIdentity = session.getIdentity();
1034
+ const senderSpkiHex = Buffer.from(senderIdentity.getPublicKey().toDer()).toString('hex');
1035
+ const allTags = [...tags, ['from_pubkey', senderSpkiHex]];
1036
+ // Compute the envelope ID from canonical serialization
1037
+ const partial = { kind: 17, ai_id: senderPrincipal, created_at: createdAt, tags: allTags, content };
1038
+ const id = computeEnvelopeId(partial);
1039
+ // BIP-340 Schnorr sign the ID hash
1040
+ const privateKeyHex = extractPrivateKeyHex(session);
1041
+ const sig = bytesToHex(schnorr.sign(id, privateKeyHex));
1042
+ return { id, kind: 17, ai_id: senderPrincipal, created_at: createdAt, tags: allTags, content, sig };
1043
+ }
1044
+ /**
1045
+ * send-msg: Encrypt a message for a recipient using IBE and output a Kind17 envelope.
812
1046
  *
813
1047
  * The recipient's Mail identity is "{recipient_principal}:Mail".
814
1048
  * The sender fetches the IBE public key from canister, encrypts locally,
815
- * and outputs a JSON envelope for transport.
1049
+ * builds a Kind17 envelope with BIP-340 Schnorr signature, and outputs JSON.
816
1050
  *
817
1051
  * Options:
818
1052
  * --to=<AI-ID or principal> (required) Recipient identifier
819
- * --text=<content> (required) Message to encrypt
1053
+ * --text=<content> Text message to encrypt
1054
+ * --file=<path> File to encrypt
820
1055
  * --json Output in JSON format (default: true for send-msg)
821
1056
  */
822
1057
  async function cmdSendMsg(session) {
@@ -834,16 +1069,13 @@ async function cmdSendMsg(session) {
834
1069
  // Resolve recipient: if it looks like an agent name (contains # and .agent),
835
1070
  // resolve to principal via registry; otherwise treat as raw principal.
836
1071
  let recipientPrincipal;
837
- let recipientDisplay;
838
1072
  if (to.includes('#') && to.includes('.agent')) {
839
- // Resolve AI-ID → principal
840
1073
  const registryActor = await session.getAnonymousRegistryActor();
841
1074
  const result = await registryActor.get_user_principal(to);
842
1075
  if (!result || result.length === 0) {
843
1076
  throw new Error(`Cannot resolve AI-ID "${to}" — agent not found in registry`);
844
1077
  }
845
1078
  recipientPrincipal = result[0].toText();
846
- recipientDisplay = to;
847
1079
  }
848
1080
  else {
849
1081
  try {
@@ -852,7 +1084,6 @@ async function cmdSendMsg(session) {
852
1084
  catch {
853
1085
  throw new Error(`Invalid recipient principal: "${to}"`);
854
1086
  }
855
- recipientDisplay = to;
856
1087
  }
857
1088
  // IBE identity for recipient's mailbox
858
1089
  const ibeIdentity = `${recipientPrincipal}:Mail`;
@@ -866,36 +1097,52 @@ async function cmdSendMsg(session) {
866
1097
  catch (e) {
867
1098
  throw canisterCallError(`get_ibe_public_key failed: ${e instanceof Error ? e.message : String(e)}`, e);
868
1099
  }
869
- // IBE-encrypt the message for the recipient's Mail identity
1100
+ // IBE-encrypt the plaintext for the recipient's Mail identity
870
1101
  const ciphertext = cryptoOps.ibeEncrypt(dpkBytes, ibeIdentity, plaintext);
871
- const senderIdentity = session.getIdentity();
872
- const senderPrincipal = senderIdentity.getPrincipal().toText();
873
- const senderPublicKeyDer = Buffer.from(senderIdentity.getPublicKey().toDer());
874
- const envelope = {
875
- from: senderPrincipal,
876
- from_pubkey: senderPublicKeyDer.toString('hex'),
877
- to: recipientDisplay,
878
- payload_type: payloadType,
879
- filename,
880
- ibe_id: ibeIdentity,
881
- ct: Buffer.from(ciphertext).toString('base64'),
882
- ts: Date.now(),
883
- sig: '',
884
- };
885
- const signature = await senderIdentity.sign(serializeEnvelopeForSigning(envelope));
886
- envelope.sig = Buffer.from(signature).toString('base64');
1102
+ const contentBase64 = Buffer.from(ciphertext).toString('base64');
1103
+ // Build tags: recipient + metadata
1104
+ const tags = [
1105
+ ['to', recipientPrincipal],
1106
+ ['payload_type', payloadType],
1107
+ ['ibe_id', ibeIdentity],
1108
+ ];
1109
+ if (filename) {
1110
+ tags.push(['filename', filename]);
1111
+ }
1112
+ // Build and sign the Kind17 envelope
1113
+ const envelope = buildSignedEnvelope(session, tags, contentBase64);
887
1114
  // Output the envelope as JSON (always JSON for machine consumption)
888
1115
  console.log(JSON.stringify(envelope));
1116
+ // Auto-POST to zMail if not explicitly disabled with --no-zmail.
1117
+ // Failure only warns via stderr — the envelope is already output to stdout.
1118
+ if (session.args['no-zmail'] !== true) {
1119
+ try {
1120
+ const zmailUrlFlag = session.args['zmail-url'];
1121
+ const zmailUrlEnv = process.env['ZMAIL_URL'];
1122
+ const zmailUrl = (typeof zmailUrlFlag === 'string' && zmailUrlFlag.length > 0)
1123
+ ? zmailUrlFlag.replace(/\/+$/, '')
1124
+ : (zmailUrlEnv && zmailUrlEnv.length > 0)
1125
+ ? zmailUrlEnv.replace(/\/+$/, '')
1126
+ : (await import('./config.js')).default.zmail_url;
1127
+ const { postEnvelopeToZmail } = await import('./zmail.js');
1128
+ const result = await postEnvelopeToZmail(zmailUrl, envelope);
1129
+ console.error(`zMail: delivered (msg_id=${result.msg_id}, to=${result.delivered_to})`);
1130
+ }
1131
+ catch (err) {
1132
+ console.error(`zMail: delivery failed — ${err instanceof Error ? err.message : String(err)}`);
1133
+ }
1134
+ }
889
1135
  }
890
1136
  /**
891
- * recv-msg: Decrypt a received encrypted message via the Mail daemon.
1137
+ * recv-msg: Decrypt a received Kind17 encrypted message via the Mail daemon.
892
1138
  *
893
1139
  * The recipient must have a running daemon with key-name="Mail".
894
1140
  * The daemon holds the VetKey for "{recipient_principal}:Mail" and
895
1141
  * performs IBE decryption via the "ibe-decrypt" RPC method.
896
1142
  *
897
1143
  * Options:
898
- * --data=<json> (required) Encrypted message JSON envelope
1144
+ * --data=<json> (required) Kind17 envelope JSON
1145
+ * --output=<path> Write decrypted file payload to this path
899
1146
  * --json Output in JSON format
900
1147
  */
901
1148
  async function cmdRecvMsg(session) {
@@ -912,22 +1159,30 @@ async function cmdRecvMsg(session) {
912
1159
  if (!dataStr) {
913
1160
  throw new Error('--data=<json_envelope> is required');
914
1161
  }
915
- // Connect to the Mail daemon to perform IBE decryption
916
- const envelope = parseEncryptedMessageEnvelope(dataStr);
1162
+ // Parse and validate the Kind17 envelope
1163
+ const envelope = parseKind17Envelope(dataStr);
917
1164
  const principal = session.getPrincipal();
918
1165
  const derivationId = `${principal}:Mail`;
919
- if (envelope.ibe_id !== derivationId) {
920
- throw new Error(`Envelope is addressed to "${envelope.ibe_id}", but current recipient is "${derivationId}"`);
921
- }
922
- verifyEnvelopeSignature(envelope);
923
- const sockPath = findRunningDaemon(derivationId);
924
- // Send ibe-decrypt RPC to daemon
1166
+ // Extract metadata from tags
1167
+ const ibeId = getTagValue(envelope.tags, 'ibe_id') ?? derivationId;
1168
+ const payloadType = (getTagValue(envelope.tags, 'payload_type') ?? 'text');
1169
+ const filename = getTagValue(envelope.tags, 'filename');
1170
+ // Verify this envelope is addressed to us
1171
+ const recipients = envelope.tags.filter(t => t[0] === 'to').map(t => t[1]);
1172
+ if (!recipients.includes(principal)) {
1173
+ throw new Error(`Envelope is not addressed to "${principal}" — recipients: ${recipients.join(', ')}`);
1174
+ }
1175
+ // Verify envelope integrity and sender authentication.
1176
+ // Returns true only if full Schnorr signature + principal binding was verified.
1177
+ const verifiedSender = verifyKind17Signature(envelope);
1178
+ // Ensure Mail daemon is running (auto-start if needed, wait for ready), then decrypt
1179
+ const sockPath = await ensureDaemon(session, 'Mail');
925
1180
  const response = await sendRpcToSocket(sockPath, {
926
1181
  id: 1,
927
1182
  method: 'ibe-decrypt',
928
1183
  params: {
929
- ibe_identity: envelope.ibe_id,
930
- ciphertext_base64: envelope.ct,
1184
+ ibe_identity: ibeId,
1185
+ ciphertext_base64: envelope.content,
931
1186
  },
932
1187
  });
933
1188
  if (response.error) {
@@ -941,64 +1196,57 @@ async function cmdRecvMsg(session) {
941
1196
  if (plaintextBytes.length !== result.plaintext_size) {
942
1197
  throw new Error(`Daemon returned mismatched plaintext size: expected ${result.plaintext_size}, got ${plaintextBytes.length}`);
943
1198
  }
944
- const shouldWriteFile = envelope.payload_type === 'file' || !!output;
1199
+ // Determine output target
1200
+ const shouldWriteFile = payloadType === 'file' || !!output;
945
1201
  const resolvedOutput = shouldWriteFile
946
- ? (output ?? defaultReceivedPath(envelope))
1202
+ ? (output ?? defaultReceivedPath(payloadType, filename, envelope.created_at))
947
1203
  : undefined;
948
1204
  if (resolvedOutput) {
949
1205
  writeFileSync(resolvedOutput, plaintextBytes);
950
1206
  }
1207
+ // Format output
951
1208
  if (jsonOutput) {
952
1209
  const base = {
953
- from: envelope.from,
954
- to: envelope.to,
955
- payload_type: envelope.payload_type,
956
- filename: envelope.filename,
957
- ibe_identity: envelope.ibe_id,
958
- verified_sender: true,
1210
+ from: envelope.ai_id,
1211
+ to: recipients,
1212
+ payload_type: payloadType,
1213
+ filename,
1214
+ verified_sender: verifiedSender,
959
1215
  plaintext_size: result.plaintext_size,
960
- timestamp: envelope.ts,
1216
+ timestamp: envelope.created_at,
961
1217
  };
962
1218
  if (resolvedOutput) {
963
- console.log(JSON.stringify({
964
- ...base,
965
- output_file: resolvedOutput,
966
- }));
1219
+ console.log(JSON.stringify({ ...base, output_file: resolvedOutput }));
967
1220
  }
968
1221
  else {
969
- console.log(JSON.stringify({
970
- ...base,
971
- plaintext: plaintextBytes.toString('utf-8'),
972
- }));
1222
+ console.log(JSON.stringify({ ...base, plaintext: plaintextBytes.toString('utf-8') }));
973
1223
  }
974
1224
  }
975
1225
  else if (resolvedOutput) {
976
1226
  console.log('Decrypted message:');
977
- console.log(` From: ${envelope.from}`);
978
- console.log(` To: ${envelope.to}`);
979
- console.log(` Identity: ${envelope.ibe_id}`);
980
- console.log(` Verified: yes`);
981
- console.log(` Payload Type: ${envelope.payload_type}`);
982
- if (envelope.filename) {
983
- console.log(` File Name: ${envelope.filename}`);
984
- }
985
- console.log(` Time: ${new Date(envelope.ts).toISOString()}`);
1227
+ console.log(` From: ${envelope.ai_id}`);
1228
+ console.log(` To: ${recipients.join(', ')}`);
1229
+ console.log(` Verified: ${verifiedSender ? 'yes' : 'id-only'}`);
1230
+ console.log(` Payload Type: ${payloadType}`);
1231
+ if (filename)
1232
+ console.log(` File Name: ${filename}`);
1233
+ console.log(` Time: ${new Date(envelope.created_at * 1000).toISOString()}`);
986
1234
  console.log(` Size: ${result.plaintext_size} bytes`);
987
1235
  console.log(` Output File: ${resolvedOutput}`);
988
1236
  }
989
1237
  else {
990
1238
  console.log('Decrypted message:');
991
- console.log(` From: ${envelope.from}`);
992
- console.log(` To: ${envelope.to}`);
993
- console.log(` Identity: ${envelope.ibe_id}`);
994
- console.log(` Verified: yes`);
995
- console.log(` Payload Type: ${envelope.payload_type}`);
996
- console.log(` Time: ${new Date(envelope.ts).toISOString()}`);
1239
+ console.log(` From: ${envelope.ai_id}`);
1240
+ console.log(` To: ${recipients.join(', ')}`);
1241
+ console.log(` Verified: ${verifiedSender ? 'yes' : 'id-only'}`);
1242
+ console.log(` Payload Type: ${payloadType}`);
1243
+ console.log(` Time: ${new Date(envelope.created_at * 1000).toISOString()}`);
997
1244
  console.log(` Size: ${result.plaintext_size} bytes`);
998
1245
  console.log(' Content:');
999
1246
  console.log(plaintextBytes.toString('utf-8'));
1000
1247
  }
1001
1248
  }
1249
+ // ── Message Input Parsing ────────────────────────────────────────────────────
1002
1250
  function readMessageInput(text, file) {
1003
1251
  if (text && file)
1004
1252
  throw new Error('Cannot specify both --text and --file');
@@ -1011,10 +1259,7 @@ function readMessageInput(text, file) {
1011
1259
  if (plaintext.length > MAX_MSG_PAYLOAD) {
1012
1260
  throw new Error(`Message too large: ${plaintext.length} bytes (max ${MAX_MSG_PAYLOAD} bytes)`);
1013
1261
  }
1014
- return {
1015
- plaintext,
1016
- payloadType: 'text',
1017
- };
1262
+ return { plaintext, payloadType: 'text' };
1018
1263
  }
1019
1264
  if (typeof file === 'string') {
1020
1265
  const stat = statSync(file);
@@ -1032,98 +1277,117 @@ function readMessageInput(text, file) {
1032
1277
  }
1033
1278
  throw new Error('Either --text or --file must be provided');
1034
1279
  }
1035
- function parseEncryptedMessageEnvelope(dataStr) {
1280
+ // ── Kind17 Envelope Parsing & Verification ───────────────────────────────────
1281
+ /**
1282
+ * Parse a JSON string into a Kind17Envelope, validating all required fields.
1283
+ */
1284
+ function parseKind17Envelope(dataStr) {
1036
1285
  let raw;
1037
1286
  try {
1038
1287
  raw = JSON.parse(dataStr);
1039
1288
  }
1040
1289
  catch {
1041
- throw new Error('Invalid message envelope (expected JSON)');
1290
+ throw new Error('Invalid Kind17 envelope (expected JSON)');
1042
1291
  }
1043
1292
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
1044
- throw new Error('Invalid message envelope: expected an object');
1045
- }
1046
- const envelope = raw;
1047
- if (typeof envelope.from !== 'string' || envelope.from.length === 0) {
1048
- throw new Error('Invalid envelope: from must be a non-empty string');
1293
+ throw new Error('Invalid Kind17 envelope: expected an object');
1049
1294
  }
1050
- if (typeof envelope.from_pubkey !== 'string' || envelope.from_pubkey.length === 0) {
1051
- throw new Error('Invalid envelope: from_pubkey must be a non-empty string');
1295
+ const e = raw;
1296
+ if (typeof e.id !== 'string' || e.id.length === 0) {
1297
+ throw new Error('Invalid envelope: id must be a non-empty string');
1052
1298
  }
1053
- if (typeof envelope.to !== 'string' || envelope.to.length === 0) {
1054
- throw new Error('Invalid envelope: to must be a non-empty string');
1299
+ if (e.kind !== 17) {
1300
+ throw new Error('Invalid envelope: kind must be 17');
1055
1301
  }
1056
- if (envelope.payload_type !== 'text' && envelope.payload_type !== 'file') {
1057
- throw new Error('Invalid envelope: payload_type must be "text" or "file"');
1302
+ if (typeof e.ai_id !== 'string' || e.ai_id.length === 0) {
1303
+ throw new Error('Invalid envelope: ai_id must be a non-empty string');
1058
1304
  }
1059
- if (typeof envelope.ibe_id !== 'string' || envelope.ibe_id.length === 0) {
1060
- throw new Error('Invalid envelope: ibe_id must be a non-empty string');
1305
+ if (typeof e.created_at !== 'number' || !Number.isInteger(e.created_at) || e.created_at <= 0) {
1306
+ throw new Error('Invalid envelope: created_at must be a positive integer (unix seconds)');
1061
1307
  }
1062
- if (typeof envelope.ct !== 'string' || envelope.ct.length === 0) {
1063
- throw new Error('Invalid envelope: ct must be a non-empty string');
1308
+ if (!Array.isArray(e.tags)) {
1309
+ throw new Error('Invalid envelope: tags must be an array');
1064
1310
  }
1065
- if (typeof envelope.ts !== 'number' || !Number.isFinite(envelope.ts) || envelope.ts <= 0) {
1066
- throw new Error('Invalid envelope: ts must be a positive number');
1311
+ if (typeof e.content !== 'string' || e.content.length === 0) {
1312
+ throw new Error('Invalid envelope: content must be a non-empty string');
1067
1313
  }
1068
- if (typeof envelope.sig !== 'string' || envelope.sig.length === 0) {
1314
+ if (typeof e.sig !== 'string' || e.sig.length === 0) {
1069
1315
  throw new Error('Invalid envelope: sig must be a non-empty string');
1070
1316
  }
1071
- if (envelope.payload_type === 'file') {
1072
- if (typeof envelope.filename !== 'string' || envelope.filename.length === 0) {
1073
- throw new Error('Invalid envelope: filename is required for file payloads');
1074
- }
1075
- }
1076
- else if (envelope.filename !== undefined) {
1077
- throw new Error('Invalid envelope: filename is only allowed for file payloads');
1317
+ // Validate that there is at least one ["to", ...] tag
1318
+ const hasRecipient = e.tags.some((tag) => Array.isArray(tag) && tag[0] === 'to' && typeof tag[1] === 'string' && tag[1].length > 0);
1319
+ if (!hasRecipient) {
1320
+ throw new Error('Invalid envelope: must have at least one ["to", principal] tag');
1078
1321
  }
1079
1322
  return {
1080
- from: envelope.from,
1081
- from_pubkey: envelope.from_pubkey,
1082
- to: envelope.to,
1083
- payload_type: envelope.payload_type,
1084
- filename: envelope.filename,
1085
- ibe_id: envelope.ibe_id,
1086
- ct: envelope.ct,
1087
- ts: envelope.ts,
1088
- sig: envelope.sig,
1323
+ id: e.id,
1324
+ kind: 17,
1325
+ ai_id: e.ai_id,
1326
+ created_at: e.created_at,
1327
+ tags: e.tags,
1328
+ content: e.content,
1329
+ sig: e.sig,
1089
1330
  };
1090
1331
  }
1091
- function serializeEnvelopeForSigning(envelope) {
1092
- const payload = JSON.stringify({
1093
- from: envelope.from,
1094
- from_pubkey: envelope.from_pubkey,
1095
- to: envelope.to,
1096
- payload_type: envelope.payload_type,
1097
- filename: envelope.filename,
1098
- ibe_id: envelope.ibe_id,
1099
- ct: envelope.ct,
1100
- ts: envelope.ts,
1332
+ /**
1333
+ * Verify the Schnorr signature on a Kind17 envelope.
1334
+ *
1335
+ * Three-step verification:
1336
+ * 1. Re-compute envelope ID from canonical serialization → must match `id`
1337
+ * 2. Derive ICP principal from SPKI in ["from_pubkey"] tag → must match `ai_id`
1338
+ * 3. Derive Schnorr pubkey from the same SPKI → verify BIP-340 signature over `id`
1339
+ *
1340
+ * If the envelope lacks a ["from_pubkey"] tag, only step 1 (ID integrity) is performed
1341
+ * and the function returns false to indicate that the sender is NOT authenticated.
1342
+ *
1343
+ * @returns true if full sender authentication succeeded, false if only ID integrity was checked
1344
+ */
1345
+ function verifyKind17Signature(envelope) {
1346
+ // Step 1: verify the ID matches the canonical serialization
1347
+ const computedId = computeEnvelopeId({
1348
+ kind: 17,
1349
+ ai_id: envelope.ai_id,
1350
+ created_at: envelope.created_at,
1351
+ tags: envelope.tags,
1352
+ content: envelope.content,
1101
1353
  });
1102
- return new TextEncoder().encode(`${MAIL_SIGNATURE_DOMAIN}\n${payload}`);
1354
+ if (computedId !== envelope.id) {
1355
+ throw new Error(`Envelope ID mismatch: computed "${computedId}" but envelope has "${envelope.id}"`);
1356
+ }
1357
+ // Step 2 + 3: verify sender identity binding and Schnorr signature
1358
+ const senderSpki = getTagValue(envelope.tags, 'from_pubkey');
1359
+ if (!senderSpki) {
1360
+ // No public key available — ID integrity is verified but sender is not authenticated.
1361
+ return false;
1362
+ }
1363
+ // Derive ICP principal from SPKI and verify it matches the claimed ai_id.
1364
+ // This prevents an attacker from signing with their own key while claiming someone else's ai_id.
1365
+ const derivedPrincipal = Principal.selfAuthenticating(Buffer.from(senderSpki, 'hex')).toText();
1366
+ if (derivedPrincipal !== envelope.ai_id) {
1367
+ throw new Error(`Sender identity mismatch: from_pubkey derives principal "${derivedPrincipal}" but ai_id is "${envelope.ai_id}"`);
1368
+ }
1369
+ // Derive Schnorr pubkey (x-coordinate) from the same SPKI and verify the signature
1370
+ const schnorrPubkeyHex = schnorrPubkeyFromSpki(senderSpki);
1371
+ const valid = schnorr.verify(hexToBytes(envelope.sig), hexToBytes(envelope.id), hexToBytes(schnorrPubkeyHex));
1372
+ if (!valid) {
1373
+ throw new Error('Envelope Schnorr signature verification failed');
1374
+ }
1375
+ return true;
1103
1376
  }
1104
- function verifyEnvelopeSignature(envelope) {
1105
- const publicKeyDer = decodeHexStrict(envelope.from_pubkey, 'from_pubkey');
1106
- const expectedPrincipal = Principal.selfAuthenticating(new Uint8Array(publicKeyDer)).toText();
1107
- if (expectedPrincipal !== envelope.from) {
1108
- throw new Error(`Envelope sender mismatch: from="${envelope.from}" does not match from_pubkey principal "${expectedPrincipal}"`);
1109
- }
1110
- const signature = decodeBase64Strict(envelope.sig, 'sig');
1111
- const verify = createVerify('sha256');
1112
- verify.update(serializeEnvelopeForSigning(envelope));
1113
- verify.end();
1114
- const publicKey = createPublicKey({
1115
- key: publicKeyDer,
1116
- format: 'der',
1117
- type: 'spki',
1118
- });
1119
- if (!verify.verify(publicKey, compactSignatureToDer(signature))) {
1120
- throw new Error('Envelope signature verification failed');
1121
- }
1377
+ /**
1378
+ * Get the first value for a given tag key from the tags array.
1379
+ */
1380
+ function getTagValue(tags, key) {
1381
+ const tag = tags.find((t) => t[0] === key);
1382
+ return tag ? tag[1] : undefined;
1122
1383
  }
1123
- function defaultReceivedPath(envelope) {
1124
- const safeName = envelope.payload_type === 'file'
1125
- ? sanitizeFilename(envelope.filename) ?? 'message.bin'
1126
- : `message-${envelope.ts}.txt`;
1384
+ /**
1385
+ * Generate a default output path for received file/text payloads.
1386
+ */
1387
+ function defaultReceivedPath(payloadType, filename, createdAt) {
1388
+ const safeName = payloadType === 'file'
1389
+ ? (sanitizeFilename(filename) ?? 'message.bin')
1390
+ : `message-${createdAt}.txt`;
1127
1391
  return `received_${Date.now()}_${safeName}`;
1128
1392
  }
1129
1393
  function sanitizeFilename(filePath) {
@@ -1135,29 +1399,6 @@ function sanitizeFilename(filePath) {
1135
1399
  }
1136
1400
  return name;
1137
1401
  }
1138
- function compactSignatureToDer(signature) {
1139
- if (signature.length !== 64) {
1140
- throw new Error(`Invalid signature length: expected 64 bytes, got ${signature.length}`);
1141
- }
1142
- const encodeInteger = (part) => {
1143
- let start = 0;
1144
- while (start < part.length - 1 && part[start] === 0) {
1145
- start += 1;
1146
- }
1147
- let value = Buffer.from(part.subarray(start));
1148
- if ((value[0] ?? 0) & 0x80) {
1149
- value = Buffer.concat([Buffer.from([0x00]), value]);
1150
- }
1151
- return Buffer.concat([Buffer.from([0x02, value.length]), value]);
1152
- };
1153
- const r = encodeInteger(signature.subarray(0, 32));
1154
- const s = encodeInteger(signature.subarray(32, 64));
1155
- const seqLen = r.length + s.length;
1156
- if (seqLen > 0x7f) {
1157
- throw new Error('DER signature too long');
1158
- }
1159
- return Buffer.concat([Buffer.from([0x30, seqLen]), r, s]);
1160
- }
1161
1402
  function decodeBase64Strict(value, fieldName) {
1162
1403
  if (value.length === 0) {
1163
1404
  return Buffer.alloc(0);
@@ -1167,10 +1408,4 @@ function decodeBase64Strict(value, fieldName) {
1167
1408
  }
1168
1409
  return Buffer.from(value, 'base64');
1169
1410
  }
1170
- function decodeHexStrict(value, fieldName) {
1171
- if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
1172
- throw new Error(`Invalid ${fieldName}: expected hex`);
1173
- }
1174
- return Buffer.from(value, 'hex');
1175
- }
1176
1411
  //# sourceMappingURL=vetkey.js.map