agent-comms 9.0.11 → 9.0.13

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 (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +12 -6
  4. package/dist/account-cli.d.ts.map +1 -1
  5. package/dist/account-cli.js +4 -1
  6. package/dist/account-cli.js.map +1 -1
  7. package/dist/bridges/user/web/dist/bundle.js +1 -1
  8. package/dist/bridges/user/web/dist/index.html +1 -7
  9. package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
  10. package/dist/bridges/user/web/dist/sw.js +1 -1
  11. package/dist/bridges/user/web/hosted-dashboard.d.ts +10 -0
  12. package/dist/bridges/user/web/hosted-dashboard.d.ts.map +1 -0
  13. package/dist/bridges/user/web/hosted-dashboard.js +10 -0
  14. package/dist/bridges/user/web/hosted-dashboard.js.map +1 -0
  15. package/dist/bridges/user/web/web-access.d.ts +0 -2
  16. package/dist/bridges/user/web/web-access.d.ts.map +1 -1
  17. package/dist/bridges/user/web/web-access.js +1 -2
  18. package/dist/bridges/user/web/web-access.js.map +1 -1
  19. package/dist/cli-main.d.ts.map +1 -1
  20. package/dist/cli-main.js +9 -12
  21. package/dist/cli-main.js.map +1 -1
  22. package/dist/core/account-bundle.d.ts +1 -1
  23. package/dist/core/account-bundle.d.ts.map +1 -1
  24. package/dist/core/account-bundle.js +26 -19
  25. package/dist/core/account-bundle.js.map +1 -1
  26. package/dist/core/account-join.d.ts.map +1 -1
  27. package/dist/core/account-join.js +5 -2
  28. package/dist/core/account-join.js.map +1 -1
  29. package/dist/core/account-ledger-crypto.d.ts.map +1 -1
  30. package/dist/core/account-ledger-crypto.js +4 -1
  31. package/dist/core/account-ledger-crypto.js.map +1 -1
  32. package/dist/core/account-ledger-store.d.ts +3 -1
  33. package/dist/core/account-ledger-store.d.ts.map +1 -1
  34. package/dist/core/account-ledger-store.js +6 -3
  35. package/dist/core/account-ledger-store.js.map +1 -1
  36. package/dist/core/account-ledger.d.ts +51 -4
  37. package/dist/core/account-ledger.d.ts.map +1 -1
  38. package/dist/core/account-ledger.js +102 -15
  39. package/dist/core/account-ledger.js.map +1 -1
  40. package/dist/core/bridge-mesh.d.ts.map +1 -1
  41. package/dist/core/bridge-mesh.js +3 -0
  42. package/dist/core/bridge-mesh.js.map +1 -1
  43. package/dist/core/bridge.d.ts.map +1 -1
  44. package/dist/core/bridge.js +16 -4
  45. package/dist/core/bridge.js.map +1 -1
  46. package/dist/core/capability-ask.d.ts.map +1 -1
  47. package/dist/core/capability-ask.js +10 -2
  48. package/dist/core/capability-ask.js.map +1 -1
  49. package/dist/core/connection-code-tool.d.ts +1 -1
  50. package/dist/core/connection-code-tool.js +2 -2
  51. package/dist/core/connection-code-tool.js.map +1 -1
  52. package/dist/core/connection-code.d.ts +5 -5
  53. package/dist/core/connection-code.d.ts.map +1 -1
  54. package/dist/core/connection-code.js +22 -6
  55. package/dist/core/connection-code.js.map +1 -1
  56. package/dist/core/device-membership.d.ts +8 -3
  57. package/dist/core/device-membership.d.ts.map +1 -1
  58. package/dist/core/device-membership.js +4 -4
  59. package/dist/core/device-membership.js.map +1 -1
  60. package/dist/core/dm-grant-actions.d.ts.map +1 -1
  61. package/dist/core/dm-grant-actions.js +7 -1
  62. package/dist/core/dm-grant-actions.js.map +1 -1
  63. package/dist/core/error-reporting.d.ts.map +1 -1
  64. package/dist/core/error-reporting.js +5 -0
  65. package/dist/core/error-reporting.js.map +1 -1
  66. package/dist/core/gateway-trust.d.ts +1 -1
  67. package/dist/core/gateway-trust.d.ts.map +1 -1
  68. package/dist/core/gateway-trust.js +4 -4
  69. package/dist/core/gateway-trust.js.map +1 -1
  70. package/dist/core/group-proofs.d.ts.map +1 -1
  71. package/dist/core/group-proofs.js +2 -2
  72. package/dist/core/group-proofs.js.map +1 -1
  73. package/dist/core/identity-store.d.ts +13 -9
  74. package/dist/core/identity-store.d.ts.map +1 -1
  75. package/dist/core/identity-store.js +122 -147
  76. package/dist/core/identity-store.js.map +1 -1
  77. package/dist/core/index.d.ts +1 -0
  78. package/dist/core/index.d.ts.map +1 -1
  79. package/dist/core/index.js +1 -0
  80. package/dist/core/index.js.map +1 -1
  81. package/dist/core/iso-timestamp.d.ts +10 -0
  82. package/dist/core/iso-timestamp.d.ts.map +1 -0
  83. package/dist/core/iso-timestamp.js +19 -0
  84. package/dist/core/iso-timestamp.js.map +1 -0
  85. package/dist/core/issuer-identity-file.d.ts +15 -0
  86. package/dist/core/issuer-identity-file.d.ts.map +1 -1
  87. package/dist/core/issuer-identity-file.js +69 -20
  88. package/dist/core/issuer-identity-file.js.map +1 -1
  89. package/dist/core/machine-identity.d.ts +1 -1
  90. package/dist/core/machine-identity.d.ts.map +1 -1
  91. package/dist/core/machine-identity.js +14 -11
  92. package/dist/core/machine-identity.js.map +1 -1
  93. package/dist/core/mesh-store.d.ts +2 -2
  94. package/dist/core/mesh-store.d.ts.map +1 -1
  95. package/dist/core/mesh-store.js.map +1 -1
  96. package/dist/core/peer-lifecycle.d.ts.map +1 -1
  97. package/dist/core/peer-lifecycle.js +8 -2
  98. package/dist/core/peer-lifecycle.js.map +1 -1
  99. package/dist/core/pid-lock-file.d.ts +27 -4
  100. package/dist/core/pid-lock-file.d.ts.map +1 -1
  101. package/dist/core/pid-lock-file.js +120 -11
  102. package/dist/core/pid-lock-file.js.map +1 -1
  103. package/dist/core/process-start.d.ts +10 -0
  104. package/dist/core/process-start.d.ts.map +1 -0
  105. package/dist/core/process-start.js +43 -0
  106. package/dist/core/process-start.js.map +1 -0
  107. package/dist/core/room-lifecycle.d.ts +3 -2
  108. package/dist/core/room-lifecycle.d.ts.map +1 -1
  109. package/dist/core/room-lifecycle.js +9 -8
  110. package/dist/core/room-lifecycle.js.map +1 -1
  111. package/dist/core/room-protocol.d.ts.map +1 -1
  112. package/dist/core/room-protocol.js +25 -3
  113. package/dist/core/room-protocol.js.map +1 -1
  114. package/dist/core/stale-agent-checker.d.ts.map +1 -1
  115. package/dist/core/stale-agent-checker.js +3 -1
  116. package/dist/core/stale-agent-checker.js.map +1 -1
  117. package/dist/core/store.d.ts +3 -0
  118. package/dist/core/store.d.ts.map +1 -1
  119. package/dist/core/store.js +42 -20
  120. package/dist/core/store.js.map +1 -1
  121. package/dist/core/tool.d.ts +5 -1
  122. package/dist/core/tool.d.ts.map +1 -1
  123. package/dist/core/tool.js +7 -1
  124. package/dist/core/tool.js.map +1 -1
  125. package/dist/core/types.d.ts +1 -1
  126. package/dist/core/types.d.ts.map +1 -1
  127. package/dist/core/types.js +8 -3
  128. package/dist/core/types.js.map +1 -1
  129. package/dist/core/user-identity.d.ts +5 -5
  130. package/dist/core/user-identity.d.ts.map +1 -1
  131. package/dist/core/user-identity.js +59 -45
  132. package/dist/core/user-identity.js.map +1 -1
  133. package/package.json +1 -1
  134. package/src/account-cli.ts +4 -1
  135. package/src/bridges/user/web/dist/bundle.js +1 -1
  136. package/src/bridges/user/web/dist/index.html +1 -7
  137. package/src/bridges/user/web/dist/mesh-worker.js +4 -4
  138. package/src/bridges/user/web/dist/sw.js +1 -1
  139. package/src/bridges/user/web/hosted-dashboard.ts +11 -0
  140. package/src/bridges/user/web/web-access.ts +1 -3
  141. package/src/cli-main.ts +10 -12
  142. package/src/core/account-bundle.ts +36 -25
  143. package/src/core/account-join.ts +5 -2
  144. package/src/core/account-ledger-crypto.ts +4 -1
  145. package/src/core/account-ledger-store.ts +11 -3
  146. package/src/core/account-ledger.ts +142 -20
  147. package/src/core/bridge-mesh.ts +4 -0
  148. package/src/core/bridge.ts +24 -4
  149. package/src/core/capability-ask.ts +16 -1
  150. package/src/core/connection-code-tool.ts +2 -2
  151. package/src/core/connection-code.ts +34 -8
  152. package/src/core/device-membership.ts +12 -8
  153. package/src/core/dm-grant-actions.ts +7 -1
  154. package/src/core/error-reporting.ts +5 -0
  155. package/src/core/gateway-trust.ts +4 -4
  156. package/src/core/group-proofs.ts +5 -2
  157. package/src/core/identity-store.ts +182 -191
  158. package/src/core/index.ts +1 -0
  159. package/src/core/iso-timestamp.ts +21 -0
  160. package/src/core/issuer-identity-file.ts +87 -23
  161. package/src/core/machine-identity.ts +15 -12
  162. package/src/core/mesh-store.ts +2 -1
  163. package/src/core/peer-lifecycle.ts +9 -4
  164. package/src/core/pid-lock-file.ts +139 -11
  165. package/src/core/process-start.ts +45 -0
  166. package/src/core/room-lifecycle.ts +12 -9
  167. package/src/core/room-protocol.ts +23 -3
  168. package/src/core/stale-agent-checker.ts +3 -1
  169. package/src/core/store.ts +48 -23
  170. package/src/core/tool.ts +14 -1
  171. package/src/core/types.ts +9 -3
  172. package/src/core/user-identity.ts +65 -51
@@ -1 +1 @@
1
- (function(){var e=`agent-comms-v1`,t=[{"revision":"66fb8e407d25a3d34fd7df939ebd2e96","url":"mesh-worker.js"},{"revision":"f1ee3afb8c9e8f0d7315fd93d6457316","url":"index.html"},{"revision":"aef2de9b9f0ed47024e02762fc047436","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))}),self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t!==e).map(async e=>self.caches.delete(e)))))});var n=200,r=300;function i(e,t){return e.method===`GET`&&t.status>=n&&t.status<r}self.addEventListener(`fetch`,t=>{if(new URL(t.request.url).pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(i(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
1
+ (function(){var e=`agent-comms-v2`,t=[{"revision":"fa356ca4b2a4b6ec4a929516b593a3e4","url":"mesh-worker.js"},{"revision":"053013aa131ee0e62e4492d27f4beb90","url":"index.html"},{"revision":"acb589210914ac93762291f24b3f6492","url":"bundle.js"}].map(e=>e.url);self.addEventListener(`install`,n=>{n.waitUntil(self.caches.open(e).then(async e=>e.addAll(t)).then(async()=>self.skipWaiting()))});var n=`agent-comms-`;self.addEventListener(`activate`,t=>{t.waitUntil(self.caches.keys().then(async t=>Promise.all(t.filter(t=>t.startsWith(n)&&t!==e).map(async e=>self.caches.delete(e)))))});var r=200,i=300;function a(e,t){return e.method===`GET`&&t.status>=r&&t.status<i}function o(e){return e.type===`basic`&&e.status>=r&&e.status<i&&(e.headers.get(`content-type`)??``).startsWith(`text/html`)}var s=500,c=new URL(`index.html`,self.location.href).href;self.addEventListener(`fetch`,t=>{let n=new URL(t.request.url);if(t.request.mode===`navigate`){t.respondWith(fetch(t.request,{cache:`no-cache`}).then(n=>{if(t.request.method===`GET`&&o(n)){let t=n.clone();self.caches.open(e).then(async e=>e.put(c,t))}return n}).then(async t=>t.status<s?t:await(await self.caches.open(e)).match(c)??t).catch(async()=>self.caches.open(e).then(async e=>e.match(c)).then(e=>e??new Response(`Offline`,{status:503}))));return}if(n.pathname.startsWith(`/api/`)){t.respondWith(fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n}).catch(async()=>self.caches.open(e).then(async e=>e.match(t.request)).then(e=>e??new Response(`Offline`,{status:503}))));return}t.respondWith(self.caches.open(e).then(async e=>e.match(t.request)).then(async n=>n||fetch(t.request).then(n=>{if(a(t.request,n)){let r=n.clone();self.caches.open(e).then(async e=>e.put(t.request,r))}return n})))})})();
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where the hosted dashboard is served from. One definition, imported by everything that names the hosted origin: the bridge's admission of it (web-access.ts) and the tests that stand in for it.
3
+ *
4
+ * The origin is the unit a browser keeps storage apart by, so it is also the line between the dashboard and every other page that shares a host with it.
5
+ */
6
+
7
+ /** The origin of the dashboard deployed to GitHub Pages. It discovers a bridge's server by probing loopback ports and then opens its `/ws/mesh` socket cross-origin, so its Origin is admitted from a client on this machine; every other foreign origin is refused. */
8
+ export const HOSTED_DASHBOARD_ORIGIN = "https://exadev.github.io";
9
+
10
+ /** The address a person opens to reach the hosted dashboard: its origin and the path its site is published under. */
11
+ export const HOSTED_DASHBOARD_URL = `${HOSTED_DASHBOARD_ORIGIN}/agent-comms/`;
@@ -7,6 +7,7 @@
7
7
  import * as crypto from "node:crypto";
8
8
  import * as net from "node:net";
9
9
  import * as os from "node:os";
10
+ import { HOSTED_DASHBOARD_ORIGIN } from "./hosted-dashboard.js";
10
11
 
11
12
  /** The environment variable that opts the web server into binding beyond loopback. Its value is an IP address literal to bind: `0.0.0.0` or `::` for every interface, or one interface's own address. */
12
13
  export const WEB_HOST_ENV = "AGENT_COMMS_WEB_HOST";
@@ -38,9 +39,6 @@ const HTTP_FOUND = 302;
38
39
  /** The authentication scheme of an `Authorization` header carrying the token. Scheme names are case-insensitive (RFC 9110 section 11.1). */
39
40
  const BEARER_SCHEME = "bearer";
40
41
 
41
- /** The origin of the dashboard deployed to GitHub Pages. It discovers a bridge's server by probing loopback ports and then opens its `/ws/mesh` socket cross-origin, so its Origin is admitted from a client on this machine; every other foreign origin is refused. */
42
- export const HOSTED_DASHBOARD_ORIGIN = "https://exadev.github.io";
43
-
44
42
  /** How long the address sets derived from the machine's interfaces and hostname are reused. Reading them costs a system call per interface, which every request would otherwise pay; this bounds how long a newly attached interface waits to be recognised. */
45
43
  const INTERFACE_CACHE_MS = 5000;
46
44
 
package/src/cli-main.ts CHANGED
@@ -35,6 +35,7 @@ import { runTui } from "./bridges/user/tui.js";
35
35
  import { runCli } from "./bridges/user/cli.js";
36
36
  import { readHiddenLine, runAccountCommand } from "./account-cli.js";
37
37
  import { openMachineAccountStore } from "./account-join-cli.js";
38
+ import { UnusableIdentityRecordError } from "./core/issuer-identity-file.js";
38
39
 
39
40
  // ---------------------------------------------------------------------------
40
41
  // Zod schemas for config files
@@ -289,10 +290,13 @@ function runBridge(
289
290
  );
290
291
  process.exit(1);
291
292
  }
292
- Promise.resolve(bridge.run(options)).catch((err: unknown) => {
293
- console.error(err);
294
- process.exit(1);
295
- });
293
+ Promise.resolve(bridge.run(options)).catch(exitWithError);
294
+ }
295
+
296
+ /** Prints why a command could not start and exits non-zero. An unusable identity file's message already says what to do about it, so it is printed without the stack trace that would bury it. */
297
+ function exitWithError(err: unknown): never {
298
+ console.error(err instanceof UnusableIdentityRecordError ? err.message : err);
299
+ process.exit(1);
296
300
  }
297
301
 
298
302
  function parseNameArg(args: readonly string[]): {
@@ -314,18 +318,12 @@ function parseNameArg(args: readonly string[]): {
314
318
 
315
319
  function runChat(args: readonly string[]): void {
316
320
  const { name } = parseNameArg(args);
317
- runTui(name).catch((err: unknown) => {
318
- console.error(err);
319
- process.exit(1);
320
- });
321
+ runTui(name).catch(exitWithError);
321
322
  }
322
323
 
323
324
  function runUserCli(cliCommand: string, args: readonly string[]): void {
324
325
  const { name, rest } = parseNameArg(args);
325
- runCli(cliCommand, rest, name).catch((err: unknown) => {
326
- console.error(err);
327
- process.exit(1);
328
- });
326
+ runCli(cliCommand, rest, name).catch(exitWithError);
329
327
  }
330
328
 
331
329
  // ---------------------------------------------------------------------------
@@ -30,6 +30,8 @@ const SALT_BYTES = 16;
30
30
  const KEY_BYTES = 32;
31
31
  /** GCM's specified IV length. */
32
32
  const IV_BYTES = 12;
33
+ /** GCM's full-length authentication tag. A shorter tag is weaker evidence that the ciphertext is untouched, and Node accepts one unless the length is pinned, so the length is checked wherever a tag comes from outside. */
34
+ const TAG_BYTES = 16;
33
35
  /** The shortest passphrase an export accepts. Twelve characters is the length NCSC and NIST guidance treat as the floor for a passphrase protecting something offline-attackable; the scrypt cost above does the rest. */
34
36
  export const MIN_ACCOUNT_PASSPHRASE_LENGTH = 12;
35
37
 
@@ -38,6 +40,22 @@ const BUNDLE_AAD = Buffer.from(ACCOUNT_BUNDLE_PREFIX);
38
40
 
39
41
  const base64url = z.string().regex(/^[A-Za-z0-9_-]*$/);
40
42
 
43
+ /** Base64url text that decodes to exactly `bytes` bytes. */
44
+ function base64urlBytes(bytes: number): z.ZodType<string> {
45
+ return base64url.refine(
46
+ (text) => Buffer.from(text, "base64url").length === bytes,
47
+ `must decode to ${String(bytes)} bytes`,
48
+ );
49
+ }
50
+
51
+ /** The parts of an envelope sealed with AES-256-GCM, each at the length `seal` writes and GCM specifies. */
52
+ const gcmFields = {
53
+ iv: base64urlBytes(IV_BYTES),
54
+ tag: base64urlBytes(TAG_BYTES),
55
+ /** The ciphertext: its length is whatever the plaintext was. */
56
+ ct: base64url,
57
+ };
58
+
41
59
  /** What an account export or a join carries: the PEM private key and the name the account asserts for itself, if it has one. */
42
60
  export interface AccountContents {
43
61
  privateKey: string;
@@ -80,19 +98,16 @@ function parseContents(plaintext: string, damaged: string): AccountContents {
80
98
  const passphraseEnvelopeSchema = z
81
99
  .object({
82
100
  kdf: z.literal("scrypt"),
83
- n: z.number().int().positive(),
84
- r: z.number().int().positive(),
85
- p: z.number().int().positive(),
86
- salt: base64url,
87
- iv: base64url,
88
- tag: base64url,
89
- ct: base64url,
101
+ // Exactly what this build writes. Above it, reading a bundle would cost more time and memory than writing one. Below it, a bundle would be accepted at a cost that does not protect the passphrase: every released version has written these parameters and no others, so nothing older is refused by requiring them, and a bundle sealed at a lower cost by another tool is refused.
102
+ n: z.literal(SCRYPT_COST),
103
+ r: z.literal(SCRYPT_BLOCK_SIZE),
104
+ p: z.literal(SCRYPT_PARALLELISM),
105
+ salt: base64urlBytes(SALT_BYTES),
106
+ ...gcmFields,
90
107
  })
91
108
  .strict();
92
109
 
93
- const inviteEnvelopeSchema = z
94
- .object({ iv: base64url, tag: base64url, ct: base64url })
95
- .strict();
110
+ const inviteEnvelopeSchema = z.object(gcmFields).strict();
96
111
 
97
112
  function seal(
98
113
  key: Buffer,
@@ -109,19 +124,20 @@ function seal(
109
124
  };
110
125
  }
111
126
 
112
- /** The plaintext of a sealed envelope, or undefined if the key is wrong or the envelope was altered. */
127
+ /** The plaintext of a sealed envelope, or undefined if the key is wrong or the envelope was altered. The envelope's iv and tag have already been checked to be the lengths `seal` writes, and the cipher is set to accept only a full-length tag as well, so a shortened one cannot authenticate. */
113
128
  function open(
114
129
  key: Buffer,
115
130
  envelope: Readonly<{ iv: string; tag: string; ct: string }>,
116
131
  ): string | undefined {
117
- const decipher = createDecipheriv(
118
- "aes-256-gcm",
119
- key,
120
- Buffer.from(envelope.iv, "base64url"),
121
- );
122
- decipher.setAAD(BUNDLE_AAD);
123
- decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
124
132
  try {
133
+ const decipher = createDecipheriv(
134
+ "aes-256-gcm",
135
+ key,
136
+ Buffer.from(envelope.iv, "base64url"),
137
+ { authTagLength: TAG_BYTES },
138
+ );
139
+ decipher.setAAD(BUNDLE_AAD);
140
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
125
141
  return Buffer.concat([
126
142
  decipher.update(Buffer.from(envelope.ct, "base64url")),
127
143
  decipher.final(),
@@ -166,7 +182,7 @@ export function sealAccountBundle(
166
182
  return `${ACCOUNT_BUNDLE_PREFIX}${Buffer.from(JSON.stringify(envelope)).toString("base64url")}`;
167
183
  }
168
184
 
169
- /** The account contents a bundle holds. Throws INVALID_BUNDLE for text that is not a bundle this build can read, and WRONG_PASSPHRASE when the passphrase does not open it. The cost parameters are read from the bundle but capped at this build's own, so a crafted bundle cannot make an import run for hours. */
185
+ /** The account contents a bundle holds. Throws INVALID_BUNDLE for text that is not a bundle this build can read (including one whose cost parameters, salt, iv or authentication tag are not in the form this build writes), and WRONG_PASSPHRASE when the passphrase does not open it. The cost parameters in the bundle must be exactly the ones this build writes, so reading a bundle costs no more than writing one, no weak derivation is accepted, and nothing fails with a raw error. */
170
186
  export function openAccountBundle(
171
187
  bundle: string,
172
188
  passphrase: string,
@@ -187,12 +203,7 @@ export function openAccountBundle(
187
203
  throw new CommsError("The account bundle is damaged", "INVALID_BUNDLE");
188
204
  }
189
205
  const envelope = passphraseEnvelopeSchema.safeParse(decoded);
190
- if (
191
- !envelope.success ||
192
- envelope.data.n > SCRYPT_COST ||
193
- envelope.data.r > SCRYPT_BLOCK_SIZE ||
194
- envelope.data.p > SCRYPT_PARALLELISM
195
- ) {
206
+ if (!envelope.success) {
196
207
  throw new CommsError("The account bundle is damaged", "INVALID_BUNDLE");
197
208
  }
198
209
  const key = passphraseKey(
@@ -26,6 +26,7 @@ import {
26
26
  import type { GatewayTrust } from "./gateway-trust.js";
27
27
  import type { MeshStoreIdentity } from "./mesh-store-shared.js";
28
28
  import { parseDisplayName } from "./display-name.js";
29
+ import { parseIsoTimestampMs } from "./iso-timestamp.js";
29
30
  import { isDeviceIdHex } from "./room-path.js";
30
31
  import { CommsError, requireDisplayName } from "./store.js";
31
32
  import type { MeshTransport } from "./transport.js";
@@ -187,7 +188,8 @@ export class AccountJoin {
187
188
  private pruneExpired(): void {
188
189
  const now = this.deps.requireIdentity().clock.now();
189
190
  for (const [nonce, invite] of this.invites) {
190
- if (Date.parse(invite.secret.expiresAt) > now) continue;
191
+ const expiresAtMs = parseIsoTimestampMs(invite.secret.expiresAt);
192
+ if (expiresAtMs !== undefined && expiresAtMs > now) continue;
191
193
  this.invites.delete(nonce);
192
194
  invite.settle("expired");
193
195
  }
@@ -232,7 +234,7 @@ export class AccountJoin {
232
234
  }
233
235
  const contents = openAccountKeyFromInvite(sealed, candidate);
234
236
  const current = this.deps.requireIdentity();
235
- const { identity, replacedFile } = importAccountKey(
237
+ const { identity, replacedFile } = await importAccountKey(
236
238
  current.userIdentityOptions,
237
239
  contents,
238
240
  );
@@ -243,6 +245,7 @@ export class AccountJoin {
243
245
  userIdentityOptions: current.userIdentityOptions,
244
246
  userIdentity: identity,
245
247
  clock: current.clock,
248
+ onDamagedEntry: current.accountLedger.reportDamagedEntry,
246
249
  }),
247
250
  });
248
251
  this.trustOwnAccount();
@@ -138,7 +138,10 @@ export function openLedgerEntry(
138
138
  }
139
139
  const iv = sealed.subarray(VERSION_BYTES, VERSION_BYTES + IV_BYTES);
140
140
  const tag = sealed.subarray(VERSION_BYTES + IV_BYTES, headerBytes);
141
- const decipher = createDecipheriv("aes-256-gcm", keys.entryKey, iv);
141
+ // The tag is the fixed TAG_BYTES slice above; pinning the length as well means Node can never accept a shortened one if the layout changes.
142
+ const decipher = createDecipheriv("aes-256-gcm", keys.entryKey, iv, {
143
+ authTagLength: TAG_BYTES,
144
+ });
142
145
  decipher.setAAD(associatedData(logId, seq));
143
146
  decipher.setAuthTag(tag);
144
147
  try {
@@ -7,7 +7,10 @@ import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage";
7
7
  import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
8
8
  import type { Clock } from "wire-mesh-core/ports/clock";
9
9
  import { generateWriterNonce } from "./account-ledger-crypto.js";
10
- import { AccountLedger } from "./account-ledger.js";
10
+ import {
11
+ AccountLedger,
12
+ type LedgerEntryDamagedError,
13
+ } from "./account-ledger.js";
11
14
  import type { PeerIdentity } from "./identity.js";
12
15
  import { createFileLedgerLock } from "./ledger-lock.js";
13
16
  import {
@@ -24,6 +27,8 @@ export interface OpenAccountLedgerOptions {
24
27
  /** The account's identity as loadOrCreateUserIdentity returned it. */
25
28
  userIdentity: Readonly<PeerIdentity>;
26
29
  clock: Clock;
30
+ /** Where an unreadable ledger entry is reported; see AccountLedgerDeps.onDamagedEntry. */
31
+ onDamagedEntry?: ((error: LedgerEntryDamagedError) => void) | undefined;
27
32
  }
28
33
 
29
34
  /** The directory an account's ledger is stored in on this machine. Named by the account's device-id, so a machine that imports a different account starts that account's ledger afresh instead of mixing two accounts' logs. */
@@ -49,18 +54,21 @@ export async function openAccountLedger(
49
54
  );
50
55
  const ledger = new AccountLedger({
51
56
  accountPrivateKey: options.userIdentity.privateKey,
52
- writerNonce: loadOrCreateLedgerWriterNonce(
57
+ writerNonce: await loadOrCreateLedgerWriterNonce(
53
58
  options.userIdentityOptions,
54
59
  generateWriterNonce,
55
60
  ),
56
61
  storage: createNodeFsStorage({ dir }),
57
62
  lock: createFileLedgerLock(`${dir}.lock`),
58
63
  clock: options.clock,
64
+ ...(options.onDamagedEntry !== undefined && {
65
+ onDamagedEntry: options.onDamagedEntry,
66
+ }),
59
67
  });
60
68
  const legacy = readLegacyIssuedGrants(options.userIdentityOptions);
61
69
  if (legacy.length > 0) {
62
70
  await ledger.recordGrants(legacy);
63
- clearLegacyIssuedGrants(options.userIdentityOptions, legacy);
71
+ await clearLegacyIssuedGrants(options.userIdentityOptions, legacy);
64
72
  }
65
73
  return ledger;
66
74
  }
@@ -4,6 +4,8 @@
4
4
  * Each machine appends to a writer log of its own rather than all of them sharing one: a data-domain log is single-writer, and two machines appending to one log while apart would both write the same sequence number and fork it, silently losing one side's grants. A writer log's id is self-authenticating (account-ledger-crypto.ts), so any account holder recognises every writer log it is offered without a roster, and the ledger is the union of all of them.
5
5
  *
6
6
  * Holding a replica grants no authority: an entry is sealed under a key derived from the account key, so a replica held by anyone else is ciphertext they can neither read nor forge, and minting or revoking still needs the account's private key.
7
+ *
8
+ * A single entry that cannot be read never blocks the rest of the ledger. Revoking is the operation the ledger exists for, and storage can lose or damage an entry, so an unreadable entry that stopped listing and revoking would turn one damaged file into a permanent inability to revoke anything for that account. Each unreadable entry is instead skipped, and a skipped entry is never silent: it is reported once per ledger as a LedgerEntryDamagedError (to `onDamagedEntry`, stderr by default), naming its log and position, listed by `unreadableEntries()` for `whoami`, and counted in the result of every read that met it (`outstandingGrantsChecked` and `revocations`), so a caller that revokes can tell its outcome is incomplete. An unreadable entry may hold a grant or a revocation: a grant recorded only in it is not listed, so it is not revoked, and a revocation recorded only in it is not applied by this machine, until the entry is repaired or replaced from another machine's replica. Revocations in the readable entries are still applied, and a revocation announced over the mesh still reaches the store.
7
9
  */
8
10
 
9
11
  import {
@@ -101,6 +103,36 @@ export interface AccountLedgerDeps {
101
103
  /** Serialises every read and write of `storage` across every bridge sharing it: appends to this machine's writer log, replicated entries stored for another machine's, and every read of either. The store's writes are not atomic (a file is truncated, then written), so a read racing another bridge's write could see an empty head and treat a log as empty. */
102
104
  lock: LedgerLock;
103
105
  clock: Clock;
106
+ /** Called once for each ledger entry that could not be read. Defaults to a line on stderr. */
107
+ onDamagedEntry?: (error: LedgerEntryDamagedError) => void;
108
+ }
109
+
110
+ /** Why an entry that opened under the account key is unreadable. Fixed text: the decoder's own message can quote the entry's content, and the report is printed whole. */
111
+ export const UNDECODABLE_REASON =
112
+ "its content is not a record this build can decode";
113
+
114
+ /** A ledger entry that is stored but cannot be read: it does not open under the account's key for its log and position, or what it holds is not a record this build can decode. */
115
+ export class LedgerEntryDamagedError extends Error {
116
+ constructor(
117
+ /** The writer log the entry belongs to, as hex. */
118
+ public readonly log: string,
119
+ /** The entry's position in that log, counting from one. */
120
+ public readonly seq: number,
121
+ /** Why it could not be read. */
122
+ public readonly reason: string,
123
+ ) {
124
+ super(
125
+ `account ledger entry ${String(seq)} of writer log ${log} cannot be read (${reason}); it was skipped, so a grant or a revocation recorded only in it is not read: such a grant is not listed or revoked, and such a revocation is not applied here, until the entry is repaired or replaced from another machine's replica of this account`,
126
+ );
127
+ this.name = "LedgerEntryDamagedError";
128
+ }
129
+ }
130
+
131
+ /** The default place an unreadable ledger entry is reported: one line on stderr. */
132
+ export function reportDamagedEntryToStderr(
133
+ error: Readonly<LedgerEntryDamagedError>,
134
+ ): void {
135
+ process.stderr.write(`agent-comms: ${error.message}\n`);
104
136
  }
105
137
 
106
138
  /** Answers and stores the data-domain frames for the account's writer logs; what a transport needs of the ledger to replicate it. */
@@ -113,16 +145,36 @@ export interface AccountLedgerReplica {
113
145
  ) => Promise<DataHaveFrame | DataRequestFrame | DataEntriesFrame | null>;
114
146
  /** A data-have for every account log this machine holds, to offer peers. */
115
147
  announcements: () => Promise<DataHaveFrame[]>;
148
+ /** The stored entries that could not be read, as far as reads so far have found. */
149
+ unreadableEntries: () => readonly LedgerEntryDamagedError[];
150
+ }
151
+
152
+ /** What a read of the ledger could not read: the number of stored entries that were skipped because they cannot be opened or decoded. Zero means the read was complete. */
153
+ export interface LedgerGaps {
154
+ unreadable: number;
116
155
  }
117
156
 
118
157
  const versionProbeSchema = z.object({ v: z.number() });
119
158
 
120
159
  const HEAD_KEY_PATTERN = /^data\/([0-9a-f]+)\/head$/;
121
160
 
161
+ /** Where a store's unreadable ledger entries are reported: its own error handler once it has one, otherwise stderr, since a handler that is not assigned yet or not at all must not make an entry that can hide a grant go unreported. `getOnError` is read on every report. */
162
+ export function damagedEntryReporter(
163
+ getOnError: () => ((error: Error) => void) | undefined,
164
+ ): (error: LedgerEntryDamagedError) => void {
165
+ return (error) => {
166
+ (getOnError() ?? reportDamagedEntryToStderr)(error);
167
+ };
168
+ }
169
+
122
170
  export class AccountLedger implements AccountLedgerReplica {
123
171
  /** This machine's own writer log. */
124
172
  readonly writerLog: DeviceId;
173
+ /** Where this ledger reports an entry it cannot read; also what a ledger opened for another account on the same machine is given, so the destination follows the store and not the account. */
174
+ readonly reportDamagedEntry: (error: LedgerEntryDamagedError) => void;
125
175
  private readonly keys: AccountLedgerKeys;
176
+ /** Every entry a read has found unreadable, keyed by log and position, each reported once. */
177
+ private readonly damaged = new Map<string, LedgerEntryDamagedError>();
126
178
  private readonly revocationListeners = new Set<
127
179
  (entry: RevocationEntry) => void
128
180
  >();
@@ -130,6 +182,7 @@ export class AccountLedger implements AccountLedgerReplica {
130
182
  constructor(private readonly deps: Readonly<AccountLedgerDeps>) {
131
183
  this.keys = deriveAccountLedgerKeys(deps.accountPrivateKey);
132
184
  this.writerLog = writerLogId(this.keys, deps.writerNonce);
185
+ this.reportDamagedEntry = deps.onDamagedEntry ?? reportDamagedEntryToStderr;
133
186
  }
134
187
 
135
188
  /** Records that the principal minted `tokenId` for `subjectHex`. */
@@ -191,14 +244,23 @@ export class AccountLedger implements AccountLedgerReplica {
191
244
  ]);
192
245
  }
193
246
 
194
- /** Every grant of `kind` for `subjectHex` that some machine of the account recorded and none has revoked, oldest first. */
247
+ /** Every grant of `kind` for `subjectHex` that some machine of the account recorded and none has revoked, oldest first, among the entries that can be read. A caller that goes on to revoke uses outstandingGrantsChecked, which also says whether entries were skipped. */
195
248
  async outstandingGrants(
196
249
  kind: IssuedGrantKind,
197
250
  subjectHex: string,
198
251
  ): Promise<OutstandingGrant[]> {
252
+ return (await this.outstandingGrantsChecked(kind, subjectHex)).grants;
253
+ }
254
+
255
+ /** outstandingGrants with the number of entries the read had to skip: when it is above zero, a grant recorded only in a skipped entry is not in `grants`, and revoking `grants` does not make the subject safe. */
256
+ async outstandingGrantsChecked(
257
+ kind: IssuedGrantKind,
258
+ subjectHex: string,
259
+ ): Promise<{ grants: OutstandingGrant[] } & LedgerGaps> {
199
260
  const granted = new Map<string, OutstandingGrant>();
200
261
  const revoked = new Set<string>();
201
- for (const record of await this.records()) {
262
+ const { records, unreadable } = await this.records();
263
+ for (const record of records) {
202
264
  if (record.kind !== kind || record.subject !== subjectHex) continue;
203
265
  const key = Buffer.from(record.tokenId).toString("hex");
204
266
  if (record.op === "revoke") {
@@ -210,17 +272,28 @@ export class AccountLedger implements AccountLedgerReplica {
210
272
  });
211
273
  }
212
274
  }
213
- return [...granted]
214
- .filter(([key]) => !revoked.has(key))
215
- .map(([, grant]) => grant)
216
- .sort((a, b) => a.at - b.at);
275
+ return {
276
+ grants: [...granted]
277
+ .filter(([key]) => !revoked.has(key))
278
+ .map(([, grant]) => grant)
279
+ .sort((a, b) => a.at - b.at),
280
+ unreadable,
281
+ };
217
282
  }
218
283
 
219
- /** Every revocation entry the ledger holds, from every machine, for a RevocationView to learn at start-up. */
220
- async revocations(): Promise<RevocationEntry[]> {
221
- return (await this.records()).flatMap((record) =>
222
- record.op === "revoke" ? [record.revocation] : [],
223
- );
284
+ /** Every revocation entry the readable part of the ledger holds, from every machine, for a RevocationView to learn at start-up, and how many entries were skipped: a revocation recorded only in one of those is not in `entries`. */
285
+ async revocations(): Promise<{ entries: RevocationEntry[] } & LedgerGaps> {
286
+ const { records, unreadable } = await this.records();
287
+ return {
288
+ entries: records.flatMap((record) =>
289
+ record.op === "revoke" ? [record.revocation] : [],
290
+ ),
291
+ unreadable,
292
+ };
293
+ }
294
+
295
+ unreadableEntries(): readonly LedgerEntryDamagedError[] {
296
+ return [...this.damaged.values()];
224
297
  }
225
298
 
226
299
  ownsLog(peer: DeviceId): boolean {
@@ -274,8 +347,12 @@ export class AccountLedger implements AccountLedgerReplica {
274
347
  handleDataEntries(storage, frame),
275
348
  );
276
349
  if (!stored.ok) return null;
277
- for (const plaintext of plaintexts) {
278
- const record = decodeRecord(plaintext);
350
+ for (const [index, plaintext] of plaintexts.entries()) {
351
+ const record = this.decodeOrReport(
352
+ frame.peer,
353
+ firstSeq + index,
354
+ plaintext,
355
+ );
279
356
  if (record?.op !== "revoke") continue;
280
357
  for (const listener of this.revocationListeners) {
281
358
  listener(record.revocation);
@@ -316,8 +393,34 @@ export class AccountLedger implements AccountLedgerReplica {
316
393
  return logs;
317
394
  }
318
395
 
319
- /** Every record in every held writer log. Throws on an entry that does not open: handleDataFrame and append never store one, so it means the storage was damaged outside the ledger, and carrying on would hide grants that can then never be revoked. */
320
- private async records(): Promise<LedgerRecord[]> {
396
+ /** The record an entry holds, or undefined after noting why it cannot be read (or, silently, for a record of a later format version). */
397
+ private decodeOrReport(
398
+ log: DeviceId,
399
+ seq: number,
400
+ plaintext: Uint8Array,
401
+ ): LedgerRecord | undefined {
402
+ try {
403
+ return decodeRecord(plaintext);
404
+ } catch {
405
+ this.noteDamaged(log, seq, UNDECODABLE_REASON);
406
+ return undefined;
407
+ }
408
+ }
409
+
410
+ /** Remembers an unreadable entry and reports it the first time it is met. */
411
+ private noteDamaged(log: DeviceId, seq: number, reason: string): void {
412
+ const hex = deviceIdToHex(log);
413
+ const key = `${hex}:${String(seq)}`;
414
+ if (this.damaged.has(key)) return;
415
+ const error = new LedgerEntryDamagedError(hex, seq, reason);
416
+ this.damaged.set(key, error);
417
+ this.reportDamagedEntry(error);
418
+ }
419
+
420
+ /** Every readable record in every held writer log, in the order stored, and how many entries were skipped. An entry that cannot be read is noted and skipped (see the module comment for why that is not a failure). handleDataFrame and append never store one, so it means the storage was damaged outside the ledger or a holder of the account key wrote one this build cannot decode. */
421
+ private async records(): Promise<
422
+ { records: LedgerRecord[] } & { unreadable: number }
423
+ > {
321
424
  const held = await this.deps.lock.run(async () =>
322
425
  Promise.all(
323
426
  (await this.heldLogs()).map(async (log) => ({
@@ -327,20 +430,32 @@ export class AccountLedger implements AccountLedgerReplica {
327
430
  ),
328
431
  );
329
432
  const records: LedgerRecord[] = [];
433
+ let unreadable = 0;
330
434
  for (const { log, entries } of held) {
331
435
  entries.forEach((sealed, index) => {
332
436
  const seq = index + 1;
333
437
  const plaintext = openLedgerEntry(this.keys, log, seq, sealed);
334
438
  if (plaintext === undefined) {
335
- throw new Error(
336
- `account ledger entry ${String(seq)} of writer log ${deviceIdToHex(log)} does not open under this account's key`,
439
+ this.noteDamaged(
440
+ log,
441
+ seq,
442
+ "it does not open under this account's key",
337
443
  );
444
+ unreadable += 1;
445
+ return;
446
+ }
447
+ let record: LedgerRecord | undefined;
448
+ try {
449
+ record = decodeRecord(plaintext);
450
+ } catch {
451
+ this.noteDamaged(log, seq, UNDECODABLE_REASON);
452
+ unreadable += 1;
453
+ return;
338
454
  }
339
- const record = decodeRecord(plaintext);
340
455
  if (record !== undefined) records.push(record);
341
456
  });
342
457
  }
343
- return records;
458
+ return { records, unreadable };
344
459
  }
345
460
  }
346
461
 
@@ -378,8 +493,15 @@ export function followLedgerRevocations(
378
493
  }, report);
379
494
  };
380
495
  const unsubscribe = identity.accountLedger.onReplicatedRevocation(learn);
381
- identity.accountLedger.revocations().then((entries) => {
496
+ identity.accountLedger.revocations().then(({ entries, unreadable }) => {
382
497
  entries.forEach(learn);
498
+ if (unreadable > 0) {
499
+ report(
500
+ new Error(
501
+ `${String(unreadable)} account ledger entries cannot be read, so a revocation recorded only in them is not applied on this machine until they are repaired or replaced from another machine's replica`,
502
+ ),
503
+ );
504
+ }
383
505
  }, report);
384
506
  return unsubscribe;
385
507
  }
@@ -24,6 +24,7 @@ import { FIRST_CONTACT_PORT } from "./first-contact.js";
24
24
  import { CommsTool } from "./tool.js";
25
25
  import { WireMeshTransport } from "./wire-mesh-transport.js";
26
26
  import { loadOrCreateIdentity, oplogDirFor } from "./identity-store.js";
27
+ import { damagedEntryReporter } from "./account-ledger.js";
27
28
  import { openAccountLedger } from "./account-ledger-store.js";
28
29
  import type { IdentitySlot } from "./identity-store.js";
29
30
  import { toIdentityPort } from "./wire-mesh-identity.js";
@@ -126,6 +127,8 @@ export function createBridgeMeshSyncFromIdentity(
126
127
  discovery: store.discovery,
127
128
  naming: store.naming,
128
129
  getNewerVersionIfAny: () => versionChecker.getNewerVersionIfAny(),
130
+ getUnreadableLedgerEntries: () =>
131
+ store.accountLedger?.unreadableEntries().length ?? 0,
129
132
  });
130
133
  const revocation = createRevocationView();
131
134
  return {
@@ -144,6 +147,7 @@ export function createBridgeMeshSyncFromIdentity(
144
147
  userIdentityOptions,
145
148
  userIdentity,
146
149
  clock,
150
+ onDamagedEntry: damagedEntryReporter(() => store.onError),
147
151
  }),
148
152
  userIdentityOptions,
149
153
  machineIdentity: await toIdentityPort(machineIdentity),
@@ -117,19 +117,39 @@ export const MCP_TOOL_PARAMS = z.object({
117
117
  /** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust, for gateway_redeem_connection_code (the code's own embedded deviceId field), and the device-id petname_set/petname_clear label. */
118
118
  device: z.string().optional(),
119
119
  /** The connection code's own nonce (gateway_redeem_connection_code). */
120
- code: z.string().optional(),
120
+ code: z
121
+ .string()
122
+ .optional()
123
+ .describe(
124
+ "gateway_redeem_connection_code: the code's nonce. An unsigned connection code is a bearer hint, not authentication: redeeming it trusts whatever device-id its text names, whoever wrote it, so it is not a way to establish trust in a stranger. Supply publicKey or fingerprint to require a signature from a key you already trust.",
125
+ ),
121
126
  /** ISO-8601 expiry for gateway_generate_connection_code's ttlMs override, or the redeemed code's own expiresAt field for gateway_redeem_connection_code. */
122
127
  expiresAt: z.string().optional(),
123
128
  /** ttlMs override for gateway_generate_connection_code; defaults to DEFAULT_CONNECTION_CODE_TTL_MS when omitted. */
124
129
  ttlMs: z.number().optional(),
125
130
  /** Armored PGP private key to sign a generated code with (gateway_generate_connection_code), or armored PGP public key to verify a redeemed code's signature against (gateway_redeem_connection_code). Both are supplied per call -- agent-comms never generates, stores, or manages PGP key material itself. */
126
131
  privateKey: z.string().optional(),
127
- publicKey: z.string().optional(),
132
+ publicKey: z
133
+ .string()
134
+ .optional()
135
+ .describe(
136
+ "gateway_redeem_connection_code: armored PGP public key to verify the code's signature against. Supplying it makes an unsigned code refused.",
137
+ ),
128
138
  /** Passphrase for privateKey, if it is passphrase-protected (gateway_generate_connection_code). */
129
139
  passphrase: z.string().optional(),
130
140
  /** The redeemed code's own signature field (gateway_redeem_connection_code), or a PGP fingerprint the caller already independently trusts, used either to pin a supplied publicKey or to fetch one from a keyserver when publicKey is omitted. */
131
- signature: z.string().optional(),
132
- fingerprint: z.string().optional(),
141
+ signature: z
142
+ .string()
143
+ .optional()
144
+ .describe(
145
+ "gateway_redeem_connection_code: the code's detached PGP signature, when it has one.",
146
+ ),
147
+ fingerprint: z
148
+ .string()
149
+ .optional()
150
+ .describe(
151
+ "gateway_redeem_connection_code: a PGP fingerprint you already trust. Supplying it makes an unsigned code refused, and the signing key must match it.",
152
+ ),
133
153
  /** For gateway_trust/gateway_untrust (and dm_admit, where `target` names it): when true, the id names a user principal (agent-comms#187) rather than a bare remote device-id (agent-comms#193). */
134
154
  principal: z.boolean().optional(),
135
155
  /** For gateway_trust/gateway_untrust: when true, the id names a machine (agent-comms#343), whose devices (every bridge one OS account runs on that host) are trusted or revoked at once. */