Repository navigation
Expand file tree
/
Copy pathdidcel
More file actions
executable file
·553 lines (518 loc) · 20.9 KB
/
Copy pathdidcel
File metadata and controls
executable file
·553 lines (518 loc) · 20.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
#!/usr/bin/env node
/**
* @file DID CEL Command Line Interface (CLI).
*
* This is an interactive REPL (Read-Eval-Print Loop) for creating and managing
* DID documents using the Cryptographic Event Log (CEL) method. The tool allows
* users to create DIDs, add/remove verification methods, update DID documents,
* and maintain a cryptographic event log of all changes.
*
* Usage:
* ./didcel # Start interactive REPL
* ./didcel -c "create" "add ..." # Execute commands and continue in REPL
* ./didcel -v # Verbose output mode.
*
* Available commands:
* create - Create a new DID document
* add - Add verification methods or services
* ls - List DID contents
* expire - Set expiration on verification methods
* remove - Remove objects from DID document
* update - Update the cryptographic event log
* witness - Generate witness proofs
* save - Save CEL to file
* resolve - Resolve a did:cel from a storage service (read-only)
* quit - Exit the REPL.
*/
import {
addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair,
deleteObjectByIdSuffix, getObjectByIdSuffix, getPreviousEventHash,
loadFromFile, loadSecrets, prettyPrintCel, read, saveSecrets, saveToFile,
sha3256Multibase, witness
} from 'didcel';
import {Argument, Command, CommanderError} from 'commander';
import {config, loadConfig} from './lib/config.js';
import {gunzipSync} from 'node:zlib';
import {join} from 'node:path';
import {mkdirSync} from 'fs';
import promptSync from 'prompt-sync';
// create the CLI and parse command-line options
const program = new Command();
program
.option('-c, --command <command...>', 'One or more commands to execute')
.option('-g, --config <path>', 'Path to config.yaml')
.option('-p, --password <password>', 'Password for encrypting private keys')
.option('-v, --verbose', 'Provide verbose output')
.parse(process.argv);
const options = program.opts();
loadConfig({configPath: options.config});
// common verification relationship and service properties in DID documents
const COMMON_PROPERTIES = [
'authentication', 'assertionMethod', 'capabilityDelegation',
'capabilityInvocation', 'keyAgreement', 'service'
];
/**
* Runs the interactive REPL for DID CEL management. Maintains session state
* including the current DID document, CEL, and secret keys.
*
* @param {object} options - Configuration options.
* @param {Array<string>} [options.commands] - Optional array of commands to
* execute before entering interactive mode.
* @param {string} [options.password] - Password for encrypting private keys.
* @returns {Promise<void>}
*/
async function repl({commands, password}) {
// configure the REPL environment
const prompt = promptSync();
const repl = new Command();
// prompt for encryption password if not provided via -p; ask twice and
// re-prompt until both entries match
if(password === undefined) {
while(true) {
const first = prompt('Encryption password: ', {echo: ''});
const second = prompt('Re-enter encryption password: ', {echo: ''});
if(first === second) {
password = first;
break;
}
console.log('Passwords do not match; please try again.');
}
}
// session state variables
// the CEL tracking all DID changes
let cryptographicEventLog;
// the current DID document
let didDocument;
// secret keys organized by verification relationship; heartbeat is a Buffer
const secretKeys = {
authentication: [],
assertionMethod: [],
capabilityInvocation: [],
capabilityDelegation: [],
keyAgreement: []
};
// index of the next heartbeat key to use for signing (0-based)
let heartbeatKeyIndex = 0;
// true when the session DID was resolved from a storage service; blocks
// all commands that modify the DID document or write it to disk
let readOnly = false;
// logs an error and returns false when the session is read-only
function _assertWritable(commandName) {
if(readOnly) {
console.log(
`error: ${commandName} is disabled - the current DID was resolved ` +
'in read-only mode.');
return false;
}
return true;
}
// configure the Commander.js REPL with custom error handling
repl.name('command')
.usage('[options]')
// don't exit process on command errors
.exitOverride();
repl.command('help')
.description('Show help')
.action(() => {
repl.help();
});
repl.command('load')
.description('Load and validate a DID from a cryptographic event log.')
.argument('<filename>', 'path to the .cel file to load')
.action(async filename => {
try {
const result = await loadFromFile({filename});
if(result.valid) {
// clone to decouple the session document from the CEL's entries
didDocument = structuredClone(result.didDocument);
cryptographicEventLog = result.cel;
readOnly = false;
const didIdentifier = didDocument.id.split(':').pop();
const loaded = await loadSecrets(
{didIdentifier, password, secretsDir: config.secrets});
Object.assign(secretKeys, loaded);
heartbeatKeyIndex = result.cel.log.length - 1;
const {log} = result.cel;
console.log(
`load: valid CEL with ${log.length} event(s): ${didDocument.id}`);
} else {
for(const err of result.errors) {
console.log(`error: ${err}`);
}
}
} catch(err) {
console.log(`error: ${err.message}`);
}
});
// command: resolve
// resolves a did:cel identifier from a storage service. Prompts for the
// storage URL, fetches `<storageUrl><did>.cel.gz`, validates the CEL
// against the recognized witnesses in config.yaml, and loads the resolved
// DID document into the session in read-only mode so it cannot be
// modified or written to disk by accident.
repl.command('resolve')
.description(
'Resolve a did:cel identifier from a storage service (read-only).')
.argument('<did>', 'the did:cel identifier to resolve')
.action(async did => {
try {
if(!did.startsWith('did:cel:')) {
console.log('error: identifier must start with "did:cel:".');
return;
}
const storageUrl = prompt('Storage URL (ending in "/"): ').trim();
if(!storageUrl.endsWith('/')) {
console.log('error: the storage URL must end in a "/" character.');
return;
}
const celUrl = `${storageUrl}${did}.cel.gz`;
console.log(`resolve: fetching ${celUrl}`);
const response = await fetch(celUrl);
if(!response.ok) {
console.log(
`error: failed to fetch CEL (HTTP ${response.status}).`);
return;
}
const compressed = Buffer.from(await response.arrayBuffer());
const cel = JSON.parse(gunzipSync(compressed).toString('utf8'));
// validate the CEL, only accepting proofs from recognized witnesses
const recognizedWitnesses = config.recognizedWitnesses ?? [];
const result = await read({cel, recognizedWitnesses});
if(!result.valid) {
for(const err of result.errors) {
console.log(`error: ${err}`);
}
return;
}
// ensure the storage service returned the requested DID
if(result.didDocument.id !== did) {
console.log(
`error: storage service returned "${result.didDocument.id}" ` +
`instead of the requested "${did}".`);
return;
}
// load into the session in read-only mode; no secret keys are
// loaded because the resolved DID is not controlled by this session
didDocument = structuredClone(result.didDocument);
cryptographicEventLog = result.cel;
readOnly = true;
console.log(
`resolve: valid CEL with ${result.cel.log.length} event(s): ` +
`${didDocument.id} (read-only)`);
} catch(err) {
console.log(`error: ${err.message}`);
}
});
// command: create
// creates a new self-certifying DID document with a heartbeat commitment.
// no assertionMethod key is generated at creation time; use 'add' afterwards.
repl.command('create')
.description('Create a new DID document')
.action(async () => {
const result = await create();
// clone to decouple the session document from the CEL's create entry
didDocument = structuredClone(result.didDocument);
// store the heartbeat master secret for deriving per-event signing keys
secretKeys.heartbeat = result.heartbeatSecret;
cryptographicEventLog = result.cryptographicEventLog;
heartbeatKeyIndex = 0;
readOnly = false;
console.log(`create successful: ${didDocument.id}`);
});
// command: add
// adds verification methods or services to the DID document
repl.command('add')
.description('Add a verification method or service to the DID document.')
.addArgument(new Argument('<property>',
'the name of the property to add to').choices(COMMON_PROPERTIES))
.addArgument(new Argument('<type>', 'the type of property to add')
.choices(['eddsa', 'ecdsa', 'bbs', 'FileService']))
.action(async (property, type) => {
if(!_assertWritable('add')) {
return;
}
// TODO: Currently only ECDSA verification methods are supported
if(property !== 'service' && type === 'ecdsa') {
// generate a new verification method for the specified relationship
const result = await addVm(
{didDocument, verificationRelationship: property, curve: 'P-256'});
didDocument = result.didDocument;
// store the secret key for this verification relationship
secretKeys[property].push(result.keyPair);
console.log(`add: new verification method for ${property}`);
}
});
// command: ls
// lists DID contents - either a summary or details of a specific object
repl.command('ls')
.description('list the contents of all identifiers, or a specific one.')
.addArgument(
new Argument('[suffix]', 'the last several characters of the identifier'))
.action(async suffix => {
// always display the DID identifier
console.log(didDocument.id);
// if suffix provided, print detailed object information
if(suffix) {
const value = getObjectByIdSuffix({didDocument, suffix});
if(value) {
console.log(prettyPrintCel(value));
return;
}
}
// if no suffix provided, display a summary of the DID document
for(const [property, value] of Object.entries(didDocument)) {
if(property === 'id') {
continue;
}
// scalar string properties (e.g. heartbeatFrequency)
if(!Array.isArray(value)) {
if(typeof value === 'string') {
console.log(` ${property}: ${value}`);
}
continue;
}
// array properties: string entries (e.g. @context, heartbeat) are
// shown as-is; object entries (verification methods, services) are
// shown as abbreviated type + identifier
let numEntries = 0;
let propertyListing = ` ${property}: `;
for(const entry of value) {
if(typeof entry === 'string') {
propertyListing += entry + ' ';
numEntries++;
} else if(typeof entry === 'object' && entry !== null) {
// display first 4 and last 4 characters of identifier, when
// present (e.g. service entries may not have an id)
const id = entry.id ?? '';
const abbreviatedId =
id ? id.slice(0, 4) + '...' + id.slice(-4) : '';
propertyListing += (entry.type ?? '') + abbreviatedId + ' ';
numEntries++;
}
}
if(numEntries > 0) {
console.log(propertyListing);
}
}
});
// command: expire
// sets an expiration timestamp on a verification method
repl.command('expire')
.description('Expire a verification method from the current DID document.')
.addArgument(new Argument('<suffix>',
'the last several characters of the identifier to expire'))
.action(async suffix => {
if(!_assertWritable('expire')) {
return;
}
if(suffix) {
const value = getObjectByIdSuffix({didDocument, suffix});
if(value) {
// generate ISO 8601 timestamp for expiration
let expireDatetime = new Date().toISOString();
// format as YYYY-MM-DDTHH:MM:SSZ (remove milliseconds)
expireDatetime =
expireDatetime.slice(0, expireDatetime.length - 5) + 'Z';
// add expires property to the verification method
value.expires = expireDatetime;
console.log(`expire: ${value.id} at ${expireDatetime}.`);
} else {
console.log(`error: Could not find object with suffix "${suffix}".`);
}
}
});
// command: remove
// removes a verification method or service from the DID document.
// the object is identified by the last few characters of its ID (suffix).
// this is useful for removing keys without typing the full identifier. note:
// The DID document is modified but not automatically committed to the CEL.
// you must run 'update' and 'witness' commands to persist the change.
repl.command('remove')
.description('Remove an object from the current DID document.')
.addArgument(new Argument('<suffix>',
'the last several characters of the identifier to remove'))
.action(async suffix => {
if(!_assertWritable('remove')) {
return;
}
if(suffix) {
// search for and delete the object matching the ID suffix
const value = deleteObjectByIdSuffix({didDocument, suffix});
if(value) {
console.log(`remove: removed ${value.id} successfully.`);
} else {
console.log(`error: Could not find object with suffix "${suffix}".`);
}
}
});
// derives the current heartbeat signing key, rotates the heartbeat commitment
// in the event data, creates a signed event hash-linked via previousEventHash,
// and appends it to the CEL. deactivate events skip heartbeat rotation.
//
// eventData is always a fresh clone so that mutations to didDocument after
// this call cannot retroactively corrupt the committed event payload.
async function _appendEvent({type, data}) {
const previousEventHash = getPreviousEventHash({cel: cryptographicEventLog});
const signingKeyPair = await deriveHeartbeatKeyPair(
secretKeys.heartbeat, heartbeatKeyIndex);
let eventData = data;
if(type !== 'deactivate') {
const nextKeyPair = await deriveHeartbeatKeyPair(
secretKeys.heartbeat, heartbeatKeyIndex + 1);
const nextHash = sha3256Multibase(nextKeyPair.id);
if(type === 'update') {
// clone before mutating so the CEL's prior events are not affected
eventData = structuredClone(data);
eventData.heartbeat = [nextHash];
// update the session DID document to match committed state
didDocument.heartbeat = [nextHash];
} else if(type === 'heartbeat') {
eventData = {heartbeat: [nextHash]};
didDocument.heartbeat = [nextHash];
}
}
const event = await createEvent({
type, data: eventData, signingKeyPair, previousEventHash});
cryptographicEventLog = await addEvent({cel: cryptographicEventLog, event});
if(type !== 'deactivate') {
heartbeatKeyIndex++;
}
}
// command: update
// creates a signed update event containing the current DID document and
// appends it to the CEL, hash-linked via previousEventHash. Run 'witness'.
repl.command('update')
.description('Update the cryptographic event log with the latest DID doc')
.action(async () => {
if(!_assertWritable('update')) {
return;
}
await _appendEvent({type: 'update', data: didDocument});
});
// command: heartbeat
// generates a heartbeat to ensure the DID document does not deactivate
repl.command('heartbeat')
.description('Update the cryptographic event log with a heartbeat')
.action(async () => {
if(!_assertWritable('heartbeat')) {
return;
}
await _appendEvent({type: 'heartbeat', data: undefined});
console.log('heartbeat: generated');
});
// command: deactivate
// deactivates the DID; terminal operation, no further events permitted
repl.command('deactivate')
.description('Deactivate the DID')
.action(async () => {
if(!_assertWritable('deactivate')) {
return;
}
await _appendEvent({type: 'deactivate', data: undefined});
console.log('deactivation: complete');
});
// command: witness
// sends a SHA2-256 digest of the most recent CEL event to each configured
// witness service. Witnesses are blind — they never see the DID document,
// only the hash — and each returns a DataIntegrityProof that is attached to
// the log entry, providing temporal anchoring and distributed attestation.
repl.command('witness')
.description(
'Witness the latest set of updates to the DID document.')
.action(async () => {
if(!_assertWritable('witness')) {
return;
}
// generate witness proofs for the most recent event in the log
// each witness independently validates and signs the event
await witness({cel: cryptographicEventLog, witnesses: config.witnesses});
console.log('witness: proofs complete');
});
// command: save
// persists the CEL to a gzip-compressed JSON file in config.logs, named
// by the DID identifier (e.g. z1abc....cel.gz). Also encrypts and saves
// all secret keys to config.secrets. Optionally writes to an explicit
// filename as well. CEL files can later be loaded to reconstruct history.
repl.command('save')
.description(
'Saves the current DID to a cryptographic event log.')
.argument('[filename]', 'the name of the file to save the event log to')
.action(async filename => {
if(!_assertWritable('save')) {
return;
}
const didIdentifier = didDocument.id.split(':').pop();
// always write to configured logs directory using DID identifier
if(config.logs) {
mkdirSync(config.logs, {recursive: true});
const logsPath = join(config.logs, `${didIdentifier}.cel.gz`);
saveToFile({filename: logsPath, cel: cryptographicEventLog});
console.error(`Wrote to ${logsPath}`);
}
// save encrypted private keys to secrets directory
await saveSecrets(
{didIdentifier, secretKeys, password, secretsDir: config.secrets});
const secretsPath = join(config.secrets, `${didIdentifier}.yaml`);
console.error(`Wrote secrets to ${secretsPath}`);
// also write CEL to explicit filename if provided
if(filename) {
saveToFile({filename, cel: cryptographicEventLog});
console.error(`Wrote to ${filename}`);
}
});
// command: quit
// exits the REPL. Unsaved changes to the DID document or CEL will be lost.
// Run 'save' before quitting to persist secret keys and the event log.
repl.command('quit')
.description('Exit without saving the cryptographic event log.')
.action(async () => {
process.exit(0);
});
// batch command execution mode: if commands were provided via the -c flag,
// execute them sequentially. Commander.js errors (unknown command, bad args)
// are suppressed so remaining commands still run; other errors propagate.
if(commands && commands.length > 0) {
for(const cmdLine of commands) {
const args = cmdLine.split(' ');
const command = args[0];
try {
// prepend two dummy argv entries so Commander.js sees the right offset
const commanderArgs = [command, command].concat(args);
await repl.parseAsync(commanderArgs);
} catch(err) {
// suppress Commander errors (e.g., unknown command, validation
// failures) to continue executing remaining commands in the batch
if(!(err instanceof CommanderError)) {
throw err;
}
}
}
}
// interactive REPL loop
// continuously prompts the user for commands until 'quit' is entered.
// commands are parsed by Commander.js which handles validation,
// argument parsing, and routing to the appropriate action handler.
let command = '';
do {
// display prompt and read user input
const args = prompt('did:cel> ').split(' ');
command = args[0];
try {
// parse and execute the user's command
const commanderArgs = [command, command].concat(args);
await repl.parseAsync(commanderArgs);
} catch(err) {
// don't exit the REPL on command errors
// this allows users to correct mistakes without restarting
if(!(err instanceof CommanderError)) {
throw err;
}
}
} while(command != 'quit');
}
// entry point: Start the REPL with any command-line options
// the function is called with commands from the -c flag if provided
await repl({
commands: options.command,
password: options.password
});