dignity.js 0.2.0 → 0.4.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/README.md +78 -15
- package/dist/dignity.cjs.js +272 -6
- package/dist/dignity.cjs.js.map +4 -4
- package/dist/dignity.esm.js +272 -6
- package/dist/dignity.esm.js.map +3 -3
- package/dist/dignity.min.js +5 -5
- package/docs/assets/docs.js +47 -0
- package/docs/assets/highlight/github-dark.min.css +10 -0
- package/docs/assets/highlight/github.min.css +10 -0
- package/docs/assets/highlight/highlight.min.js +1244 -0
- package/docs/assets/styles.css +449 -38
- package/docs/index.html +601 -81
- package/docs/openapi-like.json +44 -6
- package/package.json +22 -4
- package/src/core/dignity-p2p.js +79 -0
- package/src/index.js +2 -0
- package/src/persistence/indexeddb-persistence.js +182 -0
- package/src/react/index.js +114 -0
- package/src/security/message-security-service.js +51 -6
package/docs/openapi-like.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dignity.js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "REST-like object API over peer-to-peer replication",
|
|
5
5
|
"resources": {
|
|
6
6
|
"collections/{collection}/{id}": {
|
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
"method": "read(collection, id)"
|
|
13
13
|
},
|
|
14
14
|
"update": {
|
|
15
|
-
"method": "update(collection, id, patch)",
|
|
16
|
-
"authorization": "owner-only"
|
|
15
|
+
"method": "update(collection, id, patch, options)",
|
|
16
|
+
"authorization": "owner-only",
|
|
17
|
+
"options": {
|
|
18
|
+
"expectedVersion": "optional number; throws VERSION_CONFLICT when mismatched",
|
|
19
|
+
"broadcastScope": "optional scoped broadcast password namespace"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"updateWithRetry": {
|
|
23
|
+
"method": "updateWithRetry(collection, id, patchFn, options)",
|
|
24
|
+
"description": "read-modify-write helper with automatic retry on version conflicts"
|
|
17
25
|
},
|
|
18
26
|
"delete": {
|
|
19
27
|
"method": "remove(collection, id)",
|
|
@@ -26,6 +34,31 @@
|
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
36
|
},
|
|
37
|
+
"events": {
|
|
38
|
+
"change": "object create/update/delete applied",
|
|
39
|
+
"conflict": "local or remote version mismatch",
|
|
40
|
+
"peerdiscovered": "peer joined discovery scope",
|
|
41
|
+
"peerleft": "peer left or timed out",
|
|
42
|
+
"message": "custom decrypted message received"
|
|
43
|
+
},
|
|
44
|
+
"persistence": {
|
|
45
|
+
"IndexedDBPersistence": {
|
|
46
|
+
"method": "attach(node)",
|
|
47
|
+
"options": [
|
|
48
|
+
"dbName",
|
|
49
|
+
"storeName",
|
|
50
|
+
"collections"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"react": {
|
|
55
|
+
"entrypoint": "dignity.js/react",
|
|
56
|
+
"hooks": [
|
|
57
|
+
"useDignity",
|
|
58
|
+
"useCollection",
|
|
59
|
+
"usePeers"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
29
62
|
"signaling": {
|
|
30
63
|
"defaults": {
|
|
31
64
|
"cloudflare": "enabled by default",
|
|
@@ -45,13 +78,18 @@
|
|
|
45
78
|
"signingEnabled": true,
|
|
46
79
|
"encryptionEnabled": true,
|
|
47
80
|
"powEnabled": true,
|
|
48
|
-
"
|
|
81
|
+
"powSteps": 22,
|
|
82
|
+
"powTargetMs": 1000,
|
|
83
|
+
"kdfIterations": 100000,
|
|
84
|
+
"banDurationMs": 172800000
|
|
49
85
|
},
|
|
50
86
|
"broadcast": {
|
|
51
|
-
"encryption": "
|
|
87
|
+
"encryption": "AES secretbox with PBKDF2-SHA256 derived key",
|
|
88
|
+
"scopePasswords": "broadcastPasswords map keyed by broadcastScope",
|
|
89
|
+
"legacyKdf": "single-hash fallback accepted for older peers"
|
|
52
90
|
},
|
|
53
91
|
"direct": {
|
|
54
|
-
"encryption": "
|
|
92
|
+
"encryption": "NaCl box (X25519 + XSalsa20-Poly1305) to recipient public key"
|
|
55
93
|
},
|
|
56
94
|
"pow": {
|
|
57
95
|
"algorithm": "Sloth VDF",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dignity.js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "P2P object API for decentralized JavaScript applications",
|
|
5
5
|
"main": "dist/dignity.cjs.js",
|
|
6
6
|
"module": "dist/dignity.esm.js",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"require": "./dist/dignity.cjs.js",
|
|
13
13
|
"import": "./dist/dignity.esm.js",
|
|
14
14
|
"default": "./dist/dignity.esm.js"
|
|
15
|
+
},
|
|
16
|
+
"./react": {
|
|
17
|
+
"require": "./src/react/index.js",
|
|
18
|
+
"import": "./src/react/index.js",
|
|
19
|
+
"default": "./src/react/index.js"
|
|
15
20
|
}
|
|
16
21
|
},
|
|
17
22
|
"files": [
|
|
@@ -27,7 +32,7 @@
|
|
|
27
32
|
"test:pow-calibrate": "jest tests/unit/sloth-vdf-timing.test.js --runInBand",
|
|
28
33
|
"build": "node scripts/build.js",
|
|
29
34
|
"docs:serve": "npx http-server docs -p 4173 -o",
|
|
30
|
-
"docs:check": "node -e \"require('fs')
|
|
35
|
+
"docs:check": "node -e \"const fs=require('fs');['docs/index.html','docs/assets/highlight/highlight.min.js','docs/assets/highlight/github.min.css','docs/assets/highlight/github-dark.min.css'].forEach(p=>fs.accessSync(p));\"",
|
|
31
36
|
"example:tictactoe": "node examples/decentralized-tictactoe.js",
|
|
32
37
|
"example:chess": "node examples/decentralized-chess-lite.js",
|
|
33
38
|
"prepublishOnly": "npm test && npm run build"
|
|
@@ -39,10 +44,23 @@
|
|
|
39
44
|
"rest",
|
|
40
45
|
"objects"
|
|
41
46
|
],
|
|
42
|
-
"license": "
|
|
47
|
+
"license": "Apache 2.0",
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"react": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"react": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
43
56
|
"devDependencies": {
|
|
57
|
+
"@testing-library/react": "^16.3.0",
|
|
44
58
|
"esbuild": "^0.28.0",
|
|
45
|
-
"
|
|
59
|
+
"fake-indexeddb": "^6.0.0",
|
|
60
|
+
"jest": "^29.7.0",
|
|
61
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
62
|
+
"react": "^19.1.0",
|
|
63
|
+
"react-dom": "^19.1.0"
|
|
46
64
|
},
|
|
47
65
|
"dependencies": {
|
|
48
66
|
"peerjs": "^1.5.5",
|
package/src/core/dignity-p2p.js
CHANGED
|
@@ -178,6 +178,23 @@ class DignityP2P extends EventEmitter {
|
|
|
178
178
|
throw new Error(`Only owner ${existing.ownerId} can update object ${id}`);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
if (typeof options.expectedVersion === 'number' && existing.version !== options.expectedVersion) {
|
|
182
|
+
this.emitConflict({
|
|
183
|
+
kind: 'update',
|
|
184
|
+
collection: collectionName,
|
|
185
|
+
id,
|
|
186
|
+
expectedVersion: options.expectedVersion,
|
|
187
|
+
currentVersion: existing.version,
|
|
188
|
+
phase: 'local'
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const error = new Error(
|
|
192
|
+
`Version conflict on ${collectionName}/${id}: expected ${options.expectedVersion}, current ${existing.version}`
|
|
193
|
+
);
|
|
194
|
+
error.code = 'VERSION_CONFLICT';
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
|
|
181
198
|
const operation = {
|
|
182
199
|
opId: this.idGenerator(),
|
|
183
200
|
kind: 'update',
|
|
@@ -201,6 +218,31 @@ class DignityP2P extends EventEmitter {
|
|
|
201
218
|
return this.read(collectionName, id);
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
async updateWithRetry(collectionName, id, patchFn, options = {}) {
|
|
222
|
+
const maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 5;
|
|
223
|
+
|
|
224
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
225
|
+
const current = this.read(collectionName, id);
|
|
226
|
+
if (!current) {
|
|
227
|
+
throw new Error(`Object ${id} does not exist in ${collectionName}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const patch = await patchFn(current);
|
|
231
|
+
try {
|
|
232
|
+
return await this.update(collectionName, id, patch, {
|
|
233
|
+
...options,
|
|
234
|
+
expectedVersion: current.version
|
|
235
|
+
});
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.code !== 'VERSION_CONFLICT' || attempt === maxAttempts - 1) {
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error(`Unable to update ${collectionName}/${id} after ${maxAttempts} attempts`);
|
|
244
|
+
}
|
|
245
|
+
|
|
204
246
|
async remove(collectionName, id, options = {}) {
|
|
205
247
|
const existing = this.getCollection(collectionName).get(id);
|
|
206
248
|
|
|
@@ -534,6 +576,34 @@ class DignityP2P extends EventEmitter {
|
|
|
534
576
|
return this.getBanInfo(peerId) !== null;
|
|
535
577
|
}
|
|
536
578
|
|
|
579
|
+
emitConflict(details) {
|
|
580
|
+
this.emit('conflict', details);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
restoreRecord(collectionName, record) {
|
|
584
|
+
if (!record || !record.id) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const collection = this.getCollection(collectionName);
|
|
589
|
+
const current = collection.get(record.id);
|
|
590
|
+
if (current && current.version >= record.version) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
collection.set(record.id, {
|
|
595
|
+
id: record.id,
|
|
596
|
+
ownerId: record.ownerId,
|
|
597
|
+
data: { ...(record.data || {}) },
|
|
598
|
+
createdAt: record.createdAt,
|
|
599
|
+
updatedAt: record.updatedAt,
|
|
600
|
+
deletedAt: record.deletedAt || null,
|
|
601
|
+
version: record.version
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
537
607
|
applyOperation(operation) {
|
|
538
608
|
if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
|
|
539
609
|
return false;
|
|
@@ -571,6 +641,15 @@ class DignityP2P extends EventEmitter {
|
|
|
571
641
|
}
|
|
572
642
|
|
|
573
643
|
if (typeof operation.baseVersion === 'number' && operation.baseVersion !== current.version) {
|
|
644
|
+
this.emitConflict({
|
|
645
|
+
kind: operation.kind,
|
|
646
|
+
collection: operation.collectionName,
|
|
647
|
+
id: operation.id,
|
|
648
|
+
expectedVersion: operation.baseVersion,
|
|
649
|
+
currentVersion: current.version,
|
|
650
|
+
phase: 'remote',
|
|
651
|
+
operation
|
|
652
|
+
});
|
|
574
653
|
return false;
|
|
575
654
|
}
|
|
576
655
|
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
InMemoryNetworkHub,
|
|
16
16
|
InMemoryNetworkAdapter
|
|
17
17
|
} = require('./network/in-memory-network');
|
|
18
|
+
const IndexedDBPersistence = require('./persistence/indexeddb-persistence');
|
|
18
19
|
const {
|
|
19
20
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
20
21
|
DEFAULT_SIGNALING_FALLBACK_URLS
|
|
@@ -34,6 +35,7 @@ module.exports = {
|
|
|
34
35
|
PeerJSSignalingProvider,
|
|
35
36
|
InMemoryNetworkHub,
|
|
36
37
|
InMemoryNetworkAdapter,
|
|
38
|
+
IndexedDBPersistence,
|
|
37
39
|
DEFAULT_CLOUDFLARE_SIGNALING_URLS,
|
|
38
40
|
DEFAULT_SIGNALING_FALLBACK_URLS,
|
|
39
41
|
VDF,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
class IndexedDBPersistence {
|
|
2
|
+
constructor({
|
|
3
|
+
dbName = 'dignity',
|
|
4
|
+
storeName = 'records',
|
|
5
|
+
collections = null,
|
|
6
|
+
indexedDB = typeof globalThis !== 'undefined' ? globalThis.indexedDB : null
|
|
7
|
+
} = {}) {
|
|
8
|
+
this.dbName = dbName;
|
|
9
|
+
this.storeName = storeName;
|
|
10
|
+
this.collections = collections;
|
|
11
|
+
this.indexedDB = indexedDB;
|
|
12
|
+
this.node = null;
|
|
13
|
+
this.changeHandler = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
recordKey(collection, id) {
|
|
17
|
+
return `${collection}:${id}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
shouldPersist(collection) {
|
|
21
|
+
if (!this.collections) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return this.collections.includes(collection);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
openDb() {
|
|
29
|
+
if (!this.indexedDB) {
|
|
30
|
+
return Promise.reject(new Error('IndexedDB is not available'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const request = this.indexedDB.open(this.dbName, 1);
|
|
35
|
+
|
|
36
|
+
request.onupgradeneeded = () => {
|
|
37
|
+
const db = request.result;
|
|
38
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
39
|
+
db.createObjectStore(this.storeName, { keyPath: 'key' });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
request.onsuccess = () => resolve(request.result);
|
|
44
|
+
request.onerror = () => reject(request.error || new Error('Unable to open IndexedDB'));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
runTransaction(mode, handler) {
|
|
49
|
+
return this.openDb().then((db) => new Promise((resolve, reject) => {
|
|
50
|
+
const transaction = db.transaction(this.storeName, mode);
|
|
51
|
+
const store = transaction.objectStore(this.storeName);
|
|
52
|
+
|
|
53
|
+
Promise.resolve(handler(store))
|
|
54
|
+
.then(resolve)
|
|
55
|
+
.catch(reject);
|
|
56
|
+
|
|
57
|
+
transaction.oncomplete = () => db.close();
|
|
58
|
+
transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed'));
|
|
59
|
+
transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
serializeRecord(collection, id) {
|
|
64
|
+
const record = this.node.getCollection(collection).get(id);
|
|
65
|
+
if (!record) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
key: this.recordKey(collection, id),
|
|
71
|
+
collection,
|
|
72
|
+
id,
|
|
73
|
+
ownerId: record.ownerId,
|
|
74
|
+
data: { ...record.data },
|
|
75
|
+
createdAt: record.createdAt,
|
|
76
|
+
updatedAt: record.updatedAt,
|
|
77
|
+
deletedAt: record.deletedAt,
|
|
78
|
+
version: record.version
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async persistRecord(collection, id) {
|
|
83
|
+
if (!this.node || !this.shouldPersist(collection)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const serialized = this.serializeRecord(collection, id);
|
|
88
|
+
const key = this.recordKey(collection, id);
|
|
89
|
+
|
|
90
|
+
if (!serialized) {
|
|
91
|
+
await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
|
|
92
|
+
const request = store.delete(key);
|
|
93
|
+
request.onsuccess = () => resolve();
|
|
94
|
+
request.onerror = () => reject(request.error);
|
|
95
|
+
}));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
|
|
100
|
+
const request = store.put(serialized);
|
|
101
|
+
request.onsuccess = () => resolve();
|
|
102
|
+
request.onerror = () => reject(request.error);
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
persistChange(event) {
|
|
107
|
+
if (!event || !event.collection || !event.id) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.persistRecord(event.collection, event.id).catch((error) => {
|
|
112
|
+
this.node.emit('warning', {
|
|
113
|
+
type: 'persistence-failed',
|
|
114
|
+
collection: event.collection,
|
|
115
|
+
id: event.id,
|
|
116
|
+
error
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async loadAllRecords() {
|
|
122
|
+
return this.runTransaction('readonly', (store) => new Promise((resolve, reject) => {
|
|
123
|
+
const request = store.getAll();
|
|
124
|
+
request.onsuccess = () => resolve(request.result || []);
|
|
125
|
+
request.onerror = () => reject(request.error);
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async hydrate() {
|
|
130
|
+
if (!this.node) {
|
|
131
|
+
throw new Error('IndexedDBPersistence requires an attached node before hydrate');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const storedRecords = await this.loadAllRecords();
|
|
135
|
+
for (const stored of storedRecords) {
|
|
136
|
+
if (!this.shouldPersist(stored.collection)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.node.restoreRecord(stored.collection, {
|
|
141
|
+
id: stored.id,
|
|
142
|
+
ownerId: stored.ownerId,
|
|
143
|
+
data: stored.data,
|
|
144
|
+
createdAt: stored.createdAt,
|
|
145
|
+
updatedAt: stored.updatedAt,
|
|
146
|
+
deletedAt: stored.deletedAt,
|
|
147
|
+
version: stored.version
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async attach(node) {
|
|
153
|
+
if (!node) {
|
|
154
|
+
throw new Error('IndexedDBPersistence.attach requires a DignityP2P node');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.node = node;
|
|
158
|
+
await this.hydrate();
|
|
159
|
+
|
|
160
|
+
this.changeHandler = (event) => this.persistChange(event);
|
|
161
|
+
node.on('change', this.changeHandler);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async detach() {
|
|
165
|
+
if (this.node && this.changeHandler) {
|
|
166
|
+
this.node.off('change', this.changeHandler);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.changeHandler = null;
|
|
170
|
+
this.node = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async clear() {
|
|
174
|
+
await this.runTransaction('readwrite', (store) => new Promise((resolve, reject) => {
|
|
175
|
+
const request = store.clear();
|
|
176
|
+
request.onsuccess = () => resolve();
|
|
177
|
+
request.onerror = () => reject(request.error);
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = IndexedDBPersistence;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { useCallback, useEffect, useState } = require('react');
|
|
2
|
+
const DignityP2P = require('../core/dignity-p2p');
|
|
3
|
+
|
|
4
|
+
function useDignity(config) {
|
|
5
|
+
const [node, setNode] = useState(null);
|
|
6
|
+
const [status, setStatus] = useState('idle');
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!config) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
const instance = new DignityP2P(config);
|
|
16
|
+
setNode(instance);
|
|
17
|
+
setStatus('starting');
|
|
18
|
+
setError(null);
|
|
19
|
+
|
|
20
|
+
instance.start()
|
|
21
|
+
.then(() => {
|
|
22
|
+
if (!cancelled) {
|
|
23
|
+
setStatus('running');
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.catch((startError) => {
|
|
27
|
+
if (!cancelled) {
|
|
28
|
+
setError(startError);
|
|
29
|
+
setStatus('error');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
cancelled = true;
|
|
35
|
+
instance.stop()
|
|
36
|
+
.catch(() => undefined)
|
|
37
|
+
.finally(() => {
|
|
38
|
+
setStatus('stopped');
|
|
39
|
+
setNode(null);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}, [config]);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
node,
|
|
46
|
+
status,
|
|
47
|
+
error
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useCollection(node, collectionName) {
|
|
52
|
+
const [records, setRecords] = useState([]);
|
|
53
|
+
|
|
54
|
+
const refresh = useCallback(() => {
|
|
55
|
+
if (!node || !collectionName) {
|
|
56
|
+
setRecords([]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setRecords(node.list(collectionName));
|
|
61
|
+
}, [node, collectionName]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
refresh();
|
|
65
|
+
|
|
66
|
+
if (!node) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
node.on('change', refresh);
|
|
71
|
+
return () => node.off('change', refresh);
|
|
72
|
+
}, [node, refresh]);
|
|
73
|
+
|
|
74
|
+
return records;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usePeers(node, scope = 'main', options = {}) {
|
|
78
|
+
const includeSelf = options.includeSelf !== false;
|
|
79
|
+
const [peers, setPeers] = useState([]);
|
|
80
|
+
|
|
81
|
+
const refresh = useCallback(() => {
|
|
82
|
+
if (!node) {
|
|
83
|
+
setPeers([]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setPeers(node.listPeers(scope, { includeSelf }));
|
|
88
|
+
}, [node, scope, includeSelf]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
refresh();
|
|
92
|
+
|
|
93
|
+
if (!node) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handlePresenceChange = () => refresh();
|
|
98
|
+
node.on('peerdiscovered', handlePresenceChange);
|
|
99
|
+
node.on('peerleft', handlePresenceChange);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
node.off('peerdiscovered', handlePresenceChange);
|
|
103
|
+
node.off('peerleft', handlePresenceChange);
|
|
104
|
+
};
|
|
105
|
+
}, [node, refresh]);
|
|
106
|
+
|
|
107
|
+
return peers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
useDignity,
|
|
112
|
+
useCollection,
|
|
113
|
+
usePeers
|
|
114
|
+
};
|
|
@@ -12,7 +12,8 @@ const DEFAULT_SECURITY_OPTIONS = {
|
|
|
12
12
|
broadcastPasswords: {},
|
|
13
13
|
resolveBroadcastPassword: null,
|
|
14
14
|
powSteps: 22,
|
|
15
|
-
trustedPeerKeys: {}
|
|
15
|
+
trustedPeerKeys: {},
|
|
16
|
+
kdfIterations: 100000
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
function stableStringify(value) {
|
|
@@ -47,6 +48,37 @@ function utf8ToBytes(value) {
|
|
|
47
48
|
return naclUtil.decodeUTF8(value);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
async function deriveBroadcastKey(password, salt, iterations) {
|
|
52
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
53
|
+
|
|
54
|
+
if (subtle) {
|
|
55
|
+
const keyMaterial = await subtle.importKey(
|
|
56
|
+
'raw',
|
|
57
|
+
utf8ToBytes(password),
|
|
58
|
+
'PBKDF2',
|
|
59
|
+
false,
|
|
60
|
+
['deriveBits']
|
|
61
|
+
);
|
|
62
|
+
const bits = await subtle.deriveBits(
|
|
63
|
+
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
64
|
+
keyMaterial,
|
|
65
|
+
256
|
|
66
|
+
);
|
|
67
|
+
return new Uint8Array(bits);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const { pbkdf2Sync } = require('crypto');
|
|
72
|
+
return new Uint8Array(pbkdf2Sync(password, Buffer.from(salt), iterations, 32, 'sha256'));
|
|
73
|
+
} catch (_ignored) {
|
|
74
|
+
return hash32(concatBytes(utf8ToBytes(password), salt));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function legacyBroadcastKey(password, salt) {
|
|
79
|
+
return hash32(concatBytes(utf8ToBytes(password), salt));
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
function normalizePeerPublicKey(publicKey) {
|
|
51
83
|
if (!publicKey || typeof publicKey !== 'object') {
|
|
52
84
|
throw new Error('Public key must be an object with signingPublicKey and encryptionPublicKey');
|
|
@@ -240,7 +272,7 @@ class MessageSecurityService {
|
|
|
240
272
|
this.verifySignature(envelope);
|
|
241
273
|
}
|
|
242
274
|
|
|
243
|
-
const payload = this.decryptPayload(envelope);
|
|
275
|
+
const payload = await this.decryptPayload(envelope);
|
|
244
276
|
|
|
245
277
|
return {
|
|
246
278
|
ignored: false,
|
|
@@ -320,7 +352,8 @@ class MessageSecurityService {
|
|
|
320
352
|
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
321
353
|
const salt = nacl.randomBytes(16);
|
|
322
354
|
const password = this.resolveBroadcastPassword(scope);
|
|
323
|
-
const
|
|
355
|
+
const iterations = this.options.kdfIterations || DEFAULT_SECURITY_OPTIONS.kdfIterations;
|
|
356
|
+
const key = await deriveBroadcastKey(password, salt, iterations);
|
|
324
357
|
const encrypted = nacl.secretbox(plainText, nonce, key);
|
|
325
358
|
|
|
326
359
|
return {
|
|
@@ -330,12 +363,14 @@ class MessageSecurityService {
|
|
|
330
363
|
mode: 'broadcast',
|
|
331
364
|
scope,
|
|
332
365
|
nonce: naclUtil.encodeBase64(nonce),
|
|
333
|
-
salt: naclUtil.encodeBase64(salt)
|
|
366
|
+
salt: naclUtil.encodeBase64(salt),
|
|
367
|
+
kdf: 'pbkdf2',
|
|
368
|
+
kdfIterations: iterations
|
|
334
369
|
}
|
|
335
370
|
};
|
|
336
371
|
}
|
|
337
372
|
|
|
338
|
-
decryptPayload(envelope) {
|
|
373
|
+
async decryptPayload(envelope) {
|
|
339
374
|
const encryption = envelope.security ? envelope.security.encryption : null;
|
|
340
375
|
|
|
341
376
|
if (!encryption || !encryption.enabled || !this.options.encryptionEnabled) {
|
|
@@ -349,7 +384,15 @@ class MessageSecurityService {
|
|
|
349
384
|
const password = this.resolveBroadcastPassword(scope);
|
|
350
385
|
const salt = naclUtil.decodeBase64(encryption.salt);
|
|
351
386
|
const nonce = naclUtil.decodeBase64(encryption.nonce);
|
|
352
|
-
|
|
387
|
+
|
|
388
|
+
let key;
|
|
389
|
+
if (encryption.kdf === 'pbkdf2') {
|
|
390
|
+
const iterations = encryption.kdfIterations || DEFAULT_SECURITY_OPTIONS.kdfIterations;
|
|
391
|
+
key = await deriveBroadcastKey(password, salt, iterations);
|
|
392
|
+
} else {
|
|
393
|
+
key = legacyBroadcastKey(password, salt);
|
|
394
|
+
}
|
|
395
|
+
|
|
353
396
|
const decrypted = nacl.secretbox.open(encryptedBuffer, nonce, key);
|
|
354
397
|
|
|
355
398
|
if (!decrypted) {
|
|
@@ -470,5 +513,7 @@ class MessageSecurityService {
|
|
|
470
513
|
module.exports = {
|
|
471
514
|
MessageSecurityService,
|
|
472
515
|
stableStringify,
|
|
516
|
+
deriveBroadcastKey,
|
|
517
|
+
legacyBroadcastKey,
|
|
473
518
|
DEFAULT_SECURITY_OPTIONS
|
|
474
519
|
};
|