@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.
- package/CHANGELOG.md +91 -0
- package/README.md +192 -4
- package/dist/commands/did.d.ts +9 -0
- package/dist/commands/did.d.ts.map +1 -1
- package/dist/commands/did.js +1021 -19
- package/dist/commands/did.js.map +1 -1
- package/dist/commands/was/space.d.ts +17 -0
- package/dist/commands/was/space.d.ts.map +1 -1
- package/dist/commands/was/space.js +37 -2
- package/dist/commands/was/space.js.map +1 -1
- package/dist/commands/was.d.ts +1 -1
- package/dist/commands/was.d.ts.map +1 -1
- package/dist/commands/was.js +11 -2
- package/dist/commands/was.js.map +1 -1
- package/dist/keys/webvh-driver.d.ts +24 -0
- package/dist/keys/webvh-driver.d.ts.map +1 -0
- package/dist/keys/webvh-driver.js +73 -0
- package/dist/keys/webvh-driver.js.map +1 -0
- package/dist/keys/webvh-signer.d.ts +29 -0
- package/dist/keys/webvh-signer.d.ts.map +1 -0
- package/dist/keys/webvh-signer.js +44 -0
- package/dist/keys/webvh-signer.js.map +1 -0
- package/dist/keys/webvh-update.d.ts +33 -0
- package/dist/keys/webvh-update.d.ts.map +1 -0
- package/dist/keys/webvh-update.js +62 -0
- package/dist/keys/webvh-update.js.map +1 -0
- package/dist/storage.d.ts +74 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +69 -1
- package/dist/storage.js.map +1 -1
- package/dist/was/registry.d.ts +2 -1
- package/dist/was/registry.d.ts.map +1 -1
- package/dist/was/registry.js +16 -5
- package/dist/was/registry.js.map +1 -1
- package/package.json +5 -4
package/dist/commands/did.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
50
|
-
* Built once and reused. (Per project convention, DID/JSON-LD
|
|
51
|
-
* through `@interop/security-document-loader`, never a
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
461
|
-
didDocument =
|
|
1263
|
+
if (resolvedDoc) {
|
|
1264
|
+
didDocument = resolvedDoc;
|
|
462
1265
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
501
|
-
//
|
|
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
|