@peers-app/peers-sdk 0.23.14 → 0.25.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.
Files changed (96) hide show
  1. package/dist/context/user-context.d.ts +8 -0
  2. package/dist/context/user-context.js +9 -1
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +4 -1
  5. package/dist/contracts/system/index.d.ts +2 -0
  6. package/dist/contracts/system/index.js +5 -1
  7. package/dist/contracts/system/invites-contract.d.ts +121 -0
  8. package/dist/contracts/system/invites-contract.js +150 -0
  9. package/dist/data/create-group.d.ts +24 -0
  10. package/dist/data/create-group.js +61 -0
  11. package/dist/data/group-secret.d.ts +10 -0
  12. package/dist/data/group-secret.js +26 -0
  13. package/dist/data/group-share.d.ts +0 -20
  14. package/dist/data/group-share.js +0 -35
  15. package/dist/data/index.d.ts +2 -0
  16. package/dist/data/index.js +2 -0
  17. package/dist/data/invites.d.ts +350 -0
  18. package/dist/data/invites.js +99 -0
  19. package/dist/data/orm/data-query.d.ts +1 -0
  20. package/dist/data/orm/sql.data-source.d.ts +3 -2
  21. package/dist/data/orm/sql.data-source.js +5 -3
  22. package/dist/data/orm/subscribable.data-source.d.ts +3 -2
  23. package/dist/data/orm/subscribable.data-source.js +7 -11
  24. package/dist/data/users.d.ts +11 -0
  25. package/dist/data/users.js +17 -0
  26. package/dist/device/device-election.d.ts +23 -3
  27. package/dist/device/device-election.js +15 -0
  28. package/dist/device/device-election.test.js +73 -0
  29. package/dist/device-pairing/constants.d.ts +27 -9
  30. package/dist/device-pairing/constants.js +28 -10
  31. package/dist/device-pairing/cpace.d.ts +46 -0
  32. package/dist/device-pairing/cpace.js +173 -0
  33. package/dist/device-pairing/cpace.test.js +116 -0
  34. package/dist/device-pairing/crypto.d.ts +40 -15
  35. package/dist/device-pairing/crypto.js +74 -33
  36. package/dist/device-pairing/device-pairing.test.js +156 -119
  37. package/dist/device-pairing/index.d.ts +3 -1
  38. package/dist/device-pairing/index.js +3 -1
  39. package/dist/device-pairing/pairing-code.d.ts +27 -0
  40. package/dist/device-pairing/pairing-code.js +84 -0
  41. package/dist/device-pairing/pgp-word-list.d.ts +14 -0
  42. package/dist/device-pairing/pgp-word-list.js +531 -0
  43. package/dist/device-pairing/signaling.d.ts +17 -6
  44. package/dist/device-pairing/signaling.js +31 -8
  45. package/dist/device-pairing/transcript.d.ts +3 -3
  46. package/dist/device-pairing/transcript.js +6 -13
  47. package/dist/device-pairing/types.d.ts +870 -224
  48. package/dist/device-pairing/types.js +186 -108
  49. package/dist/index.d.ts +1 -2
  50. package/dist/index.js +1 -2
  51. package/dist/invites/data-box-schema.d.ts +15 -0
  52. package/dist/invites/data-box-schema.js +15 -0
  53. package/dist/invites/group-approval.d.ts +43 -0
  54. package/dist/invites/group-approval.js +155 -0
  55. package/dist/invites/group-approval.test.js +221 -0
  56. package/dist/invites/index.d.ts +5 -0
  57. package/dist/{group-invite → invites}/index.js +5 -3
  58. package/dist/invites/invite-epoch.d.ts +18 -0
  59. package/dist/invites/invite-epoch.js +35 -0
  60. package/dist/invites/invite-messages.d.ts +1354 -0
  61. package/dist/invites/invite-messages.js +145 -0
  62. package/dist/invites/invite-token.d.ts +334 -0
  63. package/dist/invites/invite-token.js +178 -0
  64. package/dist/invites/invite-token.test.d.ts +1 -0
  65. package/dist/invites/invite-token.test.js +123 -0
  66. package/dist/keys.d.ts +17 -1
  67. package/dist/keys.js +58 -5
  68. package/dist/keys.test.js +113 -0
  69. package/dist/rpc-types.d.ts +5 -4
  70. package/dist/rpc-types.js +2 -1
  71. package/dist/serial-json.d.ts +4 -1
  72. package/dist/serial-json.js +4 -1
  73. package/dist/system-ids.d.ts +22 -0
  74. package/dist/system-ids.js +23 -1
  75. package/package.json +1 -1
  76. package/dist/device-pairing/invitation.d.ts +0 -13
  77. package/dist/device-pairing/invitation.js +0 -85
  78. package/dist/group-invite/group-invite.d.ts +0 -53
  79. package/dist/group-invite/group-invite.js +0 -231
  80. package/dist/group-invite/group-invite.pvars.d.ts +0 -94
  81. package/dist/group-invite/group-invite.pvars.js +0 -139
  82. package/dist/group-invite/group-invite.types.d.ts +0 -124
  83. package/dist/group-invite/group-invite.types.js +0 -8
  84. package/dist/group-invite/index.d.ts +0 -3
  85. package/dist/user-connect/connection-code.d.ts +0 -67
  86. package/dist/user-connect/connection-code.js +0 -176
  87. package/dist/user-connect/connection-code.test.js +0 -213
  88. package/dist/user-connect/index.d.ts +0 -3
  89. package/dist/user-connect/index.js +0 -19
  90. package/dist/user-connect/user-connect.pvars.d.ts +0 -20
  91. package/dist/user-connect/user-connect.pvars.js +0 -63
  92. package/dist/user-connect/user-connect.pvars.test.js +0 -46
  93. package/dist/user-connect/user-connect.types.d.ts +0 -94
  94. package/dist/user-connect/user-connect.types.js +0 -68
  95. /package/dist/{user-connect/connection-code.test.d.ts → device-pairing/cpace.test.d.ts} +0 -0
  96. /package/dist/{user-connect/user-connect.pvars.test.d.ts → invites/group-approval.test.d.ts} +0 -0
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const group_member_roles_1 = require("../data/group-member-roles");
4
+ const keys_1 = require("../keys");
5
+ const utils_1 = require("../utils");
6
+ const invite_messages_1 = require("./invite-messages");
7
+ const invite_token_1 = require("./invite-token");
8
+ function identity(keys = (0, keys_1.newKeys)()) {
9
+ return {
10
+ userId: (0, utils_1.newid)(),
11
+ publicKey: keys.publicKey,
12
+ publicBoxKey: keys.publicBoxKey,
13
+ displayName: "Alice",
14
+ secretKey: keys.secretKey,
15
+ };
16
+ }
17
+ function contactToken(inviter = identity(), expiresAt = new Date(Date.now() + 60_000)) {
18
+ const { secretKey, ...publicIdentity } = inviter;
19
+ return (0, invite_token_1.createInviteToken)({
20
+ type: "contact",
21
+ inviter: publicIdentity,
22
+ expiresAt: expiresAt.toISOString(),
23
+ nonce: (0, invite_token_1.newInviteNonce)(),
24
+ epoch: 0,
25
+ uses: 1,
26
+ }, secretKey);
27
+ }
28
+ describe("PeersInvite tokens", () => {
29
+ it("round-trips through every shareable encoding and verifies the signature", () => {
30
+ const token = contactToken();
31
+ const encoded = (0, invite_token_1.encodeInviteToken)(token);
32
+ expect((0, invite_token_1.parseInviteToken)(encoded)).toEqual(token);
33
+ expect((0, invite_token_1.parseInviteToken)((0, invite_token_1.inviteUrl)(token))).toEqual(token);
34
+ expect((0, invite_token_1.parseInviteToken)((0, invite_token_1.inviteDeepLink)(token))).toEqual(token);
35
+ expect((0, invite_token_1.parseInviteToken)(`https://peers.app/?app#invite=${encoded}`)).toEqual(token);
36
+ expect((0, invite_token_1.parseInviteToken)(` ${encoded}\n`)).toEqual(token);
37
+ expect((0, invite_token_1.extractInviteTokenText)((0, invite_token_1.inviteUrl)(token))).toBe(encoded);
38
+ });
39
+ it("rejects tampered contents without echoing the input", () => {
40
+ const token = contactToken();
41
+ const tampered = (0, invite_token_1.encodeInviteToken)({ ...token, epoch: token.epoch + 1 });
42
+ expect(() => (0, invite_token_1.parseInviteToken)(tampered)).toThrow("Invite signature is not valid");
43
+ expect(() => (0, invite_token_1.parseInviteToken)("not a token")).toThrow("not a valid Peers invite");
44
+ });
45
+ it("refuses to sign with a key that is not the inviter's", () => {
46
+ const inviter = identity();
47
+ const { secretKey: _ignored, ...publicIdentity } = inviter;
48
+ expect(() => (0, invite_token_1.createInviteToken)({
49
+ type: "contact",
50
+ inviter: publicIdentity,
51
+ expiresAt: new Date(Date.now() + 1000).toISOString(),
52
+ nonce: (0, invite_token_1.newInviteNonce)(),
53
+ epoch: 0,
54
+ uses: 1,
55
+ }, (0, keys_1.newKeys)().secretKey)).toThrow("inviter's own key");
56
+ });
57
+ it("enforces expiry and type expectations", () => {
58
+ const expired = contactToken(identity(), new Date(Date.now() - 1000));
59
+ const encoded = (0, invite_token_1.encodeInviteToken)(expired);
60
+ expect(() => (0, invite_token_1.parseInviteToken)(encoded)).toThrow("expired");
61
+ expect((0, invite_token_1.parseInviteToken)(encoded, { allowExpired: true })).toEqual(expired);
62
+ expect(() => (0, invite_token_1.parseInviteToken)(encoded, { allowExpired: true, expectType: "group" })).toThrow("Expected a group invite");
63
+ });
64
+ it("requires group facts exactly when the type is group", () => {
65
+ const inviter = identity();
66
+ const { secretKey, ...publicIdentity } = inviter;
67
+ const base = {
68
+ inviter: publicIdentity,
69
+ expiresAt: new Date(Date.now() + 1000).toISOString(),
70
+ nonce: (0, invite_token_1.newInviteNonce)(),
71
+ epoch: 0,
72
+ uses: "unlimited",
73
+ };
74
+ expect(() => (0, invite_token_1.createInviteToken)({ ...base, type: "group" }, secretKey)).toThrow();
75
+ const groupKeys = (0, keys_1.newKeys)();
76
+ const token = (0, invite_token_1.createInviteToken)({
77
+ ...base,
78
+ type: "group",
79
+ group: {
80
+ groupId: (0, utils_1.newid)(),
81
+ name: "Family",
82
+ publicKey: groupKeys.publicKey,
83
+ publicBoxKey: groupKeys.publicBoxKey,
84
+ role: group_member_roles_1.GroupMemberRole.Reader,
85
+ mode: "open",
86
+ },
87
+ }, secretKey);
88
+ expect((0, invite_token_1.parseInviteToken)((0, invite_token_1.encodeInviteToken)(token), { expectType: "group" }).group?.mode).toBe("open");
89
+ });
90
+ });
91
+ describe("invite envelopes", () => {
92
+ it("seals to the recipient and exposes the verified sender keys", () => {
93
+ const sender = identity();
94
+ const recipient = identity();
95
+ const message = invite_messages_1.contactReplySchema.parse({
96
+ kind: "contact-reply",
97
+ inviteNonce: (0, invite_token_1.newInviteNonce)(),
98
+ inviteEpoch: 0,
99
+ responder: {
100
+ userId: sender.userId,
101
+ publicKey: sender.publicKey,
102
+ publicBoxKey: sender.publicBoxKey,
103
+ },
104
+ responderDeviceId: (0, utils_1.newid)(),
105
+ });
106
+ const envelope = (0, invite_messages_1.sealInviteEnvelope)(message, {
107
+ publicKey: sender.publicKey,
108
+ publicBoxKey: sender.publicBoxKey,
109
+ secretKey: sender.secretKey,
110
+ }, { userId: recipient.userId, publicBoxKey: recipient.publicBoxKey });
111
+ expect(envelope.to).toBe(recipient.userId);
112
+ expect(JSON.stringify(envelope)).not.toContain(sender.userId);
113
+ const opened = (0, invite_messages_1.openInviteEnvelope)(envelope, {
114
+ userId: recipient.userId,
115
+ secretKey: recipient.secretKey,
116
+ });
117
+ expect(opened.message).toEqual(message);
118
+ expect(opened.senderPublicKey).toBe(sender.publicKey);
119
+ expect(opened.senderPublicBoxKey).toBe(sender.publicBoxKey);
120
+ expect(() => (0, invite_messages_1.openInviteEnvelope)(envelope, { userId: (0, utils_1.newid)(), secretKey: recipient.secretKey })).toThrow("different user");
121
+ expect(() => (0, invite_messages_1.openInviteEnvelope)(envelope, { userId: recipient.userId, secretKey: (0, keys_1.newKeys)().secretKey })).toThrow();
122
+ });
123
+ });
package/dist/keys.d.ts CHANGED
@@ -10,6 +10,17 @@ export declare function stableStringify(obj: any): string;
10
10
  export declare function hashObject(obj: {
11
11
  [key: string]: any;
12
12
  }): string;
13
+ /**
14
+ * Canonical JSON used for object signatures.
15
+ *
16
+ * Signed objects round-trip through SQLite (rows come back in schema column order and `NULL`
17
+ * becomes `undefined`), msgpack, and object spreads, all of which reorder keys or blur
18
+ * absent / `null` / `undefined`. The signed bytes must not depend on any of that, so this form:
19
+ * drops `null` / `undefined` valued keys, encodes extended types via {@link toJSON}, and sorts keys
20
+ * at every level. Sign and verify with this and only this; see {@link openSignedObject} for the
21
+ * legacy insertion-order fallback that keeps pre-existing signatures valid.
22
+ */
23
+ export declare function canonicalJSONString(obj: any): string;
13
24
  /** Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats). */
14
25
  export declare function encodeBase64(data: Uint8Array): string;
15
26
  /**
@@ -52,12 +63,17 @@ export declare function signMessageWithSecretKey(msg: string, secretKey: string)
52
63
  */
53
64
  export declare function openMessageWithPublicKey(msg: string, publicKey: string): string;
54
65
  /**
55
- * Detached sign over stable JSON: `undefined` / `null` are encoded with sentinel fields for round-trip.
66
+ * Detached sign over {@link canonicalJSONString} (sorted keys, `null` / `undefined` keys dropped),
67
+ * so the signature survives key reordering and storage round-trips. A top-level `undefined` /
68
+ * `null` is encoded with a sentinel object so it can be signed and restored.
56
69
  * @returns Payload with original `contents`, `signature`, and signer's Ed25519 public key (32-byte slice, base64url)
57
70
  */
58
71
  export declare function signObjectWithSecretKey<T>(obj: T, secretKey: string): ISignedObject<T>;
59
72
  /**
60
73
  * Verifies a detached signature and returns `contents` after undoing the undefined/null sentinels.
74
+ * Verifies against {@link canonicalJSONString} first and falls back to the legacy insertion-order
75
+ * serialization so signatures written by older builds (already in databases, invite tokens, and
76
+ * `peers-services`) stay valid. New signatures are always canonical.
61
77
  * @throws If the signature is invalid
62
78
  */
63
79
  export declare function openSignedObject<T>(signedObj: ISignedObject<T>): T;
package/dist/keys.js CHANGED
@@ -5,6 +5,7 @@ exports.hashValue = hashValue;
5
5
  exports.hashBytes = hashBytes;
6
6
  exports.stableStringify = stableStringify;
7
7
  exports.hashObject = hashObject;
8
+ exports.canonicalJSONString = canonicalJSONString;
8
9
  exports.encodeBase64 = encodeBase64;
9
10
  exports.decodeBase64 = decodeBase64;
10
11
  exports.newToken = newToken;
@@ -60,6 +61,53 @@ function hashObject(obj) {
60
61
  const hash = hashValue(stableJson);
61
62
  return hash;
62
63
  }
64
+ function isPlainObject(value) {
65
+ if (value === null || typeof value !== "object")
66
+ return false;
67
+ const proto = Object.getPrototypeOf(value);
68
+ return proto === Object.prototype || proto === null;
69
+ }
70
+ /**
71
+ * Recursively drops `null` / `undefined` valued keys from plain objects and rebuilds them with
72
+ * sorted keys. Arrays keep every element (position is meaning); non-plain objects (Date, Buffer,
73
+ * Uint8Array, RegExp, Error, …) pass through untouched for {@link toJSON} to encode.
74
+ */
75
+ function canonicalize(value) {
76
+ if (Array.isArray(value)) {
77
+ return value.map(canonicalize);
78
+ }
79
+ if (!isPlainObject(value)) {
80
+ return value;
81
+ }
82
+ const out = {};
83
+ for (const key of Object.keys(value).sort()) {
84
+ const v = value[key];
85
+ if (v === undefined || v === null)
86
+ continue;
87
+ out[key] = canonicalize(v);
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Canonical JSON used for object signatures.
93
+ *
94
+ * Signed objects round-trip through SQLite (rows come back in schema column order and `NULL`
95
+ * becomes `undefined`), msgpack, and object spreads, all of which reorder keys or blur
96
+ * absent / `null` / `undefined`. The signed bytes must not depend on any of that, so this form:
97
+ * drops `null` / `undefined` valued keys, encodes extended types via {@link toJSON}, and sorts keys
98
+ * at every level. Sign and verify with this and only this; see {@link openSignedObject} for the
99
+ * legacy insertion-order fallback that keeps pre-existing signatures valid.
100
+ */
101
+ function canonicalJSONString(obj) {
102
+ return stableStringify((0, serial_json_1.toJSON)(canonicalize(obj)));
103
+ }
104
+ /**
105
+ * Pre-canonical serialization: insertion-order keys, `undefined` kept as a sentinel string.
106
+ * Only used to verify signatures written before {@link canonicalJSONString} existed.
107
+ */
108
+ function legacySignatureJSONString(obj) {
109
+ return (0, serial_json_1.toJSONString)(obj);
110
+ }
63
111
  /** Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats). */
64
112
  function encodeBase64(data) {
65
113
  // return Buffer.from(data).toString("base64url"); // no padding by default
@@ -127,7 +175,9 @@ function openMessageWithPublicKey(msg, publicKey) {
127
175
  return (0, tweetnacl_util_1.encodeUTF8)(msgOpened);
128
176
  }
129
177
  /**
130
- * Detached sign over stable JSON: `undefined` / `null` are encoded with sentinel fields for round-trip.
178
+ * Detached sign over {@link canonicalJSONString} (sorted keys, `null` / `undefined` keys dropped),
179
+ * so the signature survives key reordering and storage round-trips. A top-level `undefined` /
180
+ * `null` is encoded with a sentinel object so it can be signed and restored.
131
181
  * @returns Payload with original `contents`, `signature`, and signer's Ed25519 public key (32-byte slice, base64url)
132
182
  */
133
183
  function signObjectWithSecretKey(obj, secretKey) {
@@ -138,7 +188,7 @@ function signObjectWithSecretKey(obj, secretKey) {
138
188
  if (obj === null) {
139
189
  obj = { __isNull: true };
140
190
  }
141
- const objStr = (0, serial_json_1.toJSONString)(obj);
191
+ const objStr = canonicalJSONString(obj);
142
192
  const objDecoded = (0, tweetnacl_util_1.decodeUTF8)(objStr);
143
193
  const signatureByteArray = nacl.sign.detached(objDecoded, _secretKey);
144
194
  return {
@@ -149,14 +199,17 @@ function signObjectWithSecretKey(obj, secretKey) {
149
199
  }
150
200
  /**
151
201
  * Verifies a detached signature and returns `contents` after undoing the undefined/null sentinels.
202
+ * Verifies against {@link canonicalJSONString} first and falls back to the legacy insertion-order
203
+ * serialization so signatures written by older builds (already in databases, invite tokens, and
204
+ * `peers-services`) stay valid. New signatures are always canonical.
152
205
  * @throws If the signature is invalid
153
206
  */
154
207
  function openSignedObject(signedObj) {
155
208
  const _publicKey = decodeBase64(signedObj.publicKey);
156
- const objStr = (0, serial_json_1.toJSONString)(signedObj.contents);
157
- const objDecoded = (0, tweetnacl_util_1.decodeUTF8)(objStr);
158
209
  const signature = decodeBase64(signedObj.signature);
159
- const valid = nacl.sign.detached.verify(objDecoded, signature, _publicKey);
210
+ const verify = (objStr) => nacl.sign.detached.verify((0, tweetnacl_util_1.decodeUTF8)(objStr), signature, _publicKey);
211
+ const valid = verify(canonicalJSONString(signedObj.contents)) ||
212
+ verify(legacySignatureJSONString(signedObj.contents));
160
213
  if (!valid) {
161
214
  throw new Error("Signature verification failed");
162
215
  }
package/dist/keys.test.js CHANGED
@@ -1,6 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const SQLiteDB = require("better-sqlite3");
4
+ const nacl = require("tweetnacl");
5
+ const tweetnacl_util_1 = require("tweetnacl-util");
6
+ const zod_1 = require("zod");
7
+ const sql_data_source_1 = require("./data/orm/sql.data-source");
8
+ const types_1 = require("./data/orm/types");
3
9
  const keys_1 = require("./keys");
10
+ const serial_json_1 = require("./serial-json");
11
+ const zod_types_1 = require("./types/zod-types");
12
+ const utils_1 = require("./utils");
4
13
  describe("keys", () => {
5
14
  it("should allow creating a new key pair", () => {
6
15
  const keys = (0, keys_1.newKeys)();
@@ -220,4 +229,108 @@ describe("keys", () => {
220
229
  expect((0, keys_1.getPublicKeyFromObjectSignature)(signedObj)).toBe(keys.publicKey);
221
230
  });
222
231
  });
232
+ describe("canonical signatures", () => {
233
+ it("canonicalJSONString sorts keys at every level and drops null/undefined keys", () => {
234
+ const a = { z: 1, a: { y: [3, { q: 1, p: 2 }], x: undefined, w: null }, m: undefined };
235
+ const b = { a: { y: [3, { p: 2, q: 1 }] }, z: 1 };
236
+ expect((0, keys_1.canonicalJSONString)(a)).toBe((0, keys_1.canonicalJSONString)(b));
237
+ expect((0, keys_1.canonicalJSONString)(a)).toBe('{"a":{"y":[3,{"p":2,"q":1}]},"z":1}');
238
+ });
239
+ it("canonicalJSONString keeps array elements and encodes extended types", () => {
240
+ const date = new Date("2026-01-02T03:04:05.000Z");
241
+ expect((0, keys_1.canonicalJSONString)([1, null, undefined])).toBe('[1,null,"__UNDEFINED"]');
242
+ expect((0, keys_1.canonicalJSONString)({ when: date })).toBe(`{"when":"__DATE ${date.toISOString()}"}`);
243
+ expect((0, keys_1.canonicalJSONString)({ b: Buffer.from("hi") })).toBe('{"b":"__BUFFER aGk="}');
244
+ });
245
+ it("verifies after the keys are reordered", () => {
246
+ const keys = (0, keys_1.newKeys)();
247
+ const signed = (0, keys_1.addSignatureToObject)({ userId: "u1", name: "Alice", publicKey: keys.publicKey }, keys.secretKey);
248
+ // Same fields, different insertion order (as a row read back from SQLite would be).
249
+ const reordered = {
250
+ publicKey: signed.publicKey,
251
+ signature: signed.signature,
252
+ name: signed.name,
253
+ userId: signed.userId,
254
+ };
255
+ expect(() => (0, keys_1.verifyObjectSignature)(reordered)).not.toThrow();
256
+ });
257
+ it("treats an absent, undefined, and null optional field identically", () => {
258
+ const keys = (0, keys_1.newKeys)();
259
+ const signed = (0, keys_1.addSignatureToObject)({ userId: "u1", name: "Alice" }, keys.secretKey);
260
+ expect((0, keys_1.isObjectSignatureValid)({ ...signed, avatar: undefined })).toBe(true);
261
+ expect((0, keys_1.isObjectSignatureValid)({ ...signed, avatar: null })).toBe(true);
262
+ // A real value change still fails.
263
+ expect((0, keys_1.isObjectSignatureValid)({ ...signed, avatar: "x" })).toBe(false);
264
+ });
265
+ it("still fails when a value changes, regardless of order", () => {
266
+ const keys = (0, keys_1.newKeys)();
267
+ const signed = (0, keys_1.addSignatureToObject)({ a: 1, b: 2 }, keys.secretKey);
268
+ expect((0, keys_1.isObjectSignatureValid)({ b: 3, a: 1, signature: signed.signature })).toBe(false);
269
+ });
270
+ it("accepts legacy insertion-order signatures written before canonicalization", () => {
271
+ const keys = (0, keys_1.newKeys)();
272
+ // Key order deliberately not sorted, so canonical and legacy bytes differ.
273
+ const contents = { userId: "u1", name: "Alice", publicKey: keys.publicKey, maybe: undefined };
274
+ const legacyBytes = (0, tweetnacl_util_1.decodeUTF8)((0, serial_json_1.toJSONString)(contents));
275
+ expect((0, serial_json_1.toJSONString)(contents)).not.toBe((0, keys_1.canonicalJSONString)(contents));
276
+ const legacySig = nacl.sign.detached(legacyBytes, (0, keys_1.decodeBase64)(keys.secretKey));
277
+ const signature = Buffer.from(legacySig).toString("base64url");
278
+ expect(() => (0, keys_1.openSignedObject)({ contents, signature, publicKey: keys.publicKey })).not.toThrow();
279
+ expect(() => (0, keys_1.verifyObjectSignature)({ ...contents, signature: `${keys.publicKey}:${signature}` })).not.toThrow();
280
+ // Tampering is still caught on the legacy path.
281
+ expect(() => (0, keys_1.openSignedObject)({
282
+ contents: { ...contents, name: "Mallory" },
283
+ signature,
284
+ publicKey: keys.publicKey,
285
+ })).toThrow(/signature/i);
286
+ });
287
+ it("survives a round trip through a SQLite table", async () => {
288
+ const db = new SQLiteDB(":memory:");
289
+ const harness = {
290
+ get: async (sql, params = []) => db.prepare(sql).get(params),
291
+ all: async (sql, params = []) => db.prepare(sql).all(params),
292
+ exec: async (sql, params = []) => ({
293
+ changes: db.prepare(sql).run(params).changes,
294
+ }),
295
+ close: async () => {
296
+ db.close();
297
+ },
298
+ };
299
+ const schema = zod_1.z.object({
300
+ userId: zod_types_1.zodPeerId,
301
+ name: zod_1.z.string(),
302
+ publicKey: zod_1.z.string(),
303
+ publicBoxKey: zod_1.z.string(),
304
+ avatar: zod_1.z.string().optional(),
305
+ signature: zod_1.z.string(),
306
+ });
307
+ const metaData = {
308
+ name: "test_signed_users",
309
+ description: "signed users",
310
+ primaryKeyName: "userId",
311
+ fields: (0, types_1.schemaToFields)(schema),
312
+ };
313
+ const table = new sql_data_source_1.SQLDataSource(harness, metaData);
314
+ try {
315
+ const keys = (0, keys_1.newKeys)();
316
+ // Signed with keys in an order that differs from the schema's column order and with an
317
+ // optional field omitted; SQLite will hand back column order and `avatar: undefined`.
318
+ const signed = (0, keys_1.addSignatureToObject)({
319
+ publicBoxKey: keys.publicBoxKey,
320
+ name: "Alice",
321
+ userId: (0, utils_1.newid)(),
322
+ publicKey: keys.publicKey,
323
+ }, keys.secretKey);
324
+ await table.save(signed);
325
+ const stored = await table.get(signed.userId);
326
+ if (!stored)
327
+ throw new Error("row was not stored");
328
+ expect(Object.keys(stored)).not.toEqual(Object.keys(signed));
329
+ expect(() => (0, keys_1.verifyObjectSignature)(stored)).not.toThrow();
330
+ }
331
+ finally {
332
+ await harness.close();
333
+ }
334
+ });
335
+ });
223
336
  });
@@ -1,6 +1,6 @@
1
1
  import type { IDeviceDatabaseQueryResult, IDeviceOperationStatus, IDeviceToolRunInput } from "./contracts/system/device-operations-contract";
2
2
  import type { ITool } from "./data/tools";
3
- import type { DevicePairingCeremonyAction, DevicePairingStartDestinationInput, DevicePairingStartSourceResult, DevicePairingState } from "./device-pairing";
3
+ import type { DevicePairingCeremonyAction, DevicePairingStartDestinationInput, DevicePairingStartSourceInput, DevicePairingState } from "./device-pairing";
4
4
  import type { IConsoleLog } from "./logging/console-logs.table";
5
5
  import type { IAddOrUpdatePackageResult } from "./package-installer/types";
6
6
  import type { IAppNav } from "./types/app-nav";
@@ -66,13 +66,14 @@ export declare const rpcServerCalls: {
66
66
  seedBundledPeersCore: (dataContextId?: string) => Promise<boolean>;
67
67
  setUserIdAndSecretKey: (userId: string, secretKey: string) => Promise<void>;
68
68
  getUserId: () => Promise<string | undefined>;
69
- devicePairingStartSource: () => Promise<DevicePairingStartSourceResult>;
70
- devicePairingStartDestination: (input: DevicePairingStartDestinationInput) => Promise<DevicePairingState>;
69
+ /** Signed-in side: joins the room named by a typed or scanned pairing code. */
70
+ devicePairingStartSource: (input: DevicePairingStartSourceInput) => Promise<DevicePairingState>;
71
+ /** Signed-out side: creates a room and returns state whose `code` the UI shows as text and QR. */
72
+ devicePairingStartDestination: (input?: DevicePairingStartDestinationInput) => Promise<DevicePairingState>;
71
73
  devicePairingApprove: (input: DevicePairingCeremonyAction) => Promise<DevicePairingState>;
72
74
  devicePairingReject: (input: DevicePairingCeremonyAction) => Promise<DevicePairingState>;
73
75
  devicePairingCancel: (input: DevicePairingCeremonyAction) => Promise<DevicePairingState>;
74
76
  devicePairingGetState: () => Promise<DevicePairingState>;
75
- devicePairingGetInvitationUrl: () => Promise<string | undefined>;
76
77
  encryptData: (value: string, groupId?: string) => Promise<string>;
77
78
  /**
78
79
  * Decrypt (if secret) and copy a persistent variable's value to the system clipboard
package/dist/rpc-types.js CHANGED
@@ -21,13 +21,14 @@ exports.rpcServerCalls = {
21
21
  seedBundledPeersCore: rpcStub("seedBundledPeersCore"),
22
22
  setUserIdAndSecretKey: rpcStub("setUserIdAndSecretKey"),
23
23
  getUserId: rpcStub("getUserId"),
24
+ /** Signed-in side: joins the room named by a typed or scanned pairing code. */
24
25
  devicePairingStartSource: rpcStub("devicePairingStartSource"),
26
+ /** Signed-out side: creates a room and returns state whose `code` the UI shows as text and QR. */
25
27
  devicePairingStartDestination: rpcStub("devicePairingStartDestination"),
26
28
  devicePairingApprove: rpcStub("devicePairingApprove"),
27
29
  devicePairingReject: rpcStub("devicePairingReject"),
28
30
  devicePairingCancel: rpcStub("devicePairingCancel"),
29
31
  devicePairingGetState: rpcStub("devicePairingGetState"),
30
- devicePairingGetInvitationUrl: rpcStub("devicePairingGetInvitationUrl"),
31
32
  encryptData: rpcStub("encryptData"),
32
33
  /**
33
34
  * Decrypt (if secret) and copy a persistent variable's value to the system clipboard
@@ -10,7 +10,10 @@ export declare function toJSON(obj: any): any;
10
10
  * @param _externalReferences Reserved for future use (function revival, etc.)
11
11
  */
12
12
  export declare function fromJSON(obj: any, _externalReferences?: any): any;
13
- /** `JSON.stringify` of {@link toJSON} (stable, extended types). */
13
+ /**
14
+ * `JSON.stringify` of {@link toJSON}: extended types are encoded deterministically, but keys keep
15
+ * insertion order. Not canonical; for signatures use `canonicalJSONString` in `keys.ts`.
16
+ */
14
17
  export declare function toJSONString(obj: any): string;
15
18
  /** `JSON.parse` then {@link fromJSON}. */
16
19
  export declare function fromJSONString(str: string): any;
@@ -194,7 +194,10 @@ function fromJSON(obj, _externalReferences) {
194
194
  obj = recurse(obj);
195
195
  return obj;
196
196
  }
197
- /** `JSON.stringify` of {@link toJSON} (stable, extended types). */
197
+ /**
198
+ * `JSON.stringify` of {@link toJSON}: extended types are encoded deterministically, but keys keep
199
+ * insertion order. Not canonical; for signatures use `canonicalJSONString` in `keys.ts`.
200
+ */
198
201
  function toJSONString(obj) {
199
202
  return JSON.stringify(toJSON(obj));
200
203
  }
@@ -17,6 +17,8 @@ export declare const runWorkflowToolId = "000peers0tool0runworkflow";
17
17
  * Distinct from Device Operations contract `queryDatabase`.
18
18
  */
19
19
  export declare const queryDatabaseToolId = "000peers0tool00querydb001";
20
+ /** Stable ID of the `create-group` system tool (creates a group founded by the current user). */
21
+ export declare const createGroupToolId = "000peers0tool0creategroup";
20
22
  /** Stable package ID of the bundled peers-core package. */
21
23
  export declare const peersCorePackageId = "00mh0wlipjixk2gqmurbwee0o";
22
24
  /** Canonical source repository for peers-core. */
@@ -54,3 +56,23 @@ export declare const toolAccessControlToolIds: {
54
56
  readonly reject: "000peers0tacreject0000001";
55
57
  readonly listPending: "000peers0taclist000000001";
56
58
  };
59
+ /**
60
+ * Contract id for the local-only contact and group invite surface (System Invites).
61
+ * Not available over device-to-device connections.
62
+ */
63
+ export declare const invitesContractId = "00mu8uuzvo2zux1wfb9f1r48l";
64
+ /** Stable IDs for tools in System Invites v1. */
65
+ export declare const invitesToolIds: {
66
+ readonly createContactInvite: "00mu8uuzvqbn5o23ya563wmp5";
67
+ readonly createGroupInvite: "00mu8uuzvqz17wedq1iqmh9i4";
68
+ readonly inviteContactToGroup: "00mu8uuzvqhbk5avjpew7w9ng";
69
+ readonly previewInvite: "00mu8uuzvqwbo180wgz4ctslm";
70
+ readonly acceptInvite: "00mu8uuzvq8i1boc4ml1ywk30";
71
+ readonly declineInvite: "00mu8uuzvq97veint1977p2ey";
72
+ readonly approveJoinRequest: "00mu8uuzvqisozhlfbe1oeo0e";
73
+ readonly denyJoinRequest: "00mu8uuzvqdars4o466eansi2";
74
+ readonly revokeInvite: "00mu8uuzvqndg6qdh74dob9j1";
75
+ readonly regenerateProfileInvite: "00mu8uuzvqbu5ri9octpk64oo";
76
+ readonly syncMailbox: "00mu8uuzvq4xlaiqrn5notj1j";
77
+ readonly listInvites: "00mu8uuzvqfego6lyjjkgr4s7";
78
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.toolAccessControlToolIds = exports.toolAccessControlContractId = exports.networkControlContractId = exports.networkDiagnosticsContractId = exports.deviceOperationsContractId = exports.logsContractId = exports.peersSystemPackageId = exports.peersCorePublishPublicKey = exports.peersCorePackageRepoUrl = exports.peersCorePackageId = exports.queryDatabaseToolId = exports.runWorkflowToolId = exports.defaultSendMessageToolId = exports.defaultAssistantRunnerToolId = exports.adminAssistantId = exports.openCodeAssistantId = exports.defaultAssistantId = exports.peersRootUserId = void 0;
3
+ exports.invitesToolIds = exports.invitesContractId = exports.toolAccessControlToolIds = exports.toolAccessControlContractId = exports.networkControlContractId = exports.networkDiagnosticsContractId = exports.deviceOperationsContractId = exports.logsContractId = exports.peersSystemPackageId = exports.peersCorePublishPublicKey = exports.peersCorePackageRepoUrl = exports.peersCorePackageId = exports.createGroupToolId = exports.queryDatabaseToolId = exports.runWorkflowToolId = exports.defaultSendMessageToolId = exports.defaultAssistantRunnerToolId = exports.adminAssistantId = exports.openCodeAssistantId = exports.defaultAssistantId = exports.peersRootUserId = void 0;
4
4
  /** Stable user ID that owns built-in Peers system records. */
5
5
  exports.peersRootUserId = "000peers0user000000000001";
6
6
  /** Default assistant used when no user-selected assistant is available. */
@@ -21,6 +21,8 @@ exports.runWorkflowToolId = "000peers0tool0runworkflow";
21
21
  * Distinct from Device Operations contract `queryDatabase`.
22
22
  */
23
23
  exports.queryDatabaseToolId = "000peers0tool00querydb001";
24
+ /** Stable ID of the `create-group` system tool (creates a group founded by the current user). */
25
+ exports.createGroupToolId = "000peers0tool0creategroup";
24
26
  /** Stable package ID of the bundled peers-core package. */
25
27
  exports.peersCorePackageId = "00mh0wlipjixk2gqmurbwee0o";
26
28
  /** Canonical source repository for peers-core. */
@@ -58,3 +60,23 @@ exports.toolAccessControlToolIds = {
58
60
  reject: "000peers0tacreject0000001",
59
61
  listPending: "000peers0taclist000000001",
60
62
  };
63
+ /**
64
+ * Contract id for the local-only contact and group invite surface (System Invites).
65
+ * Not available over device-to-device connections.
66
+ */
67
+ exports.invitesContractId = "00mu8uuzvo2zux1wfb9f1r48l";
68
+ /** Stable IDs for tools in System Invites v1. */
69
+ exports.invitesToolIds = {
70
+ createContactInvite: "00mu8uuzvqbn5o23ya563wmp5",
71
+ createGroupInvite: "00mu8uuzvqz17wedq1iqmh9i4",
72
+ inviteContactToGroup: "00mu8uuzvqhbk5avjpew7w9ng",
73
+ previewInvite: "00mu8uuzvqwbo180wgz4ctslm",
74
+ acceptInvite: "00mu8uuzvq8i1boc4ml1ywk30",
75
+ declineInvite: "00mu8uuzvq97veint1977p2ey",
76
+ approveJoinRequest: "00mu8uuzvqisozhlfbe1oeo0e",
77
+ denyJoinRequest: "00mu8uuzvqdars4o466eansi2",
78
+ revokeInvite: "00mu8uuzvqndg6qdh74dob9j1",
79
+ regenerateProfileInvite: "00mu8uuzvqbu5ri9octpk64oo",
80
+ syncMailbox: "00mu8uuzvq4xlaiqrn5notj1j",
81
+ listInvites: "00mu8uuzvqfego6lyjjkgr4s7",
82
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.23.14",
3
+ "version": "0.25.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"
@@ -1,13 +0,0 @@
1
- import { type DevicePairingInvitation } from "./types";
2
- /** Creates a five-minute, high-entropy invitation for a signaling service origin. */
3
- export declare function createDevicePairingInvitation({ serviceOrigin, now, ttlMs, }: {
4
- serviceOrigin: string;
5
- now?: number;
6
- ttlMs?: number;
7
- }): DevicePairingInvitation;
8
- /** Encodes an invitation as a versioned token suitable for a URL fragment or manual entry. */
9
- export declare function encodeDevicePairingInvitation(invitation: DevicePairingInvitation): string;
10
- /** Parses and validates a versioned invitation token or fragment URL. */
11
- export declare function parseDevicePairingInvitation(input: string, now?: number): DevicePairingInvitation;
12
- /** Places the invitation in a URL fragment so it is excluded from HTTP requests and access logs. */
13
- export declare function createDevicePairingInvitationUrl(invitationBaseUrl: string, invitation: DevicePairingInvitation): string;
@@ -1,85 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createDevicePairingInvitation = createDevicePairingInvitation;
4
- exports.encodeDevicePairingInvitation = encodeDevicePairingInvitation;
5
- exports.parseDevicePairingInvitation = parseDevicePairingInvitation;
6
- exports.createDevicePairingInvitationUrl = createDevicePairingInvitationUrl;
7
- const keys_1 = require("../keys");
8
- const constants_1 = require("./constants");
9
- const types_1 = require("./types");
10
- const INVITATION_TOKEN_PREFIX = "peers-pairing-v1.";
11
- const textDecoder = new TextDecoder();
12
- const textEncoder = new TextEncoder();
13
- function normalizeServiceOrigin(value) {
14
- const url = new URL(value);
15
- if (url.protocol !== "https:" && url.protocol !== "http:") {
16
- throw new Error("Pairing service must use HTTP or HTTPS");
17
- }
18
- return url.origin;
19
- }
20
- function extractInvitationToken(input) {
21
- const trimmed = input.trim();
22
- if (trimmed.startsWith(INVITATION_TOKEN_PREFIX)) {
23
- return trimmed;
24
- }
25
- try {
26
- const url = new URL(trimmed);
27
- const fragment = url.hash.slice(1);
28
- if (fragment.startsWith("pair=")) {
29
- return decodeURIComponent(fragment.slice("pair=".length));
30
- }
31
- if (fragment.startsWith("pair?")) {
32
- const token = new URLSearchParams(fragment.slice("pair?".length)).get("token");
33
- if (token) {
34
- return token;
35
- }
36
- }
37
- }
38
- catch {
39
- // The generic error below deliberately avoids echoing invitation input.
40
- }
41
- throw new Error("Invalid pairing invitation");
42
- }
43
- /** Creates a five-minute, high-entropy invitation for a signaling service origin. */
44
- function createDevicePairingInvitation({ serviceOrigin, now = Date.now(), ttlMs = constants_1.DEVICE_PAIRING_TTL_MS, }) {
45
- if (!Number.isSafeInteger(now) || ttlMs <= 0 || ttlMs > constants_1.DEVICE_PAIRING_TTL_MS) {
46
- throw new Error("Invalid pairing invitation lifetime");
47
- }
48
- return types_1.devicePairingInvitationSchema.parse({
49
- version: constants_1.DEVICE_PAIRING_PROTOCOL_VERSION,
50
- roomId: (0, keys_1.newToken)(16),
51
- roomSecret: (0, keys_1.newToken)(32),
52
- expiresAt: now + ttlMs,
53
- serviceOrigin: normalizeServiceOrigin(serviceOrigin),
54
- });
55
- }
56
- /** Encodes an invitation as a versioned token suitable for a URL fragment or manual entry. */
57
- function encodeDevicePairingInvitation(invitation) {
58
- const parsed = types_1.devicePairingInvitationSchema.parse(invitation);
59
- return `${INVITATION_TOKEN_PREFIX}${(0, keys_1.encodeBase64)(textEncoder.encode(JSON.stringify(parsed)))}`;
60
- }
61
- /** Parses and validates a versioned invitation token or fragment URL. */
62
- function parseDevicePairingInvitation(input, now = Date.now()) {
63
- try {
64
- const token = extractInvitationToken(input);
65
- if (!token.startsWith(INVITATION_TOKEN_PREFIX)) {
66
- throw new Error("unsupported");
67
- }
68
- const encoded = token.slice(INVITATION_TOKEN_PREFIX.length);
69
- const parsed = types_1.devicePairingInvitationSchema.parse(JSON.parse(textDecoder.decode((0, keys_1.decodeBase64)(encoded))));
70
- if (parsed.expiresAt <= now ||
71
- parsed.expiresAt > now + constants_1.DEVICE_PAIRING_TTL_MS + constants_1.DEVICE_PAIRING_CLOCK_SKEW_MS) {
72
- throw new Error("expired");
73
- }
74
- return parsed;
75
- }
76
- catch {
77
- throw new Error("Invalid or expired pairing invitation");
78
- }
79
- }
80
- /** Places the invitation in a URL fragment so it is excluded from HTTP requests and access logs. */
81
- function createDevicePairingInvitationUrl(invitationBaseUrl, invitation) {
82
- const url = new URL(invitationBaseUrl);
83
- url.hash = `pair=${encodeURIComponent(encodeDevicePairingInvitation(invitation))}`;
84
- return url.toString();
85
- }