@peers-app/peers-sdk 0.25.13 → 0.25.15

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 (79) hide show
  1. package/dist/context/user-context-identity.test.d.ts +1 -0
  2. package/dist/context/user-context-identity.test.js +220 -0
  3. package/dist/context/user-context.d.ts +5 -1
  4. package/dist/context/user-context.js +63 -8
  5. package/dist/contracts/index.d.ts +1 -1
  6. package/dist/contracts/system/identity-contract.d.ts +156 -0
  7. package/dist/contracts/system/identity-contract.js +172 -0
  8. package/dist/contracts/system/index.d.ts +1 -1
  9. package/dist/data/assistants.d.ts +4 -4
  10. package/dist/data/devices.d.ts +70 -4
  11. package/dist/data/devices.js +3 -0
  12. package/dist/data/group-members.d.ts +2 -2
  13. package/dist/data/groups.d.ts +2 -2
  14. package/dist/data/invites.d.ts +93 -25
  15. package/dist/data/persistent-vars.d.ts +8 -0
  16. package/dist/data/persistent-vars.js +16 -0
  17. package/dist/data/tool-access-approvals.d.ts +2 -2
  18. package/dist/data/user-permissions.d.ts +10 -2
  19. package/dist/data/user-permissions.js +35 -4
  20. package/dist/data/user-permissions.test.js +26 -0
  21. package/dist/data/users.d.ts +48 -4
  22. package/dist/data/users.js +61 -5
  23. package/dist/data/workflows.d.ts +1 -1
  24. package/dist/device/connection.d.ts +7 -1
  25. package/dist/device/connection.js +38 -6
  26. package/dist/device/connection.test.js +33 -4
  27. package/dist/device/device.d.ts +2 -2
  28. package/dist/device/device.js +4 -2
  29. package/dist/device/get-trust-level-fn.d.ts +1 -0
  30. package/dist/device/get-trust-level-fn.js +28 -4
  31. package/dist/device/get-trust-level-fn.test.js +95 -0
  32. package/dist/device-pairing/types.d.ts +26 -26
  33. package/dist/host-credentials.d.ts +78 -0
  34. package/dist/host-credentials.js +128 -0
  35. package/dist/host-credentials.test.d.ts +1 -0
  36. package/dist/host-credentials.test.js +88 -0
  37. package/dist/identity/account-email.d.ts +59 -0
  38. package/dist/identity/account-email.js +87 -0
  39. package/dist/identity/account-email.test.d.ts +1 -0
  40. package/dist/identity/account-email.test.js +59 -0
  41. package/dist/identity/anchor-document.d.ts +205 -0
  42. package/dist/identity/anchor-document.js +119 -0
  43. package/dist/identity/anchor-document.test.d.ts +1 -0
  44. package/dist/identity/anchor-document.test.js +16 -0
  45. package/dist/identity/anchor-resolution.d.ts +61 -0
  46. package/dist/identity/anchor-resolution.js +117 -0
  47. package/dist/identity/anchor-resolution.test.d.ts +1 -0
  48. package/dist/identity/anchor-resolution.test.js +130 -0
  49. package/dist/identity/key-change.d.ts +142 -0
  50. package/dist/identity/key-change.js +472 -0
  51. package/dist/identity/key-change.test.d.ts +1 -0
  52. package/dist/identity/key-change.test.js +811 -0
  53. package/dist/identity/key-contest.d.ts +91 -0
  54. package/dist/identity/key-contest.js +55 -0
  55. package/dist/identity/key-resolutions.d.ts +55 -0
  56. package/dist/identity/key-resolutions.js +42 -0
  57. package/dist/identity/passkey.d.ts +22 -0
  58. package/dist/identity/passkey.js +33 -0
  59. package/dist/identity/user-row-change.d.ts +45 -0
  60. package/dist/identity/user-row-change.js +81 -0
  61. package/dist/index.d.ts +9 -0
  62. package/dist/index.js +9 -0
  63. package/dist/invites/invite-messages.d.ts +512 -116
  64. package/dist/invites/invite-token.d.ts +114 -24
  65. package/dist/invites/invite-token.js +6 -0
  66. package/dist/keys.d.ts +7 -0
  67. package/dist/keys.js +20 -0
  68. package/dist/package-installer/package-author-signing.test.js +11 -0
  69. package/dist/package-installer/package-publisher.d.ts +5 -1
  70. package/dist/package-installer/package-publisher.js +1 -1
  71. package/dist/package-installer/package-publisher.test.js +11 -0
  72. package/dist/package-installer/package-remote-checker.js +3 -0
  73. package/dist/system-ids.d.ts +16 -0
  74. package/dist/system-ids.js +16 -0
  75. package/dist/types/workflow.d.ts +2 -2
  76. package/dist/utils.d.ts +10 -0
  77. package/dist/utils.js +43 -0
  78. package/dist/utils.test.js +23 -0
  79. package/package.json +1 -1
@@ -2,8 +2,8 @@ import type { DataContext } from "../context/data-context";
2
2
  export declare function Workflows(dataContext?: DataContext): import("..").Table<{
3
3
  name: string;
4
4
  description: string;
5
- createdAt: Date;
6
5
  updatedAt: Date;
6
+ createdAt: Date;
7
7
  workflowId: string;
8
8
  defaultAssistantId: string;
9
9
  instructions: {
@@ -71,9 +71,9 @@ export declare class Connection {
71
71
  /** Copy of the authenticated remote device identity (empty before the handshake). */
72
72
  get remoteDeviceInfo(): {
73
73
  publicKey: string;
74
- publicBoxKey: string;
75
74
  userId: string;
76
75
  deviceId: string;
76
+ publicBoxKey: string;
77
77
  userName?: string | undefined;
78
78
  deviceName?: string | undefined;
79
79
  };
@@ -140,6 +140,12 @@ export declare class Connection {
140
140
  * frame cipher is armed in both directions before this resolves.
141
141
  */
142
142
  doHandshake(remoteAddress: string): Promise<IDeviceHandshake>;
143
+ /**
144
+ * Resets this side and asks the remote to do the same. The remote call is a courtesy: a
145
+ * peer that is not a server has no `reset` handler, and a peer we just refused may already
146
+ * be gone, so its failure is logged and never allowed to escape as an unhandled rejection.
147
+ */
148
+ private resetRemote;
143
149
  private closeLocal;
144
150
  /** Round-trip time in milliseconds. @throws if the connection is not verified */
145
151
  ping(): Promise<number>;
@@ -2,7 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Connection = void 0;
4
4
  exports.normalizeAddress = normalizeAddress;
5
+ const user_context_singleton_1 = require("../context/user-context-singleton");
5
6
  const data_1 = require("../data");
7
+ const key_change_1 = require("../identity/key-change");
6
8
  const keys_1 = require("../keys");
7
9
  const peer_device_1 = require("../types/peer-device");
8
10
  const utils_1 = require("../utils");
@@ -370,7 +372,7 @@ class Connection {
370
372
  ephemeralSecretKey = ephemeral.secretKey;
371
373
  frameCrypto = { version: 1, ephemeralBoxKey: (0, keys_1.encodeBase64)(ephemeral.publicKey) };
372
374
  }
373
- const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress, frameCrypto);
375
+ const handshake = this.localDevice.getHandshake(this.connectionId, serverAddress, frameCrypto, (0, key_change_1.peekLocalKeyContest)());
374
376
  if (ephemeralSecretKey) {
375
377
  this.pendingClientHandshake = {
376
378
  ephemeralSecretKey,
@@ -418,7 +420,8 @@ class Connection {
418
420
  });
419
421
  frameKeys = (0, frame_cipher_1.frameKeysForRole)(derived, "server");
420
422
  }
421
- const handshakeResponse = this.localDevice.handshakeResponse(signedHandshake, this.connectionId, thisServerAddress, frameCrypto);
423
+ const handshakeResponse = this.localDevice.handshakeResponse(signedHandshake, this.connectionId, thisServerAddress, frameCrypto, (0, key_change_1.peekLocalKeyContest)());
424
+ await noteHandshakeContest(_handshake.keyContest);
422
425
  this._remoteDeviceInfo = {
423
426
  userId: _handshake.userId,
424
427
  deviceId: _handshake.deviceId,
@@ -429,8 +432,7 @@ class Connection {
429
432
  };
430
433
  this.trustLevel = await this.getTrustLevel(_handshake, true);
431
434
  if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
432
- this.reset();
433
- this.emit("reset");
435
+ this.resetRemote();
434
436
  throw new Error("Untrusted connection");
435
437
  }
436
438
  this._verified = true;
@@ -465,9 +467,16 @@ class Connection {
465
467
  if (remoteDeviceInfoSigned.publicKey !== remoteDeviceInfo.publicKey) {
466
468
  throw new Error("Device info signing key does not match claimed identity key");
467
469
  }
470
+ if (remoteDeviceInfo.userId === this.localDevice.userId &&
471
+ remoteDeviceInfo.publicKey !== this.localDevice.publicKey) {
472
+ // The server will refuse this handshake. Decide here, while we still have the signed
473
+ // identity, whether the anchors say this device's key was rotated away.
474
+ await (0, key_change_1.detectIdentityRotatedAway)(this.localDevice, remoteDeviceInfo);
475
+ }
468
476
  const handshake = await this.initiateHandshake(remoteAddress, remoteDeviceInfo);
469
477
  const handshakeResponseBox = await this.emit("completeHandshake", handshake);
470
478
  const handshakeResponse = data_1.deviceHandshakeSchema.parse(await this.localDevice.openBoxedAndSignedData(handshakeResponseBox));
479
+ await noteHandshakeContest(handshakeResponse.keyContest);
471
480
  if (handshakeResponse.connectionId !== this.connectionId) {
472
481
  throw new Error(`Invalid connectionId ${handshakeResponse.connectionId}, expected ${this.connectionId}`);
473
482
  }
@@ -522,12 +531,22 @@ class Connection {
522
531
  }
523
532
  this.trustLevel = await this.getTrustLevel(currentRemoteDeviceInfo, true);
524
533
  if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
525
- this.reset();
526
- this.emit("reset");
534
+ this.resetRemote();
527
535
  throw new Error("Untrusted connection");
528
536
  }
529
537
  return handshakeResponse;
530
538
  }
539
+ /**
540
+ * Resets this side and asks the remote to do the same. The remote call is a courtesy: a
541
+ * peer that is not a server has no `reset` handler, and a peer we just refused may already
542
+ * be gone, so its failure is logged and never allowed to escape as an unhandled rejection.
543
+ */
544
+ resetRemote() {
545
+ this.reset();
546
+ this.emit("reset").catch((err) => {
547
+ console.warn(`Remote reset was not acknowledged: ${err?.error ?? err?.message ?? err}`);
548
+ });
549
+ }
531
550
  closeLocal() {
532
551
  this.reset();
533
552
  this.removeAllListeners();
@@ -559,6 +578,19 @@ class Connection {
559
578
  }
560
579
  }
561
580
  exports.Connection = Connection;
581
+ async function noteHandshakeContest(contest) {
582
+ if (!contest)
583
+ return;
584
+ const userContext = (0, user_context_singleton_1.peekUserContext)();
585
+ if (!userContext)
586
+ return;
587
+ try {
588
+ await (0, key_change_1.applyKeyContest)(userContext.userDataContext, contest);
589
+ }
590
+ catch (err) {
591
+ console.warn("Could not apply a key contest from the handshake", err);
592
+ }
593
+ }
562
594
  /** Normalizes a peer address for transport and security comparisons. */
563
595
  function normalizeAddress(address) {
564
596
  return address.toLowerCase().replace(/\/$/, "");
@@ -7,14 +7,19 @@ const binary_peer_connection_v2_1 = require("./binary-peer-connection-v2");
7
7
  const connection_1 = require("./connection");
8
8
  const device_1 = require("./device");
9
9
  const frame_cipher_1 = require("./frame-cipher");
10
+ const socket_type_1 = require("./socket.type");
10
11
  function createTestSocket(localHandlers, remoteHandlers) {
11
12
  const socket = {
12
13
  id: (0, utils_1.newid)(),
13
14
  emit(eventName, args, callback) {
14
- remoteHandlers.forEach(async ({ eventName: _eventName, handler }) => {
15
- if (eventName === _eventName) {
16
- handler(args, callback);
17
- }
15
+ const matching = remoteHandlers.filter(({ eventName: _eventName }) => eventName === _eventName);
16
+ if (matching.length === 0) {
17
+ // Same answer the real transports give for an event nobody handles.
18
+ callback?.(`No handler registered for event: ${eventName}`);
19
+ return;
20
+ }
21
+ matching.forEach(async ({ handler }) => {
22
+ handler(args, callback);
18
23
  });
19
24
  },
20
25
  on(eventName, handler) {
@@ -254,6 +259,30 @@ describe(connection_1.Connection, () => {
254
259
  const result = await clientConnection.doHandshake(serverAddress).catch((err) => String(err));
255
260
  expect(result).toMatch(/Inconsistent public keys/);
256
261
  });
262
+ it("refuses an untrusted client without leaving an unhandled rejection behind", async () => {
263
+ // The server tells the client to reset as a courtesy. A client that is not itself a
264
+ // server has no `reset` handler, so that call fails; the failure must not escape as an
265
+ // unhandled rejection, which would take down a host with no process-level handler
266
+ // (peers-services) every time a device presents a retired key.
267
+ const serverAddress = "http://localhost";
268
+ const { clientSocket, serverSocket } = createTestSocketPair();
269
+ const clientConnection = new connection_1.Connection(clientSocket, new device_1.Device());
270
+ const serverConnection = new connection_1.Connection(serverSocket, new device_1.Device(), [serverAddress], () => Promise.resolve(socket_type_1.TrustLevel.Untrusted));
271
+ const unhandled = jest.fn();
272
+ process.on("unhandledRejection", unhandled);
273
+ try {
274
+ const result = await clientConnection
275
+ .doHandshake(serverAddress)
276
+ .catch((err) => err?.error ?? String(err));
277
+ expect(result).toMatch(/Untrusted connection/);
278
+ await (0, utils_1.sleep)(20);
279
+ expect(unhandled).not.toHaveBeenCalled();
280
+ expect(serverConnection.verified).toBe(false);
281
+ }
282
+ finally {
283
+ process.off("unhandledRejection", unhandled);
284
+ }
285
+ });
257
286
  it("should handle RPC calls with large arguments and responses", async () => {
258
287
  const { clientSocket, serverSocket } = createTestSocketPair();
259
288
  const clientDevice = new device_1.Device();
@@ -73,13 +73,13 @@ export declare class Device implements IDeviceInfo {
73
73
  * Builds and signs the client half of a connection handshake.
74
74
  * @param frameCrypto Include to advertise frame-cipher support with this connection's ephemeral key.
75
75
  */
76
- getHandshake(connectionId: string, serverAddress: string, frameCrypto?: IHandshakeFrameCrypto): ISignedObject<IDeviceHandshake>;
76
+ getHandshake(connectionId: string, serverAddress: string, frameCrypto?: IHandshakeFrameCrypto, keyContest?: IDeviceHandshake["keyContest"]): ISignedObject<IDeviceHandshake>;
77
77
  /**
78
78
  * Validates a client handshake (signature, claimed key, connection ID) and
79
79
  * builds the unsigned server response. `Connection` signs and boxes it.
80
80
  * @param frameCrypto Include to accept the client's frame-cipher offer with this side's ephemeral key.
81
81
  */
82
- handshakeResponse(remoteHandshake: ISignedObject<IDeviceHandshake>, connectionId: string, thisServerAddress: string, frameCrypto?: IHandshakeFrameCrypto): IDeviceHandshake;
82
+ handshakeResponse(remoteHandshake: ISignedObject<IDeviceHandshake>, connectionId: string, thisServerAddress: string, frameCrypto?: IHandshakeFrameCrypto, keyContest?: IDeviceHandshake["keyContest"]): IDeviceHandshake;
83
83
  private deviceInfoSigned;
84
84
  /** Returns cached signed identity information, refreshing after display metadata changes. */
85
85
  getDeviceInfo(): ISignedObject<IDeviceInfo>;
@@ -135,7 +135,7 @@ class Device {
135
135
  * Builds and signs the client half of a connection handshake.
136
136
  * @param frameCrypto Include to advertise frame-cipher support with this connection's ephemeral key.
137
137
  */
138
- getHandshake(connectionId, serverAddress, frameCrypto) {
138
+ getHandshake(connectionId, serverAddress, frameCrypto, keyContest) {
139
139
  try {
140
140
  const localDeviceInfo = data_1.deviceHandshakeSchema.parse({
141
141
  connectionId,
@@ -148,6 +148,7 @@ class Device {
148
148
  serverAddress,
149
149
  timestamp: Date.now(),
150
150
  frameCrypto,
151
+ ...(keyContest ? { keyContest } : {}),
151
152
  });
152
153
  const localDeviceInfoSigned = this.signObjectWithSecretKey(localDeviceInfo);
153
154
  return localDeviceInfoSigned;
@@ -161,7 +162,7 @@ class Device {
161
162
  * builds the unsigned server response. `Connection` signs and boxes it.
162
163
  * @param frameCrypto Include to accept the client's frame-cipher offer with this side's ephemeral key.
163
164
  */
164
- handshakeResponse(remoteHandshake, connectionId, thisServerAddress, frameCrypto) {
165
+ handshakeResponse(remoteHandshake, connectionId, thisServerAddress, frameCrypto, keyContest) {
165
166
  try {
166
167
  const deviceInfo = data_1.deviceHandshakeSchema.parse((0, keys_1.openSignedObject)(remoteHandshake));
167
168
  if (remoteHandshake.publicKey !== deviceInfo.publicKey) {
@@ -181,6 +182,7 @@ class Device {
181
182
  serverAddress: thisServerAddress,
182
183
  timestamp: Date.now(),
183
184
  frameCrypto,
185
+ ...(keyContest ? { keyContest } : {}),
184
186
  });
185
187
  return handshakeResponse;
186
188
  }
@@ -5,5 +5,6 @@ import { TrustLevel } from "./socket.type";
5
5
  *
6
6
  * Known user keys are authoritative even for a newly observed device: a claimed identity
7
7
  * whose signing or box key differs from the stored user record is rejected as untrusted.
8
+ * Accepting a different key waits for the anchors on record to confirm it (identity anchors).
8
9
  */
9
10
  export declare function getTrustLevelFn(me: Pick<IDeviceInfo, "userId" | "publicKey" | "publicBoxKey">, serverUrl?: string): (deviceInfo: IDeviceInfo, _registerNew?: boolean) => Promise<TrustLevel>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getTrustLevelFn = getTrustLevelFn;
4
4
  const context_1 = require("../context");
5
5
  const data_1 = require("../data");
6
+ const key_change_1 = require("../identity/key-change");
6
7
  const socket_type_1 = require("./socket.type");
7
8
  function applyReportedDeviceName(device, deviceInfo) {
8
9
  if (deviceInfo.deviceName) {
@@ -18,11 +19,18 @@ function boundStoredDeviceNames(device) {
18
19
  *
19
20
  * Known user keys are authoritative even for a newly observed device: a claimed identity
20
21
  * whose signing or box key differs from the stored user record is rejected as untrusted.
22
+ * Accepting a different key waits for the anchors on record to confirm it (identity anchors).
21
23
  */
22
24
  function getTrustLevelFn(me, serverUrl) {
23
25
  return async function getTrustLevel(deviceInfo, _registerNew) {
24
26
  const userContext = await (0, context_1.getUserContext)();
25
27
  const userDataContext = userContext.userDataContext;
28
+ if (deviceInfo.userId === me.userId && deviceInfo.publicKey !== me.publicKey) {
29
+ // Same user, different key. Anchors decide whether this device was rotated away.
30
+ // Either way the connection is refused: this device does not hold the key it was shown.
31
+ await (0, key_change_1.detectIdentityRotatedAway)(me, deviceInfo);
32
+ return socket_type_1.TrustLevel.Untrusted;
33
+ }
26
34
  // if this is my own device it is trusted
27
35
  if (deviceInfo.userId === me.userId &&
28
36
  deviceInfo.publicKey === me.publicKey &&
@@ -85,13 +93,29 @@ function getTrustLevelFn(me, serverUrl) {
85
93
  const effectiveUser = user ?? groupUser;
86
94
  if (effectiveUser &&
87
95
  (deviceInfo.userId !== effectiveUser.userId ||
88
- deviceInfo.publicKey !== effectiveUser.publicKey ||
89
- deviceInfo.publicBoxKey !== effectiveUser.publicBoxKey ||
90
96
  (device ? deviceInfo.userId !== device.userId : false))) {
91
- // deviceInfo does not align with local info about the user or device, return Untrusted
92
- // TODO check if user has changed their public keys
93
97
  return socket_type_1.TrustLevel.Untrusted;
94
98
  }
99
+ if (effectiveUser &&
100
+ (deviceInfo.publicKey !== effectiveUser.publicKey ||
101
+ deviceInfo.publicBoxKey !== effectiveUser.publicBoxKey)) {
102
+ // A different box key for the key on record is a mismatch. A different signing key is
103
+ // accepted only when the anchors on record confirm a rotation.
104
+ if (deviceInfo.publicKey === effectiveUser.publicKey) {
105
+ return socket_type_1.TrustLevel.Untrusted;
106
+ }
107
+ const decision = await (0, key_change_1.evaluatePresentedKey)({
108
+ dataContext: userDataContext,
109
+ userId: deviceInfo.userId,
110
+ presentedKey: deviceInfo.publicKey,
111
+ presentedBoxKey: deviceInfo.publicBoxKey,
112
+ record: user ?? groupUser ?? undefined,
113
+ });
114
+ if (!decision.accepted) {
115
+ return socket_type_1.TrustLevel.Untrusted;
116
+ }
117
+ user = (await (0, data_1.Users)(userDataContext).get(deviceInfo.userId)) ?? user;
118
+ }
95
119
  if (userTrustLevel?.trustLevel &&
96
120
  userTrustLevel.trustLevel >= socket_type_1.TrustLevel.Trusted &&
97
121
  device?.trustLevel &&
@@ -11,8 +11,13 @@ jest.mock("../data", () => ({
11
11
  Users: jest.fn(),
12
12
  UserTrustLevels: jest.fn(),
13
13
  }));
14
+ jest.mock("../identity/key-change", () => ({
15
+ evaluatePresentedKey: jest.fn(async () => ({ accepted: false, state: "pending" })),
16
+ detectIdentityRotatedAway: jest.fn(async () => undefined),
17
+ }));
14
18
  const context_1 = require("../context");
15
19
  const data_1 = require("../data");
20
+ const key_change_1 = require("../identity/key-change");
16
21
  const mockGetUserContext = context_1.getUserContext;
17
22
  const mockDevices = data_1.Devices;
18
23
  const mockUsers = data_1.Users;
@@ -587,4 +592,94 @@ describe("getTrustLevelFn", () => {
587
592
  expect(mockUserTrustLevels).toHaveBeenCalled();
588
593
  });
589
594
  });
595
+ describe("anchor confirmation", () => {
596
+ it("accepts a different key when the anchors confirm a rotation", async () => {
597
+ const me = makeTestIdentity();
598
+ const other = makeTestIdentity();
599
+ const existingUser = {
600
+ userId: other.userId,
601
+ publicKey: `stored_${(0, utils_1.newid)()}`,
602
+ publicBoxKey: `stored_box_${(0, utils_1.newid)()}`,
603
+ name: "Alice",
604
+ };
605
+ const { usersTable } = setupMocks(null, existingUser, null);
606
+ usersTable.get.mockResolvedValueOnce(existingUser).mockResolvedValueOnce({
607
+ ...existingUser,
608
+ publicKey: other.publicKey,
609
+ publicBoxKey: other.publicBoxKey,
610
+ });
611
+ key_change_1.evaluatePresentedKey.mockResolvedValueOnce({
612
+ accepted: true,
613
+ state: "confirmed-rotation",
614
+ });
615
+ const getTrustLevel = (0, get_trust_level_fn_1.getTrustLevelFn)(me);
616
+ const result = await getTrustLevel(other);
617
+ expect(key_change_1.evaluatePresentedKey).toHaveBeenCalledWith(expect.objectContaining({
618
+ userId: other.userId,
619
+ presentedKey: other.publicKey,
620
+ }));
621
+ expect(result).toBe(socket_type_1.TrustLevel.NewDevice);
622
+ });
623
+ it("writes nothing for a contact's key the anchors have not confirmed", async () => {
624
+ // Pending, vetoed, and a recovery still in its delay all land here: the handshake is
625
+ // refused and neither a device record nor a user stub is created for the presenter.
626
+ const me = makeTestIdentity();
627
+ const other = makeTestIdentity();
628
+ const existingUser = {
629
+ userId: other.userId,
630
+ publicKey: `stored_${(0, utils_1.newid)()}`,
631
+ publicBoxKey: `stored_box_${(0, utils_1.newid)()}`,
632
+ name: "Alice",
633
+ };
634
+ for (const state of ["pending", "vetoed", "recovery-pending"]) {
635
+ const { usersTable, devTable } = setupMocks(null, existingUser, null);
636
+ key_change_1.evaluatePresentedKey.mockResolvedValueOnce({ accepted: false, state });
637
+ const result = await (0, get_trust_level_fn_1.getTrustLevelFn)(me)(other);
638
+ expect(result).toBe(socket_type_1.TrustLevel.Untrusted);
639
+ expect(usersTable.save).not.toHaveBeenCalled();
640
+ expect(devTable.save).not.toHaveBeenCalled();
641
+ }
642
+ });
643
+ it("judges a group-only contact's new key against the group row and copies nothing into the personal table", async () => {
644
+ const me = makeTestIdentity();
645
+ const other = makeTestIdentity();
646
+ const groupUser = {
647
+ userId: other.userId,
648
+ publicKey: `stored_${(0, utils_1.newid)()}`,
649
+ publicBoxKey: `stored_box_${(0, utils_1.newid)()}`,
650
+ name: "Alice",
651
+ };
652
+ const { usersTable, devTable } = setupMocks(null, null, null);
653
+ const groupUsersTable = makeUsersTable(groupUser);
654
+ const groupTrustTable = makeTrustTable({ trustLevel: socket_type_1.TrustLevel.Known });
655
+ const groupContext = { dataContextId: "group" };
656
+ mockGetUserContext.mockResolvedValue({
657
+ userDataContext: {},
658
+ groupDataContexts: new Map([["group", groupContext]]),
659
+ });
660
+ mockUsers.mockImplementation(((context) => context === groupContext ? groupUsersTable : usersTable));
661
+ mockUserTrustLevels.mockImplementation(((context) => context === groupContext ? groupTrustTable : makeTrustTable(null)));
662
+ key_change_1.evaluatePresentedKey.mockResolvedValueOnce({
663
+ accepted: true,
664
+ state: "confirmed-rotation",
665
+ });
666
+ const result = await (0, get_trust_level_fn_1.getTrustLevelFn)(me)(other);
667
+ expect(key_change_1.evaluatePresentedKey).toHaveBeenCalledWith(expect.objectContaining({ userId: other.userId, record: groupUser }));
668
+ // The group row is the only record; it is not copied into the personal table.
669
+ expect(usersTable.save).not.toHaveBeenCalled();
670
+ expect(groupUsersTable.save).not.toHaveBeenCalled();
671
+ expect(devTable.save).toHaveBeenCalledTimes(1);
672
+ expect(result).toBe(socket_type_1.TrustLevel.NewDevice);
673
+ });
674
+ it("treats another device of this user on a different key as untrusted", async () => {
675
+ const me = makeTestIdentity();
676
+ const otherDevice = { ...me, deviceId: (0, utils_1.newid)(), publicKey: `other_${(0, utils_1.newid)()}` };
677
+ setupMocks();
678
+ const getTrustLevel = (0, get_trust_level_fn_1.getTrustLevelFn)(me);
679
+ const result = await getTrustLevel(otherDevice);
680
+ expect(result).toBe(socket_type_1.TrustLevel.Untrusted);
681
+ expect(key_change_1.detectIdentityRotatedAway).toHaveBeenCalledWith(me, expect.objectContaining({ userId: me.userId, publicKey: otherDevice.publicKey }));
682
+ expect(mockUsers).not.toHaveBeenCalled();
683
+ });
684
+ });
590
685
  });