@ccmsg/cli 0.1.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,190 @@
1
+ import type {
2
+ Capability,
3
+ DirTreeArgs,
4
+ DirTreeResult,
5
+ LauncherConfigReadResult,
6
+ LauncherRunArgs,
7
+ LauncherRunResult,
8
+ } from "@ccmsg/protocol";
9
+ import { type HandlerInput, OpError } from "../dispatch/index.ts";
10
+ import type { LauncherConfig, LauncherTemplateConfig } from "../instance/config.ts";
11
+ import { spawnLaunch } from "./spawn.ts";
12
+ import { insideRoots } from "./roots.ts";
13
+ import { dirTree } from "./tree.ts";
14
+
15
+ /** The capability the three launcher ops need. A launcher with no roots and no
16
+ * recipes has no form to answer with, so a client is told there is none rather
17
+ * than being handed an empty one. */
18
+ export function launcherCapabilities(config?: LauncherConfig): Capability[] {
19
+ return config === undefined ? [] : ["launcher"];
20
+ }
21
+
22
+ /** One launch, as everything but the spawning sees it. */
23
+ export interface Launch {
24
+ /** The shell and its program, ready to run. */
25
+ readonly argv: readonly string[];
26
+ /** The checked directory, which is both where the command runs and what it
27
+ * sees as its working directory. */
28
+ readonly cwd: string;
29
+ readonly env: Record<string, string | undefined>;
30
+ readonly timeoutMs: number;
31
+ }
32
+
33
+ /** Whoever starts the command. The one part of a launch that touches the host,
34
+ * kept behind a name so a test can watch a launch being assembled without a
35
+ * process being started. */
36
+ export type LaunchRunner = (launch: Launch) => Promise<LauncherRunResult>;
37
+
38
+ export interface LauncherDeps {
39
+ /** The environment a launched command starts from, before `clean_env`. */
40
+ readonly env?: Record<string, string | undefined>;
41
+ readonly run?: LaunchRunner;
42
+ }
43
+
44
+ /** What the launcher may start, and the two ops that read and use it.
45
+ *
46
+ * No value a caller sends is ever spliced into shell text: the parameters reach
47
+ * the command as shell variables, and the directory is the one the containment
48
+ * check resolved rather than the one the caller wrote. */
49
+ export class Launcher {
50
+ readonly #env: Record<string, string | undefined>;
51
+ readonly #run: LaunchRunner;
52
+
53
+ constructor(
54
+ private readonly config: LauncherConfig,
55
+ deps: LauncherDeps = {},
56
+ ) {
57
+ this.#env = deps.env ?? process.env;
58
+ this.#run = deps.run ?? spawnLaunch;
59
+ }
60
+
61
+ /** The form: where a session may run, and the recipes it may run under. How
62
+ * the shell runs one is not reported, because nothing a client does with the
63
+ * answer depends on it. */
64
+ configRead(): LauncherConfigReadResult {
65
+ return {
66
+ root_dirs: [...this.config.root_dirs],
67
+ templates: this.config.templates.map((template) => ({
68
+ name: template.name,
69
+ command: template.command,
70
+ params: template.params.map((param) => ({ name: param.name, default: param.default })),
71
+ })),
72
+ };
73
+ }
74
+
75
+ tree(args: DirTreeArgs): DirTreeResult {
76
+ return dirTree(this.config, args);
77
+ }
78
+
79
+ run(args: LauncherRunArgs): Promise<LauncherRunResult> {
80
+ const cwd = insideRoots(this.config, args.cwd);
81
+ if (cwd === undefined) {
82
+ // The op states no refusal for a path, so a directory outside the roots
83
+ // is answered as what it is from here: an argument this launcher cannot
84
+ // act on.
85
+ throw new OpError("invalid_args", `${args.cwd} is not a directory the launcher may start in`);
86
+ }
87
+ const template = this.template(args.template);
88
+ const declared = new Map(template.params.map((param) => [param.name, param.default]));
89
+ for (const name of Object.keys(args.params)) {
90
+ if (!declared.has(name)) {
91
+ throw new OpError(
92
+ "invalid_args",
93
+ `${template.name} declares no parameter called ${name}, so nothing would read it`,
94
+ );
95
+ }
96
+ }
97
+ const values: Record<string, string> = {};
98
+ for (const [name, fallback] of declared) {
99
+ values[carrier(name)] = args.params[name] ?? fallback;
100
+ }
101
+ return this.#run({
102
+ argv: shellArgv(
103
+ template.shell,
104
+ program([...declared.keys()], args.command ?? template.command),
105
+ ),
106
+ cwd,
107
+ env: { ...cleaned(this.#env, this.config), ...values },
108
+ timeoutMs: this.config.timeout_secs * 1000,
109
+ });
110
+ }
111
+
112
+ /** The recipe a launch names, or the default one. An unknown name is refused
113
+ * rather than replaced with another, because running a different recipe than
114
+ * the one asked for is worse than running none. */
115
+ private template(name?: string): LauncherTemplateConfig {
116
+ if (name === undefined) {
117
+ const first = this.config.templates[0];
118
+ if (first === undefined) throw new OpError("invalid_args", "this launcher has no recipe");
119
+ return first;
120
+ }
121
+ const found = this.config.templates.find((template) => template.name === name);
122
+ if (found === undefined) throw new OpError("invalid_args", `no recipe is called ${name}`);
123
+ return found;
124
+ }
125
+ }
126
+
127
+ export function launcherHandlers(launcher: Launcher) {
128
+ return {
129
+ launcher_config_read: (): LauncherConfigReadResult => launcher.configRead(),
130
+ launcher_run: (input: HandlerInput): Promise<LauncherRunResult> =>
131
+ launcher.run(input.args as unknown as LauncherRunArgs),
132
+ dir_tree: (input: HandlerInput): DirTreeResult =>
133
+ launcher.tree(input.args as unknown as DirTreeArgs),
134
+ };
135
+ }
136
+
137
+ /** The environment variable one parameter's value travels in.
138
+ *
139
+ * A carrier of its own, in a namespace the shell erases before the command
140
+ * runs, so the value reaches the command as a plain shell variable and nothing
141
+ * the command starts inherits it. */
142
+ export function carrier(name: string): string {
143
+ return `ccmsg_launch_param_${name}`;
144
+ }
145
+
146
+ /** The shell program: the prologue that moves every parameter out of the
147
+ * environment, then the recipe's command.
148
+ *
149
+ * Both run in one shell. A nested `sh -c` would defeat the whole arrangement,
150
+ * since a variable that is not exported does not cross into a child. Every
151
+ * declared parameter is defined, empty rather than absent when nothing supplied
152
+ * one, so a command run under `set -u` does not abort on a field left blank. */
153
+ export function program(names: readonly string[], command: string): string {
154
+ const prologue = names
155
+ .map((name) => `unset -v ${name}; ${name}="$${carrier(name)}"; unset -v ${carrier(name)}`)
156
+ .join("\n");
157
+ // A newline rather than `;` so a command opening with a comment or a shell
158
+ // keyword parses exactly as it was written.
159
+ return `${prologue}\n${command}`;
160
+ }
161
+
162
+ export function shellArgv(shell: "bash" | "zsh", command: string): string[] {
163
+ return shell === "bash"
164
+ ? ["bash", "-eu", "-o", "pipefail", "-c", command]
165
+ : ["zsh", "-e", "-u", "-o", "pipefail", "-c", command];
166
+ }
167
+
168
+ /** The environment a launched command starts from: this instance's own, minus
169
+ * what `clean_env` names and back again for what `keep_env` does. Keep wins, so
170
+ * one broad pattern can be written beside its exceptions. */
171
+ function cleaned(
172
+ env: Record<string, string | undefined>,
173
+ config: LauncherConfig,
174
+ ): Record<string, string | undefined> {
175
+ const clean = config.clean_env.map(pattern);
176
+ const keep = config.keep_env.map(pattern);
177
+ const kept: Record<string, string | undefined> = {};
178
+ for (const [name, value] of Object.entries(env)) {
179
+ if (clean.some((each) => each.test(name)) && !keep.some((each) => each.test(name))) continue;
180
+ kept[name] = value;
181
+ }
182
+ return kept;
183
+ }
184
+
185
+ /** One `clean_env` pattern, where `*` stands for any run of characters and
186
+ * everything else is literal. */
187
+ function pattern(glob: string): RegExp {
188
+ const escaped = glob.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
189
+ return new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`);
190
+ }
@@ -0,0 +1,32 @@
1
+ import { statSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import type { LauncherConfig } from "../instance/config.ts";
4
+ import { canonical, within } from "../files/index.ts";
5
+
6
+ /** Whether a directory is one the launcher may act on, and what the filesystem
7
+ * calls it.
8
+ *
9
+ * The comparison runs on what both sides resolve to, so a path spelled through
10
+ * a symlink out of a root is refused however it was written — the same rule the
11
+ * file ops go through, asked of the roots config states rather than of the ones
12
+ * a session states.
13
+ *
14
+ * A root that no longer resolves grants nothing and stops nothing: another
15
+ * configured root may still hold the candidate. */
16
+ export function insideRoots(config: LauncherConfig, path: string): string | undefined {
17
+ if (!isAbsolute(path)) return undefined;
18
+ const real = canonical(path);
19
+ if (!isDirectory(real)) return undefined;
20
+ for (const root of config.root_dirs) {
21
+ if (within(real, canonical(root))) return real;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export function isDirectory(path: string): boolean {
27
+ try {
28
+ return statSync(path).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
@@ -0,0 +1,81 @@
1
+ import type { LauncherRunResult } from "@ccmsg/protocol";
2
+ import type { Launch } from "./launcher.ts";
3
+
4
+ /** How long a command stopped for outliving its allowance is given to leave
5
+ * before it is killed outright. */
6
+ const FORCE_KILL_MS = 500;
7
+
8
+ /** How long the pipes are read after the command itself has exited.
9
+ *
10
+ * A launch that detaches something — which is what starting a session in a
11
+ * terminal is — leaves that grandchild holding the write end, so end-of-file
12
+ * may never arrive. Without a bound the reply would wait for the session to
13
+ * finish. On an ordinary exit every descriptor closes at once and this costs
14
+ * nothing. */
15
+ const DRAIN_MS = 500;
16
+
17
+ /** Start one assembled launch and answer with what it did.
18
+ *
19
+ * Both pipes are read from the moment the command starts, so output larger than
20
+ * a pipe buffer cannot stop the command from finishing. */
21
+ export async function spawnLaunch(launch: Launch): Promise<LauncherRunResult> {
22
+ const child = Bun.spawn([...launch.argv], {
23
+ cwd: launch.cwd,
24
+ env: launch.env,
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ });
28
+
29
+ let timedOut = false;
30
+ let force: ReturnType<typeof setTimeout> | undefined;
31
+ const deadline = setTimeout(() => {
32
+ if (child.exitCode !== null || child.signalCode !== null) return;
33
+ timedOut = true;
34
+ child.kill("SIGTERM");
35
+ force = setTimeout(() => {
36
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
37
+ }, FORCE_KILL_MS);
38
+ }, launch.timeoutMs);
39
+
40
+ const stdout = collect(child.stdout);
41
+ const stderr = collect(child.stderr);
42
+ try {
43
+ const code = await child.exited;
44
+ const [out, err] = await Promise.all([stdout(DRAIN_MS), stderr(DRAIN_MS)]);
45
+ return {
46
+ stdout: out,
47
+ stderr: err,
48
+ // A signal leaves no code to state, so the field is absent rather than
49
+ // carrying a number that means something else.
50
+ ...(child.signalCode === null ? { exit_code: code } : {}),
51
+ timed_out: timedOut,
52
+ };
53
+ } finally {
54
+ clearTimeout(deadline);
55
+ if (force !== undefined) clearTimeout(force);
56
+ }
57
+ }
58
+
59
+ /** Read a pipe as it arrives, and answer with what got there within the grace.
60
+ *
61
+ * Not `Response.text()`: that resolves only at end-of-file, which is the one
62
+ * thing a detached grandchild can postpone forever. */
63
+ function collect(stream: ReadableStream<Uint8Array>): (graceMs: number) => Promise<string> {
64
+ const chunks: Uint8Array[] = [];
65
+ const reader = stream.getReader();
66
+ const drained = (async () => {
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ if (value !== undefined) chunks.push(value);
71
+ }
72
+ })();
73
+ return async (graceMs) => {
74
+ await Promise.race([
75
+ drained.catch(() => {}),
76
+ new Promise((resolve) => setTimeout(resolve, graceMs)),
77
+ ]);
78
+ await reader.cancel().catch(() => {});
79
+ return Buffer.concat(chunks).toString("utf8");
80
+ };
81
+ }
@@ -0,0 +1,80 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { DirTreeArgs, DirTreeEntry, DirTreeResult } from "@ccmsg/protocol";
4
+ import type { LauncherConfig } from "../instance/config.ts";
5
+ import { insideRoots } from "./roots.ts";
6
+
7
+ /** How far one request may walk, whatever it asks for.
8
+ *
9
+ * Design rationale: the depth is the client's to choose, and a walk whose cost
10
+ * the client sets has no bound at all. Five levels covers the configured depth
11
+ * plus several lazy expansions, and going deeper stays a matter of asking for a
12
+ * descendant — which is the same walk, paid for one request at a time. */
13
+ const MAX_DEPTH = 5;
14
+
15
+ /** The directories a session could be started in.
16
+ *
17
+ * Directories only, and only below the configured roots. A root the config does
18
+ * not hold contributes nothing rather than failing the request: the op states no
19
+ * refusal for a path, and a tree assembled from several roots would otherwise be
20
+ * lost whole because one of them went away. */
21
+ export function dirTree(config: LauncherConfig, args: DirTreeArgs): DirTreeResult {
22
+ const depth = Math.min(MAX_DEPTH, args.depth ?? config.depth);
23
+ // A filter of nothing is not a filter: an emptied search box shows the tree
24
+ // rather than hiding all of it.
25
+ const filter = args.filter === undefined || args.filter === "" ? undefined : args.filter;
26
+ const entries: DirTreeEntry[] = [];
27
+ for (const root of args.roots) {
28
+ const real = insideRoots(config, root);
29
+ if (real === undefined) continue;
30
+ entries.push(...walk(config, real, real, depth, filter));
31
+ }
32
+ return { entries: sorted(entries) };
33
+ }
34
+
35
+ function walk(
36
+ config: LauncherConfig,
37
+ root: string,
38
+ at: string,
39
+ depth: number,
40
+ filter: string | undefined,
41
+ ): DirTreeEntry[] {
42
+ const entries: DirTreeEntry[] = [];
43
+ for (const dirent of read(at)) {
44
+ // Design rationale: dot-directories are left out. This answers "where could
45
+ // a session run", and a repository's `.git` is not one of those places —
46
+ // browsing a session's own files is a different op with different rules.
47
+ if (dirent.name.startsWith(".")) continue;
48
+ const path = join(at, dirent.name);
49
+ if (dirent.isSymbolicLink()) {
50
+ // A link is a place to run only if what it points at is one, so it goes
51
+ // through the same containment its target would.
52
+ if (insideRoots(config, path) === undefined) continue;
53
+ } else if (!dirent.isDirectory()) continue;
54
+
55
+ const children = depth > 1 ? walk(config, root, path, depth - 1, filter) : undefined;
56
+ if (filter !== undefined) {
57
+ const matches = relative(root, path).includes(filter);
58
+ // An ancestor of a match survives the filter: without it a match several
59
+ // levels down would have nothing to hang from.
60
+ if (!matches && (children === undefined || children.length === 0)) continue;
61
+ }
62
+ entries.push({ path, ...(children === undefined ? {} : { children: sorted(children) }) });
63
+ }
64
+ return entries;
65
+ }
66
+
67
+ function read(dir: string) {
68
+ try {
69
+ return readdirSync(dir, { withFileTypes: true });
70
+ } catch {
71
+ // A directory that cannot be read is still a place to run; what it holds is
72
+ // simply not known, which is the same answer as holding nothing.
73
+ return [];
74
+ }
75
+ }
76
+
77
+ /** Codepoint order, which is the same wherever the daemon runs. */
78
+ function sorted(entries: DirTreeEntry[]): DirTreeEntry[] {
79
+ return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
80
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./keys.ts";
2
+ export * from "./mesh.ts";
3
+ export * from "./probe.ts";
4
+ export * from "./relay.ts";
5
+ export * from "./wire.ts";
@@ -0,0 +1,158 @@
1
+ import {
2
+ createPublicKey,
3
+ generateKeyPairSync,
4
+ type JsonWebKeyInput,
5
+ type KeyObject,
6
+ randomBytes,
7
+ sign,
8
+ verify,
9
+ } from "node:crypto";
10
+ import type { Endpoint } from "@ccmsg/protocol";
11
+
12
+ /** The handshake format this instance speaks (mesh-peer-auth §5.3). */
13
+ export const MESH_VER = 1;
14
+
15
+ /** The signature algorithms a proof may be made with.
16
+ *
17
+ * A set rather than one name, because the JWS header is not trusted: the header
18
+ * names an algorithm and this is what decides whether that name may be used at
19
+ * all, which is what stops `alg: none` and algorithm confusion (§5.7-7). Two
20
+ * entries have to be expressible for a migration, so it is a set even while it
21
+ * holds one. */
22
+ export const ALLOWED_ALGS: ReadonlySet<string> = new Set(["EdDSA"]);
23
+
24
+ /** How long a proof stays valid (§5.9). A handshake within one region completes
25
+ * in tens to hundreds of milliseconds, and a peer that cannot manage this could
26
+ * not hold a mesh link anyway. */
27
+ export const PROOF_LIFETIME_MS = 10_000;
28
+
29
+ /** 128 bits, the floor §5.9 sets for both the challenge and the key id: the only
30
+ * requirement on either is that it cannot be predicted. */
31
+ const RANDOM_BYTES = 16;
32
+
33
+ export function randomId(): string {
34
+ return randomBytes(RANDOM_BYTES).toString("hex");
35
+ }
36
+
37
+ /** A public key as it travels: a JWK carrying the id it answers to.
38
+ *
39
+ * The id is on the key because the receiver compares three of them — the
40
+ * greeting's, the proof header's, and this one — and a key that arrived without
41
+ * its own id could not be part of that comparison (§5.7-8). */
42
+ export interface MeshJwk {
43
+ readonly kty: string;
44
+ readonly crv?: string;
45
+ readonly x?: string;
46
+ readonly kid: string;
47
+ }
48
+
49
+ /** One connection's signing key.
50
+ *
51
+ * It lives as long as the handshake it was made for: created when the dial
52
+ * starts, fetched once by the peer, and destroyed when the acknowledgement
53
+ * arrives or the connection goes, whichever comes first (§7). Nothing rotates
54
+ * it because nothing outlives one connection. */
55
+ export class EphemeralKey {
56
+ readonly kid = randomId();
57
+ readonly #private: KeyObject;
58
+ readonly #public: KeyObject;
59
+
60
+ constructor() {
61
+ const pair = generateKeyPairSync("ed25519");
62
+ this.#private = pair.privateKey;
63
+ this.#public = pair.publicKey;
64
+ }
65
+
66
+ jwk(): MeshJwk {
67
+ return { ...(this.#public.export({ format: "jwk" }) as object), kid: this.kid } as MeshJwk;
68
+ }
69
+
70
+ /** The proof of §5.4: the challenge, and the two endpoint URLs, signed.
71
+ *
72
+ * The URLs are signed even though the greeting already carried them, because
73
+ * the greeting is not signed and therefore states nothing. */
74
+ proof(claim: ProofClaim): string {
75
+ const header = { alg: "EdDSA", kid: this.kid };
76
+ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claim))}`;
77
+ return `${input}.${sign(null, Buffer.from(input), this.#private).toString("base64url")}`;
78
+ }
79
+ }
80
+
81
+ /** What a proof asserts. */
82
+ export interface ProofClaim {
83
+ readonly ver: number;
84
+ readonly iss: Endpoint;
85
+ readonly aud: Endpoint;
86
+ readonly challenge: string;
87
+ /** Unix seconds, as a JWS `exp` is. */
88
+ readonly exp: number;
89
+ }
90
+
91
+ /** A proof that has been read but not yet judged: the header's `kid`, and the
92
+ * claim, both of which the caller compares before any signature is checked. */
93
+ export interface ParsedProof {
94
+ readonly kid: string;
95
+ readonly alg: string;
96
+ readonly claim: ProofClaim;
97
+ }
98
+
99
+ export class ProofError extends Error {}
100
+
101
+ /** Read a compact JWS into its parts, refusing anything malformed or signed
102
+ * with an algorithm outside the allowed set.
103
+ *
104
+ * The algorithm is checked here, before the key is even looked at, because the
105
+ * check exists to decide whether the header may be acted on at all (§5.7-7). */
106
+ export function parseProof(jws: string): ParsedProof {
107
+ const parts = jws.split(".");
108
+ if (parts.length !== 3) throw new ProofError("a proof is a compact JWS of three parts");
109
+ const [rawHeader, rawClaim] = parts as [string, string, string];
110
+ const header = decode(rawHeader, "header");
111
+ const alg = header["alg"];
112
+ const kid = header["kid"];
113
+ if (typeof alg !== "string" || !ALLOWED_ALGS.has(alg)) {
114
+ throw new ProofError(`a proof signed with ${String(alg)} is not one this instance accepts`);
115
+ }
116
+ if (typeof kid !== "string" || kid === "") throw new ProofError("a proof names its key");
117
+ const claim = decode(rawClaim, "claim");
118
+ for (const field of ["ver", "iss", "aud", "challenge", "exp"] as const) {
119
+ if (claim[field] === undefined) throw new ProofError(`a proof states its ${field}`);
120
+ }
121
+ return { kid, alg, claim: claim as unknown as ProofClaim };
122
+ }
123
+
124
+ /** Whether the signature was made by the key the JWK carries. */
125
+ export function verifyProof(jws: string, jwk: MeshJwk): boolean {
126
+ const parts = jws.split(".");
127
+ if (parts.length !== 3) return false;
128
+ const [rawHeader, rawClaim, rawSignature] = parts as [string, string, string];
129
+ let key: KeyObject;
130
+ try {
131
+ key = createPublicKey({ key: { ...jwk }, format: "jwk" } as JsonWebKeyInput);
132
+ } catch {
133
+ return false;
134
+ }
135
+ return verify(
136
+ null,
137
+ Buffer.from(`${rawHeader}.${rawClaim}`),
138
+ key,
139
+ Buffer.from(rawSignature, "base64url"),
140
+ );
141
+ }
142
+
143
+ function decode(part: string, what: string): Record<string, unknown> {
144
+ let parsed: unknown;
145
+ try {
146
+ parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
147
+ } catch {
148
+ throw new ProofError(`a proof's ${what} is not JSON`);
149
+ }
150
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
151
+ throw new ProofError(`a proof's ${what} is not an object`);
152
+ }
153
+ return parsed as Record<string, unknown>;
154
+ }
155
+
156
+ function b64url(text: string): string {
157
+ return Buffer.from(text, "utf8").toString("base64url");
158
+ }