@kangentic/protocol 0.11.1 → 0.13.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.
@@ -1 +1,17 @@
1
1
  export declare function deriveSessionSlotId(desktopStaticPublicKey: Uint8Array, phoneStaticPublicKey: Uint8Array): string;
2
+ /**
3
+ * Derives the relay rendezvous slot id for the PAIRING ceremony from the
4
+ * pairing token, for both peers to dial.
5
+ *
6
+ * The slot must be derived rather than being the token itself, because it
7
+ * travels in cleartext in the relay URL's query string while the same token is
8
+ * the Noise IKpsk0 pre-shared key. Dialing it verbatim published the PSK to
9
+ * every hop that can read a request URI - on a hosted relay that includes
10
+ * whatever terminates TLS - which reduced IKpsk0 to plain IK for such an
11
+ * observer. A one-way labeled hash keeps the routing label public and the PSK
12
+ * secret: the token itself now never leaves the QR code.
13
+ *
14
+ * The result is a routing label, NOT key material. Its 128 bits are sized to
15
+ * defeat blind enumeration, which is all a rendezvous label has to do.
16
+ */
17
+ export declare function derivePairingSlotId(pairingToken: Uint8Array): string;
@@ -217,17 +217,80 @@ export interface SessionEventWire {
217
217
  toolId?: string;
218
218
  detail?: string;
219
219
  }
220
+ /**
221
+ * The system column roles, mirroring the desktop's `SWIMLANE_ROLES` /
222
+ * `SwimlaneRole` (`src/shared/types.ts`). This package cannot import that
223
+ * file (see the module doc), so the two lists are a deliberate mirror, kept
224
+ * in sync by `tests/unit/protocol/board-column-role-parity.test.ts` on the
225
+ * desktop side rather than by a shared import.
226
+ */
227
+ export declare const BOARD_COLUMN_ROLES: readonly ["todo", "done"];
228
+ export type BoardColumnRoleWire = (typeof BOARD_COLUMN_ROLES)[number];
229
+ /** True for a column whose role is the system 'todo' role. Prefer this at a call site over a literal `=== 'todo'`, which the `(string & {})` widening leaves unchecked. */
230
+ export declare function isTodoRole(role: string | null | undefined): boolean;
231
+ /** True for a column whose role is the system 'done' role. Prefer this at a call site over a literal `=== 'done'`, which the `(string & {})` widening leaves unchecked. */
232
+ export declare function isDoneRole(role: string | null | undefined): boolean;
220
233
  /** Phone-needed subset of the desktop's Swimlane row (snake_case preserved so the desktop mapper is a mechanical pick). */
221
234
  export interface BoardColumnWire {
222
235
  id: string;
223
236
  name: string;
224
237
  description: string | null;
225
- role: string | null;
238
+ /**
239
+ * `'todo'` / `'done'` are the system roles; anything else (including a
240
+ * future role this package predates) is a custom or not-yet-known column,
241
+ * which is exactly what the `(string & {})` escape hatch preserves - the
242
+ * literal union alone would reject a value a newer desktop legitimately
243
+ * sends. That widening does NOT make a typo'd comparison a compile error
244
+ * (a string literal is assignable to `string & {}`), so call sites should
245
+ * use `isTodoRole` / `isDoneRole` rather than `=== 'todo'` / `=== 'done'`.
246
+ */
247
+ role: BoardColumnRoleWire | (string & {}) | null;
226
248
  position: number;
227
249
  color: string;
228
250
  icon: string | null;
229
251
  is_archived: boolean;
230
252
  is_ghost: boolean;
253
+ /**
254
+ * Whether moving a task into this column spawns a successor agent
255
+ * session, derived desktop-side from the column's `auto_spawn` flag and
256
+ * role. This is INTENT, not a guarantee:
257
+ *
258
+ * - `false` - this column does not intend to spawn. Reliable enough to act
259
+ * on ONLY when the column's own `role` is 'todo' or 'done': those two
260
+ * produce a full reset or an archive, and a Board Profile cannot change a
261
+ * column's role, so a client MAY skip a session-swap transition entirely
262
+ * for them. `role` rides this same object, so
263
+ * `isTodoRole(column.role) || isDoneRole(column.role)` is how a client
264
+ * recognizes that case. A `false` that came from `auto_spawn: false` on
265
+ * any other column is intent only, exactly like `true`: a Board Profile
266
+ * can set `autoSpawn: true` for one task on that column, and the move
267
+ * then does spawn.
268
+ * - `true` - this column intends to spawn, but a handful of desktop-side
269
+ * conditions can suspend the old session without a successor actually
270
+ * landing (a board profile disabling auto_spawn for this task, a failed
271
+ * worktree checkout that reverts the move, a shutdown or a newer move
272
+ * racing this one). A client MUST NOT treat `true` as a promise; keep
273
+ * whatever timeout already bounds a session-swap wait and let `true`
274
+ * only skip a redundant one.
275
+ * - `null` / absent - a desktop that predates this field. A client falls
276
+ * back to its pre-existing behavior, the same way an absent
277
+ * `pr_merge_readiness` reads as "never judged".
278
+ *
279
+ * Declared OPTIONAL (`?`) so a hand-built `BoardColumnWire` literal does
280
+ * not have to list it. `parseBoardColumnWire` populates the key from every
281
+ * real wire response regardless, so the optionality costs nothing at
282
+ * runtime. `docs/mobile-bridge.md` records why this differs from
283
+ * `pr_merge_readiness`, which shipped required.
284
+ *
285
+ * Ordering precondition this field depends on: the phone must learn a
286
+ * MOVE's destination column before the old session's `session-ended`
287
+ * arrives, or it cannot resolve `spawns_session` against the right column.
288
+ * That holds today because `handleTaskMove` emits its board-changed event
289
+ * at the commit point rather than at settle time. See the comment above
290
+ * `emitBoardChanged()` in `src/main/ipc/handlers/task-move.ts`, and
291
+ * `docs/mobile-bridge.md`.
292
+ */
293
+ spawns_session?: boolean | null;
231
294
  }
232
295
  /** Phone-needed subset of the desktop's Task row. A non-null `session_id` is the live-session signal the phone's triage view keys on. */
233
296
  export interface BoardTaskWire {
@@ -244,6 +307,14 @@ export interface BoardTaskWire {
244
307
  pr_number: number | null;
245
308
  pr_url: string | null;
246
309
  pr_state: string | null;
310
+ /**
311
+ * Normalized merge readiness of the linked PR: `ready` / `blocked` /
312
+ * `conflicting` / `queued` / `running` / `unknown`, or null when never
313
+ * judged. `queued` and `running` mean a blocking check is still in flight.
314
+ * Absent from desktops that predate it, which the parser reads as null; a
315
+ * value a client does not know should render as plain open.
316
+ */
317
+ pr_merge_readiness: string | null;
247
318
  base_branch: string | null;
248
319
  labels: string[];
249
320
  priority: number;
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod2) => __copyProps(__defProp({}, "__esModule", { value: tr
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BOARD_COLUMN_ROLES: () => BOARD_COLUMN_ROLES,
23
24
  BOARD_TOOL_READ_NAMES: () => BOARD_TOOL_READ_NAMES,
24
25
  BOARD_TOOL_WRITE_NAMES: () => BOARD_TOOL_WRITE_NAMES,
25
26
  CAPABILITY_VERBS: () => CAPABILITY_VERBS,
@@ -50,6 +51,7 @@ __export(index_exports, {
50
51
  createPairingResponderHandshake: () => createPairingResponderHandshake,
51
52
  decodeMessage: () => decodeMessage,
52
53
  decodePairingQrPayload: () => decodePairingQrPayload,
54
+ derivePairingSlotId: () => derivePairingSlotId,
53
55
  deriveSecretstreamPair: () => deriveSecretstreamPair,
54
56
  deriveSessionSlotId: () => deriveSessionSlotId,
55
57
  deriveShortAuthenticationString: () => deriveShortAuthenticationString,
@@ -68,11 +70,13 @@ __export(index_exports, {
68
70
  isBoardToolWriteName: () => isBoardToolWriteName,
69
71
  isBridgeEvent: () => isBridgeEvent,
70
72
  isCapabilityVerb: () => isCapabilityVerb,
73
+ isDoneRole: () => isDoneRole,
71
74
  isJsonValue: () => isJsonValue,
72
75
  isPushCategory: () => isPushCategory,
73
76
  isRecord: () => isRecord,
74
77
  isRosterEntryExpired: () => isRosterEntryExpired,
75
78
  isSecureRelayAddress: () => isSecureRelayAddress,
79
+ isTodoRole: () => isTodoRole,
76
80
  openPairingConfirm: () => openPairingConfirm,
77
81
  openPushEnvelope: () => openPushEnvelope,
78
82
  parseActivityEventPayload: () => parseActivityEventPayload,
@@ -107,7 +111,7 @@ __export(index_exports, {
107
111
  module.exports = __toCommonJS(index_exports);
108
112
 
109
113
  // src/version.ts
110
- var PROTOCOL_VERSION = "2";
114
+ var PROTOCOL_VERSION = "3";
111
115
  function encodeProtocolVersion(version = PROTOCOL_VERSION) {
112
116
  return new TextEncoder().encode(`kangentic-bridge-v${version}`);
113
117
  }
@@ -4285,6 +4289,11 @@ function deriveSessionSlotId(desktopStaticPublicKey, phoneStaticPublicKey) {
4285
4289
  const material = concatBytes4(desktopStaticPublicKey, phoneStaticPublicKey);
4286
4290
  return bytesToHex(deriveLabeledKey(material, SESSION_SLOT_LABEL, SESSION_SLOT_LENGTH));
4287
4291
  }
4292
+ var PAIRING_SLOT_LABEL = "kangentic-pairing-slot-v1";
4293
+ var PAIRING_SLOT_LENGTH = 16;
4294
+ function derivePairingSlotId(pairingToken) {
4295
+ return bytesToHex(deriveLabeledKey(pairingToken, PAIRING_SLOT_LABEL, PAIRING_SLOT_LENGTH));
4296
+ }
4288
4297
 
4289
4298
  // ../../node_modules/fflate/esm/index.mjs
4290
4299
  var import_module = require("module");
@@ -5243,6 +5252,15 @@ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5243
5252
  }
5244
5253
 
5245
5254
  // src/events/payloads.ts
5255
+ var BOARD_COLUMN_ROLES = ["todo", "done"];
5256
+ var TODO_ROLE = "todo";
5257
+ var DONE_ROLE = "done";
5258
+ function isTodoRole(role) {
5259
+ return role === TODO_ROLE;
5260
+ }
5261
+ function isDoneRole(role) {
5262
+ return role === DONE_ROLE;
5263
+ }
5246
5264
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5247
5265
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
5248
5266
  function requireString(record, field, context) {
@@ -5409,18 +5427,26 @@ function nullableNumber(record, field) {
5409
5427
  const value = record[field];
5410
5428
  return typeof value === "number" ? value : null;
5411
5429
  }
5430
+ function nullableBoolean(record, field) {
5431
+ const value = record[field];
5432
+ return typeof value === "boolean" ? value : null;
5433
+ }
5412
5434
  function parseBoardColumnWire(value) {
5413
5435
  if (!isRecord(value)) throw new Error("board column must be an object");
5414
5436
  return {
5415
5437
  id: requireString(value, "id", "board column"),
5416
5438
  name: requireString(value, "name", "board column"),
5417
5439
  description: nullableString(value, "description"),
5440
+ // An unrecognized role passes through rather than throwing: it is a
5441
+ // custom column (or a future system role this package predates), not a
5442
+ // malformed payload. See the BoardColumnRoleWire doc comment.
5418
5443
  role: nullableString(value, "role"),
5419
5444
  position: requireNumber(value, "position", "board column"),
5420
5445
  color: requireString(value, "color", "board column"),
5421
5446
  icon: nullableString(value, "icon"),
5422
5447
  is_archived: requireBoolean(value, "is_archived", "board column"),
5423
- is_ghost: requireBoolean(value, "is_ghost", "board column")
5448
+ is_ghost: requireBoolean(value, "is_ghost", "board column"),
5449
+ spawns_session: nullableBoolean(value, "spawns_session")
5424
5450
  };
5425
5451
  }
5426
5452
  function parseBoardTaskWire(value) {
@@ -5439,6 +5465,7 @@ function parseBoardTaskWire(value) {
5439
5465
  pr_number: nullableNumber(value, "pr_number"),
5440
5466
  pr_url: nullableString(value, "pr_url"),
5441
5467
  pr_state: nullableString(value, "pr_state"),
5468
+ pr_merge_readiness: nullableString(value, "pr_merge_readiness"),
5442
5469
  base_branch: nullableString(value, "base_branch"),
5443
5470
  labels: Array.isArray(value.labels) ? parseStringArray(value.labels, "board task labels") : [],
5444
5471
  priority: typeof value.priority === "number" ? value.priority : 0,
@@ -6140,6 +6167,7 @@ function isBridgeEvent(value) {
6140
6167
  }
6141
6168
  // Annotate the CommonJS export names for ESM import in node:
6142
6169
  0 && (module.exports = {
6170
+ BOARD_COLUMN_ROLES,
6143
6171
  BOARD_TOOL_READ_NAMES,
6144
6172
  BOARD_TOOL_WRITE_NAMES,
6145
6173
  CAPABILITY_VERBS,
@@ -6170,6 +6198,7 @@ function isBridgeEvent(value) {
6170
6198
  createPairingResponderHandshake,
6171
6199
  decodeMessage,
6172
6200
  decodePairingQrPayload,
6201
+ derivePairingSlotId,
6173
6202
  deriveSecretstreamPair,
6174
6203
  deriveSessionSlotId,
6175
6204
  deriveShortAuthenticationString,
@@ -6188,11 +6217,13 @@ function isBridgeEvent(value) {
6188
6217
  isBoardToolWriteName,
6189
6218
  isBridgeEvent,
6190
6219
  isCapabilityVerb,
6220
+ isDoneRole,
6191
6221
  isJsonValue,
6192
6222
  isPushCategory,
6193
6223
  isRecord,
6194
6224
  isRosterEntryExpired,
6195
6225
  isSecureRelayAddress,
6226
+ isTodoRole,
6196
6227
  openPairingConfirm,
6197
6228
  openPushEnvelope,
6198
6229
  parseActivityEventPayload,
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { createKKHandshake, type KKHandshakeOptions } from './crypto/noise/kk';
7
7
  export { createPairingInitiatorHandshake, createPairingResponderHandshake, type PairingInitiatorOptions, type PairingResponderOptions, } from './crypto/pairing-handshake';
8
8
  export { SecretstreamState, deriveSecretstreamPair, FrameTag, type SecretstreamDirectionPair, } from './crypto/secretstream';
9
9
  export { deriveShortAuthenticationString, type ShortAuthenticationString } from './crypto/sas';
10
- export { deriveSessionSlotId } from './crypto/slot';
10
+ export { deriveSessionSlotId, derivePairingSlotId } from './crypto/slot';
11
11
  export type { JsonValue, BridgeMessage, HeartbeatMessage, CapabilityRequestMessage, CapabilityResponseMessage, EventMessage } from './wire/messages';
12
12
  export { encodeMessage, decodeMessage, MAX_FRAME_LENGTH, MAX_DECODED_LENGTH, COMPRESSION_THRESHOLD } from './wire/framing';
13
13
  export { isJsonValue, isRecord } from './wire/json-value';
@@ -22,5 +22,5 @@ export { formatKeyFingerprint } from './roster/fingerprint';
22
22
  export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
23
23
  export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
24
24
  export { isBridgeEvent, parseActivityEventPayload, type BridgeEvent, type TranscriptEvent, type ActivityEvent, type ActivityEventPayload, type TerminalEvent, type TerminalResizeEvent, type BoardEvent, type BoardEventPayload, type DiffEvent, } from './events/event';
25
- export { parseTranscriptEntriesWire, parseTranscriptEventPayload, type TranscriptEventPayload, type TranscriptUpsertWire, isActivityStateWire, isActivityReasonWire, parseSessionUsageWire, parseSessionSummaryWire, parseSessionEventWire, parseTerminalDimensionsWire, parseBoardColumnWire, parseBoardTaskWire, parseBacklogItemWire, parseDiffFileListWire, parseDiffFileContentWire, type TranscriptBlockWire, type TranscriptEntryWire, type TranscriptTurnUsageWire, type TranscriptSystemSubtypeWire, type ActivityStateWire, type ActivityReasonWire, type SessionUsageWire, type SessionSummaryWire, type SessionEventWire, type TerminalDimensionsWire, type BoardColumnWire, type BoardTaskWire, type BacklogItemWire, type DiffFileStatusWire, type DiffFileWire, type DiffFileListWire, type DiffFileContentWire, } from './events/payloads';
25
+ export { parseTranscriptEntriesWire, parseTranscriptEventPayload, type TranscriptEventPayload, type TranscriptUpsertWire, isActivityStateWire, isActivityReasonWire, parseSessionUsageWire, parseSessionSummaryWire, parseSessionEventWire, parseTerminalDimensionsWire, parseBoardColumnWire, parseBoardTaskWire, parseBacklogItemWire, parseDiffFileListWire, parseDiffFileContentWire, isTodoRole, isDoneRole, BOARD_COLUMN_ROLES, type TranscriptBlockWire, type TranscriptEntryWire, type TranscriptTurnUsageWire, type TranscriptSystemSubtypeWire, type ActivityStateWire, type ActivityReasonWire, type SessionUsageWire, type SessionSummaryWire, type SessionEventWire, type TerminalDimensionsWire, type BoardColumnWire, type BoardColumnRoleWire, type BoardTaskWire, type BacklogItemWire, type DiffFileStatusWire, type DiffFileWire, type DiffFileListWire, type DiffFileContentWire, } from './events/payloads';
26
26
  export type { Transport, TransportState, Unsubscribe } from './transport/transport';
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var PROTOCOL_VERSION = "2";
2
+ var PROTOCOL_VERSION = "3";
3
3
  function encodeProtocolVersion(version = PROTOCOL_VERSION) {
4
4
  return new TextEncoder().encode(`kangentic-bridge-v${version}`);
5
5
  }
@@ -4177,6 +4177,11 @@ function deriveSessionSlotId(desktopStaticPublicKey, phoneStaticPublicKey) {
4177
4177
  const material = concatBytes4(desktopStaticPublicKey, phoneStaticPublicKey);
4178
4178
  return bytesToHex(deriveLabeledKey(material, SESSION_SLOT_LABEL, SESSION_SLOT_LENGTH));
4179
4179
  }
4180
+ var PAIRING_SLOT_LABEL = "kangentic-pairing-slot-v1";
4181
+ var PAIRING_SLOT_LENGTH = 16;
4182
+ function derivePairingSlotId(pairingToken) {
4183
+ return bytesToHex(deriveLabeledKey(pairingToken, PAIRING_SLOT_LABEL, PAIRING_SLOT_LENGTH));
4184
+ }
4180
4185
 
4181
4186
  // ../../node_modules/fflate/esm/browser.js
4182
4187
  var u8 = Uint8Array;
@@ -5126,6 +5131,15 @@ function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
5126
5131
  }
5127
5132
 
5128
5133
  // src/events/payloads.ts
5134
+ var BOARD_COLUMN_ROLES = ["todo", "done"];
5135
+ var TODO_ROLE = "todo";
5136
+ var DONE_ROLE = "done";
5137
+ function isTodoRole(role) {
5138
+ return role === TODO_ROLE;
5139
+ }
5140
+ function isDoneRole(role) {
5141
+ return role === DONE_ROLE;
5142
+ }
5129
5143
  var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
5130
5144
  var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
5131
5145
  function requireString(record, field, context) {
@@ -5292,18 +5306,26 @@ function nullableNumber(record, field) {
5292
5306
  const value = record[field];
5293
5307
  return typeof value === "number" ? value : null;
5294
5308
  }
5309
+ function nullableBoolean(record, field) {
5310
+ const value = record[field];
5311
+ return typeof value === "boolean" ? value : null;
5312
+ }
5295
5313
  function parseBoardColumnWire(value) {
5296
5314
  if (!isRecord(value)) throw new Error("board column must be an object");
5297
5315
  return {
5298
5316
  id: requireString(value, "id", "board column"),
5299
5317
  name: requireString(value, "name", "board column"),
5300
5318
  description: nullableString(value, "description"),
5319
+ // An unrecognized role passes through rather than throwing: it is a
5320
+ // custom column (or a future system role this package predates), not a
5321
+ // malformed payload. See the BoardColumnRoleWire doc comment.
5301
5322
  role: nullableString(value, "role"),
5302
5323
  position: requireNumber(value, "position", "board column"),
5303
5324
  color: requireString(value, "color", "board column"),
5304
5325
  icon: nullableString(value, "icon"),
5305
5326
  is_archived: requireBoolean(value, "is_archived", "board column"),
5306
- is_ghost: requireBoolean(value, "is_ghost", "board column")
5327
+ is_ghost: requireBoolean(value, "is_ghost", "board column"),
5328
+ spawns_session: nullableBoolean(value, "spawns_session")
5307
5329
  };
5308
5330
  }
5309
5331
  function parseBoardTaskWire(value) {
@@ -5322,6 +5344,7 @@ function parseBoardTaskWire(value) {
5322
5344
  pr_number: nullableNumber(value, "pr_number"),
5323
5345
  pr_url: nullableString(value, "pr_url"),
5324
5346
  pr_state: nullableString(value, "pr_state"),
5347
+ pr_merge_readiness: nullableString(value, "pr_merge_readiness"),
5325
5348
  base_branch: nullableString(value, "base_branch"),
5326
5349
  labels: Array.isArray(value.labels) ? parseStringArray(value.labels, "board task labels") : [],
5327
5350
  priority: typeof value.priority === "number" ? value.priority : 0,
@@ -6022,6 +6045,7 @@ function isBridgeEvent(value) {
6022
6045
  }
6023
6046
  }
6024
6047
  export {
6048
+ BOARD_COLUMN_ROLES,
6025
6049
  BOARD_TOOL_READ_NAMES,
6026
6050
  BOARD_TOOL_WRITE_NAMES,
6027
6051
  CAPABILITY_VERBS,
@@ -6052,6 +6076,7 @@ export {
6052
6076
  createPairingResponderHandshake,
6053
6077
  decodeMessage,
6054
6078
  decodePairingQrPayload,
6079
+ derivePairingSlotId,
6055
6080
  deriveSecretstreamPair,
6056
6081
  deriveSessionSlotId,
6057
6082
  deriveShortAuthenticationString,
@@ -6070,11 +6095,13 @@ export {
6070
6095
  isBoardToolWriteName,
6071
6096
  isBridgeEvent,
6072
6097
  isCapabilityVerb,
6098
+ isDoneRole,
6073
6099
  isJsonValue,
6074
6100
  isPushCategory,
6075
6101
  isRecord,
6076
6102
  isRosterEntryExpired,
6077
6103
  isSecureRelayAddress,
6104
+ isTodoRole,
6078
6105
  openPairingConfirm,
6079
6106
  openPushEnvelope,
6080
6107
  parseActivityEventPayload,
@@ -14,8 +14,14 @@
14
14
  * desktop's key, the two transcripts necessarily agree - which means the
15
15
  * SAS the human compared necessarily agreed too. The AEAD open IS the
16
16
  * desktop's SAS verification; that is why the frame carries no digits of
17
- * its own, and why a failed open is treated as a mismatch/failure, not a
18
- * silent no-op.
17
+ * its own.
18
+ *
19
+ * A failed open is IGNORED rather than treated as a ceremony-ending failure.
20
+ * The frame is unauthenticated and the relay slot is reachable by anyone who
21
+ * can read the request URI, so treating one bad frame as a mismatch let an
22
+ * observer end a pairing at will. The caller stays in its sas-pending phase
23
+ * and lets its own timeout decide - see pairing-service.ts's
24
+ * handleConfirmFrame().
19
25
  *
20
26
  * There is no reject frame. Backing out of the ceremony on the phone closes
21
27
  * the transport without sending this frame; close-without-confirm is the
@@ -35,5 +41,14 @@ export declare function sealPairingConfirm(initiatorToResponder: CipherState): U
35
41
  * tampered/wrong-key ciphertext or an unexpected plaintext, so the caller
36
42
  * can treat "could not verify" uniformly whether the failure was
37
43
  * cryptographic or structural.
44
+ *
45
+ * A false return from a FAILED DECRYPT leaves `initiatorToResponder` untouched:
46
+ * CipherState advances its nonce only on a successful decrypt, so a caller may
47
+ * keep waiting for the real frame after rejecting any number of bad ones. Scoped
48
+ * to that path deliberately - the structural checks below return false only
49
+ * after a decrypt already succeeded and already advanced the nonce. They are
50
+ * unreachable in practice (an authenticating tag means the plaintext is the one
51
+ * sealPairingConfirm wrote) and kept as belt-and-braces, but a caller that ever
52
+ * made them reachable would not inherit the keep-waiting guarantee.
38
53
  */
39
54
  export declare function openPairingConfirm(initiatorToResponder: CipherState, frame: Uint8Array): boolean;
@@ -10,15 +10,24 @@
10
10
  export declare const MAX_RELAY_ADDRESS_LENGTH = 512;
11
11
  /**
12
12
  * True if the phone's pairing QR scanner will accept this relay address.
13
- * wss:// is always secure; ws:// is accepted only for loopback, since the
14
- * pairing token doubles as the Noise PSK and is dialed verbatim as `?slot=`.
13
+ * wss:// is always secure; ws:// is accepted only for loopback.
14
+ *
15
+ * The relay address arrives inside a scanned QR payload, so it is attacker
16
+ * controllable: a crafted QR naming a plaintext host would route the whole
17
+ * ceremony through that host, and the pairing then persists it to the trust
18
+ * anchor for every later session. That is what the restriction prevents, and
19
+ * it holds regardless of how strong the frames themselves are.
20
+ *
21
+ * Note this rule predates derivePairingSlotId() and used to be justified by
22
+ * the pairing token being dialed verbatim as `?slot=`, which put the Noise PSK
23
+ * in cleartext on the wire. The slot is a derived routing label now, so that
24
+ * particular exposure is gone - the address-pinning reason above is not, which
25
+ * is why the restriction stays.
15
26
  *
16
27
  * Parses the AUTHORITY rather than prefix-matching. Prefix matching accepted
17
28
  * `ws://127.0.0.1:8080@evil.test`: everything before an '@' is userinfo, so
18
- * that address dials evil.test while looking like loopback, putting the PSK
19
- * on the wire in cleartext to an attacker-chosen host - and the pairing then
20
- * persists that host to the trust anchor for every later session. Authority
21
- * parsing also subsumes the old boundary check, since `localhost.evil.com`
22
- * is simply a different host.
29
+ * that address dials evil.test while looking like loopback. Authority parsing
30
+ * also subsumes the old boundary check, since `localhost.evil.com` is simply a
31
+ * different host.
23
32
  */
24
33
  export declare function isSecureRelayAddress(relayAddress: string): boolean;
package/dist/version.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * The protocol version string. Bumped on any wire-incompatible change to
3
- * framing, message shapes, or the handshake patterns in this package.
3
+ * framing, message shapes, the handshake patterns, or the relay slot
4
+ * derivations in this package.
5
+ *
6
+ * A slot derivation counts because the slot is a zero-negotiation rendezvous
7
+ * value: peers computing it differently never meet, and would otherwise just
8
+ * hang until the relay's park timeout. Binding the version here instead turns
9
+ * that into an explicit "update the app" at QR-scan time, before anything
10
+ * dials. v3 derives the pairing slot from the token rather than using the
11
+ * token verbatim (see crypto/slot.ts's derivePairingSlotId).
4
12
  *
5
13
  * Bound into every Noise handshake's prologue (see crypto/noise/kk.ts and
6
14
  * crypto/pairing-handshake.ts), so two peers running different protocol
@@ -8,5 +16,5 @@
8
16
  * differing prologue produces a differing transcript hash `h` from the
9
17
  * first MixHash call, which cascades into every derived key.
10
18
  */
11
- export declare const PROTOCOL_VERSION = "2";
19
+ export declare const PROTOCOL_VERSION = "3";
12
20
  export declare function encodeProtocolVersion(version?: string): Uint8Array;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangentic/protocol",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Kangentic mobile bridge wire protocol: framing, Noise-based pairing and session handshakes, signed device roster, and capability verbs shared between desktop and mobile.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",