talon-agent 5.14.0 → 5.18.2
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/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +14 -7
- package/package.json +6 -4
- package/prompts/system/heartbeat-agent.md +1 -1
- package/src/backend/agy/factory.ts +3 -0
- package/src/backend/agy/mcp/config.ts +14 -2
- package/src/backend/claude-sdk/factory.ts +3 -0
- package/src/backend/claude-sdk/options.ts +24 -3
- package/src/backend/codex/factory.ts +3 -0
- package/src/backend/codex/init.ts +4 -0
- package/src/backend/codex/mcp-config.ts +10 -0
- package/src/backend/codex/oauth-incompat.ts +1 -1
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/backend/openai-agents/factory.ts +3 -0
- package/src/backend/openai-agents/mcp-pool.ts +4 -0
- package/src/backend/remote-server/factory.ts +3 -0
- package/src/backend/remote-server/mcp.ts +3 -0
- package/src/backend/runtime/prompt/prompt-format.ts +3 -3
- package/src/bootstrap.ts +8 -0
- package/src/cli/commands/backup.ts +61 -5
- package/src/cli/commands/mesh.ts +133 -0
- package/src/cli/config.ts +3 -1
- package/src/cli/daemon-api.ts +22 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/install-sources.ts +40 -5
- package/src/cli/plugin.ts +10 -0
- package/src/cli/setup.ts +45 -4
- package/src/cli/skill.ts +3 -0
- package/src/core/agent-runtime/backend-registry.ts +16 -0
- package/src/core/backup/archive/crypt.ts +429 -0
- package/src/core/backup/archive/manifest-auth.ts +98 -0
- package/src/core/backup/passphrase.ts +130 -0
- package/src/core/backup/plan.ts +66 -13
- package/src/core/backup/restore-guard.ts +101 -0
- package/src/core/backup/restore.ts +249 -32
- package/src/core/backup/snapshot.ts +418 -60
- package/src/core/backup/sources/plugins.ts +223 -0
- package/src/core/backup/sources/relocate.ts +136 -0
- package/src/core/backup/sources/sessions.ts +198 -0
- package/src/core/backup/store.ts +3 -1
- package/src/core/backup/types.ts +66 -0
- package/src/core/backup/upload.ts +66 -6
- package/src/core/config/index.ts +140 -8
- package/src/core/daemon/control.ts +9 -0
- package/src/core/daemon/discovery.ts +7 -0
- package/src/core/engine/backend-router/headroom.ts +29 -5
- package/src/core/engine/backend-router/usage.ts +6 -2
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
- package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
- package/src/core/engine/gateway-actions/index.ts +4 -2
- package/src/core/engine/gateway-actions/native/index.ts +24 -0
- package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
- package/src/core/engine/gateway-auth.ts +164 -0
- package/src/core/engine/gateway-routes.ts +100 -5
- package/src/core/engine/gateway.ts +12 -4
- package/src/core/mcp-hub/guest-scope.ts +170 -29
- package/src/core/mcp-hub/index.ts +33 -16
- package/src/core/mcp-hub/talon-server.ts +71 -14
- package/src/core/mesh/credentials/admin.ts +146 -0
- package/src/core/mesh/credentials/index.ts +19 -0
- package/src/core/mesh/credentials/store.ts +443 -0
- package/src/core/mesh/credentials/token.ts +45 -0
- package/src/core/mesh/credentials/types.ts +83 -0
- package/src/core/mesh/devices/service.ts +58 -6
- package/src/core/mesh/links/bridge-links.ts +46 -4
- package/src/core/mesh/links/node-binaries.ts +1 -1
- package/src/core/mesh/links/node-provision.ts +8 -1
- package/src/core/models/active-model.ts +1 -1
- package/src/core/plugin/loader.ts +4 -0
- package/src/core/plugin/mcp.ts +4 -0
- package/src/core/tools/bridge.ts +2 -1
- package/src/core/types.ts +13 -0
- package/src/core/weaver/weaver.ts +47 -15
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
- package/src/frontend/discord/handlers/delivery.ts +3 -0
- package/src/frontend/discord/handlers/queue.ts +1 -0
- package/src/frontend/native/bridge/auth-guard.ts +277 -0
- package/src/frontend/native/bridge/auth.ts +84 -1
- package/src/frontend/native/bridge/credentials/claims.ts +51 -0
- package/src/frontend/native/bridge/credentials/principal.ts +200 -0
- package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
- package/src/frontend/native/bridge/routes/auth.ts +37 -0
- package/src/frontend/native/bridge/routes/chats.ts +20 -2
- package/src/frontend/native/bridge/routes/host.ts +11 -1
- package/src/frontend/native/bridge/routes/index.ts +2 -0
- package/src/frontend/native/bridge/routes/mesh.ts +72 -15
- package/src/frontend/native/bridge/routes/table.ts +73 -51
- package/src/frontend/native/bridge/server.ts +266 -83
- package/src/frontend/native/index.ts +54 -3
- package/src/frontend/native/turn/turn.ts +2 -0
- package/src/frontend/teams/turn.ts +1 -0
- package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +4 -0
- package/src/frontend/telegram/admin.ts +20 -0
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/commands/state.ts +7 -9
- package/src/frontend/telegram/handlers/access.ts +31 -10
- package/src/frontend/telegram/handlers/delivery.ts +14 -1
- package/src/frontend/telegram/handlers/group-access.ts +50 -0
- package/src/frontend/telegram/handlers/messages.ts +1 -0
- package/src/frontend/telegram/handlers/queue.ts +14 -0
- package/src/frontend/telegram/handlers/state.ts +2 -2
- package/src/frontend/telegram/index.ts +32 -9
- package/src/frontend/telegram/middleware.ts +2 -2
- package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
- package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
- package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
- package/src/frontend/telegram/userbot.ts +103 -10
- package/src/frontend/terminal/index.ts +8 -1
- package/src/frontend/whatsapp/commands.ts +3 -3
- package/src/frontend/whatsapp/messages/inbound.ts +1 -0
- package/src/plugins/playwright/index.ts +1 -1
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/db.ts +23 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeviceCredentialStore — per-device bearer credentials for the mesh.
|
|
3
|
+
*
|
|
4
|
+
* Persists `{id, deviceId, tokenHash, scopes, …}` rows to a 0600 sidecar
|
|
5
|
+
* next to the device registry (~/.talon/mesh-credentials.json). Plaintext
|
|
6
|
+
* tokens are returned exactly once, from `mint`, and never stored.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle of a credential:
|
|
9
|
+
*
|
|
10
|
+
* mint ──► (unbound: first device id it names binds it; expires unbound)
|
|
11
|
+
* ──► active ──► superseded (new one minted; revoked on the new one's
|
|
12
|
+
* first use, so a lost reply never locks a device out)
|
|
13
|
+
* ──► rotation requested (grace window, then expires)
|
|
14
|
+
* ──► revoked (listeners drop that credential's live
|
|
15
|
+
* sessions immediately)
|
|
16
|
+
*
|
|
17
|
+
* `authenticate` is synchronous — it runs on every bridge request — so the
|
|
18
|
+
* store must be `load()`ed before the bridge starts serving (MeshService.load
|
|
19
|
+
* does it). Until then it knows no credentials and every token fails closed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
import { dirs } from "../../../util/paths.js";
|
|
24
|
+
import { log, logWarn } from "../../../util/log.js";
|
|
25
|
+
import { TalonError } from "../../errors.js";
|
|
26
|
+
import { readArray, writePrivateJson } from "../persist.js";
|
|
27
|
+
import {
|
|
28
|
+
credentialIdOf,
|
|
29
|
+
digestsEqual,
|
|
30
|
+
hashCredentialToken,
|
|
31
|
+
mintCredentialToken,
|
|
32
|
+
} from "./token.js";
|
|
33
|
+
import {
|
|
34
|
+
normalizeScopes,
|
|
35
|
+
type CredentialOrigin,
|
|
36
|
+
type DeviceCredential,
|
|
37
|
+
type DeviceCredentialRecord,
|
|
38
|
+
type MeshScope,
|
|
39
|
+
} from "./types.js";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_FILE = resolve(dirs.root, "mesh-credentials.json");
|
|
42
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
/** A pairing link or installer that is never used stops being a credential. */
|
|
44
|
+
const UNBOUND_TTL_MS = 7 * DAY_MS;
|
|
45
|
+
/** How long a device has to pick up a requested rotation before it expires. */
|
|
46
|
+
const ROTATION_GRACE_MS = 7 * DAY_MS;
|
|
47
|
+
/** Revoked rows are kept this long for `talon mesh` / audit, then pruned. */
|
|
48
|
+
const REVOKED_RETENTION_MS = 30 * DAY_MS;
|
|
49
|
+
/** Hard cap on stored rows — the sidecar is rewritten on every change. */
|
|
50
|
+
const MAX_RECORDS = 512;
|
|
51
|
+
/** lastUsedAt is kept in memory per request, persisted at most this often. */
|
|
52
|
+
const TOUCH_PERSIST_MS = 60_000;
|
|
53
|
+
/** Same bound the registry applies to device ids. */
|
|
54
|
+
const MAX_DEVICE_ID_CHARS = 128;
|
|
55
|
+
|
|
56
|
+
export type RevocationListener = (credentialIds: readonly string[]) => void;
|
|
57
|
+
|
|
58
|
+
export type MintInput = {
|
|
59
|
+
/** Bind now, or null to bind on first use (pairing / installer). */
|
|
60
|
+
deviceId: string | null;
|
|
61
|
+
scopes: readonly MeshScope[];
|
|
62
|
+
origin: CredentialOrigin;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type BindResult =
|
|
66
|
+
{ ok: true; credential: DeviceCredential } | { ok: false; error: string };
|
|
67
|
+
|
|
68
|
+
export class DeviceCredentialStore {
|
|
69
|
+
private records = new Map<string, DeviceCredentialRecord>();
|
|
70
|
+
private loading: Promise<void> | null = null;
|
|
71
|
+
private readonly listeners = new Set<RevocationListener>();
|
|
72
|
+
/** Device ids seen authenticating with the shared token (this run). */
|
|
73
|
+
private readonly legacy = new Map<string, number>();
|
|
74
|
+
private lastPersist = 0;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
private readonly file: string = DEFAULT_FILE,
|
|
78
|
+
private readonly now: () => number = Date.now,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
/** Hydrate from disk. Idempotent: the first caller reads, the rest share it. */
|
|
82
|
+
load(): Promise<void> {
|
|
83
|
+
this.loading ??= readArray<DeviceCredentialRecord>(this.file).then(
|
|
84
|
+
(rows) => {
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
if (isRecord(row)) {
|
|
87
|
+
this.records.set(row.id, {
|
|
88
|
+
...row,
|
|
89
|
+
scopes: normalizeScopes(row.scopes),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.prune();
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
return this.loading;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mint a credential and wait until it is on disk — the path for handing a
|
|
101
|
+
* device a token it will replace its old one with (a crash between reply
|
|
102
|
+
* and write would otherwise strand the device on a token nobody knows).
|
|
103
|
+
* Minting for a device that already holds one marks the old one
|
|
104
|
+
* superseded: it keeps working until the new token is first presented,
|
|
105
|
+
* then it is revoked.
|
|
106
|
+
*/
|
|
107
|
+
async mint(
|
|
108
|
+
input: MintInput,
|
|
109
|
+
): Promise<{ token: string; credential: DeviceCredential }> {
|
|
110
|
+
await this.load();
|
|
111
|
+
const minted = this.create(input);
|
|
112
|
+
await this.persist();
|
|
113
|
+
return minted;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mint synchronously, persisting in the background — for pairing links
|
|
118
|
+
* and installers, whose worst case on a crash is a link that no longer
|
|
119
|
+
* works (the device never switched away from anything).
|
|
120
|
+
*/
|
|
121
|
+
mintNow(input: MintInput): { token: string; credential: DeviceCredential } {
|
|
122
|
+
const minted = this.create(input);
|
|
123
|
+
this.persistSoon();
|
|
124
|
+
return minted;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a presented token to its credential, or null when it is not a
|
|
129
|
+
* live credential (unknown, wrong secret, revoked, expired). The first use
|
|
130
|
+
* of a replacement credential revokes the one it superseded.
|
|
131
|
+
*/
|
|
132
|
+
authenticate(token: string): DeviceCredential | null {
|
|
133
|
+
const id = credentialIdOf(token);
|
|
134
|
+
const record = id ? this.records.get(id) : undefined;
|
|
135
|
+
if (!record || !digestsEqual(record.tokenHash, hashCredentialToken(token)))
|
|
136
|
+
return null;
|
|
137
|
+
if (!this.isActive(record)) return null;
|
|
138
|
+
const firstUse = record.lastUsedAt === undefined;
|
|
139
|
+
this.touch(record);
|
|
140
|
+
if (firstUse) this.retireSuperseded(record.id);
|
|
141
|
+
return publicView(record);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bind an unbound credential to the device id it just named. Refused when
|
|
146
|
+
* another live credential already holds that id — a pairing link can add
|
|
147
|
+
* a device, never take one over.
|
|
148
|
+
*/
|
|
149
|
+
bind(credentialId: string, deviceId: string): BindResult {
|
|
150
|
+
const record = this.records.get(credentialId);
|
|
151
|
+
if (!record || !this.isActive(record)) {
|
|
152
|
+
return { ok: false, error: "Credential is no longer valid" };
|
|
153
|
+
}
|
|
154
|
+
if (record.deviceId !== null) {
|
|
155
|
+
return record.deviceId === deviceId
|
|
156
|
+
? { ok: true, credential: publicView(record) }
|
|
157
|
+
: { ok: false, error: `Credential is bound to ${record.deviceId}` };
|
|
158
|
+
}
|
|
159
|
+
const id = deviceId.trim();
|
|
160
|
+
if (!id || id.length > MAX_DEVICE_ID_CHARS) {
|
|
161
|
+
return { ok: false, error: "Invalid device id" };
|
|
162
|
+
}
|
|
163
|
+
if (this.activeRecordsFor(id).length > 0) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: `Device ${id} already has a credential — revoke it first (talon mesh revoke ${id})`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
record.deviceId = id;
|
|
170
|
+
delete record.expiresAt;
|
|
171
|
+
log("mesh", `Credential ${record.id} bound to device ${id}`);
|
|
172
|
+
this.persistSoon();
|
|
173
|
+
return { ok: true, credential: publicView(record) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True when the operator asked for this credential to be replaced. */
|
|
177
|
+
rotationDue(credentialId: string): boolean {
|
|
178
|
+
const record = this.records.get(credentialId);
|
|
179
|
+
return Boolean(record?.rotateRequestedAt && !record.supersededBy);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every stored credential (live and recently revoked), newest first. */
|
|
183
|
+
list(): DeviceCredential[] {
|
|
184
|
+
return [...this.records.values()]
|
|
185
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
186
|
+
.map(publicView);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Live credentials acting as `deviceId`. */
|
|
190
|
+
activeFor(deviceId: string): DeviceCredential[] {
|
|
191
|
+
return this.activeRecordsFor(deviceId).map(publicView);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Revoke every live credential of a device; returns what was revoked. */
|
|
195
|
+
async revokeDevice(
|
|
196
|
+
deviceId: string,
|
|
197
|
+
reason: string,
|
|
198
|
+
): Promise<DeviceCredential[]> {
|
|
199
|
+
await this.load();
|
|
200
|
+
return this.revokeRecords(this.activeRecordsFor(deviceId), reason);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Revoke one credential by id. */
|
|
204
|
+
async revokeCredential(
|
|
205
|
+
credentialId: string,
|
|
206
|
+
reason: string,
|
|
207
|
+
): Promise<DeviceCredential[]> {
|
|
208
|
+
await this.load();
|
|
209
|
+
const record = this.records.get(credentialId);
|
|
210
|
+
return record && this.isActive(record)
|
|
211
|
+
? this.revokeRecords([record], reason)
|
|
212
|
+
: [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ask a device to replace its credential. It picks the request up on its
|
|
217
|
+
* next heartbeat and swaps in-band; the old credential expires after a
|
|
218
|
+
* grace window whether or not it did.
|
|
219
|
+
*/
|
|
220
|
+
async requestRotation(deviceId: string): Promise<DeviceCredential[]> {
|
|
221
|
+
await this.load();
|
|
222
|
+
const at = this.now();
|
|
223
|
+
const records = this.activeRecordsFor(deviceId);
|
|
224
|
+
for (const record of records) {
|
|
225
|
+
record.rotateRequestedAt = at;
|
|
226
|
+
record.expiresAt = Math.min(
|
|
227
|
+
record.expiresAt ?? Infinity,
|
|
228
|
+
at + ROTATION_GRACE_MS,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (records.length > 0) await this.persist();
|
|
232
|
+
return records.map(publicView);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Replace a device's scopes. Live sessions are dropped (through the
|
|
237
|
+
* revocation listeners) so nothing keeps streaming under the old grant;
|
|
238
|
+
* the device reconnects with the same token and the new scopes.
|
|
239
|
+
*/
|
|
240
|
+
async setScopes(
|
|
241
|
+
deviceId: string,
|
|
242
|
+
scopes: readonly MeshScope[],
|
|
243
|
+
): Promise<DeviceCredential[]> {
|
|
244
|
+
await this.load();
|
|
245
|
+
const next = normalizeScopes(scopes);
|
|
246
|
+
if (next.length === 0) {
|
|
247
|
+
throw new TalonError("At least one scope is required", {
|
|
248
|
+
reason: "bad_request",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const records = this.activeRecordsFor(deviceId);
|
|
252
|
+
for (const record of records) record.scopes = next;
|
|
253
|
+
if (records.length > 0) {
|
|
254
|
+
await this.persist();
|
|
255
|
+
this.emit(records.map((r) => r.id));
|
|
256
|
+
}
|
|
257
|
+
return records.map(publicView);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Subscribe to revocations (the bridge drops matching sessions). */
|
|
261
|
+
onRevoked(listener: RevocationListener): () => void {
|
|
262
|
+
this.listeners.add(listener);
|
|
263
|
+
return () => this.listeners.delete(listener);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Record that `deviceId` is still authenticating with the shared token.
|
|
268
|
+
* Warns the first time per device per run; returns true that time.
|
|
269
|
+
*/
|
|
270
|
+
noteLegacy(deviceId: string): boolean {
|
|
271
|
+
const first = !this.legacy.has(deviceId);
|
|
272
|
+
this.legacy.set(deviceId, this.now());
|
|
273
|
+
if (first) {
|
|
274
|
+
logWarn(
|
|
275
|
+
"mesh",
|
|
276
|
+
`Device ${deviceId} is authenticating with the shared native.token (legacy). ` +
|
|
277
|
+
`Current companions and talon-node swap it for a per-device credential automatically; ` +
|
|
278
|
+
`run \`talon mesh\` to see which devices are still on the shared token.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return first;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Devices seen on the shared token this run that hold no live credential. */
|
|
285
|
+
legacyDevices(): { deviceId: string; lastSeen: number }[] {
|
|
286
|
+
return [...this.legacy]
|
|
287
|
+
.filter(([deviceId]) => this.activeRecordsFor(deviceId).length === 0)
|
|
288
|
+
.map(([deviceId, lastSeen]) => ({ deviceId, lastSeen }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Internals ─────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
private create(input: MintInput): {
|
|
294
|
+
token: string;
|
|
295
|
+
credential: DeviceCredential;
|
|
296
|
+
} {
|
|
297
|
+
const scopes = normalizeScopes(input.scopes);
|
|
298
|
+
if (scopes.length === 0) {
|
|
299
|
+
throw new TalonError("A credential needs a scope", {
|
|
300
|
+
reason: "bad_request",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
this.prune();
|
|
304
|
+
if (this.records.size >= MAX_RECORDS) {
|
|
305
|
+
throw new TalonError(
|
|
306
|
+
`Credential store is full (${MAX_RECORDS}) — revoke unused devices first`,
|
|
307
|
+
{ reason: "forbidden", status: 409 },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const minted = mintCredentialToken();
|
|
311
|
+
const at = this.now();
|
|
312
|
+
const record: DeviceCredentialRecord = {
|
|
313
|
+
id: minted.id,
|
|
314
|
+
deviceId: input.deviceId,
|
|
315
|
+
tokenHash: minted.tokenHash,
|
|
316
|
+
scopes,
|
|
317
|
+
origin: input.origin,
|
|
318
|
+
createdAt: at,
|
|
319
|
+
...(input.deviceId === null ? { expiresAt: at + UNBOUND_TTL_MS } : {}),
|
|
320
|
+
};
|
|
321
|
+
if (input.deviceId !== null) this.supersede(input.deviceId, record.id, at);
|
|
322
|
+
this.records.set(record.id, record);
|
|
323
|
+
return { token: minted.token, credential: publicView(record) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private isActive(record: DeviceCredentialRecord): boolean {
|
|
327
|
+
if (record.revokedAt !== undefined) return false;
|
|
328
|
+
return record.expiresAt === undefined || this.now() < record.expiresAt;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private activeRecordsFor(deviceId: string): DeviceCredentialRecord[] {
|
|
332
|
+
return [...this.records.values()].filter(
|
|
333
|
+
(r) => r.deviceId === deviceId && this.isActive(r),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A new credential for `deviceId` supersedes its live ones. One the device
|
|
339
|
+
* has used stays valid until the replacement's first use (lost-reply
|
|
340
|
+
* safety); one it never used was never adopted — a retried upgrade — and
|
|
341
|
+
* is revoked outright, so retries cannot pile up live credentials. No
|
|
342
|
+
* session can exist for a never-used credential, so nothing to drop.
|
|
343
|
+
*/
|
|
344
|
+
private supersede(deviceId: string, replacementId: string, at: number): void {
|
|
345
|
+
for (const old of this.activeRecordsFor(deviceId)) {
|
|
346
|
+
if (old.lastUsedAt === undefined) {
|
|
347
|
+
old.revokedAt = at;
|
|
348
|
+
old.revokeReason = `never used; replaced by ${replacementId}`;
|
|
349
|
+
} else {
|
|
350
|
+
old.supersededBy = replacementId;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private touch(record: DeviceCredentialRecord): void {
|
|
356
|
+
record.lastUsedAt = this.now();
|
|
357
|
+
if (record.lastUsedAt - this.lastPersist >= TOUCH_PERSIST_MS) {
|
|
358
|
+
this.persistSoon();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private retireSuperseded(replacementId: string): void {
|
|
363
|
+
const old = [...this.records.values()].filter(
|
|
364
|
+
(r) => r.supersededBy === replacementId && this.isActive(r),
|
|
365
|
+
);
|
|
366
|
+
if (old.length === 0) return;
|
|
367
|
+
void this.revokeRecords(old, `replaced by ${replacementId}`).catch(
|
|
368
|
+
(err: unknown) =>
|
|
369
|
+
logWarn("mesh", `Could not retire superseded credential: ${err}`),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async revokeRecords(
|
|
374
|
+
records: DeviceCredentialRecord[],
|
|
375
|
+
reason: string,
|
|
376
|
+
): Promise<DeviceCredential[]> {
|
|
377
|
+
if (records.length === 0) return [];
|
|
378
|
+
const at = this.now();
|
|
379
|
+
for (const record of records) {
|
|
380
|
+
record.revokedAt = at;
|
|
381
|
+
record.revokeReason = reason.slice(0, 200);
|
|
382
|
+
log(
|
|
383
|
+
"mesh",
|
|
384
|
+
`Revoked credential ${record.id} (device ${record.deviceId ?? "unbound"}): ${record.revokeReason}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// Drop sessions before the disk write: revocation must bite now, not
|
|
388
|
+
// after an fsync.
|
|
389
|
+
this.emit(records.map((r) => r.id));
|
|
390
|
+
await this.persist();
|
|
391
|
+
return records.map(publicView);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private emit(ids: readonly string[]): void {
|
|
395
|
+
for (const listener of this.listeners) {
|
|
396
|
+
try {
|
|
397
|
+
listener(ids);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
logWarn("mesh", `Credential revocation listener threw: ${err}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Drop long-revoked and long-expired rows. */
|
|
405
|
+
private prune(): void {
|
|
406
|
+
const cutoff = this.now() - REVOKED_RETENTION_MS;
|
|
407
|
+
for (const [id, record] of this.records) {
|
|
408
|
+
const endedAt = record.revokedAt ?? record.expiresAt;
|
|
409
|
+
if (endedAt !== undefined && endedAt < cutoff) this.records.delete(id);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private persistSoon(): void {
|
|
414
|
+
void this.persist().catch((err: unknown) =>
|
|
415
|
+
logWarn("mesh", `Could not persist mesh credentials: ${err}`),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async persist(): Promise<void> {
|
|
420
|
+
this.lastPersist = this.now();
|
|
421
|
+
await writePrivateJson(this.file, [...this.records.values()]);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function publicView(record: DeviceCredentialRecord): DeviceCredential {
|
|
426
|
+
const { tokenHash: _hash, ...rest } = record;
|
|
427
|
+
return { ...rest, scopes: [...rest.scopes] };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Reject hand-edited or corrupt rows rather than trusting them. */
|
|
431
|
+
function isRecord(value: unknown): value is DeviceCredentialRecord {
|
|
432
|
+
if (typeof value !== "object" || value === null) return false;
|
|
433
|
+
const row = value as Record<string, unknown>;
|
|
434
|
+
return (
|
|
435
|
+
typeof row.id === "string" &&
|
|
436
|
+
/^[0-9a-f]{16}$/.test(row.id) &&
|
|
437
|
+
typeof row.tokenHash === "string" &&
|
|
438
|
+
/^[0-9a-f]{64}$/.test(row.tokenHash) &&
|
|
439
|
+
(row.deviceId === null || typeof row.deviceId === "string") &&
|
|
440
|
+
typeof row.createdAt === "number" &&
|
|
441
|
+
normalizeScopes(row.scopes).length > 0
|
|
442
|
+
);
|
|
443
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-device credential token format.
|
|
3
|
+
*
|
|
4
|
+
* tdc1.<id: 16 hex>.<secret: 32 random bytes, base64url>
|
|
5
|
+
*
|
|
6
|
+
* The id lets the daemon find the row without a table scan and is safe to
|
|
7
|
+
* log; the secret is 256 bits, so a plain SHA-256 is the right hash (a slow
|
|
8
|
+
* KDF buys nothing against a secret that was never guessable). The prefix
|
|
9
|
+
* makes the credential kind self-describing: clients use it to tell a
|
|
10
|
+
* per-device credential from the shared legacy token without asking.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const PREFIX = "tdc1";
|
|
16
|
+
const TOKEN_RE = /^tdc1\.([0-9a-f]{16})\.[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
|
|
18
|
+
export type MintedToken = { id: string; token: string; tokenHash: string };
|
|
19
|
+
|
|
20
|
+
export function mintCredentialToken(): MintedToken {
|
|
21
|
+
const id = randomBytes(8).toString("hex");
|
|
22
|
+
const token = `${PREFIX}.${id}.${randomBytes(32).toString("base64url")}`;
|
|
23
|
+
return { id, token, tokenHash: hashCredentialToken(token) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The credential id a well-formed token names, or null for anything else. */
|
|
27
|
+
export function credentialIdOf(token: string): string | null {
|
|
28
|
+
return TOKEN_RE.exec(token)?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when `token` has the per-device credential shape (not the shared token). */
|
|
32
|
+
export function isDeviceCredentialToken(token: string): boolean {
|
|
33
|
+
return TOKEN_RE.test(token);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hashCredentialToken(token: string): string {
|
|
37
|
+
return createHash("sha256").update(token).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Constant-time comparison of two hex digests of equal length. */
|
|
41
|
+
export function digestsEqual(a: string, b: string): boolean {
|
|
42
|
+
const left = Buffer.from(a, "hex");
|
|
43
|
+
const right = Buffer.from(b, "hex");
|
|
44
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-device mesh credentials — the shapes.
|
|
3
|
+
*
|
|
4
|
+
* A credential is a bearer secret the daemon mints for ONE device (or, for a
|
|
5
|
+
* pairing link that has not been redeemed yet, for whichever device first
|
|
6
|
+
* names itself with it) and a set of scopes. The daemon keeps only a hash of
|
|
7
|
+
* the secret plus the metadata below; the plaintext exists once, in the reply
|
|
8
|
+
* or link that hands it to the device.
|
|
9
|
+
*
|
|
10
|
+
* Scopes (least privilege — see docs/mesh-credentials.md):
|
|
11
|
+
*
|
|
12
|
+
* device register/heartbeat/report as ITSELF, receive and answer its
|
|
13
|
+
* own commands, move the files the daemon asked it to move.
|
|
14
|
+
* client the chat UI: chats, history, search, sending, non-secret
|
|
15
|
+
* settings reads, the device list.
|
|
16
|
+
* operator config and extension writes, daemon control, logs.
|
|
17
|
+
*
|
|
18
|
+
* The shared `native.token` (legacy) and an open loopback bridge carry every
|
|
19
|
+
* scope — that is what they meant before per-device credentials existed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MESH_SCOPES = ["device", "client", "operator"] as const;
|
|
23
|
+
export type MeshScope = (typeof MESH_SCOPES)[number];
|
|
24
|
+
|
|
25
|
+
/** What a paired or upgraded companion gets unless the operator widens it. */
|
|
26
|
+
export const DEFAULT_COMPANION_SCOPES: readonly MeshScope[] = [
|
|
27
|
+
"device",
|
|
28
|
+
"client",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** A headless talon-node is a device and nothing else. */
|
|
32
|
+
export const NODE_SCOPES: readonly MeshScope[] = ["device"];
|
|
33
|
+
|
|
34
|
+
/** How a credential came to exist — for the audit trail and `talon mesh`. */
|
|
35
|
+
export type CredentialOrigin =
|
|
36
|
+
/** Minted into a companion pairing link. */
|
|
37
|
+
| "pair"
|
|
38
|
+
/** Minted into a talon-node installer. */
|
|
39
|
+
| "install"
|
|
40
|
+
/** Swapped in-band for the shared legacy token. */
|
|
41
|
+
| "upgrade"
|
|
42
|
+
/** Re-issued to a device that already held a per-device credential. */
|
|
43
|
+
| "rotate";
|
|
44
|
+
|
|
45
|
+
/** The persisted row. `tokenHash` never leaves the store. */
|
|
46
|
+
export type DeviceCredentialRecord = {
|
|
47
|
+
/** Public id — the middle segment of the token; safe to log and show. */
|
|
48
|
+
id: string;
|
|
49
|
+
/**
|
|
50
|
+
* The device this credential acts as. Null until a pairing/installer
|
|
51
|
+
* credential is first used: the first device id it names binds it for
|
|
52
|
+
* good (and an id another live credential already holds is refused).
|
|
53
|
+
*/
|
|
54
|
+
deviceId: string | null;
|
|
55
|
+
/** Hex SHA-256 of the whole token. */
|
|
56
|
+
tokenHash: string;
|
|
57
|
+
scopes: MeshScope[];
|
|
58
|
+
origin: CredentialOrigin;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
lastUsedAt?: number;
|
|
61
|
+
/** Hard stop: an unbound credential's bind deadline, or a rotation's grace. */
|
|
62
|
+
expiresAt?: number;
|
|
63
|
+
/** Operator asked (`talon mesh rotate`) for this credential to be replaced. */
|
|
64
|
+
rotateRequestedAt?: number;
|
|
65
|
+
/**
|
|
66
|
+
* The credential that replaces this one. It stays valid until the
|
|
67
|
+
* replacement is first used — so a device that never received its new
|
|
68
|
+
* token (a dropped reply) is not locked out — and is revoked then.
|
|
69
|
+
*/
|
|
70
|
+
supersededBy?: string;
|
|
71
|
+
revokedAt?: number;
|
|
72
|
+
revokeReason?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The view everything outside the store sees. */
|
|
76
|
+
export type DeviceCredential = Omit<DeviceCredentialRecord, "tokenHash">;
|
|
77
|
+
|
|
78
|
+
/** Parse an untrusted scope list: known names only, canonical order, no dupes. */
|
|
79
|
+
export function normalizeScopes(value: unknown): MeshScope[] {
|
|
80
|
+
if (!Array.isArray(value)) return [];
|
|
81
|
+
const wanted = new Set(value.filter((v) => typeof v === "string"));
|
|
82
|
+
return MESH_SCOPES.filter((scope) => wanted.has(scope));
|
|
83
|
+
}
|
|
@@ -50,6 +50,10 @@ import {
|
|
|
50
50
|
type NodeBinaryResolver,
|
|
51
51
|
} from "../links/node-binaries.js";
|
|
52
52
|
import { MeshRegistry } from "./registry.js";
|
|
53
|
+
import {
|
|
54
|
+
DeviceCredentialStore,
|
|
55
|
+
type CredentialAdminContext,
|
|
56
|
+
} from "../credentials/index.js";
|
|
53
57
|
import type {
|
|
54
58
|
DeviceCommand,
|
|
55
59
|
DeviceCommandResult,
|
|
@@ -89,6 +93,12 @@ export type MeshServiceOptions = {
|
|
|
89
93
|
presenceWatchIntervalMs?: number;
|
|
90
94
|
/** Node-binary resolver override (tests — the real one builds/downloads). */
|
|
91
95
|
nodeBinaryResolver?: NodeBinaryResolver;
|
|
96
|
+
/**
|
|
97
|
+
* Per-device credential store. With one, pairing links and installers
|
|
98
|
+
* carry a device's own credential and removing a device revokes it;
|
|
99
|
+
* without one (tests, embedders) links carry the shared bridge token.
|
|
100
|
+
*/
|
|
101
|
+
credentials?: DeviceCredentialStore;
|
|
92
102
|
};
|
|
93
103
|
|
|
94
104
|
export type { MeshBridgeInfo } from "../links/bridge-links.js";
|
|
@@ -135,6 +145,10 @@ export class MeshService {
|
|
|
135
145
|
private readonly commandTimeoutMs: number;
|
|
136
146
|
private readonly presenceWatchIntervalMs: number;
|
|
137
147
|
private loading: Promise<void> | null = null;
|
|
148
|
+
/** Per-device credentials (null = shared-token-only mesh). */
|
|
149
|
+
readonly credentials: DeviceCredentialStore | null;
|
|
150
|
+
/** `native.legacySharedToken` as the bridge last reported it. */
|
|
151
|
+
private legacySharedToken = true;
|
|
138
152
|
|
|
139
153
|
constructor(
|
|
140
154
|
private readonly registry = new MeshRegistry(),
|
|
@@ -156,18 +170,47 @@ export class MeshService {
|
|
|
156
170
|
commandTimeoutMs: this.commandTimeoutMs,
|
|
157
171
|
resolveNode: this.resolveNode,
|
|
158
172
|
});
|
|
159
|
-
this.
|
|
173
|
+
this.credentials = options.credentials ?? null;
|
|
174
|
+
const store = this.credentials;
|
|
175
|
+
this.links = new BridgeLinks(
|
|
176
|
+
this.resolveNode,
|
|
177
|
+
store
|
|
178
|
+
? (scopes, origin) =>
|
|
179
|
+
store.mintNow({ deviceId: null, scopes, origin }).token
|
|
180
|
+
: undefined,
|
|
181
|
+
);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/** The native bridge reports its reachable identity here (null on stop). */
|
|
163
185
|
setBridgeInfo(info: MeshBridgeInfo | null): void {
|
|
164
186
|
this.links.setBridgeInfo(info);
|
|
187
|
+
this.legacySharedToken = info?.legacySharedToken ?? true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What `talon mesh` operates on, or null for a shared-token-only mesh.
|
|
192
|
+
* Resolves registry names to ids, so callers should `load()` first.
|
|
193
|
+
*/
|
|
194
|
+
credentialAdminContext(): CredentialAdminContext | null {
|
|
195
|
+
const store = this.credentials;
|
|
196
|
+
if (!store) return null;
|
|
197
|
+
return {
|
|
198
|
+
store,
|
|
199
|
+
resolveDeviceId: (query) => {
|
|
200
|
+
const resolved = this.resolveDevice(query);
|
|
201
|
+
return "error" in resolved ? undefined : resolved.target.id;
|
|
202
|
+
},
|
|
203
|
+
legacySharedToken: () => this.legacySharedToken,
|
|
204
|
+
};
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
/** Hydrate persisted devices/locations. Idempotent — safe to await from
|
|
168
208
|
* every entry point; the first caller does the read, the rest share it. */
|
|
169
209
|
load(): Promise<void> {
|
|
170
|
-
this.loading ??=
|
|
210
|
+
this.loading ??= Promise.all([
|
|
211
|
+
this.registry.load(),
|
|
212
|
+
this.credentials?.load(),
|
|
213
|
+
]).then(() => undefined);
|
|
171
214
|
return this.loading;
|
|
172
215
|
}
|
|
173
216
|
|
|
@@ -463,13 +506,20 @@ export class MeshService {
|
|
|
463
506
|
if (!removed) {
|
|
464
507
|
return { ok: false, text: this.noSuchDevice(query).text };
|
|
465
508
|
}
|
|
509
|
+
// A removed device keeps no way back in: its own credential dies with
|
|
510
|
+
// the registry entry (its live sessions drop with it).
|
|
511
|
+
const revoked =
|
|
512
|
+
(await this.credentials?.revokeDevice(removed.id, "device removed")) ??
|
|
513
|
+
[];
|
|
466
514
|
return {
|
|
467
515
|
ok: true,
|
|
468
516
|
text:
|
|
469
517
|
`Removed ${removed.name} [id: ${removed.id}] (${removed.platform}, last seen ${age(Date.now() - removed.lastSeen)}) from the mesh registry.` +
|
|
470
|
-
(
|
|
471
|
-
?
|
|
472
|
-
:
|
|
518
|
+
(revoked.length > 0
|
|
519
|
+
? ` Revoked its ${revoked.length} per-device credential(s); it must be paired again to rejoin.`
|
|
520
|
+
: target.online
|
|
521
|
+
? " Note: it was still online — a connected companion re-registers within ~60s, so quit the app first if it keeps coming back."
|
|
522
|
+
: ""),
|
|
473
523
|
};
|
|
474
524
|
}
|
|
475
525
|
|
|
@@ -1086,7 +1136,9 @@ let instance: MeshService | null = null;
|
|
|
1086
1136
|
|
|
1087
1137
|
/** The daemon's shared mesh service (lazily created). */
|
|
1088
1138
|
export function getMeshService(): MeshService {
|
|
1089
|
-
instance ??= new MeshService(
|
|
1139
|
+
instance ??= new MeshService(undefined, {
|
|
1140
|
+
credentials: new DeviceCredentialStore(),
|
|
1141
|
+
});
|
|
1090
1142
|
return instance;
|
|
1091
1143
|
}
|
|
1092
1144
|
|