wire-mesh-core 1.8.0 → 1.10.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.
@@ -357,12 +357,13 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
357
357
  setToken(token) {
358
358
  currentToken = token;
359
359
  },
360
- async sendManageRequest(command, scope, targetDevice, token) {
360
+ async sendManageRequest(command, scope, targetDevice, token, timeoutMs) {
361
361
  if (connection === null || state.status !== "connected") throw new Error("not connected");
362
362
  if (targetDevice !== void 0) await ensureRelayPairing(targetDevice);
363
363
  const frame = buildManageRequest(command, scope, token);
364
+ const requestId = frame["request-id"];
364
365
  const outcome = new Promise((resolve, reject) => {
365
- pendingManageRequests.set(frame["request-id"], {
366
+ pendingManageRequests.set(requestId, {
366
367
  resolve,
367
368
  reject
368
369
  });
@@ -373,7 +374,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
373
374
  });
374
375
  await transmit(frame, targetDevice !== void 0);
375
376
  emit();
376
- return outcome;
377
+ if (timeoutMs === void 0) return outcome;
378
+ return Promise.race([outcome, new Promise((resolve) => {
379
+ setTimeout(() => {
380
+ if (pendingManageRequests.delete(requestId)) resolve({
381
+ result: "error",
382
+ code: "timeout"
383
+ });
384
+ }, timeoutMs);
385
+ })]);
377
386
  },
378
387
  async sendRevocationAnnounce(entries) {
379
388
  if (connection === null || state.status !== "connected") throw new Error("not connected");
@@ -81,8 +81,8 @@ export interface MeshSession {
81
81
  setToken: (token: CapabilityToken) => void;
82
82
  /** Announces one or more already-minted revocation-entries to the peer. Sent directly over the connection, never relay-wrapped -- revocation-announce is a gossiped broadcast, not a request addressed to a specific peer, so it has no targetDevice/token parameters the way sendManageRequest does. */
83
83
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
84
- /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. */
85
- sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken) => Promise<ManageOutcome>;
84
+ /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
85
+ sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken, timeoutMs?: number) => Promise<ManageOutcome>;
86
86
  close: () => Promise<void>;
87
87
  }
88
88
  export declare function createMeshSession(transport: Readonly<Transport>, identity: Readonly<IdentityPort>, clock?: Readonly<Clock>, reconnect?: ReconnectPolicy | null,
@@ -81,8 +81,8 @@ export interface MeshSession {
81
81
  setToken: (token: CapabilityToken) => void;
82
82
  /** Announces one or more already-minted revocation-entries to the peer. Sent directly over the connection, never relay-wrapped -- revocation-announce is a gossiped broadcast, not a request addressed to a specific peer, so it has no targetDevice/token parameters the way sendManageRequest does. */
83
83
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
84
- /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. */
85
- sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken) => Promise<ManageOutcome>;
84
+ /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
85
+ sendManageRequest: (command: ManageCommand, scope: Readonly<CapabilityScope>, targetDevice?: DeviceId, token?: CapabilityToken, timeoutMs?: number) => Promise<ManageOutcome>;
86
86
  close: () => Promise<void>;
87
87
  }
88
88
  export declare function createMeshSession(transport: Readonly<Transport>, identity: Readonly<IdentityPort>, clock?: Readonly<Clock>, reconnect?: ReconnectPolicy | null,
@@ -356,12 +356,13 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
356
356
  setToken(token) {
357
357
  currentToken = token;
358
358
  },
359
- async sendManageRequest(command, scope, targetDevice, token) {
359
+ async sendManageRequest(command, scope, targetDevice, token, timeoutMs) {
360
360
  if (connection === null || state.status !== "connected") throw new Error("not connected");
361
361
  if (targetDevice !== void 0) await ensureRelayPairing(targetDevice);
362
362
  const frame = buildManageRequest(command, scope, token);
363
+ const requestId = frame["request-id"];
363
364
  const outcome = new Promise((resolve, reject) => {
364
- pendingManageRequests.set(frame["request-id"], {
365
+ pendingManageRequests.set(requestId, {
365
366
  resolve,
366
367
  reject
367
368
  });
@@ -372,7 +373,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
372
373
  });
373
374
  await transmit(frame, targetDevice !== void 0);
374
375
  emit();
375
- return outcome;
376
+ if (timeoutMs === void 0) return outcome;
377
+ return Promise.race([outcome, new Promise((resolve) => {
378
+ setTimeout(() => {
379
+ if (pendingManageRequests.delete(requestId)) resolve({
380
+ result: "error",
381
+ code: "timeout"
382
+ });
383
+ }, timeoutMs);
384
+ })]);
376
385
  },
377
386
  async sendRevocationAnnounce(entries) {
378
387
  if (connection === null || state.status !== "connected") throw new Error("not connected");
@@ -214,6 +214,26 @@ async function verifyRevocationEntry(entry, options) {
214
214
  claims
215
215
  };
216
216
  }
217
+ /** The narrowing arithmetic tokens.cddl's own delegation obligations require (bearer match, expiry within the parent's, scope narrows, same capability, delegations-remaining strictly less than the parent's) -- shared between mintCapabilityToken (which additionally builds and signs the resulting token) and canGrant (a pure query with no minting side effect at all), so the two can never silently drift into two different ideas of what "narrows" means. Returns the specific refusal reason, or undefined when every rule is satisfied. */
218
+ function checkNarrowing(parentClaims, granterDeviceId, candidate) {
219
+ if (!bytesEqual(parentClaims.bearer, granterDeviceId)) return "parent_bearer_mismatch";
220
+ if (candidate.expires > parentClaims.expires) return "expires_exceeds_parent";
221
+ if (!scopeNarrows(parentClaims.scope, candidate.scope)) return "scope_does_not_narrow";
222
+ if (parentClaims.capability !== candidate.capability) return "capability_mismatch";
223
+ const parentRemaining = parentClaims["delegations-remaining"];
224
+ if (parentRemaining !== void 0 && (candidate.delegationsRemaining === void 0 || candidate.delegationsRemaining >= parentRemaining)) return "delegation_exceeds_parent";
225
+ }
226
+ /**
227
+ * A pure query: could deviceId, presenting heldToken as its own delegation authority, successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means.
228
+ *
229
+ * Deliberately narrower than a full mint attempt in one respect: this checks only the narrowing rules tokens.cddl's own delegation obligations require (bearer match, expiry, scope, capability, delegations-remaining), the same scope mintCapabilityToken itself checks a *parent* against -- it does not verify heldToken's own signature or revocation status, exactly as mintCapabilityToken never re-verifies its own parent's signature either. A caller that also needs heldToken's cryptographic validity confirmed calls verifyCapabilityToken separately.
230
+ */
231
+ function canGrant(heldToken, deviceId, candidate, now) {
232
+ if (candidate.expires <= now) return false;
233
+ const heldClaims = decodeTokenClaims(heldToken);
234
+ if (heldClaims === void 0) return false;
235
+ return checkNarrowing(heldClaims, deviceId, candidate) === void 0;
236
+ }
217
237
  /**
218
238
  * Mints one capability token: builds token-claims from the given fields, signs it as a COSE_Sign1 under `identity`'s own key, with a protected header matching what the frozen conformance vectors actually encode (`{1: alg, 4: issuer device-id}`, not the empty header a token merely needs to verify against itself).
219
239
  *
@@ -231,26 +251,15 @@ async function mintCapabilityToken(options) {
231
251
  ok: false,
232
252
  reason: "parent_malformed"
233
253
  };
234
- if (!bytesEqual(parentClaims.bearer, options.identity.deviceId)) return {
235
- ok: false,
236
- reason: "parent_bearer_mismatch"
237
- };
238
- if (options.expires > parentClaims.expires) return {
254
+ const refusal = checkNarrowing(parentClaims, options.identity.deviceId, {
255
+ capability: options.capability,
256
+ scope: options.scope,
257
+ expires: options.expires,
258
+ ...options.delegationsRemaining !== void 0 ? { delegationsRemaining: options.delegationsRemaining } : {}
259
+ });
260
+ if (refusal !== void 0) return {
239
261
  ok: false,
240
- reason: "expires_exceeds_parent"
241
- };
242
- if (!scopeNarrows(parentClaims.scope, options.scope)) return {
243
- ok: false,
244
- reason: "scope_does_not_narrow"
245
- };
246
- if (parentClaims.capability !== options.capability) return {
247
- ok: false,
248
- reason: "capability_mismatch"
249
- };
250
- const parentRemaining = parentClaims["delegations-remaining"];
251
- if (parentRemaining !== void 0 && (options.delegationsRemaining === void 0 || options.delegationsRemaining >= parentRemaining)) return {
252
- ok: false,
253
- reason: "delegation_exceeds_parent"
262
+ reason: refusal
254
263
  };
255
264
  parentBytes = encodeBuf(options.parent);
256
265
  }
@@ -294,6 +303,7 @@ async function mintRevocationEntry(options) {
294
303
  ];
295
304
  }
296
305
  //#endregion
306
+ exports.canGrant = canGrant;
297
307
  exports.mintCapabilityToken = mintCapabilityToken;
298
308
  exports.mintRevocationEntry = mintRevocationEntry;
299
309
  exports.verifyCapabilityToken = verifyCapabilityToken;
@@ -55,6 +55,19 @@ export type MintVerdict = {
55
55
  ok: false;
56
56
  reason: MintRefusalReason;
57
57
  };
58
+ /** What a would-be delegation needs, to check it narrows a specific parent -- everything mintCapabilityToken itself checks a delegation against, independent of the tokenId/bearer/notBefore/signing concerns unique to actually minting one. */
59
+ interface NarrowingCandidate {
60
+ capability: TokenClaims["capability"];
61
+ scope: TokenClaims["scope"];
62
+ expires: number;
63
+ delegationsRemaining?: number;
64
+ }
65
+ /**
66
+ * A pure query: could deviceId, presenting heldToken as its own delegation authority, successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means.
67
+ *
68
+ * Deliberately narrower than a full mint attempt in one respect: this checks only the narrowing rules tokens.cddl's own delegation obligations require (bearer match, expiry, scope, capability, delegations-remaining), the same scope mintCapabilityToken itself checks a *parent* against -- it does not verify heldToken's own signature or revocation status, exactly as mintCapabilityToken never re-verifies its own parent's signature either. A caller that also needs heldToken's cryptographic validity confirmed calls verifyCapabilityToken separately.
69
+ */
70
+ export declare function canGrant(heldToken: CapabilityToken, deviceId: DeviceId, candidate: Readonly<NarrowingCandidate>, now: number): boolean;
58
71
  export interface MintCapabilityTokenOptions {
59
72
  /** The issuer -- signs the token, and supplies the self-certifying issuer/issuer-key claims. */
60
73
  identity: IdentityPort;
@@ -55,6 +55,19 @@ export type MintVerdict = {
55
55
  ok: false;
56
56
  reason: MintRefusalReason;
57
57
  };
58
+ /** What a would-be delegation needs, to check it narrows a specific parent -- everything mintCapabilityToken itself checks a delegation against, independent of the tokenId/bearer/notBefore/signing concerns unique to actually minting one. */
59
+ interface NarrowingCandidate {
60
+ capability: TokenClaims["capability"];
61
+ scope: TokenClaims["scope"];
62
+ expires: number;
63
+ delegationsRemaining?: number;
64
+ }
65
+ /**
66
+ * A pure query: could deviceId, presenting heldToken as its own delegation authority, successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means.
67
+ *
68
+ * Deliberately narrower than a full mint attempt in one respect: this checks only the narrowing rules tokens.cddl's own delegation obligations require (bearer match, expiry, scope, capability, delegations-remaining), the same scope mintCapabilityToken itself checks a *parent* against -- it does not verify heldToken's own signature or revocation status, exactly as mintCapabilityToken never re-verifies its own parent's signature either. A caller that also needs heldToken's cryptographic validity confirmed calls verifyCapabilityToken separately.
69
+ */
70
+ export declare function canGrant(heldToken: CapabilityToken, deviceId: DeviceId, candidate: Readonly<NarrowingCandidate>, now: number): boolean;
58
71
  export interface MintCapabilityTokenOptions {
59
72
  /** The issuer -- signs the token, and supplies the self-certifying issuer/issuer-key claims. */
60
73
  identity: IdentityPort;
@@ -213,6 +213,26 @@ async function verifyRevocationEntry(entry, options) {
213
213
  claims
214
214
  };
215
215
  }
216
+ /** The narrowing arithmetic tokens.cddl's own delegation obligations require (bearer match, expiry within the parent's, scope narrows, same capability, delegations-remaining strictly less than the parent's) -- shared between mintCapabilityToken (which additionally builds and signs the resulting token) and canGrant (a pure query with no minting side effect at all), so the two can never silently drift into two different ideas of what "narrows" means. Returns the specific refusal reason, or undefined when every rule is satisfied. */
217
+ function checkNarrowing(parentClaims, granterDeviceId, candidate) {
218
+ if (!bytesEqual(parentClaims.bearer, granterDeviceId)) return "parent_bearer_mismatch";
219
+ if (candidate.expires > parentClaims.expires) return "expires_exceeds_parent";
220
+ if (!scopeNarrows(parentClaims.scope, candidate.scope)) return "scope_does_not_narrow";
221
+ if (parentClaims.capability !== candidate.capability) return "capability_mismatch";
222
+ const parentRemaining = parentClaims["delegations-remaining"];
223
+ if (parentRemaining !== void 0 && (candidate.delegationsRemaining === void 0 || candidate.delegationsRemaining >= parentRemaining)) return "delegation_exceeds_parent";
224
+ }
225
+ /**
226
+ * A pure query: could deviceId, presenting heldToken as its own delegation authority, successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means.
227
+ *
228
+ * Deliberately narrower than a full mint attempt in one respect: this checks only the narrowing rules tokens.cddl's own delegation obligations require (bearer match, expiry, scope, capability, delegations-remaining), the same scope mintCapabilityToken itself checks a *parent* against -- it does not verify heldToken's own signature or revocation status, exactly as mintCapabilityToken never re-verifies its own parent's signature either. A caller that also needs heldToken's cryptographic validity confirmed calls verifyCapabilityToken separately.
229
+ */
230
+ function canGrant(heldToken, deviceId, candidate, now) {
231
+ if (candidate.expires <= now) return false;
232
+ const heldClaims = decodeTokenClaims(heldToken);
233
+ if (heldClaims === void 0) return false;
234
+ return checkNarrowing(heldClaims, deviceId, candidate) === void 0;
235
+ }
216
236
  /**
217
237
  * Mints one capability token: builds token-claims from the given fields, signs it as a COSE_Sign1 under `identity`'s own key, with a protected header matching what the frozen conformance vectors actually encode (`{1: alg, 4: issuer device-id}`, not the empty header a token merely needs to verify against itself).
218
238
  *
@@ -230,26 +250,15 @@ async function mintCapabilityToken(options) {
230
250
  ok: false,
231
251
  reason: "parent_malformed"
232
252
  };
233
- if (!bytesEqual(parentClaims.bearer, options.identity.deviceId)) return {
234
- ok: false,
235
- reason: "parent_bearer_mismatch"
236
- };
237
- if (options.expires > parentClaims.expires) return {
253
+ const refusal = checkNarrowing(parentClaims, options.identity.deviceId, {
254
+ capability: options.capability,
255
+ scope: options.scope,
256
+ expires: options.expires,
257
+ ...options.delegationsRemaining !== void 0 ? { delegationsRemaining: options.delegationsRemaining } : {}
258
+ });
259
+ if (refusal !== void 0) return {
238
260
  ok: false,
239
- reason: "expires_exceeds_parent"
240
- };
241
- if (!scopeNarrows(parentClaims.scope, options.scope)) return {
242
- ok: false,
243
- reason: "scope_does_not_narrow"
244
- };
245
- if (parentClaims.capability !== options.capability) return {
246
- ok: false,
247
- reason: "capability_mismatch"
248
- };
249
- const parentRemaining = parentClaims["delegations-remaining"];
250
- if (parentRemaining !== void 0 && (options.delegationsRemaining === void 0 || options.delegationsRemaining >= parentRemaining)) return {
251
- ok: false,
252
- reason: "delegation_exceeds_parent"
261
+ reason: refusal
253
262
  };
254
263
  parentBytes = encodeBuf(options.parent);
255
264
  }
@@ -293,4 +302,4 @@ async function mintRevocationEntry(options) {
293
302
  ];
294
303
  }
295
304
  //#endregion
296
- export { mintCapabilityToken, mintRevocationEntry, verifyCapabilityToken, verifyRevocationEntry };
305
+ export { canGrant, mintCapabilityToken, mintRevocationEntry, verifyCapabilityToken, verifyRevocationEntry };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wire-mesh-core",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.33.0",
6
6
  "repository": {