wire-mesh-core 4.1.0 → 4.1.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.
@@ -280,73 +280,129 @@ async function establishChannelFrom(identity, { pending, remote, peer, firstSend
280
280
  function createRelayChannels(identity, sendHello, options = {}) {
281
281
  if (options.firstSendCounter !== void 0) assertFirstSendCounter(options.firstSendCounter);
282
282
  const channels = /* @__PURE__ */ new Map();
283
- /** Registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second, then sends this side's hello. */
284
- function start(peer) {
283
+ /** Tells the owner of a failure that is local to this side. The callback is the owner's code: its own failure, thrown or as a rejected promise it returns, must not end the session or go unobserved, so it is reported once and swallowed. */
284
+ function reportFault(error) {
285
+ const report = (faultError) => {
286
+ console.error("the local fault callback failed", faultError);
287
+ };
288
+ try {
289
+ const result = options.onFault?.(error);
290
+ if (typeof result === "object" && result !== null && "then" in result && typeof result.then === "function") Promise.resolve(result).catch(report);
291
+ } catch (faultError) {
292
+ report(faultError);
293
+ }
294
+ }
295
+ /** Abandons a handshake that failed: the entry goes if it is still the one held for the peer, and whoever awaits it is told. */
296
+ function abandon(entry, error) {
297
+ const failure = error instanceof Error ? error : new Error(String(error));
298
+ if (channels.get(entry.key) === entry) channels.delete(entry.key);
299
+ entry.settle.reject(failure);
300
+ entry.sent.reject(failure);
301
+ }
302
+ /** Starts a handshake with the peer: sends this side's hello, and registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second. `answering` is true when a received hello started this, so nobody awaits the outcome and a failure to send is reported to the owner. `track` is false for a handshake answering a hello while a channel with that peer is established: it is not registered, so the established channel stays in place and in use until the new one is up and this side's hello has been sent. A hello that could not be sent leaves no channel behind: the peer would never learn this side's key. */
303
+ function start(peer, { answering, track } = {
304
+ answering: false,
305
+ track: true
306
+ }) {
285
307
  const key = require_domain_device_id.deviceIdToHex(peer);
286
- const settle = Promise.withResolvers();
287
308
  const pending = beginHandshake(identity, peer);
288
309
  const entry = {
289
310
  status: "awaiting",
290
311
  pending,
291
- settle
312
+ settle: Promise.withResolvers(),
313
+ key,
314
+ sent: Promise.withResolvers()
292
315
  };
293
- channels.set(key, entry);
294
- pending.then(async ({ hello }) => sendHello(peer, hello)).catch((error) => {
295
- settle.reject(error instanceof Error ? error : new Error(String(error)));
296
- if (channels.get(key) === entry) channels.delete(key);
316
+ entry.settle.promise.catch(() => void 0);
317
+ entry.sent.promise.catch(() => void 0);
318
+ pending.then(async ({ hello }) => {
319
+ try {
320
+ await sendHello(peer, hello);
321
+ } catch (error) {
322
+ abandon(entry, error);
323
+ entry.settle.promise.then((channel) => {
324
+ const held = channels.get(key);
325
+ if (held?.status === "established" && held.channel === channel) channels.delete(key);
326
+ }, () => void 0);
327
+ if (answering && track) reportFault(error);
328
+ return;
329
+ }
330
+ entry.sent.resolve(void 0);
331
+ }, (error) => {
332
+ abandon(entry, error);
297
333
  });
334
+ if (track) channels.set(key, entry);
298
335
  return entry;
299
336
  }
337
+ /** Completes the handshake a verified hello belongs to. Throws on any failure of this side; the caller abandons the handshake. */
338
+ async function complete({ entry, hello, peer, replaces }) {
339
+ const { key } = entry;
340
+ const established = await establishChannelFrom(identity, {
341
+ pending: await entry.pending,
342
+ remote: hello,
343
+ peer,
344
+ ...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
345
+ });
346
+ if (established === void 0) {
347
+ abandon(entry, /* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
348
+ return;
349
+ }
350
+ if (replaces !== void 0) await entry.sent.promise;
351
+ if (channels.get(key) !== (replaces ?? entry)) return;
352
+ const channel = {
353
+ ...established,
354
+ seal: async (frame) => {
355
+ try {
356
+ return await established.seal(frame);
357
+ } catch (error) {
358
+ const held = channels.get(key);
359
+ if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
360
+ throw error;
361
+ }
362
+ }
363
+ };
364
+ channels.set(key, {
365
+ status: "established",
366
+ channel,
367
+ remoteEphemeralKey: hello["ephemeral-key"]
368
+ });
369
+ entry.settle.resolve(channel);
370
+ }
300
371
  return {
301
372
  ensure: async (peer) => {
302
- const existing = channels.get(require_domain_device_id.deviceIdToHex(peer));
373
+ const key = require_domain_device_id.deviceIdToHex(peer);
374
+ const existing = channels.get(key);
303
375
  if (existing?.status === "established") return existing.channel;
304
376
  return (existing ?? start(peer)).settle.promise;
305
377
  },
306
378
  applyHello: async (hello) => {
307
- let verdict;
379
+ let entry;
308
380
  try {
309
- verdict = await verifyHello(identity, hello);
381
+ const verdict = await verifyHello(identity, hello);
382
+ if (!verdict.ok) return;
383
+ const key = require_domain_device_id.deviceIdToHex(verdict.peer.deviceId);
384
+ const current = channels.get(key);
385
+ if (current?.status === "established" && require_domain_token_scope.bytesEqual(current.remoteEphemeralKey, hello["ephemeral-key"])) return;
386
+ const replaces = current?.status === "established" ? current : void 0;
387
+ entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId, {
388
+ answering: true,
389
+ track: replaces === void 0
390
+ });
391
+ await complete({
392
+ entry,
393
+ hello,
394
+ peer: verdict.peer,
395
+ replaces
396
+ });
310
397
  } catch (error) {
311
- options.onFault?.(error);
312
- return;
313
- }
314
- if (!verdict.ok) return;
315
- const key = require_domain_device_id.deviceIdToHex(verdict.peer.deviceId);
316
- const current = channels.get(key);
317
- const entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId);
318
- const established = await establishChannelFrom(identity, {
319
- pending: await entry.pending,
320
- remote: hello,
321
- peer: verdict.peer,
322
- ...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
323
- });
324
- if (established === void 0) {
325
- entry.settle.reject(/* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
326
- if (channels.get(key) === entry) channels.delete(key);
327
- return;
398
+ if (entry !== void 0) abandon(entry, error);
399
+ reportFault(error);
328
400
  }
329
- const channel = {
330
- ...established,
331
- seal: async (frame) => {
332
- try {
333
- return await established.seal(frame);
334
- } catch (error) {
335
- const held = channels.get(key);
336
- if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
337
- throw error;
338
- }
339
- }
340
- };
341
- channels.set(key, {
342
- status: "established",
343
- channel
344
- });
345
- entry.settle.resolve(channel);
346
401
  },
347
402
  open: async (data, claimedFrom) => {
348
403
  if (claimedFrom === void 0) return;
349
- const entry = channels.get(require_domain_device_id.deviceIdToHex(claimedFrom));
404
+ const key = require_domain_device_id.deviceIdToHex(claimedFrom);
405
+ const entry = channels.get(key);
350
406
  if (entry?.status !== "established") return;
351
407
  const frame = await entry.channel.open(data);
352
408
  return frame === void 0 ? void 0 : {
@@ -355,13 +411,13 @@ function createRelayChannels(identity, sendHello, options = {}) {
355
411
  };
356
412
  },
357
413
  reset: (reason) => {
358
- for (const entry of channels.values()) if (entry.status === "awaiting") entry.settle.reject(new Error(reason));
414
+ for (const entry of [...channels.values()]) if (entry.status === "awaiting") abandon(entry, new Error(reason));
359
415
  channels.clear();
360
416
  },
361
417
  forget: (peer) => {
362
418
  const key = require_domain_device_id.deviceIdToHex(peer);
363
419
  const entry = channels.get(key);
364
- if (entry?.status === "awaiting") entry.settle.reject(/* @__PURE__ */ new Error("the relay pairing was lost"));
420
+ if (entry?.status === "awaiting") abandon(entry, /* @__PURE__ */ new Error("the relay pairing was lost"));
365
421
  channels.delete(key);
366
422
  }
367
423
  };
@@ -137,8 +137,8 @@ addresses?: readonly string[],
137
137
  onPeerAdvert?: (advert: PeerAdvert) => void,
138
138
  /** Fired once per frame received on this session's connection, with the Connection it arrived on: a new Connection after a reconnect is how a caller notices it has to announce itself again, and a core/data reply is how it receives a log it asked a hub for. Omit for a caller with no use for it, exactly today's behaviour. */
139
139
  onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>,
140
- /** Told of a failure that is local to this session and did not end it: an identity port that failed while checking a hello from a peer. The hello is dropped and the peer is told nothing. Omit for a caller with no use for it: the failure is then not reported anywhere. */
141
- onLocalFault?: (error: unknown) => void): MeshSession;
140
+ /** Told of a failure that is local to this session and did not end it, while handling a hello from a peer: an identity port that failed to check or sign, an answering hello that could not be sent, a channel that could not be established. The hello is dropped, any half-made handshake for that peer is discarded, and the peer is told nothing, so no failure in handling a hello ends the session. Called once per failing hello; if it throws, or returns a promise that rejects, that is reported once to the console and swallowed. Omit for a caller with no use for it. */
141
+ onLocalFault?: (error: unknown) => void | Promise<void>): MeshSession;
142
142
  /** A MeshSession built over a connection that already exists (a Transport's own listen() handed it to onConnection), extended with the one thing a dial-side session can't offer: the device-id of the specific peer at the other end. Unlike createMeshSession, which may end up talking to a relay gossiping about many devices at once, an accepted connection is the agent-comms case -- exactly two peers, directly connected -- so "the peer" is well-defined here in a way it structurally isn't for the dial side. */
143
143
  export interface AcceptedMeshSession extends MeshSession {
144
144
  /** Resolves with the device-id carried by the first peer-advert this connection's remote sends -- the same self-advertisement mechanism createMeshSession's own directory already relies on for every peer, just narrowed to "the one peer this specific connection is with" rather than accumulated into a directory of possibly many. There is no transport-level authentication behind this yet (see wire-mesh#45's own createTlsTransport item): it is only as trustworthy as the remote's own gossip, exactly the same trust level the dial-side directory already has for every entry in it. */
@@ -137,8 +137,8 @@ addresses?: readonly string[],
137
137
  onPeerAdvert?: (advert: PeerAdvert) => void,
138
138
  /** Fired once per frame received on this session's connection, with the Connection it arrived on: a new Connection after a reconnect is how a caller notices it has to announce itself again, and a core/data reply is how it receives a log it asked a hub for. Omit for a caller with no use for it, exactly today's behaviour. */
139
139
  onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>,
140
- /** Told of a failure that is local to this session and did not end it: an identity port that failed while checking a hello from a peer. The hello is dropped and the peer is told nothing. Omit for a caller with no use for it: the failure is then not reported anywhere. */
141
- onLocalFault?: (error: unknown) => void): MeshSession;
140
+ /** Told of a failure that is local to this session and did not end it, while handling a hello from a peer: an identity port that failed to check or sign, an answering hello that could not be sent, a channel that could not be established. The hello is dropped, any half-made handshake for that peer is discarded, and the peer is told nothing, so no failure in handling a hello ends the session. Called once per failing hello; if it throws, or returns a promise that rejects, that is reported once to the console and swallowed. Omit for a caller with no use for it. */
141
+ onLocalFault?: (error: unknown) => void | Promise<void>): MeshSession;
142
142
  /** A MeshSession built over a connection that already exists (a Transport's own listen() handed it to onConnection), extended with the one thing a dial-side session can't offer: the device-id of the specific peer at the other end. Unlike createMeshSession, which may end up talking to a relay gossiping about many devices at once, an accepted connection is the agent-comms case -- exactly two peers, directly connected -- so "the peer" is well-defined here in a way it structurally isn't for the dial side. */
143
143
  export interface AcceptedMeshSession extends MeshSession {
144
144
  /** Resolves with the device-id carried by the first peer-advert this connection's remote sends -- the same self-advertisement mechanism createMeshSession's own directory already relies on for every peer, just narrowed to "the one peer this specific connection is with" rather than accumulated into a directory of possibly many. There is no transport-level authentication behind this yet (see wire-mesh#45's own createTlsTransport item): it is only as trustworthy as the remote's own gossip, exactly the same trust level the dial-side directory already has for every entry in it. */
@@ -279,73 +279,129 @@ async function establishChannelFrom(identity, { pending, remote, peer, firstSend
279
279
  function createRelayChannels(identity, sendHello, options = {}) {
280
280
  if (options.firstSendCounter !== void 0) assertFirstSendCounter(options.firstSendCounter);
281
281
  const channels = /* @__PURE__ */ new Map();
282
- /** Registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second, then sends this side's hello. */
283
- function start(peer) {
282
+ /** Tells the owner of a failure that is local to this side. The callback is the owner's code: its own failure, thrown or as a rejected promise it returns, must not end the session or go unobserved, so it is reported once and swallowed. */
283
+ function reportFault(error) {
284
+ const report = (faultError) => {
285
+ console.error("the local fault callback failed", faultError);
286
+ };
287
+ try {
288
+ const result = options.onFault?.(error);
289
+ if (typeof result === "object" && result !== null && "then" in result && typeof result.then === "function") Promise.resolve(result).catch(report);
290
+ } catch (faultError) {
291
+ report(faultError);
292
+ }
293
+ }
294
+ /** Abandons a handshake that failed: the entry goes if it is still the one held for the peer, and whoever awaits it is told. */
295
+ function abandon(entry, error) {
296
+ const failure = error instanceof Error ? error : new Error(String(error));
297
+ if (channels.get(entry.key) === entry) channels.delete(entry.key);
298
+ entry.settle.reject(failure);
299
+ entry.sent.reject(failure);
300
+ }
301
+ /** Starts a handshake with the peer: sends this side's hello, and registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second. `answering` is true when a received hello started this, so nobody awaits the outcome and a failure to send is reported to the owner. `track` is false for a handshake answering a hello while a channel with that peer is established: it is not registered, so the established channel stays in place and in use until the new one is up and this side's hello has been sent. A hello that could not be sent leaves no channel behind: the peer would never learn this side's key. */
302
+ function start(peer, { answering, track } = {
303
+ answering: false,
304
+ track: true
305
+ }) {
284
306
  const key = deviceIdToHex(peer);
285
- const settle = Promise.withResolvers();
286
307
  const pending = beginHandshake(identity, peer);
287
308
  const entry = {
288
309
  status: "awaiting",
289
310
  pending,
290
- settle
311
+ settle: Promise.withResolvers(),
312
+ key,
313
+ sent: Promise.withResolvers()
291
314
  };
292
- channels.set(key, entry);
293
- pending.then(async ({ hello }) => sendHello(peer, hello)).catch((error) => {
294
- settle.reject(error instanceof Error ? error : new Error(String(error)));
295
- if (channels.get(key) === entry) channels.delete(key);
315
+ entry.settle.promise.catch(() => void 0);
316
+ entry.sent.promise.catch(() => void 0);
317
+ pending.then(async ({ hello }) => {
318
+ try {
319
+ await sendHello(peer, hello);
320
+ } catch (error) {
321
+ abandon(entry, error);
322
+ entry.settle.promise.then((channel) => {
323
+ const held = channels.get(key);
324
+ if (held?.status === "established" && held.channel === channel) channels.delete(key);
325
+ }, () => void 0);
326
+ if (answering && track) reportFault(error);
327
+ return;
328
+ }
329
+ entry.sent.resolve(void 0);
330
+ }, (error) => {
331
+ abandon(entry, error);
296
332
  });
333
+ if (track) channels.set(key, entry);
297
334
  return entry;
298
335
  }
336
+ /** Completes the handshake a verified hello belongs to. Throws on any failure of this side; the caller abandons the handshake. */
337
+ async function complete({ entry, hello, peer, replaces }) {
338
+ const { key } = entry;
339
+ const established = await establishChannelFrom(identity, {
340
+ pending: await entry.pending,
341
+ remote: hello,
342
+ peer,
343
+ ...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
344
+ });
345
+ if (established === void 0) {
346
+ abandon(entry, /* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
347
+ return;
348
+ }
349
+ if (replaces !== void 0) await entry.sent.promise;
350
+ if (channels.get(key) !== (replaces ?? entry)) return;
351
+ const channel = {
352
+ ...established,
353
+ seal: async (frame) => {
354
+ try {
355
+ return await established.seal(frame);
356
+ } catch (error) {
357
+ const held = channels.get(key);
358
+ if (error instanceof ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
359
+ throw error;
360
+ }
361
+ }
362
+ };
363
+ channels.set(key, {
364
+ status: "established",
365
+ channel,
366
+ remoteEphemeralKey: hello["ephemeral-key"]
367
+ });
368
+ entry.settle.resolve(channel);
369
+ }
299
370
  return {
300
371
  ensure: async (peer) => {
301
- const existing = channels.get(deviceIdToHex(peer));
372
+ const key = deviceIdToHex(peer);
373
+ const existing = channels.get(key);
302
374
  if (existing?.status === "established") return existing.channel;
303
375
  return (existing ?? start(peer)).settle.promise;
304
376
  },
305
377
  applyHello: async (hello) => {
306
- let verdict;
378
+ let entry;
307
379
  try {
308
- verdict = await verifyHello(identity, hello);
380
+ const verdict = await verifyHello(identity, hello);
381
+ if (!verdict.ok) return;
382
+ const key = deviceIdToHex(verdict.peer.deviceId);
383
+ const current = channels.get(key);
384
+ if (current?.status === "established" && bytesEqual(current.remoteEphemeralKey, hello["ephemeral-key"])) return;
385
+ const replaces = current?.status === "established" ? current : void 0;
386
+ entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId, {
387
+ answering: true,
388
+ track: replaces === void 0
389
+ });
390
+ await complete({
391
+ entry,
392
+ hello,
393
+ peer: verdict.peer,
394
+ replaces
395
+ });
309
396
  } catch (error) {
310
- options.onFault?.(error);
311
- return;
312
- }
313
- if (!verdict.ok) return;
314
- const key = deviceIdToHex(verdict.peer.deviceId);
315
- const current = channels.get(key);
316
- const entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId);
317
- const established = await establishChannelFrom(identity, {
318
- pending: await entry.pending,
319
- remote: hello,
320
- peer: verdict.peer,
321
- ...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
322
- });
323
- if (established === void 0) {
324
- entry.settle.reject(/* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
325
- if (channels.get(key) === entry) channels.delete(key);
326
- return;
397
+ if (entry !== void 0) abandon(entry, error);
398
+ reportFault(error);
327
399
  }
328
- const channel = {
329
- ...established,
330
- seal: async (frame) => {
331
- try {
332
- return await established.seal(frame);
333
- } catch (error) {
334
- const held = channels.get(key);
335
- if (error instanceof ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
336
- throw error;
337
- }
338
- }
339
- };
340
- channels.set(key, {
341
- status: "established",
342
- channel
343
- });
344
- entry.settle.resolve(channel);
345
400
  },
346
401
  open: async (data, claimedFrom) => {
347
402
  if (claimedFrom === void 0) return;
348
- const entry = channels.get(deviceIdToHex(claimedFrom));
403
+ const key = deviceIdToHex(claimedFrom);
404
+ const entry = channels.get(key);
349
405
  if (entry?.status !== "established") return;
350
406
  const frame = await entry.channel.open(data);
351
407
  return frame === void 0 ? void 0 : {
@@ -354,13 +410,13 @@ function createRelayChannels(identity, sendHello, options = {}) {
354
410
  };
355
411
  },
356
412
  reset: (reason) => {
357
- for (const entry of channels.values()) if (entry.status === "awaiting") entry.settle.reject(new Error(reason));
413
+ for (const entry of [...channels.values()]) if (entry.status === "awaiting") abandon(entry, new Error(reason));
358
414
  channels.clear();
359
415
  },
360
416
  forget: (peer) => {
361
417
  const key = deviceIdToHex(peer);
362
418
  const entry = channels.get(key);
363
- if (entry?.status === "awaiting") entry.settle.reject(/* @__PURE__ */ new Error("the relay pairing was lost"));
419
+ if (entry?.status === "awaiting") abandon(entry, /* @__PURE__ */ new Error("the relay pairing was lost"));
364
420
  channels.delete(key);
365
421
  }
366
422
  };
@@ -374,6 +374,14 @@ async function canGrantVia(heldGrantCapability, deviceId, candidate, now) {
374
374
  * When `parent` is given, every one of `tokens.cddl`'s own narrowing obligations is enforced here, at issuance, rather than left for the far end to discover minutes or hours later as a bare `delegation_exceeds_parent` from `verifyCapabilityToken` -- the same "fail loudly, fail early" reasoning that governs every other boundary in this codebase. An issuer minting an invalid delegation is a bug in the caller; this function refuses rather than producing a token indistinguishable from a valid one until someone else verifies it.
375
375
  */
376
376
  async function mintCapabilityToken(options) {
377
+ for (const verb of [
378
+ options.capability,
379
+ options.grantsCapability,
380
+ options.requestsCapability
381
+ ]) if (verb !== void 0 && !require_generated_protocol.capabilityVerbSchema.safeParse(verb).success) return {
382
+ ok: false,
383
+ reason: "invalid_capability"
384
+ };
377
385
  if (options.expires <= options.clock.now()) return {
378
386
  ok: false,
379
387
  reason: "already_expired"
@@ -67,7 +67,9 @@ export interface VerifyRevocationEntryOptions {
67
67
  * Verifies one gossiped revocation-entry (management.cddl): a well-formed COSE_Sign1 whose signature verifies against its own embedded issuer-key, where that issuer-key is self-certifying (sha256(issuer-key.public-key) equals the claimed issuer device-id). A verifier that ingests a revocation-announce frame runs each entry through this before recording it in its revocation view; entries failing here are dropped, not stored. The issuer-match against a specific token's own issuer (only a token's own issuer may revoke it) is deliberately NOT checked here -- it happens at lookup time in RevocationCheck, against whichever token is being verified.
68
68
  */
69
69
  export declare function verifyRevocationEntry(entry: RevocationEntry, options: VerifyRevocationEntryOptions): Promise<RevocationEntryVerdict>;
70
- export type MintRefusalReason = "already_expired" | "parent_malformed" | "parent_bearer_mismatch" | "expires_exceeds_parent" | "scope_does_not_narrow" | "capability_mismatch" | "delegation_exceeds_parent" |
70
+ export type MintRefusalReason = "already_expired" |
71
+ /** The capability, grants-capability or requests-capability is not a capability verb of the spec's grammar. */
72
+ "invalid_capability" | "parent_malformed" | "parent_bearer_mismatch" | "expires_exceeds_parent" | "scope_does_not_narrow" | "capability_mismatch" | "delegation_exceeds_parent" |
71
73
  /** parent and authorised-by are mutually exclusive link kinds; a manage:grant capability must be minted through authorised-by only. */
72
74
  "links_exclusive" |
73
75
  /** The cited grant-capability did not decode, or its own conditions bytes did not. */
@@ -67,7 +67,9 @@ export interface VerifyRevocationEntryOptions {
67
67
  * Verifies one gossiped revocation-entry (management.cddl): a well-formed COSE_Sign1 whose signature verifies against its own embedded issuer-key, where that issuer-key is self-certifying (sha256(issuer-key.public-key) equals the claimed issuer device-id). A verifier that ingests a revocation-announce frame runs each entry through this before recording it in its revocation view; entries failing here are dropped, not stored. The issuer-match against a specific token's own issuer (only a token's own issuer may revoke it) is deliberately NOT checked here -- it happens at lookup time in RevocationCheck, against whichever token is being verified.
68
68
  */
69
69
  export declare function verifyRevocationEntry(entry: RevocationEntry, options: VerifyRevocationEntryOptions): Promise<RevocationEntryVerdict>;
70
- export type MintRefusalReason = "already_expired" | "parent_malformed" | "parent_bearer_mismatch" | "expires_exceeds_parent" | "scope_does_not_narrow" | "capability_mismatch" | "delegation_exceeds_parent" |
70
+ export type MintRefusalReason = "already_expired" |
71
+ /** The capability, grants-capability or requests-capability is not a capability verb of the spec's grammar. */
72
+ "invalid_capability" | "parent_malformed" | "parent_bearer_mismatch" | "expires_exceeds_parent" | "scope_does_not_narrow" | "capability_mismatch" | "delegation_exceeds_parent" |
71
73
  /** parent and authorised-by are mutually exclusive link kinds; a manage:grant capability must be minted through authorised-by only. */
72
74
  "links_exclusive" |
73
75
  /** The cited grant-capability did not decode, or its own conditions bytes did not. */
@@ -1,4 +1,4 @@
1
- import { capabilityTokenSchema, revocationClaimsSchema, tokenClaimsSchema } from "../generated/protocol.mjs";
1
+ import { capabilityTokenSchema, capabilityVerbSchema, revocationClaimsSchema, tokenClaimsSchema } from "../generated/protocol.mjs";
2
2
  import { bytesToHex } from "./device-id.mjs";
3
3
  import { n as firstAsync } from "../sequential-D_W3THly.mjs";
4
4
  import { bytesEqual, scopeNarrows } from "./token-scope.mjs";
@@ -373,6 +373,14 @@ async function canGrantVia(heldGrantCapability, deviceId, candidate, now) {
373
373
  * When `parent` is given, every one of `tokens.cddl`'s own narrowing obligations is enforced here, at issuance, rather than left for the far end to discover minutes or hours later as a bare `delegation_exceeds_parent` from `verifyCapabilityToken` -- the same "fail loudly, fail early" reasoning that governs every other boundary in this codebase. An issuer minting an invalid delegation is a bug in the caller; this function refuses rather than producing a token indistinguishable from a valid one until someone else verifies it.
374
374
  */
375
375
  async function mintCapabilityToken(options) {
376
+ for (const verb of [
377
+ options.capability,
378
+ options.grantsCapability,
379
+ options.requestsCapability
380
+ ]) if (verb !== void 0 && !capabilityVerbSchema.safeParse(verb).success) return {
381
+ ok: false,
382
+ reason: "invalid_capability"
383
+ };
376
384
  if (options.expires <= options.clock.now()) return {
377
385
  ok: false,
378
386
  reason: "already_expired"
@@ -201,8 +201,8 @@ const coreDomainNameSchema = zod.z.lazy(() => zod.z.union([
201
201
  zod.z.literal("core/bulk"),
202
202
  zod.z.literal("core/version")
203
203
  ]));
204
- const namespacedDomainIdSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+/[A-Za-z0-9_.-]+")));
205
- const privateUseDomainIdSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("x-[A-Za-z0-9_.-]+")));
204
+ const namespacedDomainIdSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+/[A-Za-z0-9_.-]+)$")));
205
+ const privateUseDomainIdSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:x-[A-Za-z0-9_.-]+)$")));
206
206
  const handshakeFrameSchema = zod.z.lazy(() => zod.z.object({
207
207
  "type": zod.z.literal("handshake"),
208
208
  "version": zod.z.lazy(() => protocolVersionSchema),
@@ -281,9 +281,9 @@ const topologyPeersSchema = zod.z.lazy(() => zod.z.object({
281
281
  }));
282
282
  const topologyGetSchema = zod.z.lazy(() => zod.z.object({ "verb": zod.z.literal("topology.get") }));
283
283
  const roomPathSchema = zod.z.lazy(() => zod.z.union([zod.z.lazy(() => ownerNamedRoomPathSchema), zod.z.lazy(() => dmRoomPathSchema)]));
284
- const deviceIdHexSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}")));
285
- const ownerNamedRoomPathSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}/[A-Za-z0-9_-]+")));
286
- const dmRoomPathSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}\\+[0-9a-f]{64}")));
284
+ const deviceIdHexSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64})$")));
285
+ const ownerNamedRoomPathSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64}/[A-Za-z0-9_-]+)$")));
286
+ const dmRoomPathSchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64}\\+[0-9a-f]{64})$")));
287
287
  const roomSendSchema = zod.z.lazy(() => zod.z.object({
288
288
  "verb": zod.z.literal("room.send"),
289
289
  "message-id": zod.z.instanceof(Uint8Array),
@@ -438,9 +438,9 @@ const capabilityVerbSchema = zod.z.lazy(() => zod.z.union([
438
438
  zod.z.lazy(() => namespacedCapabilitySchema),
439
439
  zod.z.lazy(() => privateUseCapabilitySchema)
440
440
  ]));
441
- const coreCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[a-z][a-z0-9-]*:[a-z][a-z0-9-]*")));
442
- const namespacedCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+")));
443
- const privateUseCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+")));
441
+ const coreCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)$")));
442
+ const namespacedCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)$")));
443
+ const privateUseCapabilitySchema = zod.z.lazy(() => zod.z.string().regex(/* @__PURE__ */ new RegExp("^(?:x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)$")));
444
444
  const capabilityScopeSchema = zod.z.lazy(() => zod.z.object({
445
445
  "kind": zod.z.string(),
446
446
  "path": zod.z.string().optional()
@@ -200,8 +200,8 @@ const coreDomainNameSchema = z.lazy(() => z.union([
200
200
  z.literal("core/bulk"),
201
201
  z.literal("core/version")
202
202
  ]));
203
- const namespacedDomainIdSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+/[A-Za-z0-9_.-]+")));
204
- const privateUseDomainIdSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("x-[A-Za-z0-9_.-]+")));
203
+ const namespacedDomainIdSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+/[A-Za-z0-9_.-]+)$")));
204
+ const privateUseDomainIdSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:x-[A-Za-z0-9_.-]+)$")));
205
205
  const handshakeFrameSchema = z.lazy(() => z.object({
206
206
  "type": z.literal("handshake"),
207
207
  "version": z.lazy(() => protocolVersionSchema),
@@ -280,9 +280,9 @@ const topologyPeersSchema = z.lazy(() => z.object({
280
280
  }));
281
281
  const topologyGetSchema = z.lazy(() => z.object({ "verb": z.literal("topology.get") }));
282
282
  const roomPathSchema = z.lazy(() => z.union([z.lazy(() => ownerNamedRoomPathSchema), z.lazy(() => dmRoomPathSchema)]));
283
- const deviceIdHexSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}")));
284
- const ownerNamedRoomPathSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}/[A-Za-z0-9_-]+")));
285
- const dmRoomPathSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[0-9a-f]{64}\\+[0-9a-f]{64}")));
283
+ const deviceIdHexSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64})$")));
284
+ const ownerNamedRoomPathSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64}/[A-Za-z0-9_-]+)$")));
285
+ const dmRoomPathSchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[0-9a-f]{64}\\+[0-9a-f]{64})$")));
286
286
  const roomSendSchema = z.lazy(() => z.object({
287
287
  "verb": z.literal("room.send"),
288
288
  "message-id": z.instanceof(Uint8Array),
@@ -437,9 +437,9 @@ const capabilityVerbSchema = z.lazy(() => z.union([
437
437
  z.lazy(() => namespacedCapabilitySchema),
438
438
  z.lazy(() => privateUseCapabilitySchema)
439
439
  ]));
440
- const coreCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[a-z][a-z0-9-]*:[a-z][a-z0-9-]*")));
441
- const namespacedCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+")));
442
- const privateUseCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+")));
440
+ const coreCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)$")));
441
+ const namespacedCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:[a-z0-9.-]+/[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)$")));
442
+ const privateUseCapabilitySchema = z.lazy(() => z.string().regex(/* @__PURE__ */ new RegExp("^(?:x-[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)$")));
443
443
  const capabilityScopeSchema = z.lazy(() => z.object({
444
444
  "kind": z.string(),
445
445
  "path": z.string().optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "dependencies": {
5
5
  "cbor2": "2.3.0",
6
6
  "cddl.js": "1.0.1",