beachviber 1.0.34
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.
Potentially problematic release.
This version of beachviber might be problematic. Click here for more details.
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/api.js +28 -0
- package/dist/approval-hook.mjs +193 -0
- package/dist/approval-server.js +186 -0
- package/dist/config.js +60 -0
- package/dist/connection-machine.js +222 -0
- package/dist/connection.js +198 -0
- package/dist/crypto.js +101 -0
- package/dist/hook-installer.js +60 -0
- package/dist/image-download.js +142 -0
- package/dist/index.js +208 -0
- package/dist/logger.js +28 -0
- package/dist/message-handler.js +661 -0
- package/dist/pairing.js +292 -0
- package/dist/projects.js +156 -0
- package/dist/secret-store.js +245 -0
- package/dist/sessions.js +406 -0
- package/dist/state.js +82 -0
- package/dist/transcripts.js +312 -0
- package/package.json +57 -0
- package/scripts/postinstall.mjs +15 -0
- package/scripts/preuninstall.mjs +20 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop connection state machine.
|
|
3
|
+
*
|
|
4
|
+
* 8 states with validated transitions:
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────┐ CONNECT ┌────────────┐ WS_OPEN ┌─────────────┐
|
|
7
|
+
* │ disconnected │──────────▶│ connecting │──────────▶│ registering │
|
|
8
|
+
* └──────────────┘ └────────────┘ └─────────────┘
|
|
9
|
+
* ▲ │ │ │
|
|
10
|
+
* │ AUTH_FAILURE / │ WS_CLOSE │ REGISTERED│
|
|
11
|
+
* │ FORCE_DISCONNECT ▼ │ ▼
|
|
12
|
+
* │ ┌──────────────┐ │ ┌─────────┐
|
|
13
|
+
* │◀─────────────────│ reconnecting │◀────────────┼───│ pairing │
|
|
14
|
+
* │ └──────────────┘ WS_CLOSE │ └─────────┘
|
|
15
|
+
* │ ▲ │ │ │
|
|
16
|
+
* │ │ │ RECONNECT_TIMER │ │ PAIRED
|
|
17
|
+
* │ │ └─────▶ connecting │ ▼
|
|
18
|
+
* │ │ │ ┌────────┐
|
|
19
|
+
* │ └───────────────────────┴───│ paired │
|
|
20
|
+
* │ WS_CLOSE └────────┘
|
|
21
|
+
* │
|
|
22
|
+
* │ Setup mode (first-time pairing via desktops_list):
|
|
23
|
+
* │
|
|
24
|
+
* │ ENTER_SETUP ┌─────────────────────┐
|
|
25
|
+
* └──────────────────────────▶│ setup_reconnecting │
|
|
26
|
+
* └─────────────────────┘
|
|
27
|
+
* │ SETUP_RECONNECT_FAILED
|
|
28
|
+
* ▼
|
|
29
|
+
* ┌───────────────┐
|
|
30
|
+
* │ setup_pairing │──PAIRED──▶ paired
|
|
31
|
+
* └───────────────┘
|
|
32
|
+
*/
|
|
33
|
+
/** Must match RECONNECT_BASE_MS in state.ts — duplicated to avoid circular import. */
|
|
34
|
+
const DEFAULT_RECONNECT_DELAY = 1000;
|
|
35
|
+
const AUTH_FAILURE_THRESHOLD = 3;
|
|
36
|
+
export class ConnectionMachine {
|
|
37
|
+
state = "disconnected";
|
|
38
|
+
context = {
|
|
39
|
+
consecutiveFailures: 0,
|
|
40
|
+
reconnectDelay: DEFAULT_RECONNECT_DELAY,
|
|
41
|
+
disconnectReason: "none",
|
|
42
|
+
};
|
|
43
|
+
transition(event) {
|
|
44
|
+
const prev = this.state;
|
|
45
|
+
const next = this._nextState(prev, event);
|
|
46
|
+
if (next === undefined) {
|
|
47
|
+
throw new Error(`Invalid transition: ${event.type} from state "${prev}"`);
|
|
48
|
+
}
|
|
49
|
+
this.state = next;
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
/** Reset the machine to its initial state (for testing or full restart). */
|
|
53
|
+
reset() {
|
|
54
|
+
this.state = "disconnected";
|
|
55
|
+
this.context = {
|
|
56
|
+
consecutiveFailures: 0,
|
|
57
|
+
reconnectDelay: DEFAULT_RECONNECT_DELAY,
|
|
58
|
+
disconnectReason: "none",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ------------------------------------------------------------------
|
|
62
|
+
// Backward-compatible computed getters
|
|
63
|
+
// ------------------------------------------------------------------
|
|
64
|
+
get connectionPhase() {
|
|
65
|
+
switch (this.state) {
|
|
66
|
+
case "disconnected":
|
|
67
|
+
case "connecting":
|
|
68
|
+
case "registering":
|
|
69
|
+
return "registering";
|
|
70
|
+
case "pairing":
|
|
71
|
+
case "setup_reconnecting":
|
|
72
|
+
case "setup_pairing":
|
|
73
|
+
return "pairing";
|
|
74
|
+
case "paired":
|
|
75
|
+
return "paired";
|
|
76
|
+
case "reconnecting":
|
|
77
|
+
// Reconnecting implies we were previously paired or registered.
|
|
78
|
+
// Map to "paired" so incoming messages for paired state still pass
|
|
79
|
+
// the phase gate during the brief reconnect window.
|
|
80
|
+
return "paired";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
get inSetupMode() {
|
|
84
|
+
return this.state === "setup_reconnecting" || this.state === "setup_pairing";
|
|
85
|
+
}
|
|
86
|
+
get disconnectedByRemote() {
|
|
87
|
+
return this.context.disconnectReason === "remote";
|
|
88
|
+
}
|
|
89
|
+
// ------------------------------------------------------------------
|
|
90
|
+
// Transition logic
|
|
91
|
+
// ------------------------------------------------------------------
|
|
92
|
+
_nextState(current, event) {
|
|
93
|
+
switch (current) {
|
|
94
|
+
case "disconnected":
|
|
95
|
+
return this._fromDisconnected(event);
|
|
96
|
+
case "connecting":
|
|
97
|
+
return this._fromConnecting(event);
|
|
98
|
+
case "registering":
|
|
99
|
+
return this._fromRegistering(event);
|
|
100
|
+
case "pairing":
|
|
101
|
+
return this._fromPairing(event);
|
|
102
|
+
case "paired":
|
|
103
|
+
return this._fromPaired(event);
|
|
104
|
+
case "reconnecting":
|
|
105
|
+
return this._fromReconnecting(event);
|
|
106
|
+
case "setup_reconnecting":
|
|
107
|
+
return this._fromSetupReconnecting(event);
|
|
108
|
+
case "setup_pairing":
|
|
109
|
+
return this._fromSetupPairing(event);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
_fromDisconnected(event) {
|
|
113
|
+
switch (event.type) {
|
|
114
|
+
case "CONNECT":
|
|
115
|
+
this.context.disconnectReason = "none";
|
|
116
|
+
return "connecting";
|
|
117
|
+
case "ENTER_SETUP":
|
|
118
|
+
this.context.disconnectReason = "none";
|
|
119
|
+
return event.hasSavedState ? "setup_reconnecting" : "setup_pairing";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
_fromConnecting(event) {
|
|
123
|
+
switch (event.type) {
|
|
124
|
+
case "WS_OPEN":
|
|
125
|
+
this.context.consecutiveFailures = 0;
|
|
126
|
+
this.context.reconnectDelay = DEFAULT_RECONNECT_DELAY;
|
|
127
|
+
return "registering";
|
|
128
|
+
case "WS_CLOSE":
|
|
129
|
+
if (event.networkError) {
|
|
130
|
+
return "reconnecting";
|
|
131
|
+
}
|
|
132
|
+
// Non-network close before open = possible auth failure
|
|
133
|
+
this.context.consecutiveFailures++;
|
|
134
|
+
if (this.context.consecutiveFailures >= AUTH_FAILURE_THRESHOLD) {
|
|
135
|
+
this.context.disconnectReason = "auth";
|
|
136
|
+
this.context.consecutiveFailures = 0;
|
|
137
|
+
return "disconnected";
|
|
138
|
+
}
|
|
139
|
+
return "reconnecting";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
_fromRegistering(event) {
|
|
143
|
+
switch (event.type) {
|
|
144
|
+
case "REGISTERED":
|
|
145
|
+
return event.hasSharedSecret ? "paired" : "pairing";
|
|
146
|
+
case "WS_CLOSE":
|
|
147
|
+
return "reconnecting";
|
|
148
|
+
case "FORCE_DISCONNECT":
|
|
149
|
+
this.context.disconnectReason = "remote";
|
|
150
|
+
return "disconnected";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
_fromPairing(event) {
|
|
154
|
+
switch (event.type) {
|
|
155
|
+
case "PAIRED":
|
|
156
|
+
return "paired";
|
|
157
|
+
case "FORCE_DISCONNECT":
|
|
158
|
+
this.context.disconnectReason = "remote";
|
|
159
|
+
return "disconnected";
|
|
160
|
+
case "WS_CLOSE":
|
|
161
|
+
this.context.disconnectReason = "network";
|
|
162
|
+
return "disconnected";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
_fromPaired(event) {
|
|
166
|
+
switch (event.type) {
|
|
167
|
+
case "WS_CLOSE":
|
|
168
|
+
return "reconnecting";
|
|
169
|
+
case "FORCE_DISCONNECT":
|
|
170
|
+
this.context.disconnectReason = "remote";
|
|
171
|
+
return "disconnected";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
_fromReconnecting(event) {
|
|
175
|
+
switch (event.type) {
|
|
176
|
+
case "RECONNECT_TIMER":
|
|
177
|
+
return "connecting";
|
|
178
|
+
case "FORCE_DISCONNECT":
|
|
179
|
+
this.context.disconnectReason = "remote";
|
|
180
|
+
return "disconnected";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
_fromSetupReconnecting(event) {
|
|
184
|
+
switch (event.type) {
|
|
185
|
+
case "PAIRED":
|
|
186
|
+
return "paired";
|
|
187
|
+
case "SETUP_RECONNECT_FAILED":
|
|
188
|
+
return "setup_pairing";
|
|
189
|
+
// Allow connect/ws events during setup reconnect loop
|
|
190
|
+
case "CONNECT":
|
|
191
|
+
return "setup_reconnecting";
|
|
192
|
+
case "WS_OPEN":
|
|
193
|
+
return "setup_reconnecting";
|
|
194
|
+
case "WS_CLOSE":
|
|
195
|
+
return "setup_reconnecting";
|
|
196
|
+
case "REGISTERED":
|
|
197
|
+
return event.hasSharedSecret ? "paired" : "setup_pairing";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
_fromSetupPairing(event) {
|
|
201
|
+
switch (event.type) {
|
|
202
|
+
case "PAIRED":
|
|
203
|
+
return "paired";
|
|
204
|
+
case "FORCE_DISCONNECT":
|
|
205
|
+
this.context.disconnectReason = "remote";
|
|
206
|
+
return "disconnected";
|
|
207
|
+
// Allow connect/ws events during setup pairing flow
|
|
208
|
+
case "CONNECT":
|
|
209
|
+
return "setup_pairing";
|
|
210
|
+
case "WS_OPEN":
|
|
211
|
+
return "setup_pairing";
|
|
212
|
+
case "WS_CLOSE":
|
|
213
|
+
return "setup_pairing";
|
|
214
|
+
// Stay in setup_pairing until explicit PAIRED event fires after verification.
|
|
215
|
+
// handleRegistered() still runs (sends projects/sessions to webapp), but
|
|
216
|
+
// the machine must not jump to "paired" — that would break the close handler
|
|
217
|
+
// and skip the verification step.
|
|
218
|
+
case "REGISTERED":
|
|
219
|
+
return "setup_pairing";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { hostname, platform } from "os";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
5
|
+
import { state, VERSION, RELAY_URL, RECONNECT_MAX_MS, HEARTBEAT_INTERVAL_MS, } from "./state.js";
|
|
6
|
+
import { handleMessage, computeReconnectDelay } from "./message-handler.js";
|
|
7
|
+
import { encrypt, toBase64, exportPublicKeyRaw, ENCRYPTED_TYPES } from "./crypto.js";
|
|
8
|
+
import { getDefaultSessionManager } from "./sessions.js";
|
|
9
|
+
export function send(msg) {
|
|
10
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
11
|
+
let outgoing = msg;
|
|
12
|
+
// Encrypt payload for encryptable message types
|
|
13
|
+
if (state.sharedSecret && ENCRYPTED_TYPES.has(msg.type) && msg.payload) {
|
|
14
|
+
const { payload, ...envelope } = msg;
|
|
15
|
+
outgoing = {
|
|
16
|
+
...envelope,
|
|
17
|
+
encrypted: encrypt(payload, state.sharedSecret),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
else if (!state.sharedSecret && ENCRYPTED_TYPES.has(msg.type) && msg.payload) {
|
|
21
|
+
console.warn(chalk.yellow(` Dropped ${msg.type}: no encryption key available`));
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const json = JSON.stringify({ ...outgoing, version: VERSION });
|
|
25
|
+
state.ws.send(json);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.error(chalk.red(` Message dropped (not connected): ${msg.type}`));
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Wait for the current WebSocket to reach OPEN state (or timeout). */
|
|
34
|
+
export function waitForWsOpen(timeoutMs = 10_000) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
37
|
+
resolve(true);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const check = setInterval(() => {
|
|
41
|
+
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
|
42
|
+
clearInterval(check);
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(true);
|
|
45
|
+
}
|
|
46
|
+
}, 100);
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
clearInterval(check);
|
|
49
|
+
resolve(false);
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Wait for the relay to acknowledge registration (or timeout). */
|
|
54
|
+
export function waitForRegistered(timeoutMs = 10_000) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
state.registeredResolve = null;
|
|
58
|
+
resolve(false);
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
state.registeredResolve = () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve(true);
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const STALE_THRESHOLD_MS = HEARTBEAT_INTERVAL_MS * 2.5;
|
|
67
|
+
function startHeartbeat() {
|
|
68
|
+
stopHeartbeat();
|
|
69
|
+
state.lastMessageAt = Date.now();
|
|
70
|
+
state.heartbeatTimer = setInterval(() => {
|
|
71
|
+
// Detect stale connection — relay hasn't sent anything in 2.5 heartbeat cycles
|
|
72
|
+
if (state.lastMessageAt && Date.now() - state.lastMessageAt > STALE_THRESHOLD_MS) {
|
|
73
|
+
console.log(chalk.yellow(" Relay unresponsive — forcing reconnect"));
|
|
74
|
+
state.ws?.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
send({
|
|
78
|
+
type: "heartbeat",
|
|
79
|
+
sessionId: null,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
payload: {
|
|
82
|
+
activeSessions: getDefaultSessionManager().getActiveSessions(),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
86
|
+
}
|
|
87
|
+
function stopHeartbeat() {
|
|
88
|
+
if (state.heartbeatTimer) {
|
|
89
|
+
clearInterval(state.heartbeatTimer);
|
|
90
|
+
state.heartbeatTimer = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function connect() {
|
|
94
|
+
// Transition machine to connecting only if starting from disconnected.
|
|
95
|
+
// Other callers (scheduleReconnect, setup mode) manage their own transitions.
|
|
96
|
+
if (state.machine.state === "disconnected") {
|
|
97
|
+
state.machine.transition({ type: "CONNECT" });
|
|
98
|
+
}
|
|
99
|
+
// Send token via both query param (for API Gateway WebSocket authorizer)
|
|
100
|
+
// and Authorization header (for future relay support)
|
|
101
|
+
let url = RELAY_URL;
|
|
102
|
+
const wsHeaders = {};
|
|
103
|
+
if (state.deviceToken) {
|
|
104
|
+
url += `?token=${encodeURIComponent(state.deviceToken)}`;
|
|
105
|
+
wsHeaders["Authorization"] = `Bearer ${state.deviceToken}`;
|
|
106
|
+
}
|
|
107
|
+
state.ws = new WebSocket(url, { headers: wsHeaders });
|
|
108
|
+
let networkError = false;
|
|
109
|
+
state.ws.on("open", () => {
|
|
110
|
+
state.machine.transition({ type: "WS_OPEN" });
|
|
111
|
+
// Register with relay — ONLY send register here.
|
|
112
|
+
// All other messages must wait until the relay confirms registration,
|
|
113
|
+
// because the relay processes messages concurrently and the default
|
|
114
|
+
// handler will reject messages if register hasn't completed yet.
|
|
115
|
+
let computerName = hostname();
|
|
116
|
+
if (platform() === "darwin") {
|
|
117
|
+
try {
|
|
118
|
+
computerName = execFileSync("scutil", ["--get", "ComputerName"], { encoding: "utf-8" }).trim();
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
}
|
|
122
|
+
send({
|
|
123
|
+
type: "register",
|
|
124
|
+
payload: {
|
|
125
|
+
role: "desktop",
|
|
126
|
+
deviceId: state.deviceId,
|
|
127
|
+
projectName: computerName,
|
|
128
|
+
projects: state.projects.map((p) => ({ name: p.name })),
|
|
129
|
+
publicKey: toBase64(exportPublicKeyRaw(state.ownKeyPair.publicKey)),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
startHeartbeat();
|
|
133
|
+
});
|
|
134
|
+
state.ws.on("message", async (data) => {
|
|
135
|
+
state.lastMessageAt = Date.now();
|
|
136
|
+
const result = await handleMessage(data.toString(), send, state.projects, getDefaultSessionManager());
|
|
137
|
+
if (result.projects.length > 0) {
|
|
138
|
+
state.projects = result.projects;
|
|
139
|
+
}
|
|
140
|
+
// If force_disconnect or NOT_REGISTERED was received, close the socket
|
|
141
|
+
if (state.disconnectedByRemote && state.ws?.readyState === WebSocket.OPEN) {
|
|
142
|
+
state.ws.close();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
state.ws.on("close", () => {
|
|
146
|
+
stopHeartbeat();
|
|
147
|
+
// If setup mode is active, silently reconnect without entering the
|
|
148
|
+
// standard reconnect loop. The pairing flow (runSetupMode) manages
|
|
149
|
+
// its own state — we just need the WS to stay alive.
|
|
150
|
+
if (state.inSetupMode) {
|
|
151
|
+
setTimeout(() => connect(), 1000);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// If the machine already transitioned to disconnected (e.g. FORCE_DISCONNECT
|
|
155
|
+
// fired by the message handler before the WS closed), enter setup mode.
|
|
156
|
+
if (state.machine.state === "disconnected") {
|
|
157
|
+
state.enterSetupMode?.();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const newState = state.machine.transition({
|
|
161
|
+
type: "WS_CLOSE",
|
|
162
|
+
networkError,
|
|
163
|
+
});
|
|
164
|
+
if (newState === "disconnected") {
|
|
165
|
+
// Auth failure threshold reached
|
|
166
|
+
if (state.machine.context.disconnectReason === "auth") {
|
|
167
|
+
console.log(chalk.yellow(" Authentication failed — device token may be invalid."));
|
|
168
|
+
console.log(chalk.dim(" Re-entering setup mode..."));
|
|
169
|
+
console.log();
|
|
170
|
+
}
|
|
171
|
+
state.enterSetupMode?.();
|
|
172
|
+
}
|
|
173
|
+
else if (newState === "reconnecting") {
|
|
174
|
+
console.log(chalk.yellow(" Disconnected from relay"));
|
|
175
|
+
scheduleReconnect();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
state.ws.on("error", (err) => {
|
|
179
|
+
// Flag network-level errors (DNS, TCP) so the close handler
|
|
180
|
+
// doesn't count them as auth failures
|
|
181
|
+
const msg = err.message || "";
|
|
182
|
+
if (msg.includes("ENOTFOUND") || msg.includes("ETIMEDOUT") ||
|
|
183
|
+
msg.includes("ENETUNREACH") || msg.includes("ECONNREFUSED") ||
|
|
184
|
+
msg.includes("EAI_AGAIN")) {
|
|
185
|
+
networkError = true;
|
|
186
|
+
}
|
|
187
|
+
console.error(chalk.red(` Connection error: ${err.message}`));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function scheduleReconnect() {
|
|
191
|
+
const delay = state.reconnectDelay;
|
|
192
|
+
console.log(chalk.dim(` Reconnecting in ${delay / 1000}s...`));
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
state.machine.context.reconnectDelay = computeReconnectDelay(state.machine.context.reconnectDelay, RECONNECT_MAX_MS);
|
|
195
|
+
state.machine.transition({ type: "RECONNECT_TIMER" });
|
|
196
|
+
connect();
|
|
197
|
+
}, delay);
|
|
198
|
+
}
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { loadConfig, saveConfig, getProfileSuffix } from "./config.js";
|
|
3
|
+
import { storeSecret, loadSecret } from "./secret-store.js";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
const log = createLogger("crypto");
|
|
6
|
+
export function generateKeyPair() {
|
|
7
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
|
8
|
+
return { publicKey, secretKey: privateKey };
|
|
9
|
+
}
|
|
10
|
+
export function exportPublicKeyRaw(key) {
|
|
11
|
+
const jwk = key.export({ format: "jwk" });
|
|
12
|
+
return Buffer.from(jwk.x, "base64url");
|
|
13
|
+
}
|
|
14
|
+
export function importPublicKeyRaw(raw) {
|
|
15
|
+
const x = raw.toString("base64url");
|
|
16
|
+
return crypto.createPublicKey({
|
|
17
|
+
format: "jwk",
|
|
18
|
+
key: { kty: "OKP", crv: "X25519", x },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export function computeSharedSecret(theirPublicKey, mySecretKey) {
|
|
22
|
+
return crypto.diffieHellman({ publicKey: theirPublicKey, privateKey: mySecretKey });
|
|
23
|
+
}
|
|
24
|
+
export function encrypt(payload, sharedSecret) {
|
|
25
|
+
const iv = crypto.randomBytes(12);
|
|
26
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", sharedSecret, iv);
|
|
27
|
+
const message = Buffer.from(JSON.stringify(payload));
|
|
28
|
+
const encrypted = Buffer.concat([cipher.update(message), cipher.final()]);
|
|
29
|
+
const authTag = cipher.getAuthTag();
|
|
30
|
+
const ciphertextWithTag = Buffer.concat([encrypted, authTag]);
|
|
31
|
+
return {
|
|
32
|
+
nonce: iv.toString("base64"),
|
|
33
|
+
ciphertext: ciphertextWithTag.toString("base64"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function decrypt(encrypted, sharedSecret) {
|
|
37
|
+
const iv = Buffer.from(encrypted.nonce, "base64");
|
|
38
|
+
const ciphertextWithTag = Buffer.from(encrypted.ciphertext, "base64");
|
|
39
|
+
const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - 16);
|
|
40
|
+
const authTag = ciphertextWithTag.subarray(ciphertextWithTag.length - 16);
|
|
41
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", sharedSecret, iv);
|
|
42
|
+
decipher.setAuthTag(authTag);
|
|
43
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
44
|
+
return JSON.parse(decrypted.toString("utf-8"));
|
|
45
|
+
}
|
|
46
|
+
export function toBase64(bytes) {
|
|
47
|
+
return Buffer.from(bytes).toString("base64");
|
|
48
|
+
}
|
|
49
|
+
export function fromBase64(str) {
|
|
50
|
+
return Buffer.from(str, "base64");
|
|
51
|
+
}
|
|
52
|
+
function keychainAccount() {
|
|
53
|
+
return `default${getProfileSuffix()}`;
|
|
54
|
+
}
|
|
55
|
+
export function loadKeys() {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
if (!config || !config.publicKey)
|
|
58
|
+
return null;
|
|
59
|
+
const secretKey = loadSecret(keychainAccount());
|
|
60
|
+
if (!secretKey)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
publicKey: config.publicKey,
|
|
64
|
+
secretKey,
|
|
65
|
+
peerPublicKey: config.peerPublicKey,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function saveKeys(keys) {
|
|
69
|
+
const existing = loadConfig();
|
|
70
|
+
if (!storeSecret(keychainAccount(), keys.secretKey)) {
|
|
71
|
+
log.warn("OS secret store unavailable — secret key will not be persisted");
|
|
72
|
+
}
|
|
73
|
+
saveConfig({
|
|
74
|
+
...existing,
|
|
75
|
+
deviceId: existing?.deviceId || "",
|
|
76
|
+
deviceToken: existing?.deviceToken,
|
|
77
|
+
publicKey: keys.publicKey,
|
|
78
|
+
peerPublicKey: keys.peerPublicKey,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Set of message types that should be encrypted when a shared secret exists */
|
|
82
|
+
export const ENCRYPTED_TYPES = new Set([
|
|
83
|
+
"verify_code",
|
|
84
|
+
"verify_code_ack",
|
|
85
|
+
"prompt",
|
|
86
|
+
"stream_start",
|
|
87
|
+
"stream_delta",
|
|
88
|
+
"stream_end",
|
|
89
|
+
"session_create",
|
|
90
|
+
"session_created",
|
|
91
|
+
"session_end",
|
|
92
|
+
"projects_request",
|
|
93
|
+
"projects_response",
|
|
94
|
+
"tool_approval_request",
|
|
95
|
+
"tool_approval_response",
|
|
96
|
+
"sessions_request",
|
|
97
|
+
"sessions_response",
|
|
98
|
+
"session_history_request",
|
|
99
|
+
"session_history_response",
|
|
100
|
+
"error",
|
|
101
|
+
]);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { createLogger } from "./logger.js";
|
|
6
|
+
const log = createLogger("hook");
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export function getHookScriptPath() {
|
|
9
|
+
return path.join(__dirname, "approval-hook.mjs");
|
|
10
|
+
}
|
|
11
|
+
export function installHook() {
|
|
12
|
+
const settingsPath = path.join(homedir(), ".claude", "settings.json");
|
|
13
|
+
const claudeDir = path.join(homedir(), ".claude");
|
|
14
|
+
// Ensure ~/.claude exists
|
|
15
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
16
|
+
let settings = {};
|
|
17
|
+
try {
|
|
18
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
const hookCommand = `node "${getHookScriptPath()}"`;
|
|
22
|
+
settings.hooks ??= {};
|
|
23
|
+
settings.hooks.PreToolUse ??= [];
|
|
24
|
+
// Check if already installed — update path if it changed (e.g., source vs global install)
|
|
25
|
+
const existingIdx = settings.hooks.PreToolUse.findIndex((entry) => entry.hooks?.some((h) => h.command?.includes("approval-hook")));
|
|
26
|
+
if (existingIdx !== -1) {
|
|
27
|
+
const existing = settings.hooks.PreToolUse[existingIdx];
|
|
28
|
+
const existingCmd = existing.hooks?.find((h) => h.command?.includes("approval-hook"))?.command;
|
|
29
|
+
if (existingCmd === hookCommand) {
|
|
30
|
+
log.info("BeachViber approval hook already installed");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Path changed — update the hook command
|
|
34
|
+
log.info(`Updating hook path: ${existingCmd} -> ${hookCommand}`);
|
|
35
|
+
settings.hooks.PreToolUse[existingIdx] = {
|
|
36
|
+
matcher: "",
|
|
37
|
+
hooks: [{ type: "command", command: hookCommand, timeout: 310 }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
settings.hooks.PreToolUse.push({
|
|
42
|
+
matcher: "",
|
|
43
|
+
hooks: [{ type: "command", command: hookCommand, timeout: 310 }],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
47
|
+
log.info(`Installed PreToolUse hook in ${settingsPath}`);
|
|
48
|
+
}
|
|
49
|
+
export function uninstallHook() {
|
|
50
|
+
const settingsPath = path.join(homedir(), ".claude", "settings.json");
|
|
51
|
+
try {
|
|
52
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
53
|
+
if (!settings.hooks?.PreToolUse)
|
|
54
|
+
return;
|
|
55
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((entry) => !entry.hooks?.some((h) => h.command?.includes("approval-hook")));
|
|
56
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
57
|
+
log.info("Removed BeachViber approval hook");
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
}
|