@agentvault/secure-channel 0.2.0 → 0.4.1
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/README.md +229 -0
- package/dist/channel.d.ts +9 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/cli.js +153 -42
- package/dist/cli.js.map +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +154 -43
- package/dist/index.js.map +2 -2
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# @agentvault/secure-channel
|
|
2
|
+
|
|
3
|
+
End-to-end encrypted communication channel for AI agents on the [AgentVault](https://agentvault.chat) platform. Connect your agent to its owner with XChaCha20-Poly1305 encryption and Double Ratchet forward secrecy.
|
|
4
|
+
|
|
5
|
+
## What's New in v0.4.0
|
|
6
|
+
|
|
7
|
+
**Webhook Notifications** — Your agent can now receive HTTP webhook callbacks when a new message arrives, even when it's not connected via WebSocket. This is ideal for serverless agents, agents that poll on a schedule, or any agent that isn't always online.
|
|
8
|
+
|
|
9
|
+
### Upgrading from v0.3.x
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @agentvault/secure-channel@latest
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add `webhookUrl` to your config to enable:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const channel = new SecureChannel({
|
|
19
|
+
inviteToken: "your-token",
|
|
20
|
+
dataDir: "./agentvault-data",
|
|
21
|
+
apiUrl: "https://api.agentvault.chat",
|
|
22
|
+
webhookUrl: "https://your-server.com/webhook/agentvault", // NEW in 0.4.0
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No other code changes required — fully backward-compatible.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @agentvault/secure-channel
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Option 1: CLI (Interactive)
|
|
39
|
+
|
|
40
|
+
Run directly with npx using the invite token from your AgentVault dashboard:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx @agentvault/secure-channel --token=YOUR_INVITE_TOKEN
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The CLI will:
|
|
47
|
+
1. Enroll your agent with the server
|
|
48
|
+
2. Display a fingerprint for the owner to verify
|
|
49
|
+
3. Wait for owner approval
|
|
50
|
+
4. Establish an encrypted channel
|
|
51
|
+
5. Enter interactive mode where you can send/receive messages
|
|
52
|
+
|
|
53
|
+
**CLI Flags:**
|
|
54
|
+
|
|
55
|
+
| Flag | Default | Description |
|
|
56
|
+
|------|---------|-------------|
|
|
57
|
+
| `--token` | (required on first run) | Invite token from dashboard |
|
|
58
|
+
| `--name` | `"CLI Agent"` | Agent display name |
|
|
59
|
+
| `--data-dir` | `./agentvault-data` | Directory for persistent state |
|
|
60
|
+
| `--api-url` | `https://api.agentvault.chat` | API endpoint |
|
|
61
|
+
|
|
62
|
+
Environment variables (`AGENTVAULT_INVITE_TOKEN`, `AGENTVAULT_AGENT_NAME`, `AGENTVAULT_DATA_DIR`, `AGENTVAULT_API_URL`) work as alternatives to flags.
|
|
63
|
+
|
|
64
|
+
### Option 2: SDK (Programmatic)
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { SecureChannel } from "@agentvault/secure-channel";
|
|
68
|
+
|
|
69
|
+
const channel = new SecureChannel({
|
|
70
|
+
inviteToken: "YOUR_INVITE_TOKEN",
|
|
71
|
+
dataDir: "./agentvault-data",
|
|
72
|
+
apiUrl: "https://api.agentvault.chat",
|
|
73
|
+
agentName: "My Agent",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
channel.on("message", (text, metadata) => {
|
|
77
|
+
console.log(`Received: ${text}`);
|
|
78
|
+
// Echo back
|
|
79
|
+
channel.send(`You said: ${text}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
channel.on("ready", () => {
|
|
83
|
+
console.log("Secure channel established!");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await channel.start();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Webhook Notifications (v0.4.0+)
|
|
92
|
+
|
|
93
|
+
Enable webhook notifications so your agent gets an HTTP POST when a new message arrives — useful for agents that aren't always connected via WebSocket.
|
|
94
|
+
|
|
95
|
+
### Setup
|
|
96
|
+
|
|
97
|
+
Add `webhookUrl` to your config:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const channel = new SecureChannel({
|
|
101
|
+
inviteToken: "YOUR_INVITE_TOKEN",
|
|
102
|
+
dataDir: "./agentvault-data",
|
|
103
|
+
apiUrl: "https://api.agentvault.chat",
|
|
104
|
+
webhookUrl: "https://your-server.com/webhook/agentvault",
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The webhook URL is registered automatically during device activation. The channel emits a `webhook_registered` event on success:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
channel.on("webhook_registered", ({ url, secret }) => {
|
|
112
|
+
console.log(`Webhook registered at ${url}`);
|
|
113
|
+
// Save the secret to verify incoming webhooks
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Webhook Payload
|
|
118
|
+
|
|
119
|
+
When the owner sends a message, your webhook endpoint receives:
|
|
120
|
+
|
|
121
|
+
```http
|
|
122
|
+
POST /webhook/agentvault HTTP/1.1
|
|
123
|
+
Content-Type: application/json
|
|
124
|
+
X-AgentVault-Event: new_message
|
|
125
|
+
X-AgentVault-Signature: sha256=<hmac-hex>
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
"event": "new_message",
|
|
129
|
+
"conversation_id": "uuid",
|
|
130
|
+
"sender_device_id": "uuid",
|
|
131
|
+
"message_id": "uuid",
|
|
132
|
+
"timestamp": "2026-02-17T12:00:00Z"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Verifying Webhook Signatures
|
|
137
|
+
|
|
138
|
+
Each webhook includes an HMAC-SHA256 signature in the `X-AgentVault-Signature` header. Verify it using the secret from the `webhook_registered` event:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import crypto from "crypto";
|
|
142
|
+
|
|
143
|
+
function verifyWebhook(body, signature, secret) {
|
|
144
|
+
const expected = "sha256=" +
|
|
145
|
+
crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
146
|
+
return crypto.timingSafeEqual(
|
|
147
|
+
Buffer.from(signature),
|
|
148
|
+
Buffer.from(expected),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Configuration Reference
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
interface SecureChannelConfig {
|
|
159
|
+
// Required
|
|
160
|
+
inviteToken: string; // Invite token from the AgentVault dashboard
|
|
161
|
+
dataDir: string; // Directory for persistent state files
|
|
162
|
+
apiUrl: string; // API endpoint (e.g., "https://api.agentvault.chat")
|
|
163
|
+
|
|
164
|
+
// Optional
|
|
165
|
+
agentName?: string; // Display name (default: "CLI Agent")
|
|
166
|
+
platform?: string; // Platform identifier (e.g., "node")
|
|
167
|
+
maxHistorySize?: number; // Max stored messages for cross-device replay (default: 500)
|
|
168
|
+
webhookUrl?: string; // Webhook URL for new message notifications (v0.4.0+)
|
|
169
|
+
|
|
170
|
+
// Callbacks (alternative to event listeners)
|
|
171
|
+
onMessage?: (text: string, metadata: MessageMetadata) => void;
|
|
172
|
+
onStateChange?: (state: ChannelState) => void;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Events
|
|
177
|
+
|
|
178
|
+
| Event | Payload | Description |
|
|
179
|
+
|-------|---------|-------------|
|
|
180
|
+
| `message` | `(text: string, metadata: MessageMetadata)` | Owner sent a message |
|
|
181
|
+
| `ready` | none | WebSocket connected, channel operational |
|
|
182
|
+
| `state` | `(state: ChannelState)` | State transition occurred |
|
|
183
|
+
| `error` | `(error: Error)` | Fatal error |
|
|
184
|
+
| `webhook_registered` | `({ url: string, secret: string })` | Webhook registered (v0.4.0+) |
|
|
185
|
+
|
|
186
|
+
## Channel States
|
|
187
|
+
|
|
188
|
+
`idle` → `enrolling` → `polling` → `activating` → `connecting` → `ready`
|
|
189
|
+
|
|
190
|
+
If disconnected: `ready` → `disconnected` → `connecting` → `ready` (auto-reconnect)
|
|
191
|
+
|
|
192
|
+
## API
|
|
193
|
+
|
|
194
|
+
| Method | Description |
|
|
195
|
+
|--------|-------------|
|
|
196
|
+
| `start()` | Initialize, enroll, and connect |
|
|
197
|
+
| `send(text)` | Encrypt and send message to all owner devices |
|
|
198
|
+
| `stop()` | Gracefully disconnect |
|
|
199
|
+
|
|
200
|
+
| Property | Description |
|
|
201
|
+
|----------|-------------|
|
|
202
|
+
| `state` | Current channel state |
|
|
203
|
+
| `deviceId` | Agent's device ID (after enrollment) |
|
|
204
|
+
| `fingerprint` | Device fingerprint for verification |
|
|
205
|
+
| `conversationId` | Primary conversation ID |
|
|
206
|
+
| `conversationIds` | All active conversation IDs (multi-device) |
|
|
207
|
+
| `sessionCount` | Number of active encrypted sessions |
|
|
208
|
+
|
|
209
|
+
## Multi-Device Support
|
|
210
|
+
|
|
211
|
+
AgentVault supports multiple owner devices (e.g., desktop + mobile). The channel automatically:
|
|
212
|
+
- Maintains independent encrypted sessions per owner device
|
|
213
|
+
- Fans out `send()` to all active sessions
|
|
214
|
+
- Stores message history for cross-device replay (up to `maxHistorySize`)
|
|
215
|
+
- Replays history when a new device connects
|
|
216
|
+
|
|
217
|
+
No additional configuration needed — multi-device is handled transparently.
|
|
218
|
+
|
|
219
|
+
## Security
|
|
220
|
+
|
|
221
|
+
- **XChaCha20-Poly1305** symmetric encryption (192-bit nonces)
|
|
222
|
+
- **X3DH** key agreement (Ed25519 + X25519)
|
|
223
|
+
- **Double Ratchet** for forward secrecy — old keys deleted after use
|
|
224
|
+
- **Zero-knowledge server** — the server never sees plaintext
|
|
225
|
+
- **HMAC-SHA256** webhook signatures for authenticity verification
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/dist/channel.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export declare class SecureChannel extends EventEmitter {
|
|
|
25
25
|
/** Returns the number of active sessions. */
|
|
26
26
|
get sessionCount(): number;
|
|
27
27
|
start(): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Append a message to persistent history for cross-device replay.
|
|
30
|
+
*/
|
|
31
|
+
private _appendHistory;
|
|
28
32
|
/**
|
|
29
33
|
* Encrypt and send a message to ALL owner devices (fanout).
|
|
30
34
|
* Each session gets the same plaintext encrypted independently.
|
|
@@ -46,6 +50,11 @@ export declare class SecureChannel extends EventEmitter {
|
|
|
46
50
|
* This allows all owner devices to see messages from any single device.
|
|
47
51
|
*/
|
|
48
52
|
private _relaySyncToSiblings;
|
|
53
|
+
/**
|
|
54
|
+
* Send stored message history to a newly-activated session.
|
|
55
|
+
* Batches all history into a single encrypted message.
|
|
56
|
+
*/
|
|
57
|
+
private _replayHistoryToSession;
|
|
49
58
|
/**
|
|
50
59
|
* Handle a device_linked event: a new owner device has joined.
|
|
51
60
|
* Fetches the new device's public keys, performs X3DH, and initializes
|
package/dist/channel.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAa3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAa3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EAKb,MAAM,YAAY,CAAC;AAiDpB,qBAAa,aAAc,SAAQ,YAAY;IAiBjC,OAAO,CAAC,MAAM;IAhB1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,sBAAsB,CAAc;IAC5C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAGH;IACd,OAAO,CAAC,GAAG,CAA0B;IACrC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA+B;gBAE7B,MAAM,EAAE,mBAAmB;IAI/C,IAAI,KAAK,IAAI,YAAY,CAExB;IAED,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,iEAAiE;IACjE,IAAI,cAAc,IAAI,MAAM,GAAG,IAAI,CAElC;IAED,2CAA2C;IAC3C,IAAI,eAAe,IAAI,MAAM,EAAE,CAE9B;IAED,6CAA6C;IAC7C,IAAI,YAAY,IAAI,MAAM,CAEzB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;OAEG;IACH,OAAO,CAAC,cAAc;IAmBtB;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IAgDrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IA2HvB,OAAO,CAAC,QAAQ;IA4DhB;;;;OAIG;YACW,sBAAsB;IAoFpC;;;OAGG;YACW,oBAAoB;IAmClC;;;OAGG;YACW,uBAAuB;IAkCrC;;;;OAIG;YACW,mBAAmB;IAkEjC;;;OAGG;YACW,mBAAmB;IAwFjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAc5B"}
|
package/dist/cli.js
CHANGED
|
@@ -45054,7 +45054,8 @@ function migratePersistedState(raw) {
|
|
|
45054
45054
|
identityKeypair: legacy.identityKeypair,
|
|
45055
45055
|
ephemeralKeypair: legacy.ephemeralKeypair,
|
|
45056
45056
|
fingerprint: legacy.fingerprint,
|
|
45057
|
-
lastMessageTimestamp: legacy.lastMessageTimestamp
|
|
45057
|
+
lastMessageTimestamp: legacy.lastMessageTimestamp,
|
|
45058
|
+
messageHistory: []
|
|
45058
45059
|
};
|
|
45059
45060
|
}
|
|
45060
45061
|
var SecureChannel = class extends EventEmitter {
|
|
@@ -45101,6 +45102,9 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45101
45102
|
const raw = await loadState(this.config.dataDir);
|
|
45102
45103
|
if (raw) {
|
|
45103
45104
|
this._persisted = migratePersistedState(raw);
|
|
45105
|
+
if (!this._persisted.messageHistory) {
|
|
45106
|
+
this._persisted.messageHistory = [];
|
|
45107
|
+
}
|
|
45104
45108
|
this._deviceId = this._persisted.deviceId;
|
|
45105
45109
|
this._deviceJwt = this._persisted.deviceJwt;
|
|
45106
45110
|
this._primaryConversationId = this._persisted.primaryConversationId;
|
|
@@ -45112,7 +45116,8 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45112
45116
|
const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
|
|
45113
45117
|
this._sessions.set(convId, {
|
|
45114
45118
|
ownerDeviceId: sessionData.ownerDeviceId,
|
|
45115
|
-
ratchet
|
|
45119
|
+
ratchet,
|
|
45120
|
+
activated: sessionData.activated ?? false
|
|
45116
45121
|
});
|
|
45117
45122
|
}
|
|
45118
45123
|
}
|
|
@@ -45121,6 +45126,24 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45121
45126
|
}
|
|
45122
45127
|
await this._enroll();
|
|
45123
45128
|
}
|
|
45129
|
+
/**
|
|
45130
|
+
* Append a message to persistent history for cross-device replay.
|
|
45131
|
+
*/
|
|
45132
|
+
_appendHistory(sender, text) {
|
|
45133
|
+
if (!this._persisted) return;
|
|
45134
|
+
if (!this._persisted.messageHistory) {
|
|
45135
|
+
this._persisted.messageHistory = [];
|
|
45136
|
+
}
|
|
45137
|
+
const maxSize = this.config.maxHistorySize ?? 500;
|
|
45138
|
+
this._persisted.messageHistory.push({
|
|
45139
|
+
sender,
|
|
45140
|
+
text,
|
|
45141
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
45142
|
+
});
|
|
45143
|
+
if (this._persisted.messageHistory.length > maxSize) {
|
|
45144
|
+
this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
|
|
45145
|
+
}
|
|
45146
|
+
}
|
|
45124
45147
|
/**
|
|
45125
45148
|
* Encrypt and send a message to ALL owner devices (fanout).
|
|
45126
45149
|
* Each session gets the same plaintext encrypted independently.
|
|
@@ -45129,8 +45152,12 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45129
45152
|
if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
|
|
45130
45153
|
throw new Error("Channel is not ready");
|
|
45131
45154
|
}
|
|
45155
|
+
this._appendHistory("agent", plaintext);
|
|
45132
45156
|
const messageGroupId = randomUUID();
|
|
45133
45157
|
for (const [convId, session] of this._sessions) {
|
|
45158
|
+
if (!session.activated) {
|
|
45159
|
+
continue;
|
|
45160
|
+
}
|
|
45134
45161
|
const encrypted = session.ratchet.encrypt(plaintext);
|
|
45135
45162
|
const transport = encryptedMessageToTransport(encrypted);
|
|
45136
45163
|
this._ws.send(
|
|
@@ -45201,7 +45228,8 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45201
45228
|
publicKey: bytesToHex(ephemeral.publicKey),
|
|
45202
45229
|
privateKey: bytesToHex(ephemeral.privateKey)
|
|
45203
45230
|
},
|
|
45204
|
-
fingerprint: result.fingerprint
|
|
45231
|
+
fingerprint: result.fingerprint,
|
|
45232
|
+
messageHistory: []
|
|
45205
45233
|
};
|
|
45206
45234
|
this._poll();
|
|
45207
45235
|
} catch (err) {
|
|
@@ -45252,36 +45280,73 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45252
45280
|
this._deviceJwt = result.device_jwt;
|
|
45253
45281
|
const identity = this._persisted.identityKeypair;
|
|
45254
45282
|
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45255
|
-
const
|
|
45256
|
-
|
|
45257
|
-
|
|
45258
|
-
|
|
45259
|
-
|
|
45260
|
-
|
|
45261
|
-
|
|
45262
|
-
|
|
45263
|
-
|
|
45264
|
-
|
|
45265
|
-
|
|
45266
|
-
|
|
45267
|
-
|
|
45268
|
-
|
|
45269
|
-
|
|
45270
|
-
|
|
45271
|
-
|
|
45272
|
-
|
|
45283
|
+
const sessions = {};
|
|
45284
|
+
for (const conv of conversations) {
|
|
45285
|
+
const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
|
|
45286
|
+
const ownerEphemeralKey = conv.owner_ephemeral_public_key || result.owner_ephemeral_public_key || ownerIdentityKey;
|
|
45287
|
+
const sharedSecret = performX3DH({
|
|
45288
|
+
myIdentityPrivate: hexToBytes(identity.privateKey),
|
|
45289
|
+
myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
|
|
45290
|
+
theirIdentityPublic: hexToBytes(ownerIdentityKey),
|
|
45291
|
+
theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
|
|
45292
|
+
isInitiator: false
|
|
45293
|
+
});
|
|
45294
|
+
const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
|
|
45295
|
+
publicKey: hexToBytes(identity.publicKey),
|
|
45296
|
+
privateKey: hexToBytes(identity.privateKey),
|
|
45297
|
+
keyType: "ed25519"
|
|
45298
|
+
});
|
|
45299
|
+
this._sessions.set(conv.conversation_id, {
|
|
45300
|
+
ownerDeviceId: conv.owner_device_id,
|
|
45301
|
+
ratchet,
|
|
45302
|
+
activated: false
|
|
45303
|
+
// Wait for owner's first message before sending to this session
|
|
45304
|
+
});
|
|
45305
|
+
sessions[conv.conversation_id] = {
|
|
45306
|
+
ownerDeviceId: conv.owner_device_id,
|
|
45307
|
+
ratchetState: ratchet.serialize()
|
|
45308
|
+
};
|
|
45309
|
+
console.log(
|
|
45310
|
+
`[SecureChannel] Session initialized for conv ${conv.conversation_id.slice(0, 8)}... (owner ${conv.owner_device_id.slice(0, 8)}..., primary=${conv.is_primary})`
|
|
45311
|
+
);
|
|
45312
|
+
}
|
|
45273
45313
|
this._persisted = {
|
|
45274
45314
|
...this._persisted,
|
|
45275
45315
|
deviceJwt: result.device_jwt,
|
|
45276
45316
|
primaryConversationId: primary.conversation_id,
|
|
45277
|
-
sessions
|
|
45278
|
-
|
|
45279
|
-
ownerDeviceId: primary.owner_device_id,
|
|
45280
|
-
ratchetState: ratchet.serialize()
|
|
45281
|
-
}
|
|
45282
|
-
}
|
|
45317
|
+
sessions,
|
|
45318
|
+
messageHistory: this._persisted.messageHistory ?? []
|
|
45283
45319
|
};
|
|
45284
45320
|
await saveState(this.config.dataDir, this._persisted);
|
|
45321
|
+
if (this.config.webhookUrl) {
|
|
45322
|
+
try {
|
|
45323
|
+
const webhookResp = await fetch(
|
|
45324
|
+
`${this.config.apiUrl}/api/v1/devices/self/webhook`,
|
|
45325
|
+
{
|
|
45326
|
+
method: "PATCH",
|
|
45327
|
+
headers: {
|
|
45328
|
+
"Content-Type": "application/json",
|
|
45329
|
+
Authorization: `Bearer ${this._deviceJwt}`
|
|
45330
|
+
},
|
|
45331
|
+
body: JSON.stringify({ webhook_url: this.config.webhookUrl })
|
|
45332
|
+
}
|
|
45333
|
+
);
|
|
45334
|
+
if (webhookResp.ok) {
|
|
45335
|
+
const webhookData = await webhookResp.json();
|
|
45336
|
+
console.log(
|
|
45337
|
+
`[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`
|
|
45338
|
+
);
|
|
45339
|
+
this.emit("webhook_registered", {
|
|
45340
|
+
url: this.config.webhookUrl,
|
|
45341
|
+
secret: webhookData.webhook_secret
|
|
45342
|
+
});
|
|
45343
|
+
} else {
|
|
45344
|
+
console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
|
|
45345
|
+
}
|
|
45346
|
+
} catch (err) {
|
|
45347
|
+
console.warn(`[SecureChannel] Webhook registration error: ${err}`);
|
|
45348
|
+
}
|
|
45349
|
+
}
|
|
45285
45350
|
this._connect();
|
|
45286
45351
|
} catch (err) {
|
|
45287
45352
|
this._handleError(err);
|
|
@@ -45352,6 +45417,10 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45352
45417
|
ciphertext: msgData.ciphertext
|
|
45353
45418
|
});
|
|
45354
45419
|
const plaintext = session.ratchet.decrypt(encrypted);
|
|
45420
|
+
if (!session.activated) {
|
|
45421
|
+
session.activated = true;
|
|
45422
|
+
console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
|
|
45423
|
+
}
|
|
45355
45424
|
let messageText;
|
|
45356
45425
|
let messageType;
|
|
45357
45426
|
try {
|
|
@@ -45362,7 +45431,14 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45362
45431
|
messageType = "message";
|
|
45363
45432
|
messageText = plaintext;
|
|
45364
45433
|
}
|
|
45434
|
+
if (messageType === "session_init") {
|
|
45435
|
+
console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
|
|
45436
|
+
await this._replayHistoryToSession(convId);
|
|
45437
|
+
await this._persistState();
|
|
45438
|
+
return;
|
|
45439
|
+
}
|
|
45365
45440
|
if (messageType === "message") {
|
|
45441
|
+
this._appendHistory("owner", messageText);
|
|
45366
45442
|
const metadata = {
|
|
45367
45443
|
messageId: msgData.message_id,
|
|
45368
45444
|
conversationId: convId,
|
|
@@ -45391,6 +45467,7 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45391
45467
|
});
|
|
45392
45468
|
for (const [siblingConvId, siblingSession] of this._sessions) {
|
|
45393
45469
|
if (siblingConvId === sourceConvId) continue;
|
|
45470
|
+
if (!siblingSession.activated) continue;
|
|
45394
45471
|
const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
|
|
45395
45472
|
const syncTransport = encryptedMessageToTransport(syncEncrypted);
|
|
45396
45473
|
this._ws.send(
|
|
@@ -45405,6 +45482,38 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45405
45482
|
);
|
|
45406
45483
|
}
|
|
45407
45484
|
}
|
|
45485
|
+
/**
|
|
45486
|
+
* Send stored message history to a newly-activated session.
|
|
45487
|
+
* Batches all history into a single encrypted message.
|
|
45488
|
+
*/
|
|
45489
|
+
async _replayHistoryToSession(convId) {
|
|
45490
|
+
const session = this._sessions.get(convId);
|
|
45491
|
+
if (!session || !session.activated || !this._ws) return;
|
|
45492
|
+
const history = this._persisted?.messageHistory ?? [];
|
|
45493
|
+
if (history.length === 0) {
|
|
45494
|
+
console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
|
|
45495
|
+
return;
|
|
45496
|
+
}
|
|
45497
|
+
console.log(
|
|
45498
|
+
`[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`
|
|
45499
|
+
);
|
|
45500
|
+
const replayPayload = JSON.stringify({
|
|
45501
|
+
type: "history_replay",
|
|
45502
|
+
messages: history
|
|
45503
|
+
});
|
|
45504
|
+
const encrypted = session.ratchet.encrypt(replayPayload);
|
|
45505
|
+
const transport = encryptedMessageToTransport(encrypted);
|
|
45506
|
+
this._ws.send(
|
|
45507
|
+
JSON.stringify({
|
|
45508
|
+
event: "message",
|
|
45509
|
+
data: {
|
|
45510
|
+
conversation_id: convId,
|
|
45511
|
+
header_blob: transport.header_blob,
|
|
45512
|
+
ciphertext: transport.ciphertext
|
|
45513
|
+
}
|
|
45514
|
+
})
|
|
45515
|
+
);
|
|
45516
|
+
}
|
|
45408
45517
|
/**
|
|
45409
45518
|
* Handle a device_linked event: a new owner device has joined.
|
|
45410
45519
|
* Fetches the new device's public keys, performs X3DH, and initializes
|
|
@@ -45415,27 +45524,20 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45415
45524
|
`[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
|
|
45416
45525
|
);
|
|
45417
45526
|
try {
|
|
45418
|
-
|
|
45419
|
-
`${this.config.apiUrl}/api/v1/conversations/${event.conversation_id}/keys`,
|
|
45420
|
-
{
|
|
45421
|
-
headers: { Authorization: `Bearer ${this._deviceJwt}` }
|
|
45422
|
-
}
|
|
45423
|
-
);
|
|
45424
|
-
if (!keysRes.ok) {
|
|
45527
|
+
if (!event.owner_identity_public_key) {
|
|
45425
45528
|
console.error(
|
|
45426
|
-
`[SecureChannel]
|
|
45529
|
+
`[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
|
|
45427
45530
|
);
|
|
45428
45531
|
return;
|
|
45429
45532
|
}
|
|
45430
|
-
const keys = await keysRes.json();
|
|
45431
45533
|
const identity = this._persisted.identityKeypair;
|
|
45432
45534
|
const ephemeral = this._persisted.ephemeralKeypair;
|
|
45433
45535
|
const sharedSecret = performX3DH({
|
|
45434
45536
|
myIdentityPrivate: hexToBytes(identity.privateKey),
|
|
45435
45537
|
myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
|
|
45436
|
-
theirIdentityPublic: hexToBytes(
|
|
45538
|
+
theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
|
|
45437
45539
|
theirEphemeralPublic: hexToBytes(
|
|
45438
|
-
|
|
45540
|
+
event.owner_ephemeral_public_key ?? event.owner_identity_public_key
|
|
45439
45541
|
),
|
|
45440
45542
|
isInitiator: false
|
|
45441
45543
|
});
|
|
@@ -45446,15 +45548,18 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45446
45548
|
});
|
|
45447
45549
|
this._sessions.set(event.conversation_id, {
|
|
45448
45550
|
ownerDeviceId: event.owner_device_id,
|
|
45449
|
-
ratchet
|
|
45551
|
+
ratchet,
|
|
45552
|
+
activated: false
|
|
45553
|
+
// Wait for owner's first message
|
|
45450
45554
|
});
|
|
45451
45555
|
this._persisted.sessions[event.conversation_id] = {
|
|
45452
45556
|
ownerDeviceId: event.owner_device_id,
|
|
45453
|
-
ratchetState: ratchet.serialize()
|
|
45557
|
+
ratchetState: ratchet.serialize(),
|
|
45558
|
+
activated: false
|
|
45454
45559
|
};
|
|
45455
45560
|
await this._persistState();
|
|
45456
45561
|
console.log(
|
|
45457
|
-
`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}
|
|
45562
|
+
`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
|
|
45458
45563
|
);
|
|
45459
45564
|
} catch (err) {
|
|
45460
45565
|
console.error(
|
|
@@ -45493,6 +45598,10 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45493
45598
|
ciphertext: msg.ciphertext
|
|
45494
45599
|
});
|
|
45495
45600
|
const plaintext = session.ratchet.decrypt(encrypted);
|
|
45601
|
+
if (!session.activated) {
|
|
45602
|
+
session.activated = true;
|
|
45603
|
+
console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
|
|
45604
|
+
}
|
|
45496
45605
|
let messageText;
|
|
45497
45606
|
let messageType;
|
|
45498
45607
|
try {
|
|
@@ -45504,6 +45613,7 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45504
45613
|
messageText = plaintext;
|
|
45505
45614
|
}
|
|
45506
45615
|
if (messageType === "message") {
|
|
45616
|
+
this._appendHistory("owner", messageText);
|
|
45507
45617
|
const metadata = {
|
|
45508
45618
|
messageId: msg.id,
|
|
45509
45619
|
conversationId: msg.conversation_id,
|
|
@@ -45554,7 +45664,8 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45554
45664
|
for (const [convId, session] of this._sessions) {
|
|
45555
45665
|
this._persisted.sessions[convId] = {
|
|
45556
45666
|
ownerDeviceId: session.ownerDeviceId,
|
|
45557
|
-
ratchetState: session.ratchet.serialize()
|
|
45667
|
+
ratchetState: session.ratchet.serialize(),
|
|
45668
|
+
activated: session.activated
|
|
45558
45669
|
};
|
|
45559
45670
|
}
|
|
45560
45671
|
await saveState(this.config.dataDir, this._persisted);
|