@interop/did-cli 0.9.0 → 0.10.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.
@@ -1,15 +1,21 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin, stdout } from 'node:process';
1
3
  import { Command } from 'commander';
2
4
  import { decodeSecretKeySeed, generateSecretKeySeed } from '@digitalcredentials/bnid';
3
5
  import { driver } from '@interop/did-method-key';
4
6
  import * as didWeb from '@interop/did-web-resolver';
5
- import { securityLoader } from '@interop/security-document-loader';
7
+ import { createDID, resolveDIDFromLog, updateDID } from '@interop/did-method-webvh';
8
+ import { createDefaultDidResolver, securityLoader } from '@interop/security-document-loader';
6
9
  import { Ed25519VerificationKey } from '@interop/ed25519-verification-key';
7
10
  import * as EcdsaMultikey from '@interop/ecdsa-multikey';
8
11
  import { X25519KeyAgreementKey2020 } from '@interop/x25519-key-agreement-key';
9
- import { listDids, loadDidDocument, loadDidKeys, loadDidMeta, removeDidFiles, saveDidMeta, saveToDids } from '../storage.js';
12
+ import { listDids, loadDidDocument, loadDidKeys, loadDidLog, loadDidMeta, loadDidUpdateKeys, removeDidFiles, saveDidLog, saveDidMeta, saveDidUpdateKeys, saveToDids } from '../storage.js';
10
13
  import { recordKeyDidAssociation, removeKeyDidAssociation, resolveDidRef } from '../meta.js';
11
14
  import { renderTable } from '../table.js';
12
15
  import { normalizeEcdsaCurve, SUPPORTED_ECDSA_CURVES, warnIfNotVcIssuanceCapable } from '../keys/ecdsa.js';
16
+ import { makeWebvhSigner } from '../keys/webvh-signer.js';
17
+ import { makeWebvhDriver, webvhLogVerifier } from '../keys/webvh-driver.js';
18
+ import { exportUpdateKey, generateStagedKey, loadUpdateKey } from '../keys/webvh-update.js';
13
19
  /**
14
20
  * Save the artifacts of a newly created DID: the DID document, its keys file,
15
21
  * a metadata sidecar (creation timestamp plus the handle and description when
@@ -43,14 +49,547 @@ async function saveDidArtifacts({ method, didDocument, exportedKeys, fingerprint
43
49
  }
44
50
  console.error(`DID saved to ${docPath}`);
45
51
  }
52
+ /**
53
+ * Parse a raw did:webvh history log (newline-delimited JSON) into the entry
54
+ * array the library's resolver/updater expect, ignoring blank lines.
55
+ *
56
+ * @param logText {string}
57
+ * @returns {DIDLog}
58
+ */
59
+ export function parseDidLog(logText) {
60
+ return logText
61
+ .split('\n')
62
+ .filter(line => line.trim().length > 0)
63
+ .map(line => JSON.parse(line));
64
+ }
65
+ /**
66
+ * Ask the user to confirm a hard-to-undo action. Returns true immediately when
67
+ * `--yes` was passed or when stdin is not an interactive TTY (so scripts and
68
+ * tests are not blocked); otherwise prompts and requires an explicit `y`.
69
+ *
70
+ * @param options {object}
71
+ * @param options.message {string}
72
+ * @param options.yes {boolean} the value of the `--yes` flag.
73
+ * @returns {Promise<boolean>}
74
+ */
75
+ async function confirmAction({ message, yes }) {
76
+ if (yes || !stdin.isTTY) {
77
+ return true;
78
+ }
79
+ const rl = createInterface({ input: stdin, output: stdout });
80
+ try {
81
+ const answer = await rl.question(`${message} [y/N] `);
82
+ return answer.trim().toLowerCase() === 'y';
83
+ }
84
+ finally {
85
+ rl.close();
86
+ }
87
+ }
88
+ /**
89
+ * Load and resolve a locally stored did:webvh history log in preparation for an
90
+ * update, asserting it is updatable. The log is the source of truth for a
91
+ * stored did:webvh, so its absence is what "not locally stored" means.
92
+ *
93
+ * @param options {object}
94
+ * @param options.targetDid {string} the resolved did:webvh DID.
95
+ * @param options.action {string} verb used in error messages (e.g.
96
+ * `rotate keys`, `update services`).
97
+ * @returns {Promise<{ log, doc, meta }>}
98
+ * @throws {Error} with a user-facing message if the log is missing, fails to
99
+ * resolve, or the DID is deactivated.
100
+ */
101
+ async function resolveWebvhForUpdate({ targetDid, action }) {
102
+ let logText;
103
+ try {
104
+ logText = await loadDidLog(targetDid);
105
+ }
106
+ catch {
107
+ throw new Error(`No locally stored did:webvh found for ${targetDid}`);
108
+ }
109
+ const log = parseDidLog(logText);
110
+ let resolved;
111
+ try {
112
+ resolved = await resolveDIDFromLog(log, { verifier: webvhLogVerifier });
113
+ }
114
+ catch (err) {
115
+ throw new Error(`Could not resolve the DID log: ${err.message}`, {
116
+ cause: err
117
+ });
118
+ }
119
+ if (resolved.meta.deactivated) {
120
+ throw new Error(`Cannot ${action}: the DID is deactivated.`);
121
+ }
122
+ return { log, doc: resolved.doc, meta: resolved.meta };
123
+ }
124
+ /**
125
+ * Resolve a locally stored did:webvh DID from its history log (`.jsonl`) -- the
126
+ * source of truth for the current document and its accumulated parameters.
127
+ * Unlike `resolveWebvhForUpdate` this does not reject a deactivated DID, since
128
+ * `show` should still display it; it returns `undefined` when no log is stored
129
+ * so the caller can fall back to the stored document snapshot.
130
+ *
131
+ * @param did {string} the resolved did:webvh DID.
132
+ * @returns {Promise<{ doc, meta } | undefined>}
133
+ * @throws {Error} if a log exists but fails to resolve/verify.
134
+ */
135
+ async function resolveStoredWebvh(did) {
136
+ let logText;
137
+ try {
138
+ logText = await loadDidLog(did);
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ const { doc, meta } = await resolveDIDFromLog(parseDidLog(logText), {
144
+ verifier: webvhLogVerifier
145
+ });
146
+ return { doc, meta };
147
+ }
148
+ /**
149
+ * Load a did:webvh DID's update-keys sidecar, treating an absent file as
150
+ * `undefined` (no stored secrets) rather than an error.
151
+ *
152
+ * @param did {string}
153
+ * @returns {Promise<WebvhUpdateKeys | undefined>}
154
+ */
155
+ async function loadStoredUpdateKeys(did) {
156
+ try {
157
+ return await loadDidUpdateKeys(did);
158
+ }
159
+ catch (err) {
160
+ if (err.code !== 'ENOENT') {
161
+ throw err;
162
+ }
163
+ return undefined;
164
+ }
165
+ }
166
+ /**
167
+ * Pre-rotation reveal: validate and load the staged (pre-committed) update key
168
+ * that must sign this entry, returning the signer key pair plus the records
169
+ * that make it the new active key and retire the current one.
170
+ *
171
+ * @param options {object}
172
+ * @param options.stored {WebvhUpdateKeys | undefined} the update-keys sidecar.
173
+ * @param options.meta {Awaited<ReturnType<typeof resolveDIDFromLog>>['meta']}
174
+ * @param options.targetDid {string}
175
+ * @param options.action {string} verb used in error messages.
176
+ * @returns {Promise<{ signerKeyPair, newActive, retiredActive }>}
177
+ * @throws {Error} if the staged secret is missing or has diverged from the
178
+ * log's committed nextKeyHashes.
179
+ */
180
+ async function revealStagedSigner({ stored, meta, targetDid, action }) {
181
+ if (!stored?.staged) {
182
+ throw new Error(`Cannot ${action}: the pre-committed next-key secret was not found. ` +
183
+ 'While pre-rotation is armed the staged key must sign this entry; ' +
184
+ `check for a backup of the ${targetDid}.update-keys.json sidecar.`);
185
+ }
186
+ if (!meta.nextKeyHashes.includes(stored.staged.nextKeyHash)) {
187
+ throw new Error(`Cannot ${action}: the staged key's hash is not among the log's ` +
188
+ 'committed nextKeyHashes. The local update-keys record has diverged ' +
189
+ 'from the published log (an update may have happened elsewhere); ' +
190
+ 're-resolve before retrying.');
191
+ }
192
+ return {
193
+ signerKeyPair: await loadUpdateKey(stored.staged),
194
+ newActive: {
195
+ publicKeyMultibase: stored.staged.publicKeyMultibase,
196
+ secretKeyMultibase: stored.staged.secretKeyMultibase
197
+ },
198
+ retiredActive: stored.active
199
+ };
200
+ }
201
+ /**
202
+ * Ordinary (non-pre-rotation) signer: the current active update key signs.
203
+ * Returns the signer key pair and the active record it was loaded from.
204
+ *
205
+ * @param options {object}
206
+ * @param options.stored {WebvhUpdateKeys | undefined} the update-keys sidecar.
207
+ * @param options.meta {Awaited<ReturnType<typeof resolveDIDFromLog>>['meta']}
208
+ * @param options.targetDid {string}
209
+ * @param options.action {string} verb used in error messages.
210
+ * @returns {Promise<{ signerKeyPair, activeRecord }>}
211
+ * @throws {Error} if the active secret is missing or no longer matches the
212
+ * log's updateKeys.
213
+ */
214
+ async function loadActiveSigner({ stored, meta, targetDid, action }) {
215
+ const activeRecord = stored?.active.secretKeyMultibase &&
216
+ meta.updateKeys.includes(stored.active.publicKeyMultibase)
217
+ ? stored.active
218
+ : undefined;
219
+ if (!activeRecord) {
220
+ throw new Error(`Cannot ${action}: the current active update-key secret was not found ` +
221
+ `in ${targetDid}.update-keys.json.`);
222
+ }
223
+ return { signerKeyPair: await loadUpdateKey(activeRecord), activeRecord };
224
+ }
225
+ /**
226
+ * Build the entry signer for a did:webvh update from an update key pair.
227
+ *
228
+ * @param keyPair {Ed25519VerificationKey} mutated: its `id` is set in place.
229
+ * @returns the signer to pass to `updateDID`.
230
+ */
231
+ function makeWebvhEntrySigner(keyPair) {
232
+ const signer = makeWebvhSigner({ keyPair });
233
+ // `keyPair.signer()` requires an id to be set before signing.
234
+ keyPair.id = signer.getVerificationMethodId();
235
+ return signer;
236
+ }
237
+ /**
238
+ * Write the update-keys sidecar: the active key, the staged next key (or
239
+ * none), and any retired secrets. At creation there is no prior sidecar; after
240
+ * an advance/rotation the superseded active key is appended to `retired` only
241
+ * when `keepOldKey` asks to preserve it.
242
+ *
243
+ * @param options {object}
244
+ * @param options.did {string}
245
+ * @param options.newActive {WebvhUpdateKey}
246
+ * @param [options.newStaged] {WebvhUpdateKey & { nextKeyHash: string }}
247
+ * @param [options.retiredActive] {WebvhUpdateKey} the superseded active key.
248
+ * @param [options.stored] {WebvhUpdateKeys} the prior sidecar, if any.
249
+ * @param [options.keepOldKey] {boolean} retain the retired secret.
250
+ * @returns {Promise<string>} the saved sidecar path.
251
+ */
252
+ async function persistUpdateKeysSidecar({ did, newActive, newStaged, retiredActive, stored, keepOldKey }) {
253
+ const updated = { active: newActive };
254
+ if (newStaged) {
255
+ updated.staged = newStaged;
256
+ }
257
+ const retiredList = stored?.retired ? [...stored.retired] : [];
258
+ // retiredActive is undefined on the stage-only path, so this also skips that
259
+ // case without a separate guard.
260
+ if (keepOldKey && retiredActive?.secretKeyMultibase) {
261
+ retiredList.push(retiredActive);
262
+ }
263
+ if (retiredList.length > 0) {
264
+ updated.retired = retiredList;
265
+ }
266
+ return saveDidUpdateKeys({ did, updateKeys: updated });
267
+ }
268
+ /**
269
+ * Expand a service id to a full DID URL. A bare fragment (`files`) or a
270
+ * leading-`#` fragment (`#files`) is resolved against the DID; a value that is
271
+ * already a full DID URL, or any absolute URI carrying a fragment, is returned
272
+ * unchanged.
273
+ *
274
+ * @param options {object}
275
+ * @param options.did {string}
276
+ * @param options.id {string}
277
+ * @returns {string}
278
+ */
279
+ function normalizeServiceId({ did, id }) {
280
+ if (id.startsWith('#')) {
281
+ return `${did}${id}`;
282
+ }
283
+ if (id.startsWith('did:') || id.includes('#')) {
284
+ return id;
285
+ }
286
+ return `${did}#${id}`;
287
+ }
288
+ /**
289
+ * Build a service-endpoint entry from add-service options. Exactly one of
290
+ * `endpoint` (one or more endpoint values -- a single value stays a string,
291
+ * several become an array) or `endpointJson` (a raw JSON value) supplies the
292
+ * serviceEndpoint; a single `type` likewise stays a string.
293
+ *
294
+ * @param options {object}
295
+ * @param options.did {string}
296
+ * @param options.id {string}
297
+ * @param options.type {string[]}
298
+ * @param [options.endpoint] {string[]}
299
+ * @param [options.endpointJson] {string}
300
+ * @returns {ServiceEndpoint}
301
+ */
302
+ function buildServiceEntry({ did, id, type, endpoint, endpointJson }) {
303
+ const hasEndpoint = Boolean(endpoint?.length);
304
+ const hasEndpointJson = Boolean(endpointJson);
305
+ // True when both are supplied or neither is -- i.e. not exactly one.
306
+ if (hasEndpoint === hasEndpointJson) {
307
+ throw new Error('Provide exactly one of --endpoint or --endpoint-json.');
308
+ }
309
+ let serviceEndpoint;
310
+ if (endpointJson) {
311
+ try {
312
+ serviceEndpoint = JSON.parse(endpointJson);
313
+ }
314
+ catch {
315
+ throw new Error('--endpoint-json must be valid JSON.');
316
+ }
317
+ }
318
+ else {
319
+ serviceEndpoint = endpoint.length === 1 ? endpoint[0] : endpoint;
320
+ }
321
+ return {
322
+ id: normalizeServiceId({ did, id }),
323
+ type: type.length === 1 ? type[0] : type,
324
+ serviceEndpoint
325
+ };
326
+ }
327
+ /**
328
+ * Whether a stored service entry has the given (already-normalized, absolute)
329
+ * id. Service ids in a DID document may be relative (`#files`) or absolute, so
330
+ * the stored id is normalized against the DID before comparing.
331
+ *
332
+ * @param options {object}
333
+ * @param options.service {ServiceEndpoint}
334
+ * @param options.id {string} the normalized id to match.
335
+ * @param options.did {string}
336
+ * @returns {boolean}
337
+ */
338
+ function serviceHasId({ service, id, did }) {
339
+ return (service.id !== undefined &&
340
+ normalizeServiceId({ did, id: service.id }) === id);
341
+ }
342
+ /**
343
+ * Append a service entry to the current array, rejecting a duplicate id.
344
+ *
345
+ * @param options {object}
346
+ * @param options.current {ServiceEndpoint[]}
347
+ * @param options.entry {ServiceEndpoint} its `id` is already normalized.
348
+ * @param options.did {string}
349
+ * @returns {ServiceEndpoint[]}
350
+ */
351
+ function addServiceEntry({ current, entry, did }) {
352
+ if (current.some(service => serviceHasId({ service, id: entry.id, did }))) {
353
+ throw new Error(`A service with id "${entry.id}" already exists.`);
354
+ }
355
+ return [...current, entry];
356
+ }
357
+ /**
358
+ * Remove the service entry with the given (normalized) id, rejecting a missing
359
+ * id.
360
+ *
361
+ * @param options {object}
362
+ * @param options.current {ServiceEndpoint[]}
363
+ * @param options.id {string} the normalized id to remove.
364
+ * @param options.did {string}
365
+ * @returns {ServiceEndpoint[]}
366
+ */
367
+ function removeServiceEntry({ current, id, did }) {
368
+ const next = current.filter(service => !serviceHasId({ service, id, did }));
369
+ if (next.length === current.length) {
370
+ throw new Error(`No service with id "${id}" found on the DID document.`);
371
+ }
372
+ return next;
373
+ }
374
+ /**
375
+ * Apply a service-array transform to a locally stored did:web document and
376
+ * re-save it. did:web has no history log, so this is a direct document edit;
377
+ * the `service` property is dropped entirely when the array empties out.
378
+ *
379
+ * @param options {object}
380
+ * @param options.did {string}
381
+ * @param options.transform {(current: ServiceEndpoint[], did: string) => ServiceEndpoint[]}
382
+ * @returns {Promise<number>} the process exit code
383
+ */
384
+ async function runWebServiceUpdate({ did, transform }) {
385
+ let didDocument;
386
+ try {
387
+ didDocument = await loadDidDocument(did);
388
+ }
389
+ catch {
390
+ console.error(`No locally stored did:web found for ${did}`);
391
+ return 1;
392
+ }
393
+ const current = Array.isArray(didDocument.service)
394
+ ? didDocument.service
395
+ : [];
396
+ let next;
397
+ try {
398
+ next = transform(current, did);
399
+ }
400
+ catch (err) {
401
+ console.error(err.message);
402
+ return 1;
403
+ }
404
+ if (next.length > 0) {
405
+ didDocument.service = next;
406
+ }
407
+ else {
408
+ delete didDocument.service;
409
+ }
410
+ const docPath = await saveToDids({ method: 'web', did, data: didDocument });
411
+ console.error(`DID saved to ${docPath}`);
412
+ console.log(JSON.stringify({ id: didDocument.id, didDocument }, null, 2));
413
+ return 0;
414
+ }
415
+ /**
416
+ * Apply a service-array transform to a locally stored did:webvh DID by
417
+ * appending a sparse log entry that overlays only the `service` array. Update
418
+ * keys and document verification methods are carried forward unchanged -- with
419
+ * one exception: a pre-rotation-armed DID cannot author a key-neutral update
420
+ * (the library requires the staged key to sign), so the update-key ratchet is
421
+ * advanced as part of the change -- the staged key is revealed to sign and a
422
+ * fresh next key is staged.
423
+ *
424
+ * @param options {object}
425
+ * @param options.targetDid {string} the resolved did:webvh DID.
426
+ * @param options.transform {(current: ServiceEndpoint[], did: string) => ServiceEndpoint[]}
427
+ * @param [options.yes] {boolean} skip the confirmation prompt.
428
+ * @param [options.keepOldKey] {boolean} retain the retired update key secret
429
+ * (pre-rotation path only; default is to drop it).
430
+ * @returns {Promise<number>} the process exit code
431
+ */
432
+ async function runWebvhServiceUpdate({ targetDid, transform, yes, keepOldKey }) {
433
+ let log;
434
+ let doc;
435
+ let meta;
436
+ try {
437
+ ;
438
+ ({ log, doc, meta } = await resolveWebvhForUpdate({
439
+ targetDid,
440
+ action: 'update services'
441
+ }));
442
+ }
443
+ catch (err) {
444
+ console.error(err.message);
445
+ return 1;
446
+ }
447
+ // Compute the new service array from the current resolved document.
448
+ const current = Array.isArray(doc?.service) ? doc.service : [];
449
+ let services;
450
+ try {
451
+ services = transform(current, targetDid);
452
+ }
453
+ catch (err) {
454
+ console.error(err.message);
455
+ return 1;
456
+ }
457
+ const stored = await loadStoredUpdateKeys(targetDid);
458
+ // Choose the signer and key parameters. A sparse update normally omits
459
+ // updateKeys/nextKeyHashes so the keys carry forward untouched; a
460
+ // pre-rotation DID instead must reveal its staged key (which signs) and stage
461
+ // a fresh one in the same entry.
462
+ let signerKeyPair;
463
+ let updateKeys;
464
+ let nextKeyHashes;
465
+ let newActive;
466
+ let newStaged;
467
+ let retiredActive;
468
+ try {
469
+ if (meta.prerotation) {
470
+ ;
471
+ ({ signerKeyPair, newActive, retiredActive } = await revealStagedSigner({
472
+ stored,
473
+ meta,
474
+ targetDid,
475
+ action: 'update services'
476
+ }));
477
+ newStaged = await generateStagedKey();
478
+ updateKeys = [newActive.publicKeyMultibase];
479
+ nextKeyHashes = [newStaged.nextKeyHash];
480
+ }
481
+ else {
482
+ ;
483
+ ({ signerKeyPair } = await loadActiveSigner({
484
+ stored,
485
+ meta,
486
+ targetDid,
487
+ action: 'update services'
488
+ }));
489
+ }
490
+ }
491
+ catch (err) {
492
+ console.error(err.message);
493
+ return 1;
494
+ }
495
+ const confirmed = await confirmAction({
496
+ message: `Update the services of ${targetDid}? This appends a new log entry ` +
497
+ 'and is hard to undo.',
498
+ yes
499
+ });
500
+ if (!confirmed) {
501
+ console.error('Aborted.');
502
+ return 0;
503
+ }
504
+ const signer = makeWebvhEntrySigner(signerKeyPair);
505
+ let result;
506
+ try {
507
+ result = await updateDID({
508
+ log,
509
+ signer,
510
+ verifier: webvhLogVerifier,
511
+ services,
512
+ ...(updateKeys ? { updateKeys } : {}),
513
+ ...(nextKeyHashes ? { nextKeyHashes } : {})
514
+ });
515
+ }
516
+ catch (err) {
517
+ console.error(`Service update failed: ${err.message}`);
518
+ return 1;
519
+ }
520
+ const logPath = await saveDidLog({ did: result.did, log: result.log });
521
+ const docPath = await saveToDids({
522
+ method: 'webvh',
523
+ did: result.did,
524
+ data: result.doc
525
+ });
526
+ // Persist the advanced ratchet only on the pre-rotation path; an ordinary
527
+ // service update leaves the update-keys sidecar untouched.
528
+ if (meta.prerotation && newActive) {
529
+ const updateKeysPath = await persistUpdateKeysSidecar({
530
+ did: result.did,
531
+ newActive,
532
+ newStaged,
533
+ retiredActive,
534
+ stored,
535
+ keepOldKey
536
+ });
537
+ console.error(`Update keys saved to ${updateKeysPath}`);
538
+ console.error('Pre-rotation: the update key was advanced as part of this change.');
539
+ }
540
+ console.error(`DID document saved to ${docPath}`);
541
+ console.error(`DID history log saved to ${logPath}`);
542
+ console.log(JSON.stringify({ id: result.did, didDocument: result.doc }, null, 2));
543
+ return 0;
544
+ }
545
+ /**
546
+ * Resolve a DID reference, then route a service-array transform to the right
547
+ * per-method runner and return its exit code. did:web is a direct document
548
+ * edit; did:webvh appends a log entry.
549
+ *
550
+ * @param options {object}
551
+ * @param options.ref {string} a DID or a local metadata handle.
552
+ * @param options.transform {(current: ServiceEndpoint[], did: string) => ServiceEndpoint[]}
553
+ * @param [options.yes] {boolean} skip the did:webvh confirmation prompt.
554
+ * @param [options.keepOldKey] {boolean} did:webvh pre-rotation path only.
555
+ * @returns {Promise<number>} the process exit code
556
+ */
557
+ async function dispatchServiceUpdate({ ref, transform, yes, keepOldKey }) {
558
+ let resolved;
559
+ try {
560
+ resolved = await resolveDidRef({ ref });
561
+ }
562
+ catch (err) {
563
+ console.error(err.message);
564
+ return 1;
565
+ }
566
+ const did = resolved ?? ref;
567
+ if (did.startsWith('did:web:')) {
568
+ return runWebServiceUpdate({ did, transform });
569
+ }
570
+ if (did.startsWith('did:webvh:')) {
571
+ return runWebvhServiceUpdate({ targetDid: did, transform, yes, keepOldKey });
572
+ }
573
+ console.error('add-service/remove-service are only supported for did:web and ' +
574
+ 'did:webvh DIDs');
575
+ return 1;
576
+ }
46
577
  /**
47
578
  * Document loader for DID / DID-URL resolution. A bare DID resolves to its DID
48
579
  * document; a `did#fragment` URL is dereferenced straight to its
49
- * verification-method node. Works for did:key (offline) and did:web (fetched).
50
- * Built once and reused. (Per project convention, DID/JSON-LD resolution goes
51
- * through `@interop/security-document-loader`, never a hand-rolled loader.)
580
+ * verification-method node. Works for did:key (offline), did:web, and did:webvh
581
+ * (both fetched). Built once and reused. (Per project convention, DID/JSON-LD
582
+ * resolution goes through `@interop/security-document-loader`, never a
583
+ * hand-rolled loader.) The loader's default resolver only knows did:key and
584
+ * did:web, so the did:webvh driver is registered onto a copy of the defaults
585
+ * and injected -- keeping the did:webvh dependency out of the shared loader.
52
586
  */
53
- const documentLoader = securityLoader().build();
587
+ const didResolver = createDefaultDidResolver();
588
+ // `CachedResolver.use` types its argument as the full generation-capable
589
+ // `DidMethodDriver`, but only reads `.method` (and later calls `.get`) for
590
+ // resolution; the webvh driver implements just that resolution subset.
591
+ didResolver.use(makeWebvhDriver());
592
+ const documentLoader = securityLoader({ didResolver }).build();
54
593
  export function makeDidCommand() {
55
594
  const did = new Command('did').description('Manage DIDs');
56
595
  did
@@ -59,6 +598,19 @@ export function makeDidCommand() {
59
598
  .option('-t, --type <type>', 'key type (supported: ed25519, ecdsa)', 'ed25519')
60
599
  .option('--curve <curve>', `ECDSA curve for --type ecdsa (supported: ${SUPPORTED_ECDSA_CURVES})`, 'p256')
61
600
  .option('--url <url>', 'HTTPS url of the DID document (required for did:web)')
601
+ .option('--prerotation', 'arm did:webvh key pre-rotation: stage a next update key and commit ' +
602
+ 'its hash (default)')
603
+ .option('--no-prerotation', 'create the did:webvh without key pre-rotation')
604
+ .option('--portable', 'create a portable did:webvh that can later be moved to a different ' +
605
+ 'domain (default)')
606
+ .option('--no-portable', 'create a non-portable did:webvh (pinned to its domain)')
607
+ .option('--witness <did...>', 'declare a witness did:key DID authorized to co-sign did:webvh log ' +
608
+ 'entries (repeatable; declaration only -- witness proof generation is ' +
609
+ 'out of scope)')
610
+ .option('--witness-threshold <n>', 'number of did:webvh witness approvals required ' +
611
+ '(default: number of witnesses; requires --witness)')
612
+ .option('--watcher <url...>', 'declare a did:webvh watcher URL that monitors the DID log ' +
613
+ '(repeatable; https:// or http://localhost)')
62
614
  .option('--with-seed', 'include the secret key seed in output (generated if SECRET_KEY_SEED is not set)')
63
615
  .option('--save', 'save the DID document to local storage (~/.config/did-cli-wallet/dids/)')
64
616
  .option('--handle <handle>', 'short tag for the saved DID (requires --save)')
@@ -271,10 +823,175 @@ export function makeDidCommand() {
271
823
  }
272
824
  break;
273
825
  }
274
- case 'webvh':
275
- console.log(`Creating did:${method}...`);
276
- // TODO: implement
826
+ case 'webvh': {
827
+ if (!options.url) {
828
+ console.error('did:webvh requires --url (e.g. --url https://example.com)');
829
+ process.exit(1);
830
+ return;
831
+ }
832
+ // The webvh library hardcodes the `eddsa-jcs-2022` cryptosuite, so
833
+ // only Ed25519 update keys are supported for now.
834
+ if (options.type !== 'ed25519') {
835
+ console.error(`did:webvh only supports --type ed25519 (got ${options.type}); ` +
836
+ 'the eddsa-jcs-2022 cryptosuite requires an Ed25519 key.');
837
+ process.exit(1);
838
+ return;
839
+ }
840
+ // Pre-rotation is the default; --no-prerotation opts out. With no
841
+ // flag, commander leaves `prerotation` undefined, which is on.
842
+ const prerotation = options.prerotation !== false;
843
+ // Portability is the default; --no-portable opts out (same shape).
844
+ const portable = options.portable !== false;
845
+ // Witness declarations (declaration only -- generating the witness
846
+ // proofs / did-witness.json sidecar is out of scope for now).
847
+ const witnessDids = options.witness ?? [];
848
+ if (options.witnessThreshold !== undefined &&
849
+ witnessDids.length === 0) {
850
+ console.error('--witness-threshold requires at least one --witness');
851
+ process.exit(1);
852
+ return;
853
+ }
854
+ for (const witnessDid of witnessDids) {
855
+ if (!witnessDid.startsWith('did:key:')) {
856
+ console.error(`Invalid witness "${witnessDid}": witnesses must be ` +
857
+ 'did:key DIDs');
858
+ process.exit(1);
859
+ return;
860
+ }
861
+ }
862
+ let witness;
863
+ if (witnessDids.length > 0) {
864
+ let threshold = witnessDids.length;
865
+ if (options.witnessThreshold !== undefined) {
866
+ threshold = Number.parseInt(options.witnessThreshold, 10);
867
+ if (!Number.isInteger(threshold) ||
868
+ threshold < 1 ||
869
+ threshold > witnessDids.length) {
870
+ console.error('--witness-threshold must be an integer between 1 and the ' +
871
+ `number of witnesses (${witnessDids.length})`);
872
+ process.exit(1);
873
+ return;
874
+ }
875
+ }
876
+ witness = {
877
+ threshold,
878
+ witnesses: witnessDids.map(id => ({ id }))
879
+ };
880
+ }
881
+ // Watcher URLs: https:// (or http://localhost for local testing).
882
+ const watchers = options.watcher ?? [];
883
+ for (const watcher of watchers) {
884
+ let watcherUrl;
885
+ try {
886
+ watcherUrl = new URL(watcher);
887
+ }
888
+ catch {
889
+ console.error(`Invalid watcher URL: ${watcher}`);
890
+ process.exit(1);
891
+ return;
892
+ }
893
+ const isLocalhost = watcherUrl.protocol === 'http:' &&
894
+ (watcherUrl.hostname === 'localhost' ||
895
+ watcherUrl.hostname === '127.0.0.1');
896
+ if (watcherUrl.protocol !== 'https:' && !isLocalhost) {
897
+ console.error(`Invalid watcher URL "${watcher}": must be https:// ` +
898
+ '(or http://localhost)');
899
+ process.exit(1);
900
+ return;
901
+ }
902
+ }
903
+ const envSeed = process.env.SECRET_KEY_SEED;
904
+ const secretKeySeed = options.withSeed
905
+ ? (envSeed ?? (await generateSecretKeySeed()))
906
+ : envSeed;
907
+ const seedBytes = secretKeySeed
908
+ ? decodeSecretKeySeed({ secretKeySeed })
909
+ : undefined;
910
+ // Update key A: the active authorization key. It is what the DID
911
+ // identifier (SCID) derives from, so it is the seed-derived one.
912
+ const updateKey = await Ed25519VerificationKey.generate({
913
+ seed: seedBytes
914
+ });
915
+ const signer = makeWebvhEntrySigner(updateKey);
916
+ // Document verification key V, decoupled from the update keys so
917
+ // that rotating the update key never disturbs the document.
918
+ const docKey = await Ed25519VerificationKey.generate();
919
+ // Staged next update key B: when pre-rotation is on, commit its
920
+ // hash now so the next update must reveal it.
921
+ const stagedKey = prerotation
922
+ ? await generateStagedKey()
923
+ : undefined;
924
+ const result = await createDID({
925
+ address: options.url,
926
+ signer,
927
+ verifier: signer,
928
+ // A portable DID (the default) can later be moved to a different
929
+ // domain; --no-portable pins it to its origin.
930
+ portable,
931
+ updateKeys: [updateKey.publicKeyMultibase],
932
+ ...(stagedKey ? { nextKeyHashes: [stagedKey.nextKeyHash] } : {}),
933
+ ...(witness ? { witness } : {}),
934
+ ...(watchers.length > 0 ? { watchers } : {}),
935
+ verificationMethods: [
936
+ {
937
+ type: 'Multikey',
938
+ publicKeyMultibase: docKey.publicKeyMultibase,
939
+ // Wire the document key into the same relationships as did:web
940
+ // (everything but keyAgreement, which needs an X25519 key).
941
+ purpose: [
942
+ 'authentication',
943
+ 'assertionMethod',
944
+ 'capabilityDelegation',
945
+ 'capabilityInvocation'
946
+ ]
947
+ }
948
+ ]
949
+ });
950
+ if (options.save) {
951
+ // Persist the document key V keyed by its document verification
952
+ // method id (so it can be selected for signing), and set its id
953
+ // to match.
954
+ const docVmId = result.doc.verificationMethod?.[0]?.id;
955
+ if (!docVmId) {
956
+ console.error('Created did:webvh document is missing a verification method id');
957
+ process.exit(1);
958
+ return;
959
+ }
960
+ docKey.id = docVmId;
961
+ const exportedDoc = (await docKey.export({
962
+ publicKey: true,
963
+ secretKey: true
964
+ }));
965
+ await saveDidArtifacts({
966
+ method: 'webvh',
967
+ didDocument: result.doc,
968
+ exportedKeys: { [docVmId]: exportedDoc },
969
+ fingerprints: [exportedDoc.publicKeyMultibase],
970
+ handle: options.handle,
971
+ description: options.description
972
+ });
973
+ // Persist the update keys (active A, and staged B when armed) in
974
+ // a sidecar distinct from the document's keys file.
975
+ const updateKeysPath = await persistUpdateKeysSidecar({
976
+ did: result.did,
977
+ newActive: await exportUpdateKey(updateKey),
978
+ newStaged: stagedKey
979
+ });
980
+ const logPath = await saveDidLog({
981
+ did: result.did,
982
+ log: result.log
983
+ });
984
+ console.error(`DID history log saved to ${logPath}`);
985
+ console.error(`Update keys saved to ${updateKeysPath}`);
986
+ }
987
+ const output = { id: result.did };
988
+ if (options.withSeed) {
989
+ output.secretKeySeed = secretKeySeed;
990
+ }
991
+ output.didDocument = result.doc;
992
+ console.log(JSON.stringify(output, null, 2));
277
993
  break;
994
+ }
278
995
  default:
279
996
  console.error(`Unknown method: ${method}. Supported: key, web, webvh`);
280
997
  process.exit(1);
@@ -421,6 +1138,68 @@ export function makeDidCommand() {
421
1138
  output.didDocument = didDocument;
422
1139
  console.log(JSON.stringify(output, null, 2));
423
1140
  });
1141
+ did
1142
+ .command('add-service <did>')
1143
+ .description('Add a service entry to a locally stored did:web or did:webvh DID. The ' +
1144
+ 'DID may be given as a metadata handle. For did:webvh this appends a ' +
1145
+ 'log entry; if pre-rotation is armed the update key is advanced as ' +
1146
+ 'part of the change.')
1147
+ .requiredOption('--id <id>', 'service id; a bare fragment (e.g. "files") is expanded to <did>#files')
1148
+ .requiredOption('--type <type...>', 'service type(s), e.g. LinkedDomains (repeat for multiple)')
1149
+ .option('--endpoint <endpoint...>', 'serviceEndpoint value(s); a single value stays a string, several ' +
1150
+ 'become an array (mutually exclusive with --endpoint-json)')
1151
+ .option('--endpoint-json <json>', 'serviceEndpoint as a raw JSON value (mutually exclusive with --endpoint)')
1152
+ .option('--keep-old-key', 'did:webvh pre-rotation only: retain the retired update key secret in ' +
1153
+ 'the sidecar (default: drop it)')
1154
+ .option('-y, --yes', 'skip the did:webvh confirmation prompt')
1155
+ .action(async (did, options) => {
1156
+ const transform = (current, resolvedDid) => {
1157
+ const entry = buildServiceEntry({
1158
+ did: resolvedDid,
1159
+ id: options.id,
1160
+ type: options.type,
1161
+ endpoint: options.endpoint,
1162
+ endpointJson: options.endpointJson
1163
+ });
1164
+ return addServiceEntry({ current, entry, did: resolvedDid });
1165
+ };
1166
+ const code = await dispatchServiceUpdate({
1167
+ ref: did,
1168
+ transform,
1169
+ yes: options.yes,
1170
+ keepOldKey: options.keepOldKey
1171
+ });
1172
+ if (code !== 0) {
1173
+ process.exit(code);
1174
+ }
1175
+ });
1176
+ did
1177
+ .command('remove-service <did>')
1178
+ .description('Remove a service entry (by id) from a locally stored did:web or ' +
1179
+ 'did:webvh DID. The DID may be given as a metadata handle. For ' +
1180
+ 'did:webvh this appends a log entry; if pre-rotation is armed the ' +
1181
+ 'update key is advanced as part of the change.')
1182
+ .requiredOption('--id <id>', 'id of the service to remove; a bare fragment (e.g. "files") is ' +
1183
+ 'expanded to <did>#files')
1184
+ .option('--keep-old-key', 'did:webvh pre-rotation only: retain the retired update key secret in ' +
1185
+ 'the sidecar (default: drop it)')
1186
+ .option('-y, --yes', 'skip the did:webvh confirmation prompt')
1187
+ .action(async (did, options) => {
1188
+ const transform = (current, resolvedDid) => removeServiceEntry({
1189
+ current,
1190
+ id: normalizeServiceId({ did: resolvedDid, id: options.id }),
1191
+ did: resolvedDid
1192
+ });
1193
+ const code = await dispatchServiceUpdate({
1194
+ ref: did,
1195
+ transform,
1196
+ yes: options.yes,
1197
+ keepOldKey: options.keepOldKey
1198
+ });
1199
+ if (code !== 0) {
1200
+ process.exit(code);
1201
+ }
1202
+ });
424
1203
  did
425
1204
  .command('get <did>')
426
1205
  .aliases(['resolve'])
@@ -443,7 +1222,8 @@ export function makeDidCommand() {
443
1222
  .command('show <did>')
444
1223
  .aliases(['view', 'cat'])
445
1224
  .description('Show a locally stored DID document (no secret key material) by DID ' +
446
- 'or handle')
1225
+ 'or handle. For did:webvh the document is resolved from its history ' +
1226
+ 'log -- the source of truth -- rather than the stored snapshot.')
447
1227
  .option('--meta', 'show the DID metadata instead of the DID document')
448
1228
  .option('--json', 'with --meta, output the metadata as JSON')
449
1229
  .action(async (didRef, options) => {
@@ -456,14 +1236,42 @@ export function makeDidCommand() {
456
1236
  process.exit(1);
457
1237
  return;
458
1238
  }
1239
+ const targetDid = did ?? didRef;
1240
+ // For did:webvh the history log is the source of truth, so resolve the
1241
+ // current document (and its accumulated parameters) from it rather than
1242
+ // trusting the stored snapshot. A DID with no stored log falls through
1243
+ // to the stored document below.
1244
+ let webvhMeta;
1245
+ let resolvedDoc;
1246
+ if (targetDid.startsWith('did:webvh:')) {
1247
+ let resolved;
1248
+ try {
1249
+ resolved = await resolveStoredWebvh(targetDid);
1250
+ }
1251
+ catch (err) {
1252
+ console.error(`Could not resolve the DID log for ${targetDid}: ` +
1253
+ err.message);
1254
+ process.exit(1);
1255
+ return;
1256
+ }
1257
+ if (resolved) {
1258
+ resolvedDoc = resolved.doc;
1259
+ webvhMeta = resolved.meta;
1260
+ }
1261
+ }
459
1262
  let didDocument;
460
- try {
461
- didDocument = await loadDidDocument(did ?? didRef);
1263
+ if (resolvedDoc) {
1264
+ didDocument = resolvedDoc;
462
1265
  }
463
- catch {
464
- console.error(`No locally stored DID found for ${didRef}`);
465
- process.exit(1);
466
- return;
1266
+ else {
1267
+ try {
1268
+ didDocument = await loadDidDocument(targetDid);
1269
+ }
1270
+ catch {
1271
+ console.error(`No locally stored DID found for ${didRef}`);
1272
+ process.exit(1);
1273
+ return;
1274
+ }
467
1275
  }
468
1276
  if (options.meta) {
469
1277
  const docDid = didDocument.id;
@@ -471,6 +1279,10 @@ export function makeDidCommand() {
471
1279
  const keyCount = Array.isArray(didDocument.verificationMethod)
472
1280
  ? didDocument.verificationMethod.length
473
1281
  : 0;
1282
+ // Parameters resolved from the did:webvh log (absent for other
1283
+ // methods, and for a webvh DID with no stored log).
1284
+ const witnessCount = webvhMeta?.witness?.witnesses?.length ?? 0;
1285
+ const watcherCount = webvhMeta?.watchers?.length ?? 0;
474
1286
  if (options.json) {
475
1287
  const output = {
476
1288
  did: docDid,
@@ -478,7 +1290,17 @@ export function makeDidCommand() {
478
1290
  ...(meta?.created && { created: meta.created }),
479
1291
  ...(meta?.handle && { handle: meta.handle }),
480
1292
  ...(meta?.description && { description: meta.description }),
481
- keys: keyCount
1293
+ keys: keyCount,
1294
+ ...(webvhMeta && {
1295
+ versionId: webvhMeta.versionId,
1296
+ updated: webvhMeta.updated,
1297
+ portable: webvhMeta.portable,
1298
+ prerotation: webvhMeta.prerotation,
1299
+ deactivated: webvhMeta.deactivated,
1300
+ updateKeys: webvhMeta.updateKeys.length,
1301
+ witnesses: witnessCount,
1302
+ watchers: watcherCount
1303
+ })
482
1304
  };
483
1305
  console.log(JSON.stringify(output, null, 2));
484
1306
  return;
@@ -491,14 +1313,17 @@ export function makeDidCommand() {
491
1313
  ['Description', meta?.description ?? ''],
492
1314
  ['Keys', String(keyCount)]
493
1315
  ];
1316
+ if (webvhMeta) {
1317
+ rows.push(['Version', webvhMeta.versionId], ['Updated', webvhMeta.updated], ['Portable', webvhMeta.portable ? 'yes' : 'no'], ['Prerotation', webvhMeta.prerotation ? 'yes' : 'no'], ['Deactivated', webvhMeta.deactivated ? 'yes' : 'no'], ['Update keys', String(webvhMeta.updateKeys.length)], ['Witnesses', String(witnessCount)], ['Watchers', String(watcherCount)]);
1318
+ }
494
1319
  console.log(renderTable({
495
1320
  columns: [{ header: 'FIELD' }, { header: 'VALUE' }],
496
1321
  rows
497
1322
  }));
498
1323
  return;
499
1324
  }
500
- // The stored DID document holds no secret material -- signing keys live in
501
- // the separate `<did>.keys.json` file -- so it is safe to print as-is.
1325
+ // The DID document holds no secret material -- signing keys live in the
1326
+ // separate `<did>.keys.json` file -- so it is safe to print as-is.
502
1327
  console.log(JSON.stringify(didDocument, null, 2));
503
1328
  });
504
1329
  did
@@ -648,6 +1473,183 @@ export function makeDidCommand() {
648
1473
  console.error(`Removed ${filePath}`);
649
1474
  }
650
1475
  });
1476
+ const webvh = new Command('webvh').description('Manage did:webvh DIDs: rotate update (authorization) keys');
1477
+ webvh
1478
+ .command('rotate-keys <did>')
1479
+ .description('Rotate the update (authorization) key of a locally stored did:webvh ' +
1480
+ 'DID. By default advances key pre-rotation -- reveals the staged next ' +
1481
+ 'key and stages a fresh one -- and never touches the document ' +
1482
+ 'verification methods.')
1483
+ .option('--update-key <multibase...>', 'rotate to specific update key(s) by publicKeyMultibase instead of ' +
1484
+ 'generating a fresh one (ordinary mode only; rejected while ' +
1485
+ 'pre-rotation is armed, where the next keys are fixed by the prior ' +
1486
+ 'commitment)')
1487
+ .option('--enable-prerotation', 'for a DID without pre-rotation, turn it on by staging a next key this ' +
1488
+ 'rotation (alone: stage only, leaving the active key unchanged)')
1489
+ .option('--stop-prerotation', 'do not stage a next key; pre-rotation turns off after this rotation')
1490
+ .option('--keep-old-key', 'retain the retired update key secret in the sidecar (default: drop it)')
1491
+ .option('-y, --yes', 'skip the confirmation prompt')
1492
+ .action(async (didRef, options) => {
1493
+ let resolved;
1494
+ try {
1495
+ resolved = await resolveDidRef({ ref: didRef });
1496
+ }
1497
+ catch (err) {
1498
+ console.error(err.message);
1499
+ process.exit(1);
1500
+ return;
1501
+ }
1502
+ const targetDid = resolved ?? didRef;
1503
+ if (!targetDid.startsWith('did:webvh:')) {
1504
+ console.error('rotate-keys is only supported for did:webvh DIDs');
1505
+ process.exit(1);
1506
+ return;
1507
+ }
1508
+ // The history log is the source of truth for a stored did:webvh (the
1509
+ // current document is just its last entry's state), so a key-only
1510
+ // rotation ignores the resolved document.
1511
+ let log;
1512
+ let meta;
1513
+ try {
1514
+ ;
1515
+ ({ log, meta } = await resolveWebvhForUpdate({
1516
+ targetDid,
1517
+ action: 'rotate keys'
1518
+ }));
1519
+ }
1520
+ catch (err) {
1521
+ console.error(err.message);
1522
+ process.exit(1);
1523
+ return;
1524
+ }
1525
+ // Flag validation against the current pre-rotation state.
1526
+ if (options.enablePrerotation && options.stopPrerotation) {
1527
+ console.error('--enable-prerotation and --stop-prerotation are mutually exclusive');
1528
+ process.exit(1);
1529
+ return;
1530
+ }
1531
+ if (meta.prerotation) {
1532
+ if (options.updateKey) {
1533
+ console.error('--update-key is not allowed while pre-rotation is armed; the ' +
1534
+ 'next update keys are fixed by the committed nextKeyHashes');
1535
+ process.exit(1);
1536
+ return;
1537
+ }
1538
+ if (options.enablePrerotation) {
1539
+ console.error('pre-rotation is already enabled for this DID');
1540
+ process.exit(1);
1541
+ return;
1542
+ }
1543
+ }
1544
+ else if (options.stopPrerotation) {
1545
+ console.error('pre-rotation is already off for this DID');
1546
+ process.exit(1);
1547
+ return;
1548
+ }
1549
+ const stored = await loadStoredUpdateKeys(targetDid);
1550
+ // Inbound: who signs this entry, and what becomes the active key.
1551
+ let signerKeyPair;
1552
+ let newActive;
1553
+ let retiredActive;
1554
+ try {
1555
+ if (meta.prerotation) {
1556
+ // Pre-rotation reveal: the staged key signs its own activation.
1557
+ ;
1558
+ ({ signerKeyPair, newActive, retiredActive } =
1559
+ await revealStagedSigner({
1560
+ stored,
1561
+ meta,
1562
+ targetDid,
1563
+ action: 'rotate keys'
1564
+ }));
1565
+ }
1566
+ else {
1567
+ // Ordinary rotation: the current active key signs.
1568
+ let activeRecord;
1569
+ ({ signerKeyPair, activeRecord } = await loadActiveSigner({
1570
+ stored,
1571
+ meta,
1572
+ targetDid,
1573
+ action: 'rotate keys'
1574
+ }));
1575
+ if (options.enablePrerotation && !options.updateKey) {
1576
+ // Stage only: arm pre-rotation without changing the active key.
1577
+ newActive = activeRecord;
1578
+ }
1579
+ else if (options.updateKey) {
1580
+ // Rotate to externally-held key(s); the secret is not ours to store.
1581
+ newActive = { publicKeyMultibase: options.updateKey[0] };
1582
+ retiredActive = activeRecord;
1583
+ }
1584
+ else {
1585
+ newActive = await exportUpdateKey(await Ed25519VerificationKey.generate());
1586
+ retiredActive = activeRecord;
1587
+ }
1588
+ }
1589
+ }
1590
+ catch (err) {
1591
+ console.error(err.message);
1592
+ process.exit(1);
1593
+ return;
1594
+ }
1595
+ // Outbound: keep the ratchet armed (stage a fresh next key) or not.
1596
+ const arm = meta.prerotation
1597
+ ? !options.stopPrerotation
1598
+ : Boolean(options.enablePrerotation);
1599
+ const newStaged = arm ? await generateStagedKey() : undefined;
1600
+ const nextKeyHashes = newStaged ? [newStaged.nextKeyHash] : [];
1601
+ const confirmed = await confirmAction({
1602
+ message: `Rotate the update key of ${targetDid}? This appends a new log ` +
1603
+ 'entry and is hard to undo.',
1604
+ yes: options.yes
1605
+ });
1606
+ if (!confirmed) {
1607
+ console.error('Aborted.');
1608
+ return;
1609
+ }
1610
+ const signer = makeWebvhEntrySigner(signerKeyPair);
1611
+ // A sparse updateDID() carries the prior DID document state forward and
1612
+ // only overlays the fields an update actually supplies, so a key-only
1613
+ // rotation omits all document directives to leave the document
1614
+ // unchanged.
1615
+ let result;
1616
+ try {
1617
+ result = await updateDID({
1618
+ log,
1619
+ signer,
1620
+ verifier: webvhLogVerifier,
1621
+ updateKeys: [newActive.publicKeyMultibase],
1622
+ nextKeyHashes
1623
+ });
1624
+ }
1625
+ catch (err) {
1626
+ console.error(`Key rotation failed: ${err.message}`);
1627
+ process.exit(1);
1628
+ return;
1629
+ }
1630
+ const logPath = await saveDidLog({ did: result.did, log: result.log });
1631
+ const docPath = await saveToDids({
1632
+ method: 'webvh',
1633
+ did: result.did,
1634
+ data: result.doc
1635
+ });
1636
+ const updateKeysPath = await persistUpdateKeysSidecar({
1637
+ did: result.did,
1638
+ newActive,
1639
+ newStaged,
1640
+ retiredActive,
1641
+ stored,
1642
+ keepOldKey: options.keepOldKey
1643
+ });
1644
+ console.error(`DID document saved to ${docPath}`);
1645
+ console.error(`DID history log saved to ${logPath}`);
1646
+ console.error(`Update keys saved to ${updateKeysPath}`);
1647
+ console.error(nextKeyHashes.length > 0
1648
+ ? 'Pre-rotation is armed: a next update key is staged.'
1649
+ : 'Pre-rotation is off after this rotation.');
1650
+ console.log(JSON.stringify({ id: result.did, didDocument: result.doc }, null, 2));
1651
+ });
1652
+ did.addCommand(webvh);
651
1653
  return did;
652
1654
  }
653
1655
  //# sourceMappingURL=did.js.map