@enbox/dwn-sdk-js 0.4.14 → 0.4.16
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/browser.mjs +1 -1
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/src/core/constants.js +2 -0
- package/dist/esm/src/core/constants.js.map +1 -1
- package/dist/esm/src/core/dwn-error.js +2 -0
- package/dist/esm/src/core/dwn-error.js.map +1 -1
- package/dist/esm/src/core/protocol-authorization-validation.js +9 -0
- package/dist/esm/src/core/protocol-authorization-validation.js.map +1 -1
- package/dist/esm/src/handlers/messages-query.js +2 -29
- package/dist/esm/src/handlers/messages-query.js.map +1 -1
- package/dist/esm/src/handlers/messages-subscribe.js +52 -1
- package/dist/esm/src/handlers/messages-subscribe.js.map +1 -1
- package/dist/esm/src/handlers/protocols-configure.js +157 -7
- package/dist/esm/src/handlers/protocols-configure.js.map +1 -1
- package/dist/esm/src/index.js +2 -2
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/src/protocols/encryption.js +41 -10
- package/dist/esm/src/protocols/encryption.js.map +1 -1
- package/dist/esm/src/store/message-store-level.js +4 -2
- package/dist/esm/src/store/message-store-level.js.map +1 -1
- package/dist/esm/src/types/protocols-types.js.map +1 -1
- package/dist/esm/src/utils/messages.js +36 -0
- package/dist/esm/src/utils/messages.js.map +1 -1
- package/dist/esm/src/utils/replication.js +14 -0
- package/dist/esm/src/utils/replication.js.map +1 -1
- package/dist/esm/tests/features/protocol-composition.spec.js +5 -1
- package/dist/esm/tests/features/protocol-composition.spec.js.map +1 -1
- package/dist/esm/tests/handlers/messages-query.spec.js +7 -15
- package/dist/esm/tests/handlers/messages-query.spec.js.map +1 -1
- package/dist/esm/tests/handlers/messages-subscribe.spec.js +129 -1
- package/dist/esm/tests/handlers/messages-subscribe.spec.js.map +1 -1
- package/dist/esm/tests/handlers/protocols-configure.spec.js +244 -1
- package/dist/esm/tests/handlers/protocols-configure.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-read.spec.js +2 -1
- package/dist/esm/tests/handlers/records-read.spec.js.map +1 -1
- package/dist/esm/tests/handlers/records-write.spec.js +24 -1
- package/dist/esm/tests/handlers/records-write.spec.js.map +1 -1
- package/dist/esm/tests/protocols/encryption.spec.js +3 -1
- package/dist/esm/tests/protocols/encryption.spec.js.map +1 -1
- package/dist/esm/tests/store/message-store-level.spec.js +21 -0
- package/dist/esm/tests/store/message-store-level.spec.js.map +1 -1
- package/dist/types/src/core/constants.d.ts +2 -0
- package/dist/types/src/core/constants.d.ts.map +1 -1
- package/dist/types/src/core/dwn-error.d.ts +2 -0
- package/dist/types/src/core/dwn-error.d.ts.map +1 -1
- package/dist/types/src/core/protocol-authorization-validation.d.ts.map +1 -1
- package/dist/types/src/handlers/messages-query.d.ts +0 -2
- package/dist/types/src/handlers/messages-query.d.ts.map +1 -1
- package/dist/types/src/handlers/messages-subscribe.d.ts +16 -0
- package/dist/types/src/handlers/messages-subscribe.d.ts.map +1 -1
- package/dist/types/src/handlers/protocols-configure.d.ts +17 -0
- package/dist/types/src/handlers/protocols-configure.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocols/encryption.d.ts +3 -1
- package/dist/types/src/protocols/encryption.d.ts.map +1 -1
- package/dist/types/src/store/message-store-level.d.ts.map +1 -1
- package/dist/types/src/types/messages-types.d.ts +14 -0
- package/dist/types/src/types/messages-types.d.ts.map +1 -1
- package/dist/types/src/types/protocols-types.d.ts +2 -1
- package/dist/types/src/types/protocols-types.d.ts.map +1 -1
- package/dist/types/src/utils/messages.d.ts +8 -0
- package/dist/types/src/utils/messages.d.ts.map +1 -1
- package/dist/types/src/utils/replication.d.ts +8 -1
- package/dist/types/src/utils/replication.d.ts.map +1 -1
- package/dist/types/tests/features/protocol-composition.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/messages-query.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/messages-subscribe.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/protocols-configure.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-read.spec.d.ts.map +1 -1
- package/dist/types/tests/handlers/records-write.spec.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/constants.ts +2 -0
- package/src/core/dwn-error.ts +2 -0
- package/src/core/protocol-authorization-validation.ts +14 -0
- package/src/handlers/messages-query.ts +3 -41
- package/src/handlers/messages-subscribe.ts +67 -3
- package/src/handlers/protocols-configure.ts +236 -7
- package/src/index.ts +3 -0
- package/src/protocols/encryption.ts +54 -11
- package/src/store/message-store-level.ts +4 -2
- package/src/types/messages-types.ts +14 -0
- package/src/types/protocols-types.ts +2 -1
- package/src/utils/messages.ts +45 -0
- package/src/utils/replication.ts +19 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Filter } from '../types/query-types.js';
|
|
1
2
|
import type { GenericMessageReply } from '../types/message-types.js';
|
|
2
3
|
import type { ProgressToken } from '../types/subscriptions.js';
|
|
3
4
|
import type { RecordsWriteMessage } from '../types/records-types.js';
|
|
@@ -6,12 +7,14 @@ import type { HandlerDependencies, MethodHandler } from '../types/method-handler
|
|
|
6
7
|
import type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage } from '../types/protocols-types.js';
|
|
7
8
|
|
|
8
9
|
import { authenticate } from '../core/auth.js';
|
|
10
|
+
import { isEncryptionControlPath } from '../core/constants.js';
|
|
9
11
|
import { Message } from '../core/message.js';
|
|
10
12
|
import { messageReplyFromError } from '../core/message-reply.js';
|
|
11
13
|
import { ProtocolAuthorization } from '../core/protocol-authorization.js';
|
|
12
14
|
import { ProtocolsConfigure } from '../interfaces/protocols-configure.js';
|
|
13
15
|
import { ProtocolsGrantAuthorization } from '../core/protocols-grant-authorization.js';
|
|
14
16
|
import { RecordsWrite } from '../interfaces/records-write.js';
|
|
17
|
+
import { resolveProtocolTypesForPath } from '../core/protocol-authorization-validation.js';
|
|
15
18
|
import { StorageController } from '../store/storage-controller.js';
|
|
16
19
|
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
17
20
|
import { DwnInterfaceName, DwnMethodName } from '../enums/dwn-interface-method.js';
|
|
@@ -22,6 +25,7 @@ type StoredInitialWriteConfigValidity = 'valid' | 'invalid' | 'unknown';
|
|
|
22
25
|
const STORED_INITIAL_WRITE_CONFIG_INVALID_CODES = new Set<string>([
|
|
23
26
|
DwnErrorCode.EncryptionControlValidateAudienceRolePathInvalid,
|
|
24
27
|
DwnErrorCode.EncryptionControlValidateAudienceSealKeyIdMismatch,
|
|
28
|
+
DwnErrorCode.ProtocolAuthorizationEncryptionNotAllowed,
|
|
25
29
|
DwnErrorCode.ProtocolAuthorizationEncryptionRequired,
|
|
26
30
|
DwnErrorCode.ProtocolAuthorizationIncorrectDataFormat,
|
|
27
31
|
DwnErrorCode.ProtocolAuthorizationInitialWriteRevalidationNotInitial,
|
|
@@ -91,8 +95,20 @@ export class ProtocolsConfigureHandler implements MethodHandler {
|
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
|
|
98
|
+
let newestMessage = await Message.getNewestMessage(existingMessages) as ProtocolsConfigureMessage | undefined;
|
|
99
|
+
let prefetchedRecordsWrites: RecordsWriteMessage[] | undefined;
|
|
100
|
+
try {
|
|
101
|
+
prefetchedRecordsWrites = await this.validateEncryptionPolicyIsImmutable(
|
|
102
|
+
tenant,
|
|
103
|
+
message.descriptor.definition,
|
|
104
|
+
message.descriptor.messageTimestamp,
|
|
105
|
+
newestMessage,
|
|
106
|
+
);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return messageReplyFromError(e, 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
// find newest message, and if the incoming message is the newest
|
|
95
|
-
let newestMessage = await Message.getNewestMessage(existingMessages);
|
|
96
112
|
let incomingMessageIsNewest = false;
|
|
97
113
|
if (newestMessage === undefined || await Message.isNewer(message, newestMessage)) {
|
|
98
114
|
incomingMessageIsNewest = true;
|
|
@@ -135,7 +151,11 @@ export class ProtocolsConfigureHandler implements MethodHandler {
|
|
|
135
151
|
}
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
await this.purgeRecordsInvalidatedByProtocolConfig(
|
|
154
|
+
await this.purgeRecordsInvalidatedByProtocolConfig(
|
|
155
|
+
tenant,
|
|
156
|
+
message.descriptor.definition.protocol,
|
|
157
|
+
prefetchedRecordsWrites,
|
|
158
|
+
);
|
|
139
159
|
|
|
140
160
|
return messageReply;
|
|
141
161
|
};
|
|
@@ -183,21 +203,230 @@ export class ProtocolsConfigureHandler implements MethodHandler {
|
|
|
183
203
|
}
|
|
184
204
|
}
|
|
185
205
|
|
|
186
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Prevents a protocol URI from changing the at-rest representation of a path
|
|
208
|
+
* after records have been admitted under that path. A policy migration must use
|
|
209
|
+
* a new protocol URI so one store never contains both plaintext and ciphertext
|
|
210
|
+
* records governed by the same protocol version.
|
|
211
|
+
*/
|
|
212
|
+
private async validateEncryptionPolicyIsImmutable(
|
|
213
|
+
tenant: string,
|
|
214
|
+
incomingDefinition: ProtocolDefinition,
|
|
215
|
+
incomingTimestamp: string,
|
|
216
|
+
previousConfiguration: ProtocolsConfigureMessage | undefined,
|
|
217
|
+
): Promise<RecordsWriteMessage[] | undefined> {
|
|
218
|
+
if (previousConfiguration === undefined || !ProtocolsConfigureHandler.hasEncryptionPolicyChange(
|
|
219
|
+
previousConfiguration.descriptor.definition,
|
|
220
|
+
incomingDefinition,
|
|
221
|
+
)) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { messages } = await this.deps.messageStore.query(tenant, [{
|
|
226
|
+
interface : DwnInterfaceName.Records,
|
|
227
|
+
method : DwnMethodName.Write,
|
|
228
|
+
protocol : incomingDefinition.protocol,
|
|
229
|
+
}]);
|
|
230
|
+
const recordsWrites = messages as RecordsWriteMessage[];
|
|
231
|
+
const incomingPolicyByPath = new Map<string, boolean>();
|
|
232
|
+
for (const recordsWrite of recordsWrites) {
|
|
233
|
+
const { protocolPath } = recordsWrite.descriptor;
|
|
234
|
+
if (isEncryptionControlPath(protocolPath)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Removing a path does not change its representation policy. Existing
|
|
239
|
+
// records remain governed by the configuration active at their timestamp.
|
|
240
|
+
if (getRuleSetAtPath(protocolPath, incomingDefinition.structure) === undefined) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let incomingEncryptionRequired = incomingPolicyByPath.get(protocolPath);
|
|
245
|
+
if (incomingEncryptionRequired === undefined) {
|
|
246
|
+
const incomingTypes = await resolveProtocolTypesForPath(
|
|
247
|
+
tenant,
|
|
248
|
+
protocolPath,
|
|
249
|
+
incomingDefinition,
|
|
250
|
+
this.deps.validationStateReader,
|
|
251
|
+
incomingTimestamp,
|
|
252
|
+
);
|
|
253
|
+
incomingEncryptionRequired = incomingTypes[getTypeName(protocolPath)]?.encryptionRequired === true;
|
|
254
|
+
incomingPolicyByPath.set(protocolPath, incomingEncryptionRequired);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if ((recordsWrite.encryption !== undefined) === incomingEncryptionRequired) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
throw new DwnError(
|
|
262
|
+
DwnErrorCode.ProtocolsConfigureEncryptionPolicyImmutable,
|
|
263
|
+
`cannot change encryption policy for protocol path '${protocolPath}' after records exist; ` +
|
|
264
|
+
'install the changed definition under a new protocol URI.'
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await this.validateComposedEncryptionPolicyIsImmutable(tenant, incomingDefinition);
|
|
269
|
+
return recordsWrites;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Prevents a referenced protocol from changing the representation of records stored by composing protocols. */
|
|
273
|
+
private async validateComposedEncryptionPolicyIsImmutable(
|
|
274
|
+
tenant: string,
|
|
275
|
+
incomingDefinition: ProtocolDefinition,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const { messages: configurationMessages } = await this.deps.messageStore.query(tenant, [{
|
|
278
|
+
interface : DwnInterfaceName.Protocols,
|
|
279
|
+
isLatestBaseState : true,
|
|
280
|
+
method : DwnMethodName.Configure,
|
|
281
|
+
}]);
|
|
282
|
+
const policiesByProtocol = ProtocolsConfigureHandler.getComposedPathPolicies(
|
|
283
|
+
configurationMessages as ProtocolsConfigureMessage[],
|
|
284
|
+
incomingDefinition,
|
|
285
|
+
);
|
|
286
|
+
if (policiesByProtocol.size === 0) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { messages: dependentRecordsWrites } = await this.deps.messageStore.query(
|
|
291
|
+
tenant,
|
|
292
|
+
[...policiesByProtocol.keys()].map((protocol): Filter => ({
|
|
293
|
+
interface : DwnInterfaceName.Records,
|
|
294
|
+
method : DwnMethodName.Write,
|
|
295
|
+
protocol,
|
|
296
|
+
})),
|
|
297
|
+
);
|
|
298
|
+
for (const message of dependentRecordsWrites) {
|
|
299
|
+
const recordsWrite = message as RecordsWriteMessage;
|
|
300
|
+
const { protocol, protocolPath } = recordsWrite.descriptor;
|
|
301
|
+
if (protocol === undefined) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const encryptionRequired = policiesByProtocol.get(protocol)?.get(protocolPath);
|
|
306
|
+
if (encryptionRequired === undefined || (recordsWrite.encryption !== undefined) === encryptionRequired) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
throw new DwnError(
|
|
311
|
+
DwnErrorCode.ProtocolsConfigureEncryptionPolicyImmutable,
|
|
312
|
+
`cannot change encryption policy for protocol path '${protocolPath}' imported by protocol '${protocol}' ` +
|
|
313
|
+
`after records exist; install the changed definition under a new protocol URI.`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Maps each installed composing protocol to root `$ref` paths whose policy comes from the incoming definition. */
|
|
319
|
+
private static getComposedPathPolicies(
|
|
320
|
+
configurations: ProtocolsConfigureMessage[],
|
|
321
|
+
incomingDefinition: ProtocolDefinition,
|
|
322
|
+
): Map<string, Map<string, boolean>> {
|
|
323
|
+
const policiesByProtocol = new Map<string, Map<string, boolean>>();
|
|
324
|
+
for (const configuration of configurations) {
|
|
325
|
+
const composingDefinition = configuration.descriptor.definition;
|
|
326
|
+
const pathPolicies = ProtocolsConfigureHandler.getImportedPathPolicies(
|
|
327
|
+
composingDefinition,
|
|
328
|
+
incomingDefinition,
|
|
329
|
+
);
|
|
330
|
+
if (pathPolicies.size > 0) {
|
|
331
|
+
policiesByProtocol.set(composingDefinition.protocol, pathPolicies);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return policiesByProtocol;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Resolves the effective policy for root `$ref` paths that import one definition. */
|
|
339
|
+
private static getImportedPathPolicies(
|
|
340
|
+
composingDefinition: ProtocolDefinition,
|
|
341
|
+
incomingDefinition: ProtocolDefinition,
|
|
342
|
+
): Map<string, boolean> {
|
|
343
|
+
const pathPolicies = new Map<string, boolean>();
|
|
344
|
+
const importedAliases = new Set(
|
|
345
|
+
Object.entries(composingDefinition.uses ?? {})
|
|
346
|
+
.filter(([, protocol]): boolean => protocol === incomingDefinition.protocol)
|
|
347
|
+
.map(([alias]): string => alias),
|
|
348
|
+
);
|
|
349
|
+
if (importedAliases.size === 0) {
|
|
350
|
+
return pathPolicies;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [protocolPath, ruleSet] of Object.entries(composingDefinition.structure)) {
|
|
354
|
+
const reference = ruleSet.$ref === undefined ? undefined : parseCrossProtocolRef(ruleSet.$ref);
|
|
355
|
+
if (reference === undefined || !importedAliases.has(reference.alias)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const typeName = getTypeName(protocolPath);
|
|
360
|
+
pathPolicies.set(protocolPath, incomingDefinition.types[typeName]?.encryptionRequired === true);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return pathPolicies;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Returns true when a configure can change a path's stored representation. */
|
|
367
|
+
private static hasEncryptionPolicyChange(
|
|
368
|
+
previousDefinition: ProtocolDefinition,
|
|
369
|
+
incomingDefinition: ProtocolDefinition,
|
|
370
|
+
): boolean {
|
|
371
|
+
const typeNames = new Set([
|
|
372
|
+
...Object.keys(previousDefinition.types),
|
|
373
|
+
...Object.keys(incomingDefinition.types),
|
|
374
|
+
]);
|
|
375
|
+
for (const typeName of typeNames) {
|
|
376
|
+
const previousRequired = previousDefinition.types[typeName]?.encryptionRequired === true;
|
|
377
|
+
const incomingRequired = incomingDefinition.types[typeName]?.encryptionRequired === true;
|
|
378
|
+
if (previousRequired !== incomingRequired) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return ProtocolsConfigureHandler.getCompositionEncryptionPolicySignature(previousDefinition) !==
|
|
384
|
+
ProtocolsConfigureHandler.getCompositionEncryptionPolicySignature(incomingDefinition);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Captures only composition fields that can redirect a path to another type policy. */
|
|
388
|
+
private static getCompositionEncryptionPolicySignature(definition: ProtocolDefinition): string {
|
|
389
|
+
const references: string[] = [];
|
|
390
|
+
const visit = (ruleSet: ProtocolRuleSet, parentPath: string): void => {
|
|
391
|
+
for (const [typeName, value] of Object.entries(ruleSet)) {
|
|
392
|
+
if (typeName.startsWith('$')) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const childRuleSet = value as ProtocolRuleSet;
|
|
397
|
+
const protocolPath = parentPath === '' ? typeName : `${parentPath}/${typeName}`;
|
|
398
|
+
if (childRuleSet.$ref !== undefined) {
|
|
399
|
+
references.push(`${protocolPath}=${childRuleSet.$ref}`);
|
|
400
|
+
}
|
|
401
|
+
visit(childRuleSet, protocolPath);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
visit(definition.structure as ProtocolRuleSet, '');
|
|
405
|
+
|
|
406
|
+
const uses = Object.entries(definition.uses ?? {})
|
|
407
|
+
.sort(([left], [right]): number => left.localeCompare(right));
|
|
408
|
+
references.sort((left, right): number => left.localeCompare(right));
|
|
409
|
+
return JSON.stringify({ references, uses });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async purgeRecordsInvalidatedByProtocolConfig(
|
|
413
|
+
tenant: string,
|
|
414
|
+
protocol: string,
|
|
415
|
+
prefetchedRecordsWrites?: RecordsWriteMessage[],
|
|
416
|
+
): Promise<void> {
|
|
187
417
|
const dataStore = this.deps.dataStore;
|
|
188
418
|
if (dataStore === undefined) {
|
|
189
419
|
return;
|
|
190
420
|
}
|
|
191
421
|
|
|
192
|
-
const
|
|
422
|
+
const recordsWrites = prefetchedRecordsWrites ?? (await this.deps.messageStore.query(tenant, [{
|
|
193
423
|
interface : DwnInterfaceName.Records,
|
|
194
424
|
method : DwnMethodName.Write,
|
|
195
425
|
protocol,
|
|
196
|
-
}]);
|
|
426
|
+
}])).messages as RecordsWriteMessage[];
|
|
197
427
|
|
|
198
428
|
const checkedRecordIds = new Set<string>();
|
|
199
|
-
for (const
|
|
200
|
-
const recordsWriteMessage = message as RecordsWriteMessage;
|
|
429
|
+
for (const recordsWriteMessage of recordsWrites) {
|
|
201
430
|
if (checkedRecordIds.has(recordsWriteMessage.recordId)) {
|
|
202
431
|
continue;
|
|
203
432
|
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,8 @@ export {
|
|
|
25
25
|
ENCRYPTION_CONTROL_PATHS,
|
|
26
26
|
ENCRYPTION_CONTROL_ROOT_PATH,
|
|
27
27
|
ENCRYPTION_PROTOCOL_URI,
|
|
28
|
+
ENCRYPTION_PROTOCOL_GRANT_KEY_PATH,
|
|
29
|
+
ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH,
|
|
28
30
|
PERMISSIONS_REVOCATION_PATH,
|
|
29
31
|
isEncryptionControlPath,
|
|
30
32
|
} from './core/constants.js';
|
|
@@ -98,6 +100,7 @@ export {
|
|
|
98
100
|
getRoleAudienceContextId,
|
|
99
101
|
getRoleContextPrefix,
|
|
100
102
|
getRuleSetAtPath,
|
|
103
|
+
getTypeName,
|
|
101
104
|
isCrossProtocolRef,
|
|
102
105
|
parseCrossProtocolRef,
|
|
103
106
|
} from './utils/protocols.js';
|
|
@@ -10,12 +10,16 @@ import type { ContentEncryptionAlgorithm, X25519KeyEncryptionBase } from '../uti
|
|
|
10
10
|
|
|
11
11
|
import { DwnConstant } from '../core/dwn-constant.js';
|
|
12
12
|
import { Encoder } from '../utils/encoder.js';
|
|
13
|
-
import { ENCRYPTION_PROTOCOL_URI } from '../core/constants.js';
|
|
14
13
|
import { FilterUtility } from '../utils/filter.js';
|
|
15
14
|
import { Message } from '../core/message.js';
|
|
16
15
|
import { Records } from '../utils/records.js';
|
|
17
16
|
import { validateJsonSchema } from '../schema-validator.js';
|
|
18
17
|
import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
|
|
18
|
+
import {
|
|
19
|
+
ENCRYPTION_PROTOCOL_GRANT_KEY_PATH,
|
|
20
|
+
ENCRYPTION_PROTOCOL_URI,
|
|
21
|
+
ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH,
|
|
22
|
+
} from '../core/constants.js';
|
|
19
23
|
import { grantKeyScopeCoversDeliveredScope, isGrantKeyEligibleRecordsScope, isGrantKeyRecordsScope } from '../utils/grant-key-coverage.js';
|
|
20
24
|
|
|
21
25
|
export const WRAPPED_GRANT_KEY_FORMAT = 'enbox/wrapped-grant-key@1';
|
|
@@ -44,15 +48,21 @@ export function assertWrappedGrantKeyEnvelope(envelope: unknown): asserts envelo
|
|
|
44
48
|
|
|
45
49
|
export class EncryptionProtocol implements CoreProtocol {
|
|
46
50
|
public static readonly uri = ENCRYPTION_PROTOCOL_URI;
|
|
47
|
-
public static readonly grantKeyPath =
|
|
51
|
+
public static readonly grantKeyPath = ENCRYPTION_PROTOCOL_GRANT_KEY_PATH;
|
|
52
|
+
public static readonly wrappedGrantKeyPath = ENCRYPTION_PROTOCOL_WRAPPED_GRANT_KEY_PATH;
|
|
48
53
|
|
|
49
54
|
public static readonly definition: ProtocolDefinition = {
|
|
50
55
|
published : true,
|
|
51
56
|
protocol : EncryptionProtocol.uri,
|
|
52
57
|
types : {
|
|
53
58
|
grantKey: {
|
|
59
|
+
dataFormats : ['application/json'],
|
|
60
|
+
encryptionRequired : true,
|
|
61
|
+
schema : 'https://identity.foundation/dwn/json-schemas/encryption/grant-key.json',
|
|
62
|
+
},
|
|
63
|
+
wrappedGrantKey: {
|
|
54
64
|
dataFormats : ['application/json'],
|
|
55
|
-
schema : 'https://identity.foundation/dwn/json-schemas/encryption/grant-key.json',
|
|
65
|
+
schema : 'https://identity.foundation/dwn/json-schemas/encryption/wrapped-grant-key-envelope.json',
|
|
56
66
|
},
|
|
57
67
|
},
|
|
58
68
|
structure: {
|
|
@@ -71,6 +81,21 @@ export class EncryptionProtocol implements CoreProtocol {
|
|
|
71
81
|
protocolPath : { type: 'string' },
|
|
72
82
|
},
|
|
73
83
|
},
|
|
84
|
+
wrappedGrantKey: {
|
|
85
|
+
$actions: [
|
|
86
|
+
{ can: ['create'], who: 'anyone' },
|
|
87
|
+
{ can: ['read'], of: 'wrappedGrantKey', who: 'recipient' },
|
|
88
|
+
],
|
|
89
|
+
$immutable : true,
|
|
90
|
+
$tags : {
|
|
91
|
+
$allowUndefinedTags : false,
|
|
92
|
+
$requiredTags : ['grantId', 'protocol', 'keyId'],
|
|
93
|
+
grantId : { type: 'string' },
|
|
94
|
+
keyId : { maxLength: 43, minLength: 43, pattern: '^[A-Za-z0-9_-]{43}$', type: 'string' },
|
|
95
|
+
protocol : { type: 'string' },
|
|
96
|
+
protocolPath : { type: 'string' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
74
99
|
}
|
|
75
100
|
};
|
|
76
101
|
|
|
@@ -87,13 +112,13 @@ export class EncryptionProtocol implements CoreProtocol {
|
|
|
87
112
|
message: RecordsWriteMessage,
|
|
88
113
|
validationStateReader: ValidationStateReader,
|
|
89
114
|
): Promise<void> {
|
|
90
|
-
if (message.descriptor.protocolPath
|
|
115
|
+
if (EncryptionProtocol.isGrantKeyPath(message.descriptor.protocolPath)) {
|
|
91
116
|
await EncryptionProtocol.preProcessGrantKey(tenant, message, validationStateReader);
|
|
92
117
|
}
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
public async validateRecord(message: RecordsWriteMessage, dataBytes: Uint8Array): Promise<void> {
|
|
96
|
-
if (message.descriptor.protocolPath
|
|
121
|
+
if (EncryptionProtocol.isGrantKeyPath(message.descriptor.protocolPath)) {
|
|
97
122
|
EncryptionProtocol.verifyGrantKeyDelivery(message, dataBytes);
|
|
98
123
|
return;
|
|
99
124
|
}
|
|
@@ -109,7 +134,7 @@ export class EncryptionProtocol implements CoreProtocol {
|
|
|
109
134
|
message: RecordsWriteMessage,
|
|
110
135
|
validationStateReader: ValidationStateReader,
|
|
111
136
|
): Promise<void> {
|
|
112
|
-
EncryptionProtocol.
|
|
137
|
+
EncryptionProtocol.verifyGrantKeyRepresentation(message);
|
|
113
138
|
|
|
114
139
|
const grantId = EncryptionProtocol.getRequiredStringTag(message, 'grantId');
|
|
115
140
|
const protocol = EncryptionProtocol.getRequiredStringTag(message, 'protocol');
|
|
@@ -130,21 +155,34 @@ export class EncryptionProtocol implements CoreProtocol {
|
|
|
130
155
|
});
|
|
131
156
|
}
|
|
132
157
|
|
|
133
|
-
private static
|
|
134
|
-
if (message.
|
|
158
|
+
private static verifyGrantKeyRepresentation(message: RecordsWriteMessage): void {
|
|
159
|
+
if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
|
|
160
|
+
if (message.encryption === undefined) {
|
|
161
|
+
throw new DwnError(
|
|
162
|
+
DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
|
|
163
|
+
'grantKey records must be encrypted.'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
135
166
|
return;
|
|
136
167
|
}
|
|
137
168
|
|
|
169
|
+
if (message.encryption !== undefined) {
|
|
170
|
+
throw new DwnError(
|
|
171
|
+
DwnErrorCode.ProtocolAuthorizationEncryptionNotAllowed,
|
|
172
|
+
'wrappedGrantKey records must be plaintext at the DWN record level.'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
if (message.descriptor.dataSize > DwnConstant.maxDataSizeAllowedToBeEncoded) {
|
|
139
177
|
throw new DwnError(
|
|
140
178
|
DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
|
|
141
|
-
'
|
|
179
|
+
'wrappedGrantKey records must carry an inline wrapped grantKey envelope.'
|
|
142
180
|
);
|
|
143
181
|
}
|
|
144
182
|
}
|
|
145
183
|
|
|
146
184
|
private static verifyGrantKeyDelivery(message: RecordsWriteMessage, dataBytes: Uint8Array): void {
|
|
147
|
-
if (message.
|
|
185
|
+
if (message.descriptor.protocolPath === EncryptionProtocol.grantKeyPath) {
|
|
148
186
|
return;
|
|
149
187
|
}
|
|
150
188
|
|
|
@@ -159,11 +197,16 @@ export class EncryptionProtocol implements CoreProtocol {
|
|
|
159
197
|
const detail = error instanceof Error ? error.message : String(error);
|
|
160
198
|
throw new DwnError(
|
|
161
199
|
DwnErrorCode.EncryptionProtocolValidateEncryptedDeliveryMissingEncryption,
|
|
162
|
-
`${message.descriptor.protocolPath} records must
|
|
200
|
+
`${message.descriptor.protocolPath} records must carry a valid wrapped grantKey envelope: ${detail}`
|
|
163
201
|
);
|
|
164
202
|
}
|
|
165
203
|
}
|
|
166
204
|
|
|
205
|
+
private static isGrantKeyPath(protocolPath: string): boolean {
|
|
206
|
+
return protocolPath === EncryptionProtocol.grantKeyPath ||
|
|
207
|
+
protocolPath === EncryptionProtocol.wrappedGrantKeyPath;
|
|
208
|
+
}
|
|
209
|
+
|
|
167
210
|
private static getRequiredStringTag(message: RecordsWriteMessage, tag: string): string {
|
|
168
211
|
const value = message.descriptor.tags?.[tag];
|
|
169
212
|
if (typeof value !== 'string') {
|
|
@@ -731,8 +731,10 @@ export class MessageStoreLevel implements MessageStore, ReplicationFeedReader {
|
|
|
731
731
|
const startPosition = cursor === undefined ? 0n : BigInt(cursor.position);
|
|
732
732
|
|
|
733
733
|
if (head === 0n) {
|
|
734
|
-
// Nothing to scan — caught up at the input position.
|
|
735
|
-
|
|
734
|
+
// Nothing to scan — caught up at the input position. A caller without a
|
|
735
|
+
// cursor still gets the position-zero anchor so an empty log yields a
|
|
736
|
+
// checkpointable token instead of forcing a rescan on every pass.
|
|
737
|
+
return { events: [], cursor: cursor ?? await this.buildToken(tenant, 0n), drained: true };
|
|
736
738
|
}
|
|
737
739
|
|
|
738
740
|
const maxResults = limit ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -87,6 +87,20 @@ export type MessagesSubscribeMessage = {
|
|
|
87
87
|
|
|
88
88
|
export type MessagesSubscribeReply = GenericMessageReply & {
|
|
89
89
|
subscription?: MessageSubscription;
|
|
90
|
+
/**
|
|
91
|
+
* Feed fingerprint over the subscription's filter scopes, observed after the
|
|
92
|
+
* subscription became active. Present only when the filters map onto
|
|
93
|
+
* fingerprint domains and the message store exposes the replication feed.
|
|
94
|
+
*/
|
|
95
|
+
fingerprint?: string;
|
|
96
|
+
/**
|
|
97
|
+
* High-water progress token of the tenant's replication log, observed after
|
|
98
|
+
* the subscription became active — the position-zero anchor when the log is
|
|
99
|
+
* empty. NOT a delivery cursor: adopting it as a checkpoint is sound only
|
|
100
|
+
* after verifying `fingerprint` against the local feed, since resuming from
|
|
101
|
+
* it skips every earlier log position.
|
|
102
|
+
*/
|
|
103
|
+
head?: ProgressToken;
|
|
90
104
|
/** Present when status.code is 410 — structured gap metadata. */
|
|
91
105
|
error?: { code: 'ProgressGap' } & ProgressGapInfo;
|
|
92
106
|
};
|
|
@@ -53,7 +53,8 @@ export type ProtocolType = {
|
|
|
53
53
|
* DID must have an X25519 keyAgreement key; protocol installation will
|
|
54
54
|
* fail if it does not.
|
|
55
55
|
*
|
|
56
|
-
* When `false` or omitted,
|
|
56
|
+
* When `false` or omitted, records of this type **must** be plaintext at the
|
|
57
|
+
* DWN record level. Applications cannot override this protocol policy per write.
|
|
57
58
|
*/
|
|
58
59
|
encryptionRequired?: boolean,
|
|
59
60
|
};
|
package/src/utils/messages.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { MessagesFilter } from '../types/messages-types.js';
|
|
|
6
6
|
import { FilterUtility } from './filter.js';
|
|
7
7
|
import { normalizeProtocolUrl } from './url.js';
|
|
8
8
|
import { Records } from './records.js';
|
|
9
|
+
import { Replication } from './replication.js';
|
|
9
10
|
import { isEmptyObject, removeUndefinedProperties } from '@enbox/common';
|
|
10
11
|
|
|
11
12
|
type StoredMessageWithEncodedData = GenericMessage & { encodedData?: string };
|
|
@@ -122,6 +123,50 @@ export class Messages {
|
|
|
122
123
|
return metadataFilter;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Maps a filter set onto the replication fingerprint domains it covers, or
|
|
128
|
+
* `undefined` when the filters do not correspond to fingerprint domains.
|
|
129
|
+
* An empty filter set covers the global domain; a set of protocol-only
|
|
130
|
+
* filters covers each protocol's domain plus its tagged core-protocol
|
|
131
|
+
* domains. Any other filter shape has no domain mapping.
|
|
132
|
+
*/
|
|
133
|
+
public static computeFingerprintScopes(filters: MessagesFilter[]): string[] | undefined {
|
|
134
|
+
if (filters.length === 0) {
|
|
135
|
+
return [Replication.globalDomain];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const protocols = new Set<string>();
|
|
139
|
+
for (const filter of filters) {
|
|
140
|
+
const keys = Object.keys(filter);
|
|
141
|
+
if (keys.length !== 1 || typeof filter.protocol !== 'string') {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
protocols.add(filter.protocol);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Fail closed for filter sets naming a core protocol directly: the core
|
|
149
|
+
// protocols contribute no tagged domains for themselves, so the domain
|
|
150
|
+
// set would not span everything such filters enumerate — two feeds could
|
|
151
|
+
// fingerprint identically while differing in enumerable messages. No
|
|
152
|
+
// fingerprint beats one whose coverage is known-incomplete.
|
|
153
|
+
for (const protocol of protocols) {
|
|
154
|
+
if (Replication.isCoreProtocolUri(protocol)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scopes: string[] = [];
|
|
160
|
+
for (const protocol of protocols) {
|
|
161
|
+
scopes.push(
|
|
162
|
+
Replication.protocolDomain(protocol),
|
|
163
|
+
...Replication.taggedCoreProtocolDomains(protocol, protocols),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return scopes;
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
/**
|
|
126
171
|
* Returns a copy of a RecordsWrite message without inline encodedData, and the
|
|
127
172
|
* detached encodedData value for wire surfaces that carry data beside the message.
|
package/src/utils/replication.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GenericMessage } from '../types/message-types.js';
|
|
2
2
|
import type { KeyValues } from '../types/query-types.js';
|
|
3
|
+
import type { ReplicationFeedReader } from '../types/subscriptions.js';
|
|
3
4
|
|
|
4
5
|
import { EncryptionProtocol } from '../protocols/encryption.js';
|
|
5
6
|
import { PermissionsProtocol } from '../protocols/permissions.js';
|
|
@@ -44,6 +45,22 @@ export class Replication {
|
|
|
44
45
|
return domains;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Narrows a message store to its replication feed surface when it implements
|
|
50
|
+
* one. Returns `undefined` for stores without replication feed support.
|
|
51
|
+
*/
|
|
52
|
+
public static asFeedReader(candidate: unknown): ReplicationFeedReader | undefined {
|
|
53
|
+
const partial = candidate as Partial<ReplicationFeedReader>;
|
|
54
|
+
if (
|
|
55
|
+
typeof partial.logRead === 'function' &&
|
|
56
|
+
typeof partial.logBounds === 'function' &&
|
|
57
|
+
typeof partial.fingerprint === 'function' &&
|
|
58
|
+
typeof partial.epoch === 'function'
|
|
59
|
+
) {
|
|
60
|
+
return partial as ReplicationFeedReader;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
public static async deriveStreamId(tenant: string): Promise<string> {
|
|
48
65
|
const bytes = new TextEncoder().encode(tenant);
|
|
49
66
|
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
|
@@ -100,7 +117,8 @@ export class Replication {
|
|
|
100
117
|
);
|
|
101
118
|
}
|
|
102
119
|
|
|
103
|
-
|
|
120
|
+
/** Whether a protocol URI is one of the core protocols (permissions, encryption). */
|
|
121
|
+
public static isCoreProtocolUri(protocolUri: string): boolean {
|
|
104
122
|
return protocolUri === PermissionsProtocol.uri || protocolUri === EncryptionProtocol.uri;
|
|
105
123
|
}
|
|
106
124
|
|