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.
@@ -0,0 +1,427 @@
1
+ const nacl = require('tweetnacl');
2
+ const naclUtil = require('tweetnacl-util');
3
+ const { stableStringify } = require('./message-security-service');
4
+ const {
5
+ deriveKeyPairFromCredentials,
6
+ deriveColdRecoverySigningKey,
7
+ keyPairToPublicBundle
8
+ } = require('./derive-key-pair');
9
+
10
+ const ROTATION_TYPES = new Set(['compromise-recovery', 'password-change']);
11
+
12
+ function utf8ToBytes(value) {
13
+ return naclUtil.decodeUTF8(value);
14
+ }
15
+
16
+ function normalizePublicKeyBundle(publicKey) {
17
+ if (!publicKey || !publicKey.signingPublicKey || !publicKey.encryptionPublicKey) {
18
+ throw new Error('Public key bundle requires signingPublicKey and encryptionPublicKey');
19
+ }
20
+ return {
21
+ signingPublicKey: publicKey.signingPublicKey,
22
+ encryptionPublicKey: publicKey.encryptionPublicKey
23
+ };
24
+ }
25
+
26
+ function buildIdentityRotationPayload({
27
+ username,
28
+ fromGeneration,
29
+ toGeneration,
30
+ previousPublicKey,
31
+ nextPublicKey,
32
+ rotationKind,
33
+ reason,
34
+ timestamp
35
+ }) {
36
+ if (!username) {
37
+ throw new Error('Identity rotation requires username');
38
+ }
39
+ if (!ROTATION_TYPES.has(rotationKind)) {
40
+ throw new Error(`Identity rotation kind must be one of: ${[...ROTATION_TYPES].join(', ')}`);
41
+ }
42
+ if (toGeneration !== fromGeneration + 1) {
43
+ throw new Error('Identity rotation must advance generation by exactly 1');
44
+ }
45
+
46
+ return {
47
+ version: 1,
48
+ type: 'identity:rotate',
49
+ username,
50
+ fromGeneration,
51
+ toGeneration,
52
+ previousPublicKey: normalizePublicKeyBundle(previousPublicKey),
53
+ nextPublicKey: normalizePublicKeyBundle(nextPublicKey),
54
+ rotationKind,
55
+ reason: reason || '',
56
+ timestamp: typeof timestamp === 'number' ? timestamp : Date.now()
57
+ };
58
+ }
59
+
60
+ function signIdentityRotationPayload(payload, signingSecretKey) {
61
+ const message = utf8ToBytes(stableStringify(payload));
62
+ const signature = nacl.sign.detached(message, signingSecretKey);
63
+ return naclUtil.encodeBase64(signature);
64
+ }
65
+
66
+ function verifyDetachedSignature(payload, signatureBase64, signingPublicKeyBase64) {
67
+ const message = utf8ToBytes(stableStringify(payload));
68
+ const signatureBytes = naclUtil.decodeBase64(signatureBase64);
69
+ const signingPublicKey = naclUtil.decodeBase64(signingPublicKeyBase64);
70
+ return nacl.sign.detached.verify(message, signatureBytes, signingPublicKey);
71
+ }
72
+
73
+ function createIdentityRotation({
74
+ username,
75
+ fromGeneration,
76
+ toGeneration,
77
+ previousPublicKey,
78
+ nextKeyPair,
79
+ rotationKind,
80
+ reason,
81
+ timestamp,
82
+ coldRecoverySigningSecretKey
83
+ }) {
84
+ if (!nextKeyPair || !nextKeyPair.signing || !nextKeyPair.signing.secretKey) {
85
+ throw new Error('Identity rotation requires nextKeyPair with signing secret');
86
+ }
87
+
88
+ const payload = buildIdentityRotationPayload({
89
+ username,
90
+ fromGeneration,
91
+ toGeneration,
92
+ previousPublicKey,
93
+ nextPublicKey: keyPairToPublicBundle(nextKeyPair),
94
+ rotationKind,
95
+ reason,
96
+ timestamp
97
+ });
98
+
99
+ const rotation = {
100
+ ...payload,
101
+ signature: signIdentityRotationPayload(payload, nextKeyPair.signing.secretKey)
102
+ };
103
+
104
+ if (coldRecoverySigningSecretKey) {
105
+ rotation.recoverySignature = signIdentityRotationPayload(
106
+ payload,
107
+ coldRecoverySigningSecretKey
108
+ );
109
+ }
110
+
111
+ return rotation;
112
+ }
113
+
114
+ function buildColdRecoveryEnrollmentPayload({ username, recoveryPublicKey, timestamp }) {
115
+ if (!username) {
116
+ throw new Error('Cold recovery enrollment requires username');
117
+ }
118
+ if (!recoveryPublicKey) {
119
+ throw new Error('Cold recovery enrollment requires recoveryPublicKey');
120
+ }
121
+
122
+ return {
123
+ version: 1,
124
+ type: 'identity:cold-enroll',
125
+ username,
126
+ recoveryPublicKey,
127
+ timestamp: typeof timestamp === 'number' ? timestamp : Date.now()
128
+ };
129
+ }
130
+
131
+ function createColdRecoveryEnrollment({
132
+ username,
133
+ coldRecoverySigningSecretKey,
134
+ recoveryPublicKey,
135
+ timestamp
136
+ }) {
137
+ if (!coldRecoverySigningSecretKey) {
138
+ throw new Error('Cold recovery enrollment requires cold recovery signing secret');
139
+ }
140
+ if (!recoveryPublicKey) {
141
+ throw new Error('Cold recovery enrollment requires recoveryPublicKey');
142
+ }
143
+
144
+ const payload = buildColdRecoveryEnrollmentPayload({
145
+ username,
146
+ recoveryPublicKey,
147
+ timestamp
148
+ });
149
+
150
+ return {
151
+ ...payload,
152
+ signature: signIdentityRotationPayload(payload, coldRecoverySigningSecretKey)
153
+ };
154
+ }
155
+
156
+ function verifyColdRecoveryEnrollment(enrollment) {
157
+ if (!enrollment || enrollment.type !== 'identity:cold-enroll' || enrollment.version !== 1) {
158
+ return { ok: false, error: 'invalid-enrollment-shape' };
159
+ }
160
+
161
+ if (!enrollment.signature || !enrollment.recoveryPublicKey) {
162
+ return { ok: false, error: 'missing-enrollment-fields' };
163
+ }
164
+
165
+ const { signature, ...payload } = enrollment;
166
+ const verified = verifyDetachedSignature(payload, signature, enrollment.recoveryPublicKey);
167
+
168
+ if (!verified) {
169
+ return { ok: false, error: 'invalid-enrollment-signature' };
170
+ }
171
+
172
+ return { ok: true, enrollment };
173
+ }
174
+
175
+ function verifyIdentityRotation(rotation, options = {}) {
176
+ if (!rotation || rotation.type !== 'identity:rotate' || rotation.version !== 1) {
177
+ return { ok: false, error: 'invalid-rotation-shape' };
178
+ }
179
+
180
+ if (!rotation.signature || !rotation.nextPublicKey || !rotation.previousPublicKey) {
181
+ return { ok: false, error: 'missing-rotation-fields' };
182
+ }
183
+
184
+ if (rotation.toGeneration !== rotation.fromGeneration + 1) {
185
+ return { ok: false, error: 'invalid-generation-step' };
186
+ }
187
+
188
+ if (!ROTATION_TYPES.has(rotation.rotationKind)) {
189
+ return { ok: false, error: 'invalid-rotation-kind' };
190
+ }
191
+
192
+ const { signature, recoverySignature, ...payload } = rotation;
193
+ const verified = verifyDetachedSignature(payload, signature, rotation.nextPublicKey.signingPublicKey);
194
+
195
+ if (!verified) {
196
+ return { ok: false, error: 'invalid-signature' };
197
+ }
198
+
199
+ const requiredRecoveryPublicKey = options.requiredRecoveryPublicKey || null;
200
+ if (requiredRecoveryPublicKey) {
201
+ if (!recoverySignature) {
202
+ return { ok: false, error: 'missing-recovery-signature' };
203
+ }
204
+
205
+ const recoveryVerified = verifyDetachedSignature(
206
+ payload,
207
+ recoverySignature,
208
+ requiredRecoveryPublicKey
209
+ );
210
+
211
+ if (!recoveryVerified) {
212
+ return { ok: false, error: 'invalid-recovery-signature' };
213
+ }
214
+ }
215
+
216
+ if (rotation.previousPublicKey.signingPublicKey === rotation.nextPublicKey.signingPublicKey) {
217
+ return { ok: false, error: 'unchanged-signing-key' };
218
+ }
219
+
220
+ return { ok: true, rotation };
221
+ }
222
+
223
+ async function resolveColdRecoverySigningSecretKey({
224
+ username,
225
+ coldPassword,
226
+ pepper,
227
+ kdfIterations
228
+ }) {
229
+ if (!coldPassword) {
230
+ return null;
231
+ }
232
+
233
+ const coldRecovery = await deriveColdRecoverySigningKey({
234
+ username,
235
+ coldPassword,
236
+ pepper,
237
+ kdfIterations
238
+ });
239
+ return coldRecovery.signing.secretKey;
240
+ }
241
+
242
+ /**
243
+ * Same username + password, next generation (key #2, #3, …).
244
+ * Optional coldPassword co-signs the rotation so a stolen primary password
245
+ * cannot lock out the legitimate user after cold recovery is enrolled.
246
+ */
247
+ async function revokeAndRotateIdentity({
248
+ username,
249
+ password,
250
+ coldPassword,
251
+ currentGeneration = 1,
252
+ reason = 'compromise-recovery',
253
+ pepper = '',
254
+ kdfIterations,
255
+ timestamp
256
+ } = {}) {
257
+ const currentKeyPair = await deriveKeyPairFromCredentials({
258
+ username,
259
+ password,
260
+ generation: currentGeneration,
261
+ pepper,
262
+ kdfIterations
263
+ });
264
+ const nextGeneration = currentGeneration + 1;
265
+ const nextKeyPair = await deriveKeyPairFromCredentials({
266
+ username,
267
+ password,
268
+ generation: nextGeneration,
269
+ pepper,
270
+ kdfIterations
271
+ });
272
+
273
+ const coldRecoverySigningSecretKey = await resolveColdRecoverySigningSecretKey({
274
+ username,
275
+ coldPassword,
276
+ pepper,
277
+ kdfIterations
278
+ });
279
+
280
+ const rotation = createIdentityRotation({
281
+ username,
282
+ fromGeneration: currentGeneration,
283
+ toGeneration: nextGeneration,
284
+ previousPublicKey: keyPairToPublicBundle(currentKeyPair),
285
+ nextKeyPair,
286
+ rotationKind: 'compromise-recovery',
287
+ reason,
288
+ timestamp,
289
+ coldRecoverySigningSecretKey
290
+ });
291
+
292
+ return {
293
+ rotation,
294
+ currentKeyPair,
295
+ nextKeyPair,
296
+ nextGeneration,
297
+ coldRecoveryUsed: Boolean(coldRecoverySigningSecretKey)
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Password change: signed with new-password gen N+1 keys.
303
+ * Optional coldPassword prevents an attacker who stole the old primary password
304
+ * from rotating the identity away from the legitimate user.
305
+ */
306
+ async function rotateIdentityPassword({
307
+ username,
308
+ currentPassword,
309
+ newPassword,
310
+ coldPassword,
311
+ currentGeneration = 1,
312
+ reason = 'password-change',
313
+ pepper = '',
314
+ kdfIterations,
315
+ timestamp
316
+ } = {}) {
317
+ const currentKeyPair = await deriveKeyPairFromCredentials({
318
+ username,
319
+ password: currentPassword,
320
+ generation: currentGeneration,
321
+ pepper,
322
+ kdfIterations
323
+ });
324
+ const nextGeneration = currentGeneration + 1;
325
+ const nextKeyPair = await deriveKeyPairFromCredentials({
326
+ username,
327
+ password: newPassword,
328
+ generation: nextGeneration,
329
+ pepper,
330
+ kdfIterations
331
+ });
332
+
333
+ const coldRecoverySigningSecretKey = await resolveColdRecoverySigningSecretKey({
334
+ username,
335
+ coldPassword,
336
+ pepper,
337
+ kdfIterations
338
+ });
339
+
340
+ const rotation = createIdentityRotation({
341
+ username,
342
+ fromGeneration: currentGeneration,
343
+ toGeneration: nextGeneration,
344
+ previousPublicKey: keyPairToPublicBundle(currentKeyPair),
345
+ nextKeyPair,
346
+ rotationKind: 'password-change',
347
+ reason,
348
+ timestamp,
349
+ coldRecoverySigningSecretKey
350
+ });
351
+
352
+ return {
353
+ rotation,
354
+ currentKeyPair,
355
+ nextKeyPair,
356
+ nextGeneration,
357
+ coldRecoveryUsed: Boolean(coldRecoverySigningSecretKey)
358
+ };
359
+ }
360
+
361
+ async function enrollColdRecoveryPassword({
362
+ username,
363
+ coldPassword,
364
+ pepper = '',
365
+ kdfIterations,
366
+ timestamp
367
+ } = {}) {
368
+ const coldRecovery = await deriveColdRecoverySigningKey({
369
+ username,
370
+ coldPassword,
371
+ pepper,
372
+ kdfIterations
373
+ });
374
+
375
+ const enrollment = createColdRecoveryEnrollment({
376
+ username,
377
+ coldRecoverySigningSecretKey: coldRecovery.signing.secretKey,
378
+ recoveryPublicKey: coldRecovery.recoveryPublicKey,
379
+ timestamp
380
+ });
381
+
382
+ return {
383
+ enrollment,
384
+ recoveryPublicKey: coldRecovery.recoveryPublicKey,
385
+ coldRecovery
386
+ };
387
+ }
388
+
389
+ function shouldApplyIdentityRotation(currentState, rotation, options = {}) {
390
+ const requiredRecoveryPublicKey = options.enrolledRecoveryPublicKey
391
+ || (currentState && currentState.recoveryPublicKey)
392
+ || null;
393
+
394
+ const verified = verifyIdentityRotation(rotation, { requiredRecoveryPublicKey });
395
+ if (!verified.ok) {
396
+ return { apply: false, reason: verified.error };
397
+ }
398
+
399
+ if (currentState && rotation.toGeneration <= currentState.generation) {
400
+ return { apply: false, reason: 'stale-generation' };
401
+ }
402
+
403
+ if (
404
+ currentState
405
+ && currentState.publicKey
406
+ && currentState.publicKey.signingPublicKey !== rotation.previousPublicKey.signingPublicKey
407
+ ) {
408
+ return { apply: false, reason: 'previous-key-mismatch' };
409
+ }
410
+
411
+ return { apply: true, rotation: verified.rotation };
412
+ }
413
+
414
+ module.exports = {
415
+ createIdentityRotation,
416
+ createColdRecoveryEnrollment,
417
+ verifyIdentityRotation,
418
+ verifyColdRecoveryEnrollment,
419
+ revokeAndRotateIdentity,
420
+ rotateIdentityPassword,
421
+ enrollColdRecoveryPassword,
422
+ shouldApplyIdentityRotation,
423
+ keyPairToPublicBundle,
424
+ buildIdentityRotationPayload,
425
+ buildColdRecoveryEnrollmentPayload,
426
+ signIdentityRotationPayload
427
+ };
@@ -2,13 +2,15 @@ const nacl = require('tweetnacl');
2
2
  const naclUtil = require('tweetnacl-util');
3
3
  const VDF = require('./vdf');
4
4
 
5
+ const DEFAULT_APP_PASSWORD = 'change-this-app-password';
6
+
5
7
  const DEFAULT_SECURITY_OPTIONS = {
6
8
  enabled: true,
7
9
  signingEnabled: true,
8
10
  encryptionEnabled: true,
9
11
  powEnabled: true,
10
12
  powTargetMs: 1000,
11
- appPassword: 'change-this-app-password',
13
+ appPassword: DEFAULT_APP_PASSWORD,
12
14
  broadcastPasswords: {},
13
15
  resolveBroadcastPassword: null,
14
16
  powSteps: 22,
@@ -123,8 +125,11 @@ class MessageSecurityService {
123
125
  };
124
126
 
125
127
  this.peerPublicKeys = new Map();
128
+ this.peerIdentityGenerations = new Map(); // peerId -> generation number
129
+ this.peerRecoveryPublicKeys = new Map(); // peerId -> cold recovery signing pubkey (base64)
126
130
  for (const [peerId, peerKey] of Object.entries(this.options.trustedPeerKeys || {})) {
127
131
  this.peerPublicKeys.set(peerId, normalizePeerPublicKey(peerKey));
132
+ this.peerIdentityGenerations.set(peerId, 1);
128
133
  }
129
134
 
130
135
  this.calibratedPowSteps = this.options.powSteps;
@@ -134,8 +139,92 @@ class MessageSecurityService {
134
139
  return { ...this.publicKeyBundle };
135
140
  }
136
141
 
137
- registerPeerPublicKey(peerId, publicKey) {
138
- this.peerPublicKeys.set(peerId, normalizePeerPublicKey(publicKey));
142
+ registerPeerPublicKey(peerId, publicKey, options = {}) {
143
+ const normalized = normalizePeerPublicKey(publicKey);
144
+ const generation = typeof options.generation === 'number' ? options.generation : 1;
145
+ const currentGeneration = this.peerIdentityGenerations.get(peerId) || 0;
146
+
147
+ if (generation < currentGeneration && options.allowDowngrade !== true) {
148
+ throw new Error(`Refusing older identity generation for peer ${peerId}`);
149
+ }
150
+
151
+ this.peerPublicKeys.set(peerId, normalized);
152
+ this.peerIdentityGenerations.set(peerId, Math.max(currentGeneration, generation));
153
+ }
154
+
155
+ getPeerIdentityGeneration(peerId) {
156
+ return this.peerIdentityGenerations.get(peerId) || 0;
157
+ }
158
+
159
+ getPeerIdentityState(peerId) {
160
+ const publicKey = this.peerPublicKeys.get(peerId);
161
+ const recoveryPublicKey = this.peerRecoveryPublicKeys.get(peerId) || null;
162
+
163
+ if (!publicKey && !recoveryPublicKey) {
164
+ return null;
165
+ }
166
+
167
+ return {
168
+ publicKey: publicKey ? { ...publicKey } : null,
169
+ generation: this.getPeerIdentityGeneration(peerId),
170
+ recoveryPublicKey
171
+ };
172
+ }
173
+
174
+ registerPeerRecoveryPublicKey(peerId, recoveryPublicKey) {
175
+ if (!peerId || !recoveryPublicKey) {
176
+ throw new Error('registerPeerRecoveryPublicKey requires peerId and recoveryPublicKey');
177
+ }
178
+ this.peerRecoveryPublicKeys.set(peerId, recoveryPublicKey);
179
+ }
180
+
181
+ getPeerRecoveryPublicKey(peerId) {
182
+ return this.peerRecoveryPublicKeys.get(peerId) || null;
183
+ }
184
+
185
+ applyColdRecoveryEnrollment(peerId, enrollment) {
186
+ const { verifyColdRecoveryEnrollment } = require('./identity-rotation');
187
+ const verified = verifyColdRecoveryEnrollment(enrollment);
188
+ if (!verified.ok) {
189
+ const error = new Error(`Invalid cold recovery enrollment: ${verified.error}`);
190
+ error.code = 'INVALID_COLD_RECOVERY_ENROLLMENT';
191
+ throw error;
192
+ }
193
+
194
+ this.registerPeerRecoveryPublicKey(peerId, enrollment.recoveryPublicKey);
195
+ return { applied: true, recoveryPublicKey: enrollment.recoveryPublicKey };
196
+ }
197
+
198
+ applyIdentityRotation(peerId, rotation) {
199
+ const { shouldApplyIdentityRotation } = require('./identity-rotation');
200
+
201
+ const currentState = this.getPeerIdentityState(peerId);
202
+ const decision = shouldApplyIdentityRotation(currentState, rotation, {
203
+ enrolledRecoveryPublicKey: this.getPeerRecoveryPublicKey(peerId)
204
+ });
205
+ if (!decision.apply) {
206
+ if (
207
+ decision.reason
208
+ && decision.reason !== 'stale-generation'
209
+ && decision.reason !== 'previous-key-mismatch'
210
+ ) {
211
+ const error = new Error(`Invalid identity rotation: ${decision.reason}`);
212
+ error.code = 'INVALID_IDENTITY_ROTATION';
213
+ throw error;
214
+ }
215
+ return { applied: false, reason: decision.reason };
216
+ }
217
+
218
+ this.registerPeerPublicKey(peerId, rotation.nextPublicKey, {
219
+ generation: rotation.toGeneration
220
+ });
221
+
222
+ return {
223
+ applied: true,
224
+ fromGeneration: rotation.fromGeneration,
225
+ toGeneration: rotation.toGeneration,
226
+ rotationKind: rotation.rotationKind
227
+ };
139
228
  }
140
229
 
141
230
  resolvePeerPublicKey(peerId, fallbackPublicKey) {
@@ -523,5 +612,6 @@ module.exports = {
523
612
  stableStringify,
524
613
  deriveBroadcastKey,
525
614
  legacyBroadcastKey,
526
- DEFAULT_SECURITY_OPTIONS
615
+ DEFAULT_SECURITY_OPTIONS,
616
+ DEFAULT_APP_PASSWORD
527
617
  };