ciphermesh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,126 @@
1
+ export const PROTOCOL_VERSION = 1;
2
+
3
+ // Network
4
+ export const SERVER_PORT = 3600;
5
+ export const HEARTBEAT_INTERVAL_MS = 30_000;
6
+ export const RECONNECT_BASE_MS = 1_000;
7
+ export const RECONNECT_MAX_MS = 30_000;
8
+
9
+ // Limits
10
+ export const MAX_NICKNAME_LENGTH = 20;
11
+ export const MAX_PAYLOAD_SIZE = 65_536;
12
+ export const RATE_LIMIT_PER_SECOND = 30;
13
+ export const SESSION_TIMEOUT_MS = 300_000;
14
+
15
+ // Server hardening
16
+ export const MAX_CONNECTIONS_TOTAL = 500; // global socket cap
17
+ export const MAX_CONNECTIONS_PER_IP = 20; // per-source-IP socket cap
18
+ export const JOIN_TIMEOUT_MS = 15_000; // drop sockets that never JOIN
19
+ export const MESSAGE_RATE_LIMIT_PER_SECOND = 60; // per connection, ALL message types
20
+
21
+ // Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
22
+ export const NONCE_SIZE = 24;
23
+ export const PUBLIC_KEY_SIZE = 32;
24
+ export const SECRET_KEY_SIZE = 32;
25
+ export const MAC_SIZE = 16;
26
+ export const SHARED_KEY_SIZE = 32;
27
+
28
+ // Nonce structure offsets
29
+ export const NONCE_TIMESTAMP_OFFSET = 0;
30
+ export const NONCE_TIMESTAMP_SIZE = 8;
31
+ export const NONCE_COUNTER_OFFSET = 8;
32
+ export const NONCE_COUNTER_SIZE = 4;
33
+ export const NONCE_RANDOM_OFFSET = 12;
34
+ export const NONCE_RANDOM_SIZE = 12;
35
+
36
+ // Anti-replay
37
+ export const NONCE_MAX_AGE_MS = 30_000;
38
+
39
+ // Offline queue
40
+ export const OFFLINE_QUEUE_MAX_PER_PEER = 100;
41
+ export const OFFLINE_QUEUE_MAX_AGE_MS = 3_600_000; // 1h
42
+ export const OFFLINE_QUEUE_MAX_TOTAL = 1000;
43
+
44
+ // Message padding (anti-metadata): every ciphertext is padded up to one of
45
+ // these bucket sizes so the relay can't read the true plaintext length.
46
+ export const MESSAGE_PAD_BUCKETS = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];
47
+
48
+ // Cover traffic (anti-metadata): when enabled, decoy messages are sent at
49
+ // jittered intervals so an observer can't tell active chatting from idle.
50
+ export const COVER_MIN_MS = 20_000; // shortest gap between decoys (jitter mode)
51
+ export const COVER_MAX_MS = 60_000; // longest gap between decoys (jitter mode)
52
+ export const COVER_MAX_FILLER = 2000; // random filler bytes → varied padding buckets
53
+ // Constant-rate mode: fixed cadence. Each slot carries a queued real message or,
54
+ // if none, a decoy — so the wire rate is steady whether you chat or idle.
55
+ export const COVER_CONSTANT_MS = 3000;
56
+
57
+ // Key rotation
58
+ export const KEY_ROTATION_INTERVAL_MS = 3_600_000; // 1h
59
+ export const KEY_ROTATION_GRACE_MS = 30_000; // 30s — keep old key for in-flight msgs
60
+
61
+ // Double Ratchet (PFS)
62
+ export const RATCHET_MAX_SKIP = 100;
63
+ export const RATCHET_SKIP_KEY_MAX_AGE_MS = 60_000; // 60s
64
+
65
+ // File transfer
66
+ export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
67
+ // 16KB raw. The chunk is base64'd into the JSON payload, encrypted, then the
68
+ // ciphertext is base64'd again into the wire message — a ~1.78x expansion. At
69
+ // 16KB the plaintext stays under padMessage's 2-byte length field (65535) and
70
+ // the final wire frame (~29KB) stays well under MAX_PAYLOAD_SIZE.
71
+ export const FILE_CHUNK_SIZE = 16_384;
72
+
73
+ // Emoji map for reactions and inline shortcodes
74
+ export const EMOJI_MAP = {
75
+ ':thumbsup:': '\uD83D\uDC4D',
76
+ ':heart:': '\u2764\uFE0F',
77
+ ':laugh:': '\uD83D\uDE02',
78
+ ':fire:': '\uD83D\uDD25',
79
+ ':check:': '\u2705',
80
+ ':x:': '\u274C',
81
+ ':eyes:': '\uD83D\uDC40',
82
+ ':clap:': '\uD83D\uDC4F',
83
+ ':smile:': '\uD83D\uDE04',
84
+ ':sad:': '\uD83D\uDE22',
85
+ ':cry:': '\uD83D\uDE2D',
86
+ ':party:': '\uD83C\uDF89',
87
+ ':rocket:': '\uD83D\uDE80',
88
+ ':100:': '\uD83D\uDCAF',
89
+ ':wave:': '\uD83D\uDC4B',
90
+ ':thinking:': '\uD83E\uDD14',
91
+ ':skull:': '\uD83D\uDC80',
92
+ ':pray:': '\uD83D\uDE4F',
93
+ ':ok:': '\uD83D\uDC4C',
94
+ ':poop:': '\uD83D\uDCA9',
95
+ ':thumbsdown:': '\uD83D\uDC4E',
96
+ ':wink:': '\uD83D\uDE09',
97
+ ':cool:': '\uD83D\uDE0E',
98
+ ':angry:': '\uD83D\uDE20',
99
+ ':love:': '\uD83D\uDE0D',
100
+ ':kiss:': '\uD83D\uDE18',
101
+ ':tongue:': '\uD83D\uDE1B',
102
+ ':shush:': '\uD83E\uDD2B',
103
+ ':star:': '\u2B50',
104
+ ':warning:': '\u26A0\uFE0F',
105
+ ':question:': '\u2753',
106
+ ':bulb:': '\uD83D\uDCA1',
107
+ ':lock:': '\uD83D\uDD12',
108
+ ':key:': '\uD83D\uDD11',
109
+ ':bell:': '\uD83D\uDD14',
110
+ ':tada:': '\uD83C\uDF8A',
111
+ ':coffee:': '\u2615',
112
+ ':beer:': '\uD83C\uDF7A',
113
+ ':pizza:': '\uD83C\uDF55',
114
+ ':ghost:': '\uD83D\uDC7B',
115
+ ':robot:': '\uD83E\uDD16',
116
+ ':alien:': '\uD83D\uDC7D',
117
+ ':sun:': '\u2600\uFE0F',
118
+ ':moon:': '\uD83C\uDF19',
119
+ ':zap:': '\u26A1',
120
+ ':boom:': '\uD83D\uDCA5',
121
+ ':muscle:': '\uD83D\uDCAA',
122
+ ':point_up:': '\u261D\uFE0F',
123
+ ':raised_hands:': '\uD83D\uDE4C',
124
+ ':facepalm:': '\uD83E\uDD26',
125
+ ':shrug:': '\uD83E\uDD37',
126
+ };
@@ -0,0 +1,34 @@
1
+ import { COVER_MIN_MS, COVER_MAX_MS, COVER_MAX_FILLER } from './constants.js';
2
+
3
+ // Cover traffic hides *when* and *how much* you chat from a relay/observer that
4
+ // can already only see ciphertext. Real messages are padded to fixed buckets
5
+ // (MessageCrypto.padMessage), so a decoy with random filler is indistinguishable
6
+ // from a real message on the wire. Decoys are dropped silently by the receiver.
7
+
8
+ /**
9
+ * Jittered delay (ms) until the next decoy — uniform in [min, max].
10
+ * `rand` is injectable for deterministic tests.
11
+ */
12
+ export function nextCoverDelay(rand = Math.random, min = COVER_MIN_MS, max = COVER_MAX_MS) {
13
+ return Math.round(min + rand() * (max - min));
14
+ }
15
+
16
+ /**
17
+ * Build a decoy payload. The random filler varies the plaintext length so the
18
+ * encrypted+padded decoy lands across the same buckets as real chat messages.
19
+ * @param {number} now - timestamp (Date.now())
20
+ * @param {() => number} rand - injectable RNG for tests
21
+ */
22
+ export function coverPayload(now, rand = Math.random) {
23
+ const fillerLen = Math.floor(rand() * COVER_MAX_FILLER);
24
+ return JSON.stringify({
25
+ action: 'cover',
26
+ sentAt: now,
27
+ x: 'x'.repeat(fillerLen), // opaque filler; the receiver ignores it
28
+ });
29
+ }
30
+
31
+ /** True if a decoded payload is a decoy that should be dropped without any effect. */
32
+ export function isCover(data) {
33
+ return !!data && data.action === 'cover';
34
+ }
@@ -0,0 +1,60 @@
1
+ // Do-not-disturb / mentions-only notification gating.
2
+ // mode: 'off' (normal), 'mentions' (only @mentions), 'on' (full silence).
3
+ // An optional quiet-hours window makes the effective mode 'mentions' inside it.
4
+
5
+ export function parseDndWindow(str) {
6
+ const m = String(str).match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
7
+ if (!m) {
8
+ return null;
9
+ }
10
+ const h1 = Number(m[1]);
11
+ const min1 = Number(m[2]);
12
+ const h2 = Number(m[3]);
13
+ const min2 = Number(m[4]);
14
+ if (h1 > 23 || h2 > 23 || min1 > 59 || min2 > 59) {
15
+ return null;
16
+ }
17
+ return { start: h1 * 60 + min1, end: h2 * 60 + min2 };
18
+ }
19
+
20
+ export function inWindow(window, nowMinutes) {
21
+ if (!window) {
22
+ return false;
23
+ }
24
+ const { start, end } = window;
25
+ // Handle windows that wrap past midnight (e.g. 22:00-08:00).
26
+ return start <= end
27
+ ? nowMinutes >= start && nowMinutes < end
28
+ : nowMinutes >= start || nowMinutes < end;
29
+ }
30
+
31
+ // Whether an incoming message should notify at all. A 'true' result still lets
32
+ // the caller apply its own sound/desktop-enabled toggles.
33
+ export function shouldNotify(mode, window, nowMinutes, mentioned) {
34
+ const eff = inWindow(window, nowMinutes) ? 'mentions' : mode;
35
+ if (eff === 'on') {
36
+ return false; // full silence
37
+ }
38
+ if (eff === 'mentions') {
39
+ return !!mentioned;
40
+ }
41
+ return true; // 'off' → normal
42
+ }
43
+
44
+ export function nowMinutes(date = new Date()) {
45
+ return date.getHours() * 60 + date.getMinutes();
46
+ }
47
+
48
+ // True if `text` mentions `nickname` (via @nick or the bare word). Nicknames are
49
+ // validated to [a-zA-Z0-9_-], so no regex escaping is needed.
50
+ export function mentionsMe(text, nickname) {
51
+ if (typeof text !== 'string' || !nickname) {
52
+ return false;
53
+ }
54
+ const nick = nickname.toLowerCase();
55
+ const t = text.toLowerCase();
56
+ if (t.includes(`@${nick}`)) {
57
+ return true;
58
+ }
59
+ return new RegExp(`(^|[^a-z0-9_-])${nick}([^a-z0-9_-]|$)`).test(t);
60
+ }
@@ -0,0 +1,17 @@
1
+ import { EMOJI_MAP } from './constants.js';
2
+
3
+ const SHORTCODE_REGEX = /:([a-z0-9_+-]+):/g;
4
+
5
+ /**
6
+ * Replace :shortcode: occurrences with their emoji. Unknown codes stay as-is.
7
+ */
8
+ export function applyShortcodes(text) {
9
+ return text.replace(SHORTCODE_REGEX, (match) => EMOJI_MAP[match] || match);
10
+ }
11
+
12
+ /**
13
+ * Shortcodes starting with the given prefix (prefix includes the leading ':').
14
+ */
15
+ export function shortcodeSuggestions(prefix) {
16
+ return Object.keys(EMOJI_MAP).filter((code) => code.startsWith(prefix));
17
+ }
@@ -0,0 +1,40 @@
1
+ // Subsequence fuzzy matching for the command palette. `query` matches `text`
2
+ // when its characters appear in order (not necessarily contiguous). Lower score
3
+ // = better match (earlier positions, fewer gaps). Returns -1 for no match.
4
+
5
+ export function fuzzyScore(text, query) {
6
+ if (!query) {
7
+ return 0;
8
+ }
9
+ const t = String(text).toLowerCase();
10
+ const q = query.toLowerCase();
11
+ let from = 0;
12
+ let score = 0;
13
+ let prev = -1;
14
+ for (const ch of q) {
15
+ const idx = t.indexOf(ch, from);
16
+ if (idx === -1) {
17
+ return -1;
18
+ }
19
+ score += idx; // earlier matches rank higher
20
+ if (prev !== -1 && idx !== prev + 1) {
21
+ score += 5; // penalise gaps (favour contiguous matches)
22
+ }
23
+ prev = idx;
24
+ from = idx + 1;
25
+ }
26
+ return score;
27
+ }
28
+
29
+ // Filter + rank items by fuzzy match against a query. `key` extracts the string
30
+ // to match from each item. With no query, returns all items unchanged.
31
+ export function fuzzyFilter(items, query, key = (x) => x) {
32
+ if (!query) {
33
+ return items.slice();
34
+ }
35
+ return items
36
+ .map((item) => ({ item, score: fuzzyScore(key(item), query) }))
37
+ .filter((x) => x.score >= 0)
38
+ .sort((a, b) => a.score - b.score || key(a.item).length - key(b.item).length)
39
+ .map((x) => x.item);
40
+ }
@@ -0,0 +1,61 @@
1
+ const INVITE_SCHEME = 'ciphermesh://';
2
+ const ROOM_REGEX = /^[a-zA-Z0-9_-]{1,20}$/;
3
+ const HOST_REGEX = /^[a-zA-Z0-9._-]+$/;
4
+
5
+ /**
6
+ * Build an invite URI: ciphermesh://host:port/room
7
+ * @param {string} hostPort - "host:port" (ex: 100.124.6.27:3600)
8
+ * @param {string} [room] - room name (default: general)
9
+ * @returns {string|null} invite URI or null if input is invalid
10
+ */
11
+ export function buildInvite(hostPort, room = 'general') {
12
+ const parsed = parseHostPort(hostPort);
13
+ if (!parsed || !ROOM_REGEX.test(room)) {
14
+ return null;
15
+ }
16
+ return `${INVITE_SCHEME}${parsed.host}:${parsed.port}/${room}`;
17
+ }
18
+
19
+ /**
20
+ * Parse an invite URI into its parts.
21
+ * @param {string} uri - ciphermesh://host:port[/room]
22
+ * @returns {{ host: string, port: number, room: string, wsUrl: string }|null}
23
+ */
24
+ export function parseInvite(uri) {
25
+ if (typeof uri !== 'string' || !uri.startsWith(INVITE_SCHEME)) {
26
+ return null;
27
+ }
28
+
29
+ const rest = uri.slice(INVITE_SCHEME.length);
30
+ const slash = rest.indexOf('/');
31
+ const hostPort = slash === -1 ? rest : rest.slice(0, slash);
32
+ const room = slash === -1 ? 'general' : rest.slice(slash + 1) || 'general';
33
+
34
+ const parsed = parseHostPort(hostPort);
35
+ if (!parsed || !ROOM_REGEX.test(room)) {
36
+ return null;
37
+ }
38
+
39
+ return {
40
+ host: parsed.host,
41
+ port: parsed.port,
42
+ room,
43
+ wsUrl: `wss://${parsed.host}:${parsed.port}`,
44
+ };
45
+ }
46
+
47
+ function parseHostPort(hostPort) {
48
+ if (typeof hostPort !== 'string') {
49
+ return null;
50
+ }
51
+ const colon = hostPort.lastIndexOf(':');
52
+ if (colon === -1) {
53
+ return null;
54
+ }
55
+ const host = hostPort.slice(0, colon);
56
+ const port = parseInt(hostPort.slice(colon + 1), 10);
57
+ if (!HOST_REGEX.test(host) || !Number.isInteger(port) || port < 1 || port > 65535) {
58
+ return null;
59
+ }
60
+ return { host, port };
61
+ }
@@ -0,0 +1,66 @@
1
+ // Deterministic "randomart" for a key — the OpenSSH "drunken bishop" walk.
2
+ // A bishop starts at the center of a 17x9 board and moves diagonally, one step
3
+ // per 2-bit pair of the input bytes, incrementing a counter at each cell. The
4
+ // resulting density map is drawn with coin characters. Same key → same picture,
5
+ // so a changed key produces a visibly different picture (MITM detection aid).
6
+
7
+ const WIDTH = 17;
8
+ const HEIGHT = 9;
9
+ const COINS = ' .o+=*BOX@%&#/^'; // index = visit count (clamped)
10
+
11
+ /**
12
+ * @param {Buffer|Uint8Array} bytes - key material (e.g. the 32-byte public key)
13
+ * @param {string} [title] - short label centered on the top border
14
+ * @returns {string} multi-line box, 11 lines of 19 chars
15
+ */
16
+ export function keyArt(bytes, title = '') {
17
+ const field = Array.from({ length: HEIGHT }, () => new Array(WIDTH).fill(0));
18
+
19
+ const startX = Math.floor(WIDTH / 2);
20
+ const startY = Math.floor(HEIGHT / 2);
21
+ let x = startX;
22
+ let y = startY;
23
+
24
+ for (const byte of bytes) {
25
+ let b = byte;
26
+ for (let i = 0; i < 4; i++) {
27
+ x += b & 0x1 ? 1 : -1; // bit 0 → right / left
28
+ y += b & 0x2 ? 1 : -1; // bit 1 → down / up
29
+ x = Math.max(0, Math.min(WIDTH - 1, x));
30
+ y = Math.max(0, Math.min(HEIGHT - 1, y));
31
+ field[y][x] += 1;
32
+ b >>= 2;
33
+ }
34
+ }
35
+
36
+ const lines = [border(title), ...rows(field, startX, startY, x, y), border('')];
37
+ return lines.join('\n');
38
+ }
39
+
40
+ function border(title) {
41
+ if (!title) {
42
+ return `+${'-'.repeat(WIDTH)}+`;
43
+ }
44
+ const label = `[${title.slice(0, WIDTH - 2)}]`;
45
+ const pad = WIDTH - label.length;
46
+ const left = Math.floor(pad / 2);
47
+ return `+${'-'.repeat(left)}${label}${'-'.repeat(pad - left)}+`;
48
+ }
49
+
50
+ function rows(field, startX, startY, endX, endY) {
51
+ const out = [];
52
+ for (let j = 0; j < HEIGHT; j++) {
53
+ let row = '|';
54
+ for (let i = 0; i < WIDTH; i++) {
55
+ if (i === startX && j === startY) {
56
+ row += 'S';
57
+ } else if (i === endX && j === endY) {
58
+ row += 'E';
59
+ } else {
60
+ row += COINS[Math.min(field[j][i], COINS.length - 1)];
61
+ }
62
+ }
63
+ out.push(`${row}|`);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,38 @@
1
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
2
+
3
+ const currentLevel = LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LEVELS.info;
4
+
5
+ function timestamp() {
6
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
7
+ }
8
+
9
+ function format(level, module, message) {
10
+ return `[${timestamp()}] [${level.toUpperCase().padEnd(5)}] [${module}] ${message}`;
11
+ }
12
+
13
+ function createLogger(module) {
14
+ return {
15
+ debug: (msg) => {
16
+ if (currentLevel <= LEVELS.debug) {
17
+ console.log(format('debug', module, msg));
18
+ }
19
+ },
20
+ info: (msg) => {
21
+ if (currentLevel <= LEVELS.info) {
22
+ console.log(format('info', module, msg));
23
+ }
24
+ },
25
+ warn: (msg) => {
26
+ if (currentLevel <= LEVELS.warn) {
27
+ console.warn(format('warn', module, msg));
28
+ }
29
+ },
30
+ error: (msg) => {
31
+ if (currentLevel <= LEVELS.error) {
32
+ console.error(format('error', module, msg));
33
+ }
34
+ },
35
+ };
36
+ }
37
+
38
+ export { createLogger };
@@ -0,0 +1,38 @@
1
+ import { StateManager } from '../crypto/StateManager.js';
2
+
3
+ // Panic / duress wipe: securely delete every at-rest secret. Best-effort and
4
+ // never throws — under duress it must run to completion no matter what.
5
+ export function panicWipe({ historyStore, trustStore, auditLog } = {}) {
6
+ const wiped = [];
7
+ try {
8
+ new StateManager().clearState(); // encrypted session state
9
+ wiped.push('session');
10
+ } catch {
11
+ /* best effort */
12
+ }
13
+ try {
14
+ if (historyStore) {
15
+ historyStore.wipe();
16
+ wiped.push('history');
17
+ }
18
+ } catch {
19
+ /* best effort */
20
+ }
21
+ try {
22
+ if (trustStore) {
23
+ trustStore.wipe();
24
+ wiped.push('trust');
25
+ }
26
+ } catch {
27
+ /* best effort */
28
+ }
29
+ try {
30
+ if (auditLog) {
31
+ auditLog.wipe();
32
+ wiped.push('audit');
33
+ }
34
+ } catch {
35
+ /* best effort */
36
+ }
37
+ return wiped;
38
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ask a question on a readline interface WITHOUT echoing the typed characters
3
+ * (for passphrases). Uses the standard readline `_writeToOutput` override so it
4
+ * composes with an existing readline/promises interface.
5
+ *
6
+ * @param {import('node:readline/promises').Interface} rl
7
+ * @param {string} query - prompt text (shown once)
8
+ * @returns {Promise<string>} the typed answer
9
+ */
10
+ export function questionHidden(rl, query) {
11
+ const original = rl._writeToOutput?.bind(rl);
12
+ let promptShown = false;
13
+
14
+ rl._writeToOutput = (str) => {
15
+ if (!promptShown) {
16
+ // The first write is the prompt itself — show it, then mask the rest.
17
+ rl.output.write(query);
18
+ promptShown = true;
19
+ return;
20
+ }
21
+ // Preserve line breaks (so the cursor advances on Enter), swallow the
22
+ // character echoes so the passphrase never appears on screen.
23
+ if (str.includes('\n') || str.includes('\r')) {
24
+ rl.output.write('\n');
25
+ }
26
+ };
27
+
28
+ return rl.question(query).finally(() => {
29
+ rl._writeToOutput = original;
30
+ });
31
+ }
@@ -0,0 +1,72 @@
1
+ // Detect and encode inline-image support for capable terminals (kitty / iTerm2).
2
+ // The half-block preview always works; these enable full-resolution rendering
3
+ // where the terminal supports a real graphics protocol.
4
+
5
+ /**
6
+ * @returns {'kitty'|'iterm'|null}
7
+ */
8
+ export function detectImageProtocol(env = process.env) {
9
+ if (env.TERM === 'xterm-kitty' || env.KITTY_WINDOW_ID) {
10
+ return 'kitty';
11
+ }
12
+ if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') {
13
+ return 'iterm';
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function supportsTrueColor(env = process.env) {
19
+ const ct = (env.COLORTERM || '').toLowerCase();
20
+ if (ct === 'truecolor' || ct === '24bit') {
21
+ return true;
22
+ }
23
+ return (env.TERM || '').includes('kitty') || env.TERM_PROGRAM === 'iTerm.app';
24
+ }
25
+
26
+ /**
27
+ * iTerm2 inline image escape. Accepts raw image bytes of any format.
28
+ * @param {Buffer} buffer
29
+ * @param {{ widthCells?: number }} [opts]
30
+ */
31
+ export function encodeITermImage(buffer, opts = {}) {
32
+ const args = ['inline=1', `size=${buffer.length}`, 'preserveAspectRatio=1'];
33
+ if (opts.widthCells) {
34
+ args.push(`width=${opts.widthCells}`);
35
+ }
36
+ return `\x1b]1337;File=${args.join(';')}:${buffer.toString('base64')}\x07`;
37
+ }
38
+
39
+ /**
40
+ * kitty graphics protocol escape. Requires a PNG buffer (f=100), chunked so no
41
+ * single escape exceeds the protocol limit.
42
+ * @param {Buffer} pngBuffer
43
+ */
44
+ export function encodeKittyImage(pngBuffer) {
45
+ const b64 = pngBuffer.toString('base64');
46
+ const CHUNK = 4096;
47
+ let out = '';
48
+ for (let i = 0; i < b64.length; i += CHUNK) {
49
+ const chunk = b64.slice(i, i + CHUNK);
50
+ const more = i + CHUNK < b64.length ? 1 : 0;
51
+ const control = i === 0 ? `f=100,a=T,m=${more}` : `m=${more}`;
52
+ out += `\x1b_G${control};${chunk}\x1b\\`;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Build the inline-image escape for the detected protocol.
59
+ * @param {'kitty'|'iterm'} protocol
60
+ * @param {{ raw: Buffer, png: Buffer }} images - raw bytes and a PNG re-encoding
61
+ * @param {{ widthCells?: number }} [opts]
62
+ * @returns {string|null}
63
+ */
64
+ export function encodeInlineImage(protocol, images, opts = {}) {
65
+ if (protocol === 'iterm') {
66
+ return encodeITermImage(images.raw, opts);
67
+ }
68
+ if (protocol === 'kitty') {
69
+ return encodeKittyImage(images.png);
70
+ }
71
+ return null;
72
+ }
@@ -0,0 +1,36 @@
1
+ // Selectable colour themes for per-user nick colours. Each palette is a list of
2
+ // blessed colour tokens (named colours or #hex). The banner keeps the neon
3
+ // brand; themes affect the ongoing message colours, which is what you actually
4
+ // look at all session.
5
+
6
+ export const THEMES = {
7
+ neon: ['cyan', 'green', 'magenta', 'yellow', 'red'],
8
+ matrix: ['green', '#00ff41', '#39ff14', '#7fff00', '#00c853'],
9
+ mono: ['white', '#bbbbbb', '#888888', '#dddddd', '#00b8ff'],
10
+ sunset: ['red', 'yellow', 'magenta', '#ff7b00', '#ff2d95'],
11
+ ocean: ['cyan', 'blue', '#00b8ff', '#4cc9f0', '#7b2dff'],
12
+ };
13
+
14
+ const DEFAULT = 'neon';
15
+ let active = DEFAULT;
16
+
17
+ /** Set the active theme by name; ignores unknown names. Returns the active name. */
18
+ export function setTheme(name) {
19
+ if (typeof name === 'string' && Object.prototype.hasOwnProperty.call(THEMES, name)) {
20
+ active = name;
21
+ }
22
+ return active;
23
+ }
24
+
25
+ export function getThemeName() {
26
+ return active;
27
+ }
28
+
29
+ /** The active palette (array of colour tokens). */
30
+ export function nickPalette() {
31
+ return THEMES[active];
32
+ }
33
+
34
+ export function themeNames() {
35
+ return Object.keys(THEMES);
36
+ }