@zcloak/ai-agent 1.0.17 → 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/SKILL.md +764 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +48 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +18 -0
- package/dist/daemon.js +39 -0
- package/dist/daemon.js.map +1 -1
- package/dist/pre-check.d.ts +40 -0
- package/dist/pre-check.js +199 -0
- package/dist/pre-check.js.map +1 -0
- package/dist/register.js +64 -11
- package/dist/register.js.map +1 -1
- package/dist/types/config.d.ts +2 -0
- package/dist/types/registry.d.ts +1 -1
- package/dist/types/registry.js +1 -1
- package/dist/types/sign-event.d.ts +1 -1
- package/dist/types/sign-event.js +1 -1
- package/dist/vetkey.d.ts +54 -0
- package/dist/vetkey.js +412 -177
- package/dist/vetkey.js.map +1 -1
- package/dist/zmail.d.ts +46 -0
- package/dist/zmail.js +465 -0
- package/dist/zmail.js.map +1 -0
- package/package.json +6 -3
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 {
|
|
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
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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>
|
|
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
|
|
1100
|
+
// IBE-encrypt the plaintext for the recipient's Mail identity
|
|
870
1101
|
const ciphertext = cryptoOps.ibeEncrypt(dpkBytes, ibeIdentity, plaintext);
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
filename,
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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)
|
|
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
|
-
//
|
|
916
|
-
const envelope =
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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:
|
|
930
|
-
ciphertext_base64: envelope.
|
|
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
|
-
|
|
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.
|
|
954
|
-
to:
|
|
955
|
-
payload_type:
|
|
956
|
-
filename
|
|
957
|
-
|
|
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.
|
|
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.
|
|
978
|
-
console.log(` To: ${
|
|
979
|
-
console.log(`
|
|
980
|
-
console.log(`
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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.
|
|
992
|
-
console.log(` To: ${
|
|
993
|
-
console.log(`
|
|
994
|
-
console.log(`
|
|
995
|
-
console.log(`
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1051
|
-
|
|
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 (
|
|
1054
|
-
throw new Error('Invalid envelope:
|
|
1299
|
+
if (e.kind !== 17) {
|
|
1300
|
+
throw new Error('Invalid envelope: kind must be 17');
|
|
1055
1301
|
}
|
|
1056
|
-
if (
|
|
1057
|
-
throw new Error('Invalid envelope:
|
|
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
|
|
1060
|
-
throw new Error('Invalid envelope:
|
|
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 (
|
|
1063
|
-
throw new Error('Invalid envelope:
|
|
1308
|
+
if (!Array.isArray(e.tags)) {
|
|
1309
|
+
throw new Error('Invalid envelope: tags must be an array');
|
|
1064
1310
|
}
|
|
1065
|
-
if (typeof
|
|
1066
|
-
throw new Error('Invalid envelope:
|
|
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
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|