@peers-app/peers-sdk 0.27.4 → 0.28.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.
- package/dist/context/user-context.d.ts +7 -1
- package/dist/context/user-context.js +4 -9
- package/dist/contracts/contract-proxy.d.ts +5 -0
- package/dist/contracts/contract-proxy.js +2 -2
- package/dist/contracts/contract-resolvers.d.ts +2 -2
- package/dist/contracts/contract-resolvers.js +14 -15
- package/dist/contracts/contract-transport.d.ts +2 -1
- package/dist/contracts/contract-transport.js +24 -2
- package/dist/contracts/system/network-contracts.d.ts +1 -1
- package/dist/contracts/tool-catalog-service.js +1 -1
- package/dist/contracts/tool-catalog.js +6 -2
- package/dist/data/assistants.d.ts +3 -3
- package/dist/data/authority-write-checks.d.ts +42 -0
- package/dist/data/authority-write-checks.js +324 -0
- package/dist/data/change-tracking.d.ts +4 -4
- package/dist/data/create-group.js +1 -0
- package/dist/data/devices.d.ts +27 -4
- package/dist/data/devices.js +31 -0
- package/dist/data/group-authority.d.ts +111 -0
- package/dist/data/group-authority.js +281 -0
- package/dist/data/group-members.d.ts +8 -2
- package/dist/data/group-members.js +41 -18
- package/dist/data/group-permissions.d.ts +15 -31
- package/dist/data/group-permissions.js +25 -95
- package/dist/data/group-resign.d.ts +38 -0
- package/dist/data/group-resign.js +71 -0
- package/dist/data/groups.d.ts +11 -0
- package/dist/data/groups.js +31 -13
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.js +3 -0
- package/dist/data/invites.d.ts +14 -0
- package/dist/data/orm/index.d.ts +1 -0
- package/dist/data/orm/index.js +1 -0
- package/dist/data/orm/sql.data-source.d.ts +2 -2
- package/dist/data/orm/sql.data-source.js +8 -9
- package/dist/data/orm/table-container.js +14 -8
- package/dist/data/orm/write-checks.d.ts +89 -0
- package/dist/data/orm/write-checks.js +115 -0
- package/dist/data/package-table-identity.d.ts +6 -0
- package/dist/data/package-table-identity.js +51 -0
- package/dist/data/package-version-resolver.d.ts +15 -0
- package/dist/data/package-version-resolver.js +58 -3
- package/dist/data/package-versions.d.ts +20 -14
- package/dist/data/package-versions.js +15 -9
- package/dist/data/packages.d.ts +6 -1
- package/dist/data/packages.js +15 -20
- package/dist/data/persistent-vars.js +2 -39
- package/dist/data/sync-recovery-queue.d.ts +4 -4
- package/dist/data/sync-recovery-queue.js +7 -1
- package/dist/data/tool-access-approvals.d.ts +3 -3
- package/dist/data/user-permissions.d.ts +33 -2
- package/dist/data/user-permissions.js +87 -10
- package/dist/data/users.d.ts +49 -12
- package/dist/data/users.js +21 -1
- package/dist/data/workflows.d.ts +1 -1
- package/dist/device/binary-peer-connection-v2.d.ts +8 -16
- package/dist/device/binary-peer-connection-v2.js +15 -31
- package/dist/device/connection.d.ts +35 -44
- package/dist/device/connection.js +188 -186
- package/dist/device/device.d.ts +1 -8
- package/dist/device/device.js +1 -30
- package/dist/device/frame-cipher.d.ts +1 -1
- package/dist/device/frame-cipher.js +1 -1
- package/dist/device/get-trust-level-fn.js +3 -6
- package/dist/device/socket.type.d.ts +6 -12
- package/dist/device-pairing/types.d.ts +26 -26
- package/dist/identity/user-row-change.d.ts +3 -1
- package/dist/identity/user-row-change.js +33 -18
- package/dist/invites/group-approval.js +23 -24
- package/dist/invites/invite-messages.d.ts +250 -28
- package/dist/invites/invite-token.d.ts +18 -0
- package/dist/invites/invite-token.js +2 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +27 -0
- package/dist/package-installer/package-installer.d.ts +1 -1
- package/dist/package-installer/package-installer.js +8 -3
- package/dist/package-installer/package-propagation.d.ts +2 -0
- package/dist/package-installer/package-propagation.js +20 -38
- package/dist/package-installer/package-remote-checker.js +15 -3
- package/dist/package-loader/isolated-artifact.js +0 -2
- package/dist/package-loader/package-loader.js +6 -0
- package/dist/types/peer-device.d.ts +2 -0
- package/dist/types/workflow.d.ts +2 -2
- package/package.json +1 -1
- package/dist/data/package-permissions.d.ts +0 -14
- package/dist/data/package-permissions.js +0 -26
- package/dist/data/package-version-permissions.d.ts +0 -14
- package/dist/data/package-version-permissions.js +0 -30
- package/dist/device/binary-peer-connection.d.ts +0 -58
- package/dist/device/binary-peer-connection.js +0 -516
- package/dist/device/streamed-socket.d.ts +0 -23
- package/dist/device/streamed-socket.js +0 -255
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkGroupMember = checkGroupMember;
|
|
4
|
+
exports.checkGroup = checkGroup;
|
|
5
|
+
exports.checkPackageWrite = checkPackageWrite;
|
|
6
|
+
exports.isPackageRowAdminSigned = isPackageRowAdminSigned;
|
|
7
|
+
exports.checkPackageVersion = checkPackageVersion;
|
|
8
|
+
const lodash_1 = require("lodash");
|
|
9
|
+
const package_author_signing_1 = require("../package-installer/package-author-signing");
|
|
10
|
+
const system_ids_1 = require("../system-ids");
|
|
11
|
+
const devices_1 = require("./devices");
|
|
12
|
+
const group_authority_1 = require("./group-authority");
|
|
13
|
+
const group_member_roles_1 = require("./group-member-roles");
|
|
14
|
+
const write_checks_1 = require("./orm/write-checks");
|
|
15
|
+
const package_table_identity_1 = require("./package-table-identity");
|
|
16
|
+
const persistent_vars_1 = require("./persistent-vars");
|
|
17
|
+
const user_permissions_1 = require("./user-permissions");
|
|
18
|
+
/**
|
|
19
|
+
* Write checks for the rows that decide authority in a group: memberships, the group row, user
|
|
20
|
+
* profiles (roles are traced through their keys), device ownership claims, packages and package
|
|
21
|
+
* versions. Sync writes rows without running the table classes' checks, so the host runs these
|
|
22
|
+
* on every synced change to a group context, and the table classes run the same checks on local
|
|
23
|
+
* saves. A refused change stays in history but its row isn't written. The personal context is
|
|
24
|
+
* left alone: only the user's own devices write it.
|
|
25
|
+
*
|
|
26
|
+
* Table names are literals here: the table modules import these checks, so importing their
|
|
27
|
+
* constants would make a cycle.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The counted member who signed `row` and their role under the group's verified authority;
|
|
31
|
+
* refuses the write when nobody who counts signed it. Call only for a group context.
|
|
32
|
+
*/
|
|
33
|
+
async function requireSigner(tableName, row, context) {
|
|
34
|
+
const authority = await (0, group_authority_1.getGroupAuthority)(context.dataContext.userContext, context.dataContext.dataContextId);
|
|
35
|
+
const signer = (0, group_authority_1.groupSigner)(authority, row);
|
|
36
|
+
if (!signer) {
|
|
37
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signature doesn't verify against a counted member's key");
|
|
38
|
+
}
|
|
39
|
+
return { authority, ...signer };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check the signed stamp: package approvals belong to this context, and a replacement must
|
|
43
|
+
* not have been signed before the row on record. The
|
|
44
|
+
* tables' `signAndSave` puts `signedAt` under the signature, so a member can't restore a revoked
|
|
45
|
+
* grant, an earlier group setting or an earlier release selection by serving a copy of an older,
|
|
46
|
+
* validly signed row as a new change. Remember the stored stamp in device-local personal
|
|
47
|
+
* storage before it can be deleted: an unsigned package delete must not erase revocations.
|
|
48
|
+
* A row signed before the field existed counts as oldest.
|
|
49
|
+
*/
|
|
50
|
+
async function checkSignedOrder(tableName, existing, candidate, context) {
|
|
51
|
+
if (candidate &&
|
|
52
|
+
write_checks_1.packageApprovalTables.has(tableName) &&
|
|
53
|
+
candidate.signedContextId !== context.dataContext.groupId) {
|
|
54
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signature is for another context or has no context");
|
|
55
|
+
}
|
|
56
|
+
if (candidate?.signedAt !== undefined && !Number.isSafeInteger(candidate.signedAt)) {
|
|
57
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "invalid signed timestamp");
|
|
58
|
+
}
|
|
59
|
+
const deletedFloor = write_checks_1.packageApprovalTables.has(tableName)
|
|
60
|
+
? await (0, write_checks_1.deletedSignedAt)(tableName, context, candidate ? undefined : existing)
|
|
61
|
+
: 0;
|
|
62
|
+
const floor = Math.max(deletedFloor, existing?.signedAt ?? 0);
|
|
63
|
+
if (candidate &&
|
|
64
|
+
((candidate.signedAt ?? 0) < floor ||
|
|
65
|
+
(deletedFloor > 0 && (candidate.signedAt ?? 0) <= deletedFloor))) {
|
|
66
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "signed before the row on record");
|
|
67
|
+
}
|
|
68
|
+
if (candidate?.signedAt !== undefined &&
|
|
69
|
+
candidate.signedAt === existing?.signedAt &&
|
|
70
|
+
candidate.signature !== existing.signature) {
|
|
71
|
+
throw (0, write_checks_1.writeNotAuthorized)(tableName, "conflicting signatures at the same signed timestamp");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A membership must be signed by a member who counts as at least Admin, at least the role
|
|
76
|
+
* granted, and at least the member's current role. Only the founder writes the founder's row.
|
|
77
|
+
* A membership is removed by a signed soft delete; a hard delete is allowed only for a
|
|
78
|
+
* duplicate row (the user has another membership row), as unique-conflict cleanup writes.
|
|
79
|
+
*/
|
|
80
|
+
async function checkGroupMember(existing, candidate, context) {
|
|
81
|
+
const groupId = context.dataContext.groupId;
|
|
82
|
+
if (!groupId)
|
|
83
|
+
return;
|
|
84
|
+
const { GroupMembers } = await Promise.resolve().then(() => require("./group-members"));
|
|
85
|
+
if (!candidate) {
|
|
86
|
+
if (!existing)
|
|
87
|
+
return;
|
|
88
|
+
const rows = [
|
|
89
|
+
...(await GroupMembers(context.dataContext).list({ userId: existing.userId })),
|
|
90
|
+
...(context.retainedRows ?? []),
|
|
91
|
+
].filter((row) => !context.deletedRecordIds?.includes(row.groupMemberId));
|
|
92
|
+
if (rows.some((row) => row.groupId === existing.groupId &&
|
|
93
|
+
row.userId === existing.userId &&
|
|
94
|
+
row.groupMemberId !== existing.groupMemberId))
|
|
95
|
+
return;
|
|
96
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "memberships are removed by a signed delete");
|
|
97
|
+
}
|
|
98
|
+
if (candidate.groupId !== groupId || candidate.groupMemberId !== context.recordId) {
|
|
99
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "the row names another group");
|
|
100
|
+
}
|
|
101
|
+
if (existing &&
|
|
102
|
+
(existing.userId !== candidate.userId || existing.groupId !== candidate.groupId)) {
|
|
103
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "membership identity cannot change");
|
|
104
|
+
}
|
|
105
|
+
await checkSignedOrder("GroupMembers", existing, candidate, context);
|
|
106
|
+
const sameMember = await GroupMembers(context.dataContext).findOne({
|
|
107
|
+
groupId,
|
|
108
|
+
userId: candidate.userId,
|
|
109
|
+
});
|
|
110
|
+
if (sameMember && sameMember.groupMemberId !== candidate.groupMemberId) {
|
|
111
|
+
// Duplicate repair may change the primary key, but must retain the member's revocation.
|
|
112
|
+
await checkSignedOrder("GroupMembers", sameMember, candidate, context);
|
|
113
|
+
}
|
|
114
|
+
const { authority, signer, signerRole } = await requireSigner("GroupMembers", candidate, context);
|
|
115
|
+
if (candidate.userId === authority.founderUserId && signer !== authority.founderUserId) {
|
|
116
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", "only the founder writes the founder's row");
|
|
117
|
+
}
|
|
118
|
+
const currentRole = authority.roles.get(candidate.userId) ?? group_member_roles_1.GroupMemberRole.None;
|
|
119
|
+
if (signerRole < group_member_roles_1.GroupMemberRole.Admin ||
|
|
120
|
+
signerRole < candidate.role ||
|
|
121
|
+
signerRole < currentRole) {
|
|
122
|
+
throw (0, write_checks_1.writeNotAuthorized)("GroupMembers", `${signer} (${group_member_roles_1.GroupMemberRole[signerRole] ?? signerRole}) can't set this membership`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The group row must be signed by the founder or an Admin who counts, and only the founder can
|
|
127
|
+
* name a different founder. Sync never deletes it.
|
|
128
|
+
*/
|
|
129
|
+
async function checkGroup(existing, candidate, context) {
|
|
130
|
+
const groupId = context.dataContext.groupId;
|
|
131
|
+
if (!groupId)
|
|
132
|
+
return;
|
|
133
|
+
if (!candidate) {
|
|
134
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "the group row isn't deleted by sync");
|
|
135
|
+
}
|
|
136
|
+
if (candidate.groupId !== groupId || context.recordId !== groupId) {
|
|
137
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "the row names another group");
|
|
138
|
+
}
|
|
139
|
+
await checkSignedOrder("Groups", existing, candidate, context);
|
|
140
|
+
// A counted signer implies a pinned founder: every key is traced back to one.
|
|
141
|
+
const { authority, signer, signerRole } = await requireSigner("Groups", candidate, context);
|
|
142
|
+
if (candidate.founderUserId !== authority.founderUserId && signer !== authority.founderUserId) {
|
|
143
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "only the founder can name a new founder");
|
|
144
|
+
}
|
|
145
|
+
if (signerRole < group_member_roles_1.GroupMemberRole.Admin) {
|
|
146
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", `${signer} isn't an Admin`);
|
|
147
|
+
}
|
|
148
|
+
if ((candidate.publicRole ?? group_member_roles_1.GroupMemberRole.None) > signerRole) {
|
|
149
|
+
throw (0, write_checks_1.writeNotAuthorized)("Groups", "public role exceeds the signer's role");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Fields of a Packages row a Writer may change; everything else needs an Admin. */
|
|
153
|
+
const writerPackageFields = new Set(["name", "description", "signature", "signedAt"]);
|
|
154
|
+
/**
|
|
155
|
+
* A Writer may add a package; changing an existing one beyond its name and description (the
|
|
156
|
+
* active version, follow policy, update URL, publisher key, repository, disabled) needs an
|
|
157
|
+
* Admin. Deletes stay open to any member, as the table allows locally.
|
|
158
|
+
*/
|
|
159
|
+
async function checkPackageWrite(existing, candidate, context) {
|
|
160
|
+
if (!context.dataContext.groupId)
|
|
161
|
+
return;
|
|
162
|
+
await checkSignedOrder("Packages", existing, candidate, context);
|
|
163
|
+
if (!candidate)
|
|
164
|
+
return;
|
|
165
|
+
const { signerRole } = await requireSigner("Packages", candidate, context);
|
|
166
|
+
const needsAdmin = existing
|
|
167
|
+
? [...new Set([...Object.keys(existing), ...Object.keys(candidate)])].some((field) => !writerPackageFields.has(field) &&
|
|
168
|
+
!(0, lodash_1.isEqual)(existing[field], candidate[field]))
|
|
169
|
+
: !!candidate.activePackageVersionId;
|
|
170
|
+
if (signerRole < (needsAdmin ? group_member_roles_1.GroupMemberRole.Admin : group_member_roles_1.GroupMemberRole.Writer)) {
|
|
171
|
+
throw (0, write_checks_1.writeNotAuthorized)("Packages", needsAdmin
|
|
172
|
+
? "only an Admin can change a package's settings or select a version"
|
|
173
|
+
: "only a Writer can add a package");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether an Admin's device may act on a package row by itself: install releases from its
|
|
178
|
+
* `updateUrl`, copy releases signed by its publisher key in from other contexts, and select one
|
|
179
|
+
* by its follow policy. Each of those signs the result with the Admin's key, so the row must be
|
|
180
|
+
* one an Admin signed: a Writer may add a package row, or delete one and add it again, with any
|
|
181
|
+
* publisher key and update URL. A peers-core row instead qualifies only while it names the
|
|
182
|
+
* peers-core publisher key (or a revoked one, which is re-pinned to it before any release is
|
|
183
|
+
* checked), whoever signed it. Always true outside a group.
|
|
184
|
+
*
|
|
185
|
+
* @param pkg - The package row in `dataContext`.
|
|
186
|
+
* @param dataContext - The context the row belongs to.
|
|
187
|
+
*/
|
|
188
|
+
async function isPackageRowAdminSigned(pkg, dataContext) {
|
|
189
|
+
if (!dataContext.groupId)
|
|
190
|
+
return true;
|
|
191
|
+
if (pkg.packageId === system_ids_1.peersCorePackageId) {
|
|
192
|
+
return (!!system_ids_1.peersCorePublishPublicKey &&
|
|
193
|
+
(pkg.publishPublicKey === system_ids_1.peersCorePublishPublicKey ||
|
|
194
|
+
(0, package_author_signing_1.isRevokedPublisherKey)(pkg.publishPublicKey)));
|
|
195
|
+
}
|
|
196
|
+
if (pkg.signedContextId !== dataContext.groupId)
|
|
197
|
+
return false;
|
|
198
|
+
const authority = await (0, group_authority_1.getGroupAuthority)(dataContext.userContext, dataContext.groupId);
|
|
199
|
+
return ((0, group_authority_1.groupSigner)(authority, pkg)?.signerRole ?? group_member_roles_1.GroupMemberRole.None) >= group_member_roles_1.GroupMemberRole.Admin;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A dev version needs a Writer's signature; any other tag needs an Admin's, so no member can
|
|
203
|
+
* choose the code other members' devices run. peers-core versions signed by its publisher are
|
|
204
|
+
* accepted from anyone, since every device installs the bundled release.
|
|
205
|
+
*/
|
|
206
|
+
async function checkPackageVersion(existing, candidate, context) {
|
|
207
|
+
if (!context.dataContext.groupId)
|
|
208
|
+
return;
|
|
209
|
+
if (!candidate)
|
|
210
|
+
return checkSignedOrder("PackageVersions", existing, candidate, context);
|
|
211
|
+
const isDev = candidate.versionTag === "dev" && (!existing || existing.versionTag === "dev");
|
|
212
|
+
if (!isDev &&
|
|
213
|
+
candidate.packageId === system_ids_1.peersCorePackageId &&
|
|
214
|
+
(0, package_author_signing_1.verifyPackageAuthorSignature)(candidate, system_ids_1.peersCorePublishPublicKey).valid) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await checkSignedOrder("PackageVersions", existing, candidate, context);
|
|
218
|
+
const { signerRole } = await requireSigner("PackageVersions", candidate, context);
|
|
219
|
+
if (signerRole < (isDev ? group_member_roles_1.GroupMemberRole.Writer : group_member_roles_1.GroupMemberRole.Admin)) {
|
|
220
|
+
throw (0, write_checks_1.writeNotAuthorized)("PackageVersions", isDev ? "only a Writer can add a dev version" : "only an Admin can release a version");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A user's profile row is theirs: once a self-signed row is stored, a replacement must be
|
|
225
|
+
* self-signed too, and it isn't deleted. Roles are traced through these rows' keys, so a member
|
|
226
|
+
* who could overwrite another member's row could stop that member's grants from counting. The
|
|
227
|
+
* same rule `UsersTable.save` applies locally; a key change is still judged by the anchors on
|
|
228
|
+
* record when the row syncs.
|
|
229
|
+
*/
|
|
230
|
+
async function checkUser(existing, candidate, context) {
|
|
231
|
+
if (!context.dataContext.groupId)
|
|
232
|
+
return;
|
|
233
|
+
if (candidate && candidate.userId !== context.recordId) {
|
|
234
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "the row names another user");
|
|
235
|
+
}
|
|
236
|
+
if (candidate?.signature && !(0, user_permissions_1.isSelfSignedUser)(candidate)) {
|
|
237
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "the profile isn't signed by its own key");
|
|
238
|
+
}
|
|
239
|
+
if (candidate) {
|
|
240
|
+
// The same identity/anchor rule applies to direct writes and recovery snapshots, which
|
|
241
|
+
// do not pass through the sync-specific Users hook. A personal pin also governs first insert.
|
|
242
|
+
const { resolveUserRowChange } = await Promise.resolve().then(() => require("../identity/user-row-change"));
|
|
243
|
+
const decision = await resolveUserRowChange({
|
|
244
|
+
personalContext: context.dataContext.userContext.userDataContext,
|
|
245
|
+
existing,
|
|
246
|
+
incoming: candidate,
|
|
247
|
+
});
|
|
248
|
+
if (decision.action !== "apply")
|
|
249
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", decision.reason);
|
|
250
|
+
candidate.anchors = decision.row.anchors;
|
|
251
|
+
candidate.anchorsSignature = decision.row.anchorsSignature;
|
|
252
|
+
if (!candidate.anchors)
|
|
253
|
+
delete candidate.anchors;
|
|
254
|
+
}
|
|
255
|
+
if (!existing?.signature)
|
|
256
|
+
return;
|
|
257
|
+
if (!candidate) {
|
|
258
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "a signed profile isn't deleted");
|
|
259
|
+
}
|
|
260
|
+
if (!candidate.signature) {
|
|
261
|
+
throw (0, write_checks_1.writeNotAuthorized)("Users", "an unsigned profile can't replace a signed one");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A device its owner signed for (`ownerSignature`, checked against the owner's verified key)
|
|
266
|
+
* stays theirs: it isn't deleted, and a replacement keeps the owner and a valid signature. Mesh
|
|
267
|
+
* routing encrypts to a device's owner, so a member who could claim another member's device id
|
|
268
|
+
* would receive what was meant for it. Verified owners stay pinned across key changes; a new
|
|
269
|
+
* claim waits for its owner's verified profile. An unsigned discovery row may be replaced.
|
|
270
|
+
*/
|
|
271
|
+
async function checkDevice(existing, candidate, context) {
|
|
272
|
+
const { dataContext, recordId } = context;
|
|
273
|
+
if (!dataContext.groupId)
|
|
274
|
+
return;
|
|
275
|
+
if (candidate && candidate.deviceId !== recordId) {
|
|
276
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "the row names another device");
|
|
277
|
+
}
|
|
278
|
+
const { users } = await (0, group_authority_1.getGroupAuthority)(dataContext.userContext, dataContext.groupId);
|
|
279
|
+
const signed = (row) => !!row && (0, devices_1.isDeviceOwnerSigned)(row, users.get(row.userId)?.publicKey);
|
|
280
|
+
const vars = (0, persistent_vars_1.PersistentVars)(dataContext.userContext.userDataContext);
|
|
281
|
+
const name = `deviceOwner:${dataContext.groupId}:${recordId}`;
|
|
282
|
+
const remembered = await vars.findOne({ name, scope: "device" });
|
|
283
|
+
const ownerId = remembered?.value.value ??
|
|
284
|
+
(signed(existing) ? existing?.userId : signed(candidate) ? candidate?.userId : undefined);
|
|
285
|
+
// Remember a verified owner before writing: profile rotation/recovery must not unpin an
|
|
286
|
+
// offline device. This is local evidence, retained through history replay and compaction.
|
|
287
|
+
if (ownerId && !remembered) {
|
|
288
|
+
await vars.save({ persistentVarId: "", name, scope: "device", value: { value: ownerId } });
|
|
289
|
+
}
|
|
290
|
+
if (ownerId && !candidate) {
|
|
291
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "a device its owner signed for isn't deleted");
|
|
292
|
+
}
|
|
293
|
+
if (ownerId && (candidate?.userId !== ownerId || !candidate?.ownerSignature)) {
|
|
294
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "a device its owner signed for stays theirs");
|
|
295
|
+
}
|
|
296
|
+
// An unchanged established claim survives a key change; renewing it requires the new key.
|
|
297
|
+
// A new signed claim waits for its owner's verified profile, so it is pinned on first save.
|
|
298
|
+
if (candidate?.ownerSignature &&
|
|
299
|
+
!(ownerId && candidate.ownerSignature === existing?.ownerSignature) &&
|
|
300
|
+
!signed(candidate)) {
|
|
301
|
+
throw (0, write_checks_1.writeNotAuthorized)("Devices", "the ownership claim doesn't verify");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
(0, write_checks_1.registerTableWriteCheck)("Users", checkUser);
|
|
305
|
+
(0, write_checks_1.registerTableWriteCheck)("Devices", checkDevice);
|
|
306
|
+
(0, write_checks_1.registerTableWriteCheck)("GroupMembers", checkGroupMember);
|
|
307
|
+
(0, write_checks_1.registerTableWriteCheck)("Groups", checkGroup);
|
|
308
|
+
(0, write_checks_1.registerTableWriteCheck)("Packages", checkPackageWrite);
|
|
309
|
+
(0, write_checks_1.registerTableWriteCheck)("PackageVersions", checkPackageVersion);
|
|
310
|
+
(0, write_checks_1.registerTableWriteCheck)("PersistentVars", (_existing, candidate) => {
|
|
311
|
+
if (candidate && candidate.scope !== "group") {
|
|
312
|
+
throw (0, write_checks_1.writeNotAuthorized)("PersistentVars", "personal and device settings do not belong in a group");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
(0, write_checks_1.registerTableWriteCheck)("TableDefinitions", (_existing, candidate) => {
|
|
316
|
+
if (!candidate)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
(0, package_table_identity_1.validateTableDefinitionRecord)(candidate);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw (0, write_checks_1.writeNotAuthorized)("TableDefinitions", String(error));
|
|
323
|
+
}
|
|
324
|
+
});
|
|
@@ -26,10 +26,10 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
26
26
|
supersededAt: z.ZodOptional<z.ZodNumber>;
|
|
27
27
|
}, "strip", z.ZodTypeAny, {
|
|
28
28
|
path: string;
|
|
29
|
-
createdAt: number;
|
|
30
|
-
changeId: string;
|
|
31
29
|
tableName: string;
|
|
30
|
+
createdAt: number;
|
|
32
31
|
recordId: string;
|
|
32
|
+
changeId: string;
|
|
33
33
|
op: "set" | "delete" | "patch-text";
|
|
34
34
|
appliedAt: number;
|
|
35
35
|
value?: any;
|
|
@@ -37,10 +37,10 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
37
37
|
supersededAt?: number | undefined;
|
|
38
38
|
}, {
|
|
39
39
|
path: string;
|
|
40
|
-
createdAt: number;
|
|
41
|
-
changeId: string;
|
|
42
40
|
tableName: string;
|
|
41
|
+
createdAt: number;
|
|
43
42
|
recordId: string;
|
|
43
|
+
changeId: string;
|
|
44
44
|
op: "set" | "delete" | "patch-text";
|
|
45
45
|
appliedAt: number;
|
|
46
46
|
value?: any;
|
package/dist/data/devices.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare const deviceSchema: z.ZodObject<{
|
|
|
15
15
|
reportedName: z.ZodOptional<z.ZodString>;
|
|
16
16
|
serverUrl: z.ZodOptional<z.ZodString>;
|
|
17
17
|
trustLevel: z.ZodNativeEnum<typeof TrustLevel>;
|
|
18
|
+
ownerSignature: z.ZodOptional<z.ZodString>;
|
|
18
19
|
}, "strip", z.ZodTypeAny, {
|
|
19
20
|
userId: string;
|
|
20
21
|
deviceId: string;
|
|
@@ -24,6 +25,7 @@ export declare const deviceSchema: z.ZodObject<{
|
|
|
24
25
|
name?: string | undefined;
|
|
25
26
|
reportedName?: string | undefined;
|
|
26
27
|
serverUrl?: string | undefined;
|
|
28
|
+
ownerSignature?: string | undefined;
|
|
27
29
|
}, {
|
|
28
30
|
userId: string;
|
|
29
31
|
deviceId: string;
|
|
@@ -33,9 +35,29 @@ export declare const deviceSchema: z.ZodObject<{
|
|
|
33
35
|
name?: string | undefined;
|
|
34
36
|
reportedName?: string | undefined;
|
|
35
37
|
serverUrl?: string | undefined;
|
|
38
|
+
ownerSignature?: string | undefined;
|
|
36
39
|
}>;
|
|
37
40
|
/** A context-scoped device record containing local state and owner-reported metadata. */
|
|
38
41
|
export type IDevice = z.infer<typeof deviceSchema>;
|
|
42
|
+
/**
|
|
43
|
+
* What a device's owner signs to claim it: the contents of `ownerSignature`. Each device signs
|
|
44
|
+
* its own claim with its user's key and writes it to its row in every group it belongs to.
|
|
45
|
+
*
|
|
46
|
+
* @param device - The device id and the user it belongs to.
|
|
47
|
+
*/
|
|
48
|
+
export declare function deviceOwnerStatement(device: Pick<IDevice, "deviceId" | "userId">): {
|
|
49
|
+
purpose: string;
|
|
50
|
+
deviceId: string;
|
|
51
|
+
userId: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Whether `device.ownerSignature` is `ownerPublicKey`'s signature over
|
|
55
|
+
* {@link deviceOwnerStatement}: the owner, not whoever wrote the row, says the device is theirs.
|
|
56
|
+
*
|
|
57
|
+
* @param device - A Devices row.
|
|
58
|
+
* @param ownerPublicKey - The signing key of `device.userId`, as this device knows it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isDeviceOwnerSigned(device: Pick<IDevice, "deviceId" | "userId" | "ownerSignature">, ownerPublicKey: string | undefined): boolean;
|
|
39
61
|
/** Schema for the signed identity and bounded presentation hints exchanged by devices. */
|
|
40
62
|
export declare const deviceInfoSchema: z.ZodObject<{
|
|
41
63
|
userId: z.ZodEffects<z.ZodString, string, string>;
|
|
@@ -47,15 +69,15 @@ export declare const deviceInfoSchema: z.ZodObject<{
|
|
|
47
69
|
}, "strip", z.ZodTypeAny, {
|
|
48
70
|
publicKey: string;
|
|
49
71
|
userId: string;
|
|
50
|
-
deviceId: string;
|
|
51
72
|
publicBoxKey: string;
|
|
73
|
+
deviceId: string;
|
|
52
74
|
userName?: string | undefined;
|
|
53
75
|
deviceName?: string | undefined;
|
|
54
76
|
}, {
|
|
55
77
|
publicKey: string;
|
|
56
78
|
userId: string;
|
|
57
|
-
deviceId: string;
|
|
58
79
|
publicBoxKey: string;
|
|
80
|
+
deviceId: string;
|
|
59
81
|
userName?: string | undefined;
|
|
60
82
|
deviceName?: string | undefined;
|
|
61
83
|
}>;
|
|
@@ -155,9 +177,9 @@ export declare const deviceHandshakeSchema: z.ZodObject<{
|
|
|
155
177
|
timestamp: number;
|
|
156
178
|
publicKey: string;
|
|
157
179
|
userId: string;
|
|
180
|
+
publicBoxKey: string;
|
|
158
181
|
connectionId: string;
|
|
159
182
|
deviceId: string;
|
|
160
|
-
publicBoxKey: string;
|
|
161
183
|
serverAddress: string;
|
|
162
184
|
userName?: string | undefined;
|
|
163
185
|
deviceName?: string | undefined;
|
|
@@ -180,9 +202,9 @@ export declare const deviceHandshakeSchema: z.ZodObject<{
|
|
|
180
202
|
timestamp: number;
|
|
181
203
|
publicKey: string;
|
|
182
204
|
userId: string;
|
|
205
|
+
publicBoxKey: string;
|
|
183
206
|
connectionId: string;
|
|
184
207
|
deviceId: string;
|
|
185
|
-
publicBoxKey: string;
|
|
186
208
|
serverAddress: string;
|
|
187
209
|
userName?: string | undefined;
|
|
188
210
|
deviceName?: string | undefined;
|
|
@@ -214,6 +236,7 @@ export declare function Devices(dataContext?: DataContext): import("..").Table<{
|
|
|
214
236
|
name?: string | undefined;
|
|
215
237
|
reportedName?: string | undefined;
|
|
216
238
|
serverUrl?: string | undefined;
|
|
239
|
+
ownerSignature?: string | undefined;
|
|
217
240
|
}>;
|
|
218
241
|
/** Relay server URLs trusted by the active group. */
|
|
219
242
|
export declare const trustedServers: import("./persistent-vars").PersistentVar<string[]>;
|
package/dist/data/devices.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.thisDeviceId = exports.trustedServers = exports.deviceHandshakeSchema = exports.handshakeFrameCryptoSchema = exports.deviceInfoSchema = exports.deviceSchema = exports.displayNameSchema = exports.DISPLAY_NAME_MAX_LENGTH = void 0;
|
|
4
|
+
exports.deviceOwnerStatement = deviceOwnerStatement;
|
|
5
|
+
exports.isDeviceOwnerSigned = isDeviceOwnerSigned;
|
|
4
6
|
exports.Devices = Devices;
|
|
5
7
|
const zod_1 = require("zod");
|
|
6
8
|
const user_context_singleton_1 = require("../context/user-context-singleton");
|
|
7
9
|
const socket_type_1 = require("../device/socket.type");
|
|
8
10
|
const key_contest_1 = require("../identity/key-contest");
|
|
11
|
+
const keys_1 = require("../keys");
|
|
9
12
|
const zod_types_1 = require("../types/zod-types");
|
|
10
13
|
const table_definitions_system_1 = require("./orm/table-definitions.system");
|
|
11
14
|
const types_1 = require("./orm/types");
|
|
@@ -24,7 +27,35 @@ exports.deviceSchema = zod_1.z.object({
|
|
|
24
27
|
reportedName: exports.displayNameSchema.optional(),
|
|
25
28
|
serverUrl: zod_1.z.string().optional(),
|
|
26
29
|
trustLevel: zod_1.z.nativeEnum(socket_type_1.TrustLevel),
|
|
30
|
+
ownerSignature: zod_1.z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("The owner's signature claiming this device for userId (see deviceOwnerStatement). In a group, only a row with a valid one says who a device belongs to, and it stays theirs"),
|
|
27
34
|
});
|
|
35
|
+
/**
|
|
36
|
+
* What a device's owner signs to claim it: the contents of `ownerSignature`. Each device signs
|
|
37
|
+
* its own claim with its user's key and writes it to its row in every group it belongs to.
|
|
38
|
+
*
|
|
39
|
+
* @param device - The device id and the user it belongs to.
|
|
40
|
+
*/
|
|
41
|
+
function deviceOwnerStatement(device) {
|
|
42
|
+
return { purpose: "peers-device-owner", deviceId: device.deviceId, userId: device.userId };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether `device.ownerSignature` is `ownerPublicKey`'s signature over
|
|
46
|
+
* {@link deviceOwnerStatement}: the owner, not whoever wrote the row, says the device is theirs.
|
|
47
|
+
*
|
|
48
|
+
* @param device - A Devices row.
|
|
49
|
+
* @param ownerPublicKey - The signing key of `device.userId`, as this device knows it.
|
|
50
|
+
*/
|
|
51
|
+
function isDeviceOwnerSigned(device, ownerPublicKey) {
|
|
52
|
+
if (!device.ownerSignature || !ownerPublicKey)
|
|
53
|
+
return false;
|
|
54
|
+
return ((0, keys_1.signedByKey)({
|
|
55
|
+
...deviceOwnerStatement(device),
|
|
56
|
+
signature: `${ownerPublicKey}:${device.ownerSignature}`,
|
|
57
|
+
}) === ownerPublicKey);
|
|
58
|
+
}
|
|
28
59
|
/** Schema for the signed identity and bounded presentation hints exchanged by devices. */
|
|
29
60
|
exports.deviceInfoSchema = zod_1.z.object({
|
|
30
61
|
userId: zod_types_1.zodPeerId,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { UserContext } from "../context/user-context";
|
|
2
|
+
import { GroupMemberRole } from "./group-member-roles";
|
|
3
|
+
import type { IGroupMember } from "./group-members";
|
|
4
|
+
import type { IGroup } from "./groups";
|
|
5
|
+
import type { IUser } from "./users";
|
|
6
|
+
/**
|
|
7
|
+
* Who holds which role in a group, counting only rows whose signatures check out against the
|
|
8
|
+
* group's own chain of authority.
|
|
9
|
+
*
|
|
10
|
+
* Sync writes rows without running the tables' checks, and any member's device can serve a
|
|
11
|
+
* row, so a role or setting is believed only when its signer could have granted it. Built by
|
|
12
|
+
* {@link computeGroupAuthority}; read through {@link getGroupAuthority}.
|
|
13
|
+
*/
|
|
14
|
+
export interface IGroupAuthority {
|
|
15
|
+
groupId: string;
|
|
16
|
+
/** The founder: the root every role is traced back to. Undefined when nothing names one. */
|
|
17
|
+
founderUserId?: string;
|
|
18
|
+
/** Role anyone without a membership gets, from the group row when that row counts. */
|
|
19
|
+
publicRole: GroupMemberRole;
|
|
20
|
+
/** Role of each member whose membership counts. The founder is {@link GroupMemberRole.Founder}. */
|
|
21
|
+
roles: ReadonlyMap<string, GroupMemberRole>;
|
|
22
|
+
/**
|
|
23
|
+
* The signing key each counted role belongs to, by user id. A device presenting a member's
|
|
24
|
+
* user id with another key doesn't get the member's role (see {@link roleInGroupAuthority}).
|
|
25
|
+
* Absent for a founder without a verified profile row and for members with role None.
|
|
26
|
+
*/
|
|
27
|
+
keys: ReadonlyMap<string, string>;
|
|
28
|
+
/** The group-context Groups row, when it counts (signed by the founder or a counted Admin). */
|
|
29
|
+
group?: IGroup;
|
|
30
|
+
/** Each user's verified profile row (personal rows first), by user id. */
|
|
31
|
+
users: ReadonlyMap<string, IUser>;
|
|
32
|
+
}
|
|
33
|
+
/** The rows {@link computeGroupAuthority} works from. */
|
|
34
|
+
export interface IGroupAuthorityInput {
|
|
35
|
+
groupId: string;
|
|
36
|
+
/**
|
|
37
|
+
* The user's own copy of the group row (personal context), written when they created or
|
|
38
|
+
* joined the group. Only their own devices write it, so its founder is the root of trust.
|
|
39
|
+
*/
|
|
40
|
+
pinnedGroup?: IGroup;
|
|
41
|
+
/** The group's row in the group context, which any member's device can serve. */
|
|
42
|
+
group?: IGroup;
|
|
43
|
+
/** Every GroupMembers row in the group context. */
|
|
44
|
+
members: readonly IGroupMember[];
|
|
45
|
+
/**
|
|
46
|
+
* Users rows that map signing keys to users, most trusted first: the first row pins the
|
|
47
|
+
* keys, and a matching self-signed row verifies them. Pass personal rows before group rows.
|
|
48
|
+
*/
|
|
49
|
+
users: readonly IUser[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a group's {@link IGroupAuthority} from its rows. Pure and deterministic: the same rows
|
|
53
|
+
* give the same roles on every device, whatever order they arrived in.
|
|
54
|
+
*
|
|
55
|
+
* - **Profiles:** `users` maps each user to the profile row that settles their keys: the first
|
|
56
|
+
* row for a user pins the keys (an unsigned contact stub included) and a self-signed row with
|
|
57
|
+
* the same keys verifies them.
|
|
58
|
+
* - **Founder:** from `pinnedGroup`. Without a personal pin no authority counts. A group row
|
|
59
|
+
* naming a different founder counts only if the pinned founder signed it.
|
|
60
|
+
* - **Members:** starting from the founder, a membership counts when its signature is valid
|
|
61
|
+
* and its signer already counts as at least Admin and at least the role granted. Repeated
|
|
62
|
+
* until nothing changes, so a row can't justify itself or another row that justifies it.
|
|
63
|
+
* Deleted rows don't count.
|
|
64
|
+
* - **Keys:** a role belongs to a key, not only a user id. The founder's key is their verified
|
|
65
|
+
* profile row's. A membership names the member's key: it counts for that key, or for the
|
|
66
|
+
* key the member's verified profile row has if that row shows the named key handing over to
|
|
67
|
+
* it ({@link keySucceeds}). A profile row with any other key (a namesake a member forged on a
|
|
68
|
+
* device that had never seen the real one) stops the grant counting rather than inheriting
|
|
69
|
+
* it. Signatures count only by these keys.
|
|
70
|
+
* - **Group settings:** the group row's `publicRole` counts only when that row is signed by the
|
|
71
|
+
* founder or a counted Admin; otherwise no public access is granted.
|
|
72
|
+
*
|
|
73
|
+
* @param input - The group's rows.
|
|
74
|
+
*/
|
|
75
|
+
export declare function computeGroupAuthority(input: IGroupAuthorityInput): IGroupAuthority;
|
|
76
|
+
/**
|
|
77
|
+
* A user's role under `authority`: their counted membership, else the group's public role.
|
|
78
|
+
*
|
|
79
|
+
* Pass `publicKey` whenever the user is a remote caller: the role then counts only if it
|
|
80
|
+
* belongs to the key the caller authenticated with ({@link IGroupAuthority.keys}). Otherwise a
|
|
81
|
+
* device that proved a namesake's key somewhere else (another group's copy of a forged profile)
|
|
82
|
+
* would get the member's role here. A membership with role None applies whatever the key.
|
|
83
|
+
*
|
|
84
|
+
* @param authority - From {@link computeGroupAuthority} or {@link getGroupAuthority}.
|
|
85
|
+
* @param userId - User whose role is requested.
|
|
86
|
+
* @param publicKey - The signing key the caller authenticated with, when it is remote.
|
|
87
|
+
*/
|
|
88
|
+
export declare function roleInGroupAuthority(authority: IGroupAuthority, userId: string, publicKey?: string): GroupMemberRole;
|
|
89
|
+
/**
|
|
90
|
+
* The counted member who signed `row` and their role: its signature verifies with the key
|
|
91
|
+
* their role belongs to under `authority`. Undefined for anyone else, whatever their profile says.
|
|
92
|
+
*
|
|
93
|
+
* @param authority - The group's verified authority.
|
|
94
|
+
* @param row - A signed row.
|
|
95
|
+
*/
|
|
96
|
+
export declare function groupSigner(authority: Pick<IGroupAuthority, "keys" | "roles">, row: {
|
|
97
|
+
signature?: string;
|
|
98
|
+
}): {
|
|
99
|
+
signer: string;
|
|
100
|
+
signerRole: GroupMemberRole;
|
|
101
|
+
} | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* The verified authority for `groupId`, cached per user context and rebuilt after any change
|
|
104
|
+
* to that group's Groups, GroupMembers or Users rows, or to the user's own Groups or Users rows.
|
|
105
|
+
*
|
|
106
|
+
* @param userContext - Context that owns the group.
|
|
107
|
+
* @param groupId - Group id (never the personal context).
|
|
108
|
+
*/
|
|
109
|
+
export declare function getGroupAuthority(userContext: UserContext, groupId: string): Promise<IGroupAuthority>;
|
|
110
|
+
/** Drop cached authority for one group, or every group of `userContext`. */
|
|
111
|
+
export declare function invalidateGroupAuthority(userContext: UserContext, groupId?: string): void;
|