@peers-app/peers-sdk 0.24.0 → 0.25.1
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.
- package/dist/context/user-context.d.ts +8 -0
- package/dist/context/user-context.js +9 -1
- package/dist/data/orm/data-query.d.ts +1 -0
- package/dist/data/orm/sql.data-source.d.ts +3 -2
- package/dist/data/orm/sql.data-source.js +5 -3
- package/dist/data/orm/subscribable.data-source.d.ts +3 -2
- package/dist/data/orm/subscribable.data-source.js +7 -11
- package/dist/data/users.d.ts +11 -0
- package/dist/data/users.js +15 -2
- package/dist/device/device-election.d.ts +23 -3
- package/dist/device/device-election.js +15 -0
- package/dist/device/device-election.test.js +73 -0
- package/dist/keys.d.ts +17 -1
- package/dist/keys.js +58 -5
- package/dist/keys.test.js +113 -0
- package/dist/serial-json.d.ts +4 -1
- package/dist/serial-json.js +4 -1
- package/package.json +1 -1
|
@@ -76,6 +76,14 @@ export declare class UserContext {
|
|
|
76
76
|
*/
|
|
77
77
|
syncUserAndGroupObjects(userId: string, keys: IPublicPrivateKeys, me?: IUser): Promise<void>;
|
|
78
78
|
private syncMyUserToAllGroups;
|
|
79
|
+
/**
|
|
80
|
+
* Copy my personal (signed) `Users` row into one group context so members there can
|
|
81
|
+
* verify my identity. The write is authoritative, not a `weakInsert`: other members seed
|
|
82
|
+
* an unsigned placeholder for me when they approve my join, and a weakly-timestamped
|
|
83
|
+
* signed row would lose last-write-wins to that placeholder on sync, leaving every device
|
|
84
|
+
* (including mine) with an unverifiable stub. Serialized per group so overlapping calls
|
|
85
|
+
* cannot interleave.
|
|
86
|
+
*/
|
|
79
87
|
private syncMyUserToGroup;
|
|
80
88
|
/**
|
|
81
89
|
* Subscribe to `dataChanged` events for one table across the personal
|
|
@@ -294,6 +294,14 @@ class UserContext {
|
|
|
294
294
|
async syncMyUserToAllGroups() {
|
|
295
295
|
await Promise.all(Array.from(this.groupDataContexts.values(), (dataContext) => this.syncMyUserToGroup(dataContext)));
|
|
296
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Copy my personal (signed) `Users` row into one group context so members there can
|
|
299
|
+
* verify my identity. The write is authoritative, not a `weakInsert`: other members seed
|
|
300
|
+
* an unsigned placeholder for me when they approve my join, and a weakly-timestamped
|
|
301
|
+
* signed row would lose last-write-wins to that placeholder on sync, leaving every device
|
|
302
|
+
* (including mine) with an unverifiable stub. Serialized per group so overlapping calls
|
|
303
|
+
* cannot interleave.
|
|
304
|
+
*/
|
|
297
305
|
async syncMyUserToGroup(dataContext) {
|
|
298
306
|
const contextId = dataContext.dataContextId;
|
|
299
307
|
const previousSync = this.groupUserSyncs.get(contextId);
|
|
@@ -306,7 +314,7 @@ class UserContext {
|
|
|
306
314
|
}
|
|
307
315
|
const groupMe = await (0, data_1.Users)(dataContext).get(currentUser.userId);
|
|
308
316
|
if (!(0, lodash_1.isEqual)(groupMe, currentUser)) {
|
|
309
|
-
await (0, data_1.Users)(dataContext).save(currentUser
|
|
317
|
+
await (0, data_1.Users)(dataContext).save(currentUser);
|
|
310
318
|
}
|
|
311
319
|
});
|
|
312
320
|
this.groupUserSyncs.set(contextId, currentSync);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { Emitter } from "../../events";
|
|
3
3
|
import type { FieldType, IField } from "../../types/field-type";
|
|
4
|
-
import { type DataFilter, type ICursorIterable, type IDataQueryParams, type IDataSource } from "./data-query";
|
|
4
|
+
import { type DataFilter, type ICursorIterable, type IDataQueryParams, type IDataSource, type ISaveOptions } from "./data-query";
|
|
5
5
|
import type { ISqlDb } from "./sql-db";
|
|
6
6
|
import type { IDataChangedEvent } from "./subscribable.data-source";
|
|
7
7
|
import { type ITableMetaData } from "./types";
|
|
@@ -27,7 +27,8 @@ export declare class SQLDataSource<T extends {
|
|
|
27
27
|
findOne(filter?: DataFilter<T>, opts?: {
|
|
28
28
|
enforceUnique?: boolean;
|
|
29
29
|
}): Promise<T | undefined>;
|
|
30
|
-
|
|
30
|
+
/** Insert or update by primary key. Honors `insertOnly` (leave an existing row untouched). */
|
|
31
|
+
save(record: T, opts?: ISaveOptions): Promise<T>;
|
|
31
32
|
sqlRecordToJsRecord(record: any): T;
|
|
32
33
|
insert(record: T): Promise<T>;
|
|
33
34
|
update(record: T): Promise<T>;
|
|
@@ -208,7 +208,8 @@ class SQLDataSource {
|
|
|
208
208
|
}
|
|
209
209
|
return records[0];
|
|
210
210
|
}
|
|
211
|
-
|
|
211
|
+
/** Insert or update by primary key. Honors `insertOnly` (leave an existing row untouched). */
|
|
212
|
+
async save(record, opts) {
|
|
212
213
|
await this.initTable();
|
|
213
214
|
let newRecord = true;
|
|
214
215
|
const primaryKey = record[this.metaData.primaryKeyName];
|
|
@@ -221,9 +222,10 @@ class SQLDataSource {
|
|
|
221
222
|
if (newRecord) {
|
|
222
223
|
return await this.insert(record);
|
|
223
224
|
}
|
|
224
|
-
|
|
225
|
-
return await this.
|
|
225
|
+
if (opts?.insertOnly) {
|
|
226
|
+
return (await this.get(primaryKey));
|
|
226
227
|
}
|
|
228
|
+
return await this.update(record);
|
|
227
229
|
}
|
|
228
230
|
sqlRecordToJsRecord(record) {
|
|
229
231
|
if (record) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Emitter, type Event } from "../../events";
|
|
2
|
-
import { type DataFilter, type ICursorIterable, type IDataQueryParams, type IDataSource } from "./data-query";
|
|
2
|
+
import { type DataFilter, type ICursorIterable, type IDataQueryParams, type IDataSource, type ISaveOptions } from "./data-query";
|
|
3
3
|
export interface IDataChangedEvent<T> {
|
|
4
4
|
dataObject: T;
|
|
5
5
|
op: "insert" | "update" | "delete";
|
|
@@ -18,7 +18,8 @@ export declare class SubscribableDataSource<T extends {
|
|
|
18
18
|
list(filter?: DataFilter<T>, opts?: IDataQueryParams<T>): Promise<T[]>;
|
|
19
19
|
count(filter?: DataFilter<T>): Promise<number>;
|
|
20
20
|
cursor(filter?: DataFilter<T>, opts?: IDataQueryParams<T>): ICursorIterable<T>;
|
|
21
|
-
|
|
21
|
+
/** Insert or update by primary key. Honors `insertOnly` (leave an existing row untouched). */
|
|
22
|
+
save(record: T, opts?: ISaveOptions): Promise<T>;
|
|
22
23
|
insert(record: T): Promise<T>;
|
|
23
24
|
update(record: T): Promise<T>;
|
|
24
25
|
delete(idOrRecord: string | T): Promise<void>;
|
|
@@ -33,21 +33,17 @@ class SubscribableDataSource {
|
|
|
33
33
|
cursor(filter = {}, opts = {}) {
|
|
34
34
|
return (0, data_query_1.dataSourceCursor)(this.dataSource || this, filter, opts);
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/** Insert or update by primary key. Honors `insertOnly` (leave an existing row untouched). */
|
|
37
|
+
async save(record, opts) {
|
|
38
38
|
const primaryKey = record[this.primaryKeyName];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (existingRecord) {
|
|
42
|
-
newRecord = false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
if (newRecord) {
|
|
39
|
+
const existingRecord = primaryKey ? await this.dataSource.get(primaryKey) : undefined;
|
|
40
|
+
if (!existingRecord) {
|
|
46
41
|
return await this.insert(record);
|
|
47
42
|
}
|
|
48
|
-
|
|
49
|
-
return
|
|
43
|
+
if (opts?.insertOnly) {
|
|
44
|
+
return existingRecord;
|
|
50
45
|
}
|
|
46
|
+
return await this.update(record);
|
|
51
47
|
}
|
|
52
48
|
async insert(record) {
|
|
53
49
|
record = await this.dataSource.insert(record);
|
package/dist/data/users.d.ts
CHANGED
|
@@ -27,6 +27,17 @@ export type IUser = z.infer<typeof userSchema>;
|
|
|
27
27
|
/** User table with owner-signature enforcement outside the personal context. */
|
|
28
28
|
export declare class UsersTable extends Table<IUser> {
|
|
29
29
|
static isPassthrough: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Save a user row. Outside the personal context the write must carry a valid owner
|
|
32
|
+
* signature (or be an unsigned stub where nothing signed exists yet).
|
|
33
|
+
*
|
|
34
|
+
* An unsigned `weakInsert` is a discovery placeholder (connection handshake, invite
|
|
35
|
+
* identity, join approval). It is written `insertOnly`: seed the row when the table has
|
|
36
|
+
* nothing for that user, otherwise leave whatever is stored alone. Checking here and then
|
|
37
|
+
* writing is not enough — the owner's signed row (via sync or `syncMyUserToGroup`) can land
|
|
38
|
+
* between the two, and a plain update would replace it with an unsigned snapshot carrying a
|
|
39
|
+
* fresh timestamp that then wins last-write-wins on every device.
|
|
40
|
+
*/
|
|
30
41
|
save(user: IUser, opts?: ISaveOptions): Promise<IUser>;
|
|
31
42
|
signAndSave(user: IUser, opts?: ISaveOptions): Promise<IUser>;
|
|
32
43
|
private static addSignatureToUser;
|
package/dist/data/users.js
CHANGED
|
@@ -79,9 +79,23 @@ let UsersTable = (() => {
|
|
|
79
79
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
80
80
|
}
|
|
81
81
|
static isPassthrough = false;
|
|
82
|
+
/**
|
|
83
|
+
* Save a user row. Outside the personal context the write must carry a valid owner
|
|
84
|
+
* signature (or be an unsigned stub where nothing signed exists yet).
|
|
85
|
+
*
|
|
86
|
+
* An unsigned `weakInsert` is a discovery placeholder (connection handshake, invite
|
|
87
|
+
* identity, join approval). It is written `insertOnly`: seed the row when the table has
|
|
88
|
+
* nothing for that user, otherwise leave whatever is stored alone. Checking here and then
|
|
89
|
+
* writing is not enough — the owner's signed row (via sync or `syncMyUserToGroup`) can land
|
|
90
|
+
* between the two, and a plain update would replace it with an unsigned snapshot carrying a
|
|
91
|
+
* fresh timestamp that then wins last-write-wins on every device.
|
|
92
|
+
*/
|
|
82
93
|
async save(user, opts) {
|
|
83
94
|
opts = opts || {};
|
|
84
95
|
opts = { saveAsSnapshot: true, ...opts };
|
|
96
|
+
if (opts.weakInsert && !user.signature) {
|
|
97
|
+
opts = { ...opts, insertOnly: true };
|
|
98
|
+
}
|
|
85
99
|
if (UsersTable.isPassthrough) {
|
|
86
100
|
return super.save(user, opts);
|
|
87
101
|
}
|
|
@@ -93,8 +107,7 @@ let UsersTable = (() => {
|
|
|
93
107
|
}
|
|
94
108
|
// Get the old user record if it exists
|
|
95
109
|
const oldUser = await this.get(user.userId);
|
|
96
|
-
if (opts.
|
|
97
|
-
// A weak (placeholder) write must never clobber a signed record; keep what we have.
|
|
110
|
+
if (opts.insertOnly && oldUser) {
|
|
98
111
|
return oldUser;
|
|
99
112
|
}
|
|
100
113
|
await (0, user_permissions_1.verifyUserSignature)(user, oldUser);
|
|
@@ -18,11 +18,31 @@ export interface ISortConnectionsArgs {
|
|
|
18
18
|
unconnectedDeviceIds: Set<string>;
|
|
19
19
|
connections: ISortableConnection[];
|
|
20
20
|
}
|
|
21
|
+
/** A direct connection annotated with the shared groups it serves, as seen by the shedding logic. */
|
|
22
|
+
export interface IShedCandidateConnection extends IDeviceConnection {
|
|
23
|
+
/** Data-context ids this connection is a member of on both sides. */
|
|
24
|
+
groups: string[];
|
|
25
|
+
/**
|
|
26
|
+
* How many other devices the remote is known to be connected to (from its last network
|
|
27
|
+
* snapshot). `undefined` when the remote's topology is not known yet. A device whose only
|
|
28
|
+
* known route into the mesh is this connection has `0` alternatives.
|
|
29
|
+
*/
|
|
30
|
+
alternativeRoutes?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Inputs for {@link getLeastPreferredConnection}. */
|
|
21
33
|
export interface IGetLeastPreferredConnectionArgs {
|
|
22
|
-
connections:
|
|
23
|
-
groups: string[];
|
|
24
|
-
})[];
|
|
34
|
+
connections: IShedCandidateConnection[];
|
|
25
35
|
preferredDeviceIds: string[];
|
|
26
36
|
preferredByDeviceIds: string[];
|
|
27
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Choose the connection to drop when this device is at its connection cap.
|
|
40
|
+
*
|
|
41
|
+
* Preference order: connections that are neither preferred by us nor prefer us, then
|
|
42
|
+
* connections we do not depend on, then connections that do not depend on us, then anything.
|
|
43
|
+
* Within the chosen tier, a remote whose only known route into the mesh is this connection is
|
|
44
|
+
* never shed while some other candidate is known to have alternative routes; shedding it would
|
|
45
|
+
* isolate it and it would only redial us and force another shed. Remaining ties go to the
|
|
46
|
+
* connection serving the fewest groups, then to the worst latency x error rate.
|
|
47
|
+
*/
|
|
28
48
|
export declare function getLeastPreferredConnection({ connections, preferredDeviceIds, preferredByDeviceIds, }: IGetLeastPreferredConnectionArgs): IDeviceConnection | undefined;
|
|
@@ -104,6 +104,16 @@ function sortConnections({ myDeviceId, connections, unconnectedDeviceIds }) {
|
|
|
104
104
|
});
|
|
105
105
|
return connections;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Choose the connection to drop when this device is at its connection cap.
|
|
109
|
+
*
|
|
110
|
+
* Preference order: connections that are neither preferred by us nor prefer us, then
|
|
111
|
+
* connections we do not depend on, then connections that do not depend on us, then anything.
|
|
112
|
+
* Within the chosen tier, a remote whose only known route into the mesh is this connection is
|
|
113
|
+
* never shed while some other candidate is known to have alternative routes; shedding it would
|
|
114
|
+
* isolate it and it would only redial us and force another shed. Remaining ties go to the
|
|
115
|
+
* connection serving the fewest groups, then to the worst latency x error rate.
|
|
116
|
+
*/
|
|
107
117
|
function getLeastPreferredConnection({ connections, preferredDeviceIds, preferredByDeviceIds, }) {
|
|
108
118
|
// first consider connections that are not critical to me and I'm not critical to them
|
|
109
119
|
let candidates = connections.filter((c) => !(preferredByDeviceIds.includes(c.deviceId) || preferredDeviceIds.includes(c.deviceId)));
|
|
@@ -119,6 +129,11 @@ function getLeastPreferredConnection({ connections, preferredDeviceIds, preferre
|
|
|
119
129
|
// if none, then just consider all connections
|
|
120
130
|
candidates = [...connections];
|
|
121
131
|
}
|
|
132
|
+
// never strand a device: if anyone here is known to have another way into the mesh, leave
|
|
133
|
+
// the candidates whose only known route is through us alone
|
|
134
|
+
if (candidates.some((c) => (c.alternativeRoutes ?? 0) > 0)) {
|
|
135
|
+
candidates = candidates.filter((c) => c.alternativeRoutes !== 0);
|
|
136
|
+
}
|
|
122
137
|
// start with random order to avoid biasing the selection to newer connections
|
|
123
138
|
candidates = (0, lodash_1.shuffle)(candidates);
|
|
124
139
|
// sort by latency * error rate (smaller is better)
|
|
@@ -308,6 +308,79 @@ describe("device-election", () => {
|
|
|
308
308
|
expect(result).toBeDefined();
|
|
309
309
|
expect([device1, device2, device3]).toContain(result?.deviceId);
|
|
310
310
|
});
|
|
311
|
+
describe("alternativeRoutes", () => {
|
|
312
|
+
it("never sheds a device whose only known route is through us when others have alternatives", () => {
|
|
313
|
+
const stranded = (0, utils_1.newid)();
|
|
314
|
+
const others = [(0, utils_1.newid)(), (0, utils_1.newid)(), (0, utils_1.newid)()];
|
|
315
|
+
const connections = [
|
|
316
|
+
// worst latency and most groups would normally make this the victim
|
|
317
|
+
{ ...createConnection(stranded, 900, 0.5), groups: ["g"], alternativeRoutes: 0 },
|
|
318
|
+
...others.map((id) => ({
|
|
319
|
+
...createConnection(id, 5, 0.001),
|
|
320
|
+
groups: ["g", "h"],
|
|
321
|
+
alternativeRoutes: 3,
|
|
322
|
+
})),
|
|
323
|
+
];
|
|
324
|
+
for (let i = 0; i < 20; i++) {
|
|
325
|
+
const result = (0, device_election_1.getLeastPreferredConnection)({
|
|
326
|
+
connections: (0, lodash_1.shuffle)(connections),
|
|
327
|
+
preferredDeviceIds: [],
|
|
328
|
+
preferredByDeviceIds: [],
|
|
329
|
+
});
|
|
330
|
+
expect(result).toBeDefined();
|
|
331
|
+
expect(result?.deviceId).not.toBe(stranded);
|
|
332
|
+
expect(others).toContain(result?.deviceId);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
it("falls back to the usual ordering when no candidate is known to have alternatives", () => {
|
|
336
|
+
const device1 = (0, utils_1.newid)();
|
|
337
|
+
const device2 = (0, utils_1.newid)();
|
|
338
|
+
const connections = [
|
|
339
|
+
{ ...createConnection(device1, 10, 0.001), groups: ["g"], alternativeRoutes: 0 },
|
|
340
|
+
{ ...createConnection(device2, 500, 0.5), groups: ["g"], alternativeRoutes: 0 },
|
|
341
|
+
];
|
|
342
|
+
const result = (0, device_election_1.getLeastPreferredConnection)({
|
|
343
|
+
connections,
|
|
344
|
+
preferredDeviceIds: [],
|
|
345
|
+
preferredByDeviceIds: [],
|
|
346
|
+
});
|
|
347
|
+
expect(result?.deviceId).toBe(device2);
|
|
348
|
+
});
|
|
349
|
+
it("treats unknown topology as neither protected nor preferred", () => {
|
|
350
|
+
const unknown = (0, utils_1.newid)();
|
|
351
|
+
const stranded = (0, utils_1.newid)();
|
|
352
|
+
const connected = (0, utils_1.newid)();
|
|
353
|
+
const connections = [
|
|
354
|
+
{ ...createConnection(unknown, 500, 0.5), groups: ["g"] },
|
|
355
|
+
{ ...createConnection(stranded, 10, 0.001), groups: ["g"], alternativeRoutes: 0 },
|
|
356
|
+
{ ...createConnection(connected, 10, 0.001), groups: ["g"], alternativeRoutes: 2 },
|
|
357
|
+
];
|
|
358
|
+
for (let i = 0; i < 20; i++) {
|
|
359
|
+
const result = (0, device_election_1.getLeastPreferredConnection)({
|
|
360
|
+
connections: (0, lodash_1.shuffle)(connections),
|
|
361
|
+
preferredDeviceIds: [],
|
|
362
|
+
preferredByDeviceIds: [],
|
|
363
|
+
});
|
|
364
|
+
// the stranded device is excluded; unknown stays in the pool and loses on latency
|
|
365
|
+
expect(result?.deviceId).toBe(unknown);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
it("still respects preference tiers before route protection", () => {
|
|
369
|
+
const stranded = (0, utils_1.newid)();
|
|
370
|
+
const preferred = (0, utils_1.newid)();
|
|
371
|
+
const connections = [
|
|
372
|
+
{ ...createConnection(stranded), groups: ["g"], alternativeRoutes: 0 },
|
|
373
|
+
{ ...createConnection(preferred), groups: ["g"], alternativeRoutes: 4 },
|
|
374
|
+
];
|
|
375
|
+
// the only non-preferred candidate is the stranded one, so the tier leaves just it
|
|
376
|
+
const result = (0, device_election_1.getLeastPreferredConnection)({
|
|
377
|
+
connections,
|
|
378
|
+
preferredDeviceIds: [preferred],
|
|
379
|
+
preferredByDeviceIds: [],
|
|
380
|
+
});
|
|
381
|
+
expect(result?.deviceId).toBe(stranded);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
311
384
|
});
|
|
312
385
|
describe("Network simulations", () => {
|
|
313
386
|
it.skip("should work as connections grow", () => {
|
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
|
|
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
|
|
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 = (
|
|
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
|
|
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
|
});
|
package/dist/serial-json.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/serial-json.js
CHANGED
|
@@ -194,7 +194,10 @@ function fromJSON(obj, _externalReferences) {
|
|
|
194
194
|
obj = recurse(obj);
|
|
195
195
|
return obj;
|
|
196
196
|
}
|
|
197
|
-
/**
|
|
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
|
}
|