dignity.js 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,17 @@
1
1
  const nacl = require('tweetnacl');
2
2
  const naclUtil = require('tweetnacl-util');
3
3
  const EventEmitter = require('../utils/event-emitter');
4
- const { MessageSecurityService, stableStringify } = require('../security/message-security-service');
4
+ const {
5
+ MessageSecurityService,
6
+ stableStringify,
7
+ DEFAULT_APP_PASSWORD
8
+ } = require('../security/message-security-service');
9
+ const {
10
+ revokeAndRotateIdentity,
11
+ rotateIdentityPassword,
12
+ enrollColdRecoveryPassword
13
+ } = require('../security/identity-rotation');
14
+ const { deriveKeyPairFromCredentials } = require('../security/derive-key-pair');
5
15
  const {
6
16
  DEFAULT_PEER_GROUP_OPTIONS,
7
17
  peerGroupScope,
@@ -91,6 +101,13 @@ class DignityP2P extends EventEmitter {
91
101
  this.gossipIdTtlMs = security && typeof security.gossipIdTtlMs === 'number'
92
102
  ? security.gossipIdTtlMs
93
103
  : 5 * 60 * 1000;
104
+ this.maxSeenGossipIds = security && typeof security.maxSeenGossipIds === 'number'
105
+ ? security.maxSeenGossipIds
106
+ : 100000;
107
+ this.gossipPublishMinIntervalMs = security && typeof security.gossipPublishMinIntervalMs === 'number'
108
+ ? security.gossipPublishMinIntervalMs
109
+ : 0;
110
+ this.lastGossipPublishAt = new Map(); // groupId -> timestamp
94
111
  this.maxAppliedOperations = security && typeof security.maxAppliedOperations === 'number'
95
112
  ? security.maxAppliedOperations
96
113
  : 50000;
@@ -103,6 +120,14 @@ class DignityP2P extends EventEmitter {
103
120
  async start() {
104
121
  this.networkAdapter.onMessage(this.boundMessageHandler);
105
122
  await this.networkAdapter.start(this.nodeId);
123
+
124
+ const appPassword = this.securityService.options.appPassword;
125
+ if (!appPassword || appPassword === DEFAULT_APP_PASSWORD) {
126
+ this.emit('warning', {
127
+ type: 'default-app-password',
128
+ message: 'Using the default appPassword is insecure; set a strong shared secret in production.'
129
+ });
130
+ }
106
131
  }
107
132
 
108
133
  async stop() {
@@ -461,8 +486,154 @@ class DignityP2P extends EventEmitter {
461
486
  });
462
487
  }
463
488
 
464
- registerPeerPublicKey(peerId, publicKey) {
465
- this.securityService.registerPeerPublicKey(peerId, publicKey);
489
+ registerPeerPublicKey(peerId, publicKey, options = {}) {
490
+ this.securityService.registerPeerPublicKey(peerId, publicKey, options);
491
+ }
492
+
493
+ getPeerIdentityGeneration(peerId) {
494
+ return this.securityService.getPeerIdentityGeneration(peerId);
495
+ }
496
+
497
+ getPeerIdentityState(peerId) {
498
+ return this.securityService.getPeerIdentityState(peerId);
499
+ }
500
+
501
+ applyPeerIdentityRotation(peerId, rotation) {
502
+ const result = this.securityService.applyIdentityRotation(peerId, rotation);
503
+ if (result.applied) {
504
+ this.emit('identityrotated', {
505
+ peerId,
506
+ username: rotation.username,
507
+ fromGeneration: result.fromGeneration,
508
+ toGeneration: result.toGeneration,
509
+ rotationKind: result.rotationKind
510
+ });
511
+ }
512
+ return result;
513
+ }
514
+
515
+ async broadcastIdentityRotation(rotation, options = {}) {
516
+ return this.broadcastMessage('identity:rotate', rotation, options);
517
+ }
518
+
519
+ async broadcastColdRecoveryEnrollment(enrollment, options = {}) {
520
+ return this.broadcastMessage('identity:cold-enroll', enrollment, options);
521
+ }
522
+
523
+ applyPeerColdRecoveryEnrollment(peerId, enrollment) {
524
+ const result = this.securityService.applyColdRecoveryEnrollment(peerId, enrollment);
525
+ if (result.applied) {
526
+ this.emit('coldrecoveryenrolled', {
527
+ peerId,
528
+ username: enrollment.username,
529
+ recoveryPublicKey: enrollment.recoveryPublicKey
530
+ });
531
+ }
532
+ return result;
533
+ }
534
+
535
+ async enrollAndBroadcastColdRecovery({
536
+ username,
537
+ coldPassword,
538
+ pepper = '',
539
+ kdfIterations,
540
+ broadcastOptions = {}
541
+ } = {}) {
542
+ const result = await enrollColdRecoveryPassword({
543
+ username,
544
+ coldPassword,
545
+ pepper,
546
+ kdfIterations
547
+ });
548
+ await this.broadcastColdRecoveryEnrollment(result.enrollment, broadcastOptions);
549
+ return result;
550
+ }
551
+
552
+ async revokeAndRotateDerivedIdentity({
553
+ username,
554
+ password,
555
+ coldPassword,
556
+ currentGeneration = 1,
557
+ reason = 'compromise-recovery',
558
+ pepper = '',
559
+ kdfIterations,
560
+ broadcast = false,
561
+ broadcastOptions = {}
562
+ } = {}) {
563
+ const result = await revokeAndRotateIdentity({
564
+ username,
565
+ password,
566
+ coldPassword,
567
+ currentGeneration,
568
+ reason,
569
+ pepper,
570
+ kdfIterations
571
+ });
572
+
573
+ if (broadcast) {
574
+ await this.broadcastIdentityRotation(result.rotation, broadcastOptions);
575
+ }
576
+
577
+ return result;
578
+ }
579
+
580
+ async rotateDerivedIdentityPassword({
581
+ username,
582
+ currentPassword,
583
+ newPassword,
584
+ coldPassword,
585
+ currentGeneration = 1,
586
+ reason = 'password-change',
587
+ pepper = '',
588
+ kdfIterations,
589
+ broadcast = false,
590
+ broadcastOptions = {}
591
+ } = {}) {
592
+ const result = await rotateIdentityPassword({
593
+ username,
594
+ currentPassword,
595
+ newPassword,
596
+ coldPassword,
597
+ currentGeneration,
598
+ reason,
599
+ pepper,
600
+ kdfIterations
601
+ });
602
+
603
+ if (broadcast) {
604
+ await this.broadcastIdentityRotation(result.rotation, broadcastOptions);
605
+ }
606
+
607
+ return result;
608
+ }
609
+
610
+ async adoptDerivedIdentityKeyPair(keyPair, { generation = 1 } = {}) {
611
+ if (!keyPair || !keyPair.signing || !keyPair.encryption) {
612
+ throw new Error('adoptDerivedIdentityKeyPair requires a derived keyPair');
613
+ }
614
+
615
+ this.securityService.signingSecretKey = keyPair.signing.secretKey;
616
+ this.securityService.signingPublicKey = keyPair.signing.publicKey;
617
+ this.securityService.encryptionSecretKey = keyPair.encryption.secretKey;
618
+ this.securityService.encryptionPublicKey = keyPair.encryption.publicKey;
619
+ this.securityService.publicKeyBundle = {
620
+ signingPublicKey: naclUtil.encodeBase64(keyPair.signing.publicKey),
621
+ encryptionPublicKey: naclUtil.encodeBase64(keyPair.encryption.publicKey)
622
+ };
623
+ this.securityService.options.keyPair = keyPair;
624
+ this.securityService.options.identityGeneration = generation;
625
+ }
626
+
627
+ async deriveAndAdoptIdentity({ username, password, generation = 1, pepper = '', kdfIterations } = {}) {
628
+ const keyPair = await deriveKeyPairFromCredentials({
629
+ username,
630
+ password,
631
+ generation,
632
+ pepper,
633
+ kdfIterations
634
+ });
635
+ await this.adoptDerivedIdentityKeyPair(keyPair, { generation });
636
+ return keyPair;
466
637
  }
467
638
 
468
639
  trustPeerPublicKey(peerId, publicKey) {
@@ -624,6 +795,14 @@ class DignityP2P extends EventEmitter {
624
795
  this.seenGossipIds.delete(gossipId);
625
796
  }
626
797
  }
798
+
799
+ while (this.seenGossipIds.size > this.maxSeenGossipIds) {
800
+ const oldestGossipId = this.seenGossipIds.keys().next().value;
801
+ if (!oldestGossipId) {
802
+ break;
803
+ }
804
+ this.seenGossipIds.delete(oldestGossipId);
805
+ }
627
806
  }
628
807
 
629
808
  hasSeenGossip(gossipId) {
@@ -641,6 +820,7 @@ class DignityP2P extends EventEmitter {
641
820
  }
642
821
 
643
822
  this.seenGossipIds.set(gossipId, this.now() + this.gossipIdTtlMs);
823
+ this.pruneSeenGossip();
644
824
  }
645
825
 
646
826
  listConnectedPeerIds() {
@@ -734,6 +914,16 @@ class DignityP2P extends EventEmitter {
734
914
  throw new Error(`PeerGroup ${groupId} has not been joined`);
735
915
  }
736
916
 
917
+ if (this.gossipPublishMinIntervalMs > 0) {
918
+ const lastPublishAt = this.lastGossipPublishAt.get(groupId) || 0;
919
+ const elapsed = this.now() - lastPublishAt;
920
+ if (elapsed < this.gossipPublishMinIntervalMs) {
921
+ const error = new Error(`Gossip publish rate limit exceeded for group ${groupId}`);
922
+ error.code = 'GOSSIP_RATE_LIMIT';
923
+ throw error;
924
+ }
925
+ }
926
+
737
927
  const fanout = typeof options.fanout === 'number'
738
928
  ? options.fanout
739
929
  : (group ? group.fanout : this.defaultPeerGroupFanout);
@@ -750,6 +940,7 @@ class DignityP2P extends EventEmitter {
750
940
 
751
941
  const gossipId = options.gossipId || this.idGenerator();
752
942
  this.markSeenGossip(gossipId);
943
+ this.lastGossipPublishAt.set(groupId, this.now());
753
944
 
754
945
  await this.broadcastMessage('peer-group:gossip', {
755
946
  groupId,
@@ -1117,6 +1308,37 @@ class DignityP2P extends EventEmitter {
1117
1308
  return;
1118
1309
  }
1119
1310
 
1311
+ if (decrypted.messageType === 'identity:rotate') {
1312
+ const peerId = decrypted.senderId || decrypted.payload?.username;
1313
+ if (peerId && decrypted.payload) {
1314
+ const result = this.applyPeerIdentityRotation(peerId, decrypted.payload);
1315
+ if (!result.applied) {
1316
+ this.emit('warning', {
1317
+ type: 'identity-rotation-ignored',
1318
+ peerId,
1319
+ reason: result.reason
1320
+ });
1321
+ }
1322
+ }
1323
+ return;
1324
+ }
1325
+
1326
+ if (decrypted.messageType === 'identity:cold-enroll') {
1327
+ const peerId = decrypted.senderId || decrypted.payload?.username;
1328
+ if (peerId && decrypted.payload) {
1329
+ try {
1330
+ this.applyPeerColdRecoveryEnrollment(peerId, decrypted.payload);
1331
+ } catch (error) {
1332
+ this.emit('warning', {
1333
+ type: 'cold-recovery-enrollment-rejected',
1334
+ peerId,
1335
+ error
1336
+ });
1337
+ }
1338
+ }
1339
+ return;
1340
+ }
1341
+
1120
1342
  if (message && message.senderId && message.senderPublicKey) {
1121
1343
  this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
1122
1344
  }
@@ -1131,7 +1353,10 @@ class DignityP2P extends EventEmitter {
1131
1353
  const { collectionName, record } = payload;
1132
1354
 
1133
1355
  if (collectionName && record) {
1134
- const applied = this.restoreRecord(collectionName, record);
1356
+ const applied = this.restoreRecord(collectionName, record, {
1357
+ rejectOnHashMismatch: true,
1358
+ via: 'direct-mesh'
1359
+ });
1135
1360
  if (applied) {
1136
1361
  this.emit('change', {
1137
1362
  kind: 'snapshot',
package/src/index.js CHANGED
@@ -28,8 +28,20 @@ const VDF = require('./security/vdf');
28
28
  const SlothPermutation = require('./security/sloth-vdf');
29
29
  const {
30
30
  MessageSecurityService,
31
- DEFAULT_SECURITY_OPTIONS
31
+ DEFAULT_SECURITY_OPTIONS,
32
+ DEFAULT_APP_PASSWORD
32
33
  } = require('./security/message-security-service');
34
+ const { deriveKeyPairFromCredentials, keyPairToPublicBundle, deriveColdRecoverySigningKey } = require('./security/derive-key-pair');
35
+ const {
36
+ createIdentityRotation,
37
+ verifyIdentityRotation,
38
+ revokeAndRotateIdentity,
39
+ rotateIdentityPassword,
40
+ enrollColdRecoveryPassword,
41
+ verifyColdRecoveryEnrollment,
42
+ shouldApplyIdentityRotation
43
+ } = require('./security/identity-rotation');
44
+ const parsePeerJsServerUrl = require('./signaling/parse-peerjs-url');
33
45
  const {
34
46
  PEER_GROUP_SCOPE_PREFIX,
35
47
  DEFAULT_PEER_GROUP_OPTIONS,
@@ -55,6 +67,18 @@ module.exports = {
55
67
  SlothPermutation,
56
68
  MessageSecurityService,
57
69
  DEFAULT_SECURITY_OPTIONS,
70
+ DEFAULT_APP_PASSWORD,
71
+ deriveKeyPairFromCredentials,
72
+ deriveColdRecoverySigningKey,
73
+ keyPairToPublicBundle,
74
+ createIdentityRotation,
75
+ verifyIdentityRotation,
76
+ revokeAndRotateIdentity,
77
+ rotateIdentityPassword,
78
+ enrollColdRecoveryPassword,
79
+ verifyColdRecoveryEnrollment,
80
+ shouldApplyIdentityRotation,
81
+ parsePeerJsServerUrl,
58
82
  PEER_GROUP_SCOPE_PREFIX,
59
83
  DEFAULT_PEER_GROUP_OPTIONS,
60
84
  peerGroupScope,
@@ -0,0 +1,147 @@
1
+ const nacl = require('tweetnacl');
2
+ const naclUtil = require('tweetnacl-util');
3
+ const { deriveBroadcastKey, DEFAULT_SECURITY_OPTIONS } = require('./message-security-service');
4
+
5
+ const SIGNING_INFO = 'dignity-signing-v1';
6
+ const ENCRYPTION_INFO = 'dignity-encryption-v1';
7
+ const COLD_RECOVERY_INFO = 'dignity-cold-recovery-v1';
8
+
9
+ function utf8ToBytes(value) {
10
+ return naclUtil.decodeUTF8(value);
11
+ }
12
+
13
+ function concatBytes(...parts) {
14
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
15
+ const result = new Uint8Array(total);
16
+ let offset = 0;
17
+ for (const part of parts) {
18
+ result.set(part, offset);
19
+ offset += part.length;
20
+ }
21
+ return result;
22
+ }
23
+
24
+ function buildColdRecoverySalt(username, pepper = '') {
25
+ if (!username || typeof username !== 'string') {
26
+ throw new Error('deriveColdRecoverySigningKey requires username');
27
+ }
28
+
29
+ const segments = ['dignity-cold-recovery-v1'];
30
+ if (pepper) {
31
+ segments.push(pepper);
32
+ }
33
+ segments.push(username, COLD_RECOVERY_INFO);
34
+ return utf8ToBytes(segments.join('\0'));
35
+ }
36
+
37
+ async function deriveColdRecoverySigningKey({
38
+ username,
39
+ coldPassword,
40
+ pepper = '',
41
+ kdfIterations
42
+ } = {}) {
43
+ if (!coldPassword || typeof coldPassword !== 'string') {
44
+ throw new Error('deriveColdRecoverySigningKey requires coldPassword');
45
+ }
46
+
47
+ const salt = buildColdRecoverySalt(username, pepper);
48
+ const iterations = typeof kdfIterations === 'number'
49
+ ? kdfIterations
50
+ : DEFAULT_SECURITY_OPTIONS.kdfIterations;
51
+ const seed = await deriveBroadcastKey(coldPassword, salt, iterations);
52
+ const signing = nacl.sign.keyPair.fromSeed(seed);
53
+
54
+ return {
55
+ signing,
56
+ recoveryPublicKey: naclUtil.encodeBase64(signing.publicKey)
57
+ };
58
+ }
59
+
60
+ function buildIdentitySalt(username, info, pepper = '', generation = 1) {
61
+ if (!username || typeof username !== 'string') {
62
+ throw new Error('deriveKeyPairFromCredentials requires username');
63
+ }
64
+ if (!info || typeof info !== 'string') {
65
+ throw new Error('deriveKeyPairFromCredentials requires info label');
66
+ }
67
+ const normalizedGeneration = Number(generation);
68
+ if (!Number.isInteger(normalizedGeneration) || normalizedGeneration < 1) {
69
+ throw new Error('deriveKeyPairFromCredentials requires generation >= 1');
70
+ }
71
+
72
+ const segments = ['dignity-identity-v1'];
73
+ if (pepper) {
74
+ segments.push(pepper);
75
+ }
76
+ segments.push(username, `gen:${normalizedGeneration}`, info);
77
+ return utf8ToBytes(segments.join('\0'));
78
+ }
79
+
80
+ async function deriveIdentitySeed({ password, username, info, pepper, generation, kdfIterations }) {
81
+ if (!password || typeof password !== 'string') {
82
+ throw new Error('deriveKeyPairFromCredentials requires password');
83
+ }
84
+
85
+ const salt = buildIdentitySalt(username, info, pepper, generation);
86
+ const iterations = typeof kdfIterations === 'number'
87
+ ? kdfIterations
88
+ : DEFAULT_SECURITY_OPTIONS.kdfIterations;
89
+
90
+ return deriveBroadcastKey(password, salt, iterations);
91
+ }
92
+
93
+ /**
94
+ * Derive deterministic Ed25519 signing and Curve25519 box key pairs from
95
+ * public username + private password. Same inputs yield the same keys across
96
+ * runs and environments. Bump `generation` (2, 3, …) after compromise recovery;
97
+ * see `identity-rotation.js` for signed revocation / password-change flows.
98
+ */
99
+ async function deriveKeyPairFromCredentials({
100
+ username,
101
+ password,
102
+ pepper = '',
103
+ generation = 1,
104
+ kdfIterations
105
+ } = {}) {
106
+ const signingSeed = await deriveIdentitySeed({
107
+ password,
108
+ username,
109
+ info: SIGNING_INFO,
110
+ pepper,
111
+ generation,
112
+ kdfIterations
113
+ });
114
+ const encryptionSecret = await deriveIdentitySeed({
115
+ password,
116
+ username,
117
+ info: ENCRYPTION_INFO,
118
+ pepper,
119
+ generation,
120
+ kdfIterations
121
+ });
122
+
123
+ return {
124
+ signing: nacl.sign.keyPair.fromSeed(signingSeed),
125
+ encryption: nacl.box.keyPair.fromSecretKey(encryptionSecret),
126
+ generation
127
+ };
128
+ }
129
+
130
+ function keyPairToPublicBundle(keyPair) {
131
+ return {
132
+ signingPublicKey: naclUtil.encodeBase64(keyPair.signing.publicKey),
133
+ encryptionPublicKey: naclUtil.encodeBase64(keyPair.encryption.publicKey)
134
+ };
135
+ }
136
+
137
+ module.exports = {
138
+ deriveKeyPairFromCredentials,
139
+ deriveColdRecoverySigningKey,
140
+ keyPairToPublicBundle,
141
+ buildIdentitySalt,
142
+ buildColdRecoverySalt,
143
+ SIGNING_INFO,
144
+ ENCRYPTION_INFO,
145
+ COLD_RECOVERY_INFO,
146
+ concatBytes
147
+ };