From f70fd559f53951a26d8ba2ec578e0b2ec3b80dce Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 4 Nov 2025 16:03:17 -0500 Subject: [PATCH 01/44] Update repl commands to ensure more complete feature set. --- didcel | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/didcel b/didcel index 223a387..bf8c90d 100755 --- a/didcel +++ b/didcel @@ -38,19 +38,25 @@ async function repl({commands}) { }); repl.command('add') - .description('Add a verification method to the current DID document.') + .description('Add a verification method or service to the current DID document.') + .addArgument(new Argument('', 'the name of the property to add to') + .choices(['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service'])) + .addArgument(new Argument('', 'the type of property to add') + .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) .action(() => { console.error('add not implemented'); }); repl.command('expire') .description('Expire a verification method from the current DID document.') + .argument('', 'the id of the verification method to expire') .action(() => { console.error('expire not implemented'); }); repl.command('remove') - .description('Expire a verification method from the current DID document.') + .description('Remove an object from the current DID document.') + .argument('', 'the id of the object to remove') .action(() => { console.error('remove not implemented'); }); From c39fc6ed17f9750a75b28821961b0f371e9d958b Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 4 Nov 2025 17:36:19 -0500 Subject: [PATCH 02/44] Change how cel and didcel libs load. --- didcel | 31 +++++++++++++++++++------------ lib/cel.js | 39 +++++++++++++++++++++++++++++++++++++++ lib/index.js | 0 3 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 lib/cel.js create mode 100644 lib/index.js diff --git a/didcel b/didcel index bf8c90d..1e9663b 100755 --- a/didcel +++ b/didcel @@ -2,12 +2,8 @@ import { Argument, Command, CommanderError } from 'commander'; import path from 'path'; import promptSync from 'prompt-sync'; -// import { -// createLog, addEvent, witnessEvent, -// } from './lib/cel.js'; -// import { -// create, read, update, deactivate -// } from './lib/did-cel-driver.js'; +import cel from './lib/cel.js'; +import didcel from './lib/didcel.js'; // create the CLI and parse the options const program = new Command(); @@ -21,6 +17,9 @@ async function repl({commands}) { // configure the REPL const prompt = promptSync(); const repl = new Command(); + let log; + let didDocument; + repl.name('command') .usage('[options]') .exitOverride(); @@ -29,13 +28,21 @@ async function repl({commands}) { .description('Show help') .action(() => { repl.help(); - }); + }); + + repl.command('load') + .description('Load a DID from a cryptographic event log.') + .action(() => { + console.error('load not implemented'); + }); repl.command('create') .description('Create a new DID document') .action(() => { - console.error('create not implemented'); - }); + didDocument = didcel.create({}); + log = cel.create({data: didDocument}); + console.log('CEL: ', JSON.stringify(log, null, 2)); + }); repl.command('add') .description('Add a verification method or service to the current DID document.') @@ -45,21 +52,21 @@ async function repl({commands}) { .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) .action(() => { console.error('add not implemented'); - }); + }); repl.command('expire') .description('Expire a verification method from the current DID document.') .argument('', 'the id of the verification method to expire') .action(() => { console.error('expire not implemented'); - }); + }); repl.command('remove') .description('Remove an object from the current DID document.') .argument('', 'the id of the object to remove') .action(() => { console.error('remove not implemented'); - }); + }); repl.command('save') .description( diff --git a/lib/cel.js b/lib/cel.js new file mode 100644 index 0000000..89e2b8a --- /dev/null +++ b/lib/cel.js @@ -0,0 +1,39 @@ +export function create({data, options}) { + let log = { + log: [{ + event: { + operation: { + type: 'create', + data + } + } + }] + }; + + // set a previous log if there is one + if(options?.previousLog) { + log.previousLog = options.previousLog; + } + + return log; +} + +export function update({cel, data, options}) { + // TODO: Calculate hash of previous event + let previousEvent = 'TODO'; + + // push event to end of log + cel.log.push({ + event: { + previousEvent, + operation: { + type: 'update', + data + } + } + }); + + return log; +} + +export default {create, update}; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..e69de29 From 2e48a679a7c1bdeab154a7e32a44e84dd0adb9dc Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 4 Nov 2025 18:06:32 -0500 Subject: [PATCH 03/44] Add addition of keys to DID Document. --- didcel | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/didcel b/didcel index 1e9663b..634173b 100755 --- a/didcel +++ b/didcel @@ -12,7 +12,7 @@ program .parse(process.argv); const options = program.opts(); -// Run the repl +// Runs the repl until exit async function repl({commands}) { // configure the REPL const prompt = promptSync(); @@ -50,8 +50,18 @@ async function repl({commands}) { .choices(['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service'])) .addArgument(new Argument('', 'the type of property to add') .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) - .action(() => { - console.error('add not implemented'); + .action((property, type) => { + if(property !== 'service' && type === 'ecdsa') { + const vm = { + id: '#zDnYeGRG5ehnPAmxcf5mDZpvaerx9CtbPJ1q36T5Ln5wYt3MQ', + type: 'Multikey', + controller: didDocument.id, + publicKeyMultibase: 'zDnYeGRG5ehnPAmxcf5mDZpvaerx9CtbPJ1q36T5Ln5wYt3MQ' + }; + didDocument.verificationMethod.push(vm); + didDocument[property].push(vm.id); + } + console.log(`add ${property} ${type}: `, JSON.stringify(didDocument, null, 2)); }); repl.command('expire') @@ -68,6 +78,13 @@ async function repl({commands}) { console.error('remove not implemented'); }); + repl.command('witness') + .description( + 'Witness the latest set of updates to the DID document.') + .action(async () => { + console.error('witnessing not implemented'); + }); + repl.command('save') .description( 'Saves the current DID to a cryptographic event log.') From 43b01235559b7f8d744f62edc062c18565cc0509 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Wed, 5 Nov 2025 00:04:52 -0500 Subject: [PATCH 04/44] Add didcel helper functions. --- lib/didcel.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 lib/didcel.js diff --git a/lib/didcel.js b/lib/didcel.js new file mode 100644 index 0000000..585d3aa --- /dev/null +++ b/lib/didcel.js @@ -0,0 +1,46 @@ +export function create({options}) { + let didDocument = { + '@context': 'https://www.w3.org/ns/did/v1.1', + id: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq', + verificationMethod: [{ + id: '#key-1', + type: 'Multikey', + controller: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq', + publicKeyMultibase: 'zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv' + }], + authentication: [], + assertionMethod: ['#key-1'], + capabilityDelegation: [], + capabilityInvocation: [], + proof: { + type: 'DataIntegrityProof', + cryptosuite: 'ecdsa-jcs-2019', + created: '2025-11-29T13:56:28Z', + verificationMethod: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq#key-1', + proofPurpose: 'assertionMethod', + proofValue: 'z5obCSsrQxuFJdq6PrUMCtqY93gBHqGDBtQLPFxpZxzwVWgHYrXxoV' + } + } + + return didDocument; +} + +export function update({cel, data, options}) { + // TODO: Calculate hash of previous event + let previousEvent = 'TODO'; + + // push event to end of log + cel.log.push({ + event: { + previousEvent, + operation: { + type: 'update', + data + } + } + }); + + return log; +} + +export default {create, update}; From 2e93207a72493a2c193d49479f74c4d2ef6439d9 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Wed, 5 Nov 2025 22:00:38 -0500 Subject: [PATCH 05/44] Make creation of did:cel initial document real. --- didcel | 5 +-- lib/didcel.js | 84 ++++++++++++++++++++++++++++++++++++++------------- package.json | 6 ++++ 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/didcel b/didcel index 634173b..e0602f8 100755 --- a/didcel +++ b/didcel @@ -38,8 +38,9 @@ async function repl({commands}) { repl.command('create') .description('Create a new DID document') - .action(() => { - didDocument = didcel.create({}); + .action(async () => { + let result = await didcel.create({curve: 'P-256'}); + didDocument = result.didDocument; log = cel.create({data: didDocument}); console.log('CEL: ', JSON.stringify(log, null, 2)); }); diff --git a/lib/didcel.js b/lib/didcel.js index 585d3aa..1dfe510 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -1,28 +1,70 @@ -export function create({options}) { +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; +import {JsonLdDocumentLoader} from 'jsonld-document-loader'; +import {base58btc} from 'multiformats/bases/base58'; +import canonicalize from 'canonicalize'; +import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; +import jsigs from 'jsonld-signatures'; +import * as mfHasher from 'multiformats/hashes/hasher'; +import {sha3_256} from '@noble/hashes/sha3.js'; + +const {purposes: {AssertionProofPurpose}} = jsigs; +const jdl = new JsonLdDocumentLoader(); + +export async function create({options}) { + const keyPair = + await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); + const publicKey = + await keyPair.export({publicKey: true, includeContext: false}); + publicKey.id = '#' + publicKey.publicKeyMultibase; + + // update document loader + jdl.addStatic(publicKey.id, publicKey); + let didDocument = { '@context': 'https://www.w3.org/ns/did/v1.1', - id: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq', - verificationMethod: [{ - id: '#key-1', - type: 'Multikey', - controller: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq', - publicKeyMultibase: 'zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv' - }], - authentication: [], - assertionMethod: ['#key-1'], - capabilityDelegation: [], - capabilityInvocation: [], - proof: { - type: 'DataIntegrityProof', - cryptosuite: 'ecdsa-jcs-2019', - created: '2025-11-29T13:56:28Z', - verificationMethod: 'did:cel:zhU7329bdjo83Jw739ndhJSkejJAAp323bWq#key-1', - proofPurpose: 'assertionMethod', - proofValue: 'z5obCSsrQxuFJdq6PrUMCtqY93gBHqGDBtQLPFxpZxzwVWgHYrXxoV' - } + assertionMethod: [publicKey] + } + + // generate the did:cel identifier + const utf8Encoder = new TextEncoder(); + const canonicalizedDidDocument = canonicalize(didDocument); + console.log(canonicalizedDidDocument); + const sha3256Hasher = mfHasher.from({ + name: 'sha3-256', + code: 0x16, + encode: input => sha3_256(input), + }); + const mfHash = await sha3256Hasher.digest( + utf8Encoder.encode(canonicalizedDidDocument)).bytes; + const encodedHash = base58btc.encode(mfHash); + const controller = 'did:cel:' + encodedHash; + didDocument.id = controller; + publicKey.controller = controller; + + // place a proof on the DID Document + const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); + const suite = new DataIntegrityProof({ + signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite + }); + + // create signed credential + let documentLoader = jdl.build(); + const signedDidDocument = await jsigs.sign(didDocument, { + suite, + purpose: new AssertionProofPurpose(), + documentLoader + }); + + // rewrite DID Document to place the `id` at the top of the document + didDocument = { + '@context': 'https://www.w3.org/ns/did/v1.1', + id: controller, + assertionMethod: [publicKey], + proof: signedDidDocument.proof } - return didDocument; + return {keyPair, didDocument}; } export function update({cel, data, options}) { diff --git a/package.json b/package.json index be5ab22..a2974aa 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,14 @@ "license": "BSD", "description": "", "dependencies": { + "@digitalbazaar/data-integrity": "^2.5.0", + "@digitalbazaar/ecdsa-jcs-2019-cryptosuite": "^1.0.0", + "@noble/hashes": "^2.0.1", + "canonicalize": "^2.1.0", "commander": "^12.1.0", "dotenv": "^16.4.5", + "jsonld-document-loader": "^2.3.0", + "multiformats": "^13.4.1", "prompt-sync": "^4.2.0" } } From 15f4225dce7efaf5b63e36621b32610ab42c4683 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Wed, 5 Nov 2025 23:20:52 -0500 Subject: [PATCH 06/44] Add saving of event logs. --- didcel | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/didcel b/didcel index e0602f8..b43571a 100755 --- a/didcel +++ b/didcel @@ -1,9 +1,9 @@ #!/usr/bin/env node import { Argument, Command, CommanderError } from 'commander'; -import path from 'path'; -import promptSync from 'prompt-sync'; import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; +import promptSync from 'prompt-sync'; +import {writeFileSync} from 'fs'; // create the CLI and parse the options const program = new Command(); @@ -17,7 +17,7 @@ async function repl({commands}) { // configure the REPL const prompt = promptSync(); const repl = new Command(); - let log; + let cryptographicEventLog; let didDocument; repl.name('command') @@ -41,8 +41,8 @@ async function repl({commands}) { .action(async () => { let result = await didcel.create({curve: 'P-256'}); didDocument = result.didDocument; - log = cel.create({data: didDocument}); - console.log('CEL: ', JSON.stringify(log, null, 2)); + cryptographicEventLog = cel.create({data: didDocument}); + console.log('CEL: ', JSON.stringify(cryptographicEventLog, null, 2)); }); repl.command('add') @@ -89,8 +89,11 @@ async function repl({commands}) { repl.command('save') .description( 'Saves the current DID to a cryptographic event log.') - .action(async () => { - console.error('save not implemented'); + .argument('[filename]', 'the name of the file to save the event log to') + .action(async (filename) => { + const celFilename = filename || 'did.cel'; + writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, null, 2)); + console.error(`Wrote to ${celFilename}`); }); repl.command('quit') From 5d081dd07bf87ddf4a9b205ad01cf0e3625d5a52 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Wed, 5 Nov 2025 23:37:34 -0500 Subject: [PATCH 07/44] Fix running multiple commands from command line. --- didcel | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/didcel b/didcel index b43571a..e95b046 100755 --- a/didcel +++ b/didcel @@ -8,6 +8,7 @@ import {writeFileSync} from 'fs'; // create the CLI and parse the options const program = new Command(); program + .option('-c, --command ', 'One or more commands to execute') .option('-v, --verbose', 'Provide verbose output') .parse(process.argv); const options = program.opts(); @@ -104,10 +105,9 @@ async function repl({commands}) { // if command-line commands were provided, run them and exit if(commands && commands.length > 0) { - commands.forEach(async (cmdLine) => { - let args = cmdLine.split(' '); - let command = args[0]; - + for(const cmdLine of commands) { + const args = cmdLine.split(' '); + const command = args[0]; try { const commanderArgs = [command, command].concat(args); await repl.parseAsync(commanderArgs); @@ -116,7 +116,7 @@ async function repl({commands}) { throw err; } } - }); + }; process.exit(0); } From f4465ccf9fc0e63030414d6fbb0c602ab108dffe Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Thu, 6 Nov 2025 17:51:11 -0500 Subject: [PATCH 08/44] Add JSON-LD pretty printer. --- lib/utils.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 lib/utils.js diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..6e7a4a5 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,29 @@ +export function createJsonldPrettyPrinter({preferOrder}) { + return (key, value) => { + let result = value; + if(value instanceof Object && !(value instanceof Array)) { + let sortedKeys = Object.keys(value).sort(); + let prettyKeys = []; + + for(let pkey of preferOrder) { + if(value[pkey] !== undefined) { + prettyKeys.push(pkey); + } + } + for(let skey of sortedKeys) { + if(!preferOrder.includes(skey)) { + prettyKeys.push(skey); + } + } + + result = prettyKeys.reduce((sorted, key) => { + sorted[key] = value[key]; + return sorted; + }, {}); + } + + return result; + } +} + +export default {createJsonldPrettyPrinter}; From 3cbed25f63f905cdfc0706f58104ab19bb88fb2d Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Thu, 6 Nov 2025 17:51:55 -0500 Subject: [PATCH 09/44] Add log witnessing. --- didcel | 10 +++++-- lib/cel.js | 45 ++++++++++++++++++++++++++++++- lib/witness.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 lib/witness.js diff --git a/didcel b/didcel index e95b046..2b2084a 100755 --- a/didcel +++ b/didcel @@ -4,6 +4,7 @@ import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; import promptSync from 'prompt-sync'; import {writeFileSync} from 'fs'; +import {createJsonldPrettyPrinter} from './lib/utils.js'; // create the CLI and parse the options const program = new Command(); @@ -13,6 +14,10 @@ program .parse(process.argv); const options = program.opts(); +const jsonldPretty = createJsonldPrettyPrinter({ + preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite'] +}); + // Runs the repl until exit async function repl({commands}) { // configure the REPL @@ -84,7 +89,8 @@ async function repl({commands}) { .description( 'Witness the latest set of updates to the DID document.') .action(async () => { - console.error('witnessing not implemented'); + const proof = await cel.witness({cel: cryptographicEventLog}) + console.log('Generated witness proofs:', JSON.stringify(proof, null, 2)); }); repl.command('save') @@ -93,7 +99,7 @@ async function repl({commands}) { .argument('[filename]', 'the name of the file to save the event log to') .action(async (filename) => { const celFilename = filename || 'did.cel'; - writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, null, 2)); + writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, jsonldPretty, 2)); console.error(`Wrote to ${celFilename}`); }); diff --git a/lib/cel.js b/lib/cel.js index 89e2b8a..3045b09 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -1,3 +1,23 @@ +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; +import {JsonLdDocumentLoader} from 'jsonld-document-loader'; +import {base58btc} from 'multiformats/bases/base58'; +import canonicalize from 'canonicalize'; +import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; +import jsigs from 'jsonld-signatures'; +import * as mfHasher from 'multiformats/hashes/hasher'; +import {sha3_256} from '@noble/hashes/sha3.js'; +import * as witnessService from './witness.js'; + +const {purposes: {AssertionProofPurpose}} = jsigs; +const jdl = new JsonLdDocumentLoader(); + +let witnesses = [ + "did:web:red-witness.example", + "did:web:green-witness.example", + "did:web:blue-witness.example" +]; + export function create({data, options}) { let log = { log: [{ @@ -18,6 +38,29 @@ export function create({data, options}) { return log; } +export async function witness({cel, options}) { + const proofs = []; + const event = cel.log[cel.log.length-1]; + + // 1. If a previous event exists: + if(cel.log.length > 1) { + // 1.1. Get the previous event + // 1.2. Calculate hash of previous event + // 1.3. Include the previous event hash in the current event + let previousEvent = 'TODO'; + } + + // 2. For each witness: + // 2.1. Create a proof for the current event + for(let witness of witnesses) { + const proof = await witnessService.generateProof( + {data: event, options: {witness}}); + proofs.push(proof); + } + + return proofs; +} + export function update({cel, data, options}) { // TODO: Calculate hash of previous event let previousEvent = 'TODO'; @@ -36,4 +79,4 @@ export function update({cel, data, options}) { return log; } -export default {create, update}; +export default {create, update, witness}; diff --git a/lib/witness.js b/lib/witness.js new file mode 100644 index 0000000..a083865 --- /dev/null +++ b/lib/witness.js @@ -0,0 +1,72 @@ +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; +import {JsonLdDocumentLoader} from 'jsonld-document-loader'; +import {base58btc} from 'multiformats/bases/base58'; +import canonicalize from 'canonicalize'; +import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; +import jsigs from 'jsonld-signatures'; +import * as mfHasher from 'multiformats/hashes/hasher'; +import {sha3_256} from '@noble/hashes/sha3.js'; + +const {purposes: {AssertionProofPurpose}} = jsigs; +const jdl = new JsonLdDocumentLoader(); + +// TODO: move to separate service -- generate all of the witness keys +const secretKeys = [{ + "@context": "https://w3id.org/security/multikey/v1", + "id": "did:web:red-witness.example#vm-red-1", + "type": "Multikey", + "controller": "did:web:red-witness.example", + "publicKeyMultibase": "zDnaeRQKUJYxFwB1zgHisFGeHXYhgoDkXQ3cgzTJHVPfxtfxY", + "secretKeyMultibase": "z42twzpeKSKsX7NNH5v4CGREKhmcEKGu5RXXAVQQCqjDMnPg" +}, { + "@context": "https://w3id.org/security/multikey/v1", + "id": "did:web:green-witness.example#vm-green-1", + "type": "Multikey", + "controller": "did:web:green-witness.example", + "publicKeyMultibase": "zDnaecDuyWKVKwfHEZrh6bNtLDK46Y88nGLEEEjqcTbCYwWYW", + "secretKeyMultibase": "z42tp2TDou6md8m7oq78f52mdYCDdUwSqhuvYEPsdG6cXGHo" +}, { + "@context": "https://w3id.org/security/multikey/v1", + "id": "did:web:blue-witness.example#vm-blue-1", + "type": "Multikey", + "controller": "did:web:blue-witness.example", + "publicKeyMultibase": "zDnaeo6TCxLGbQ2G1k4jvzv5keBaaADp8v7vgiYLbi2heCFPF", + "secretKeyMultibase": "z42ttRq6VGC727Z4F5c8q6zjBvgJ6MTT3t16JoJEWFzujeSq" +}]; +let witnesses = {}; +for(let secretKey of secretKeys) { + const keyPair = + await EcdsaMultikey.from(secretKey); + const publicKey = + await keyPair.export({publicKey: true, includeContext: false}); + const exportedKeyPair = + await keyPair.export({publicKey: true, secretKey: true}); + console.log('WITNESS KEY:', JSON.stringify(exportedKeyPair, null, 2)); + + // update document loader + witnesses[secretKey.controller] = {secretKey, keyPair}; + jdl.addStatic(publicKey.id, publicKey); +} + + +export async function generateProof({data, options}) { + const keyPair = witnesses[options.witness].keyPair; + const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); + const suite = new DataIntegrityProof({ + signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite + }); + + // create signed credential + let documentLoader = jdl.build(); + const signedData = await jsigs.sign(data, { + suite, + purpose: new AssertionProofPurpose(), + documentLoader + }); + console.log("WITNESSED", signedData); + + return signedData.proof; +} + +export default {generateProof}; From 2a6ab63035b6b12fb63a8e6fbdb7c39925246602 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Thu, 6 Nov 2025 22:26:10 -0500 Subject: [PATCH 10/44] Add did:cel update, addProof, and cel update implementation. --- didcel | 41 +++++++++++++++++++++++----------- lib/cel.js | 21 ++++++++++++++---- lib/didcel.js | 60 ++++++++++++++++++++++++++++++++++++-------------- lib/witness.js | 2 -- 4 files changed, 89 insertions(+), 35 deletions(-) diff --git a/didcel b/didcel index 2b2084a..4d2c797 100755 --- a/didcel +++ b/didcel @@ -14,8 +14,10 @@ program .parse(process.argv); const options = program.opts(); +// create the JSON-LD pretty printer const jsonldPretty = createJsonldPrettyPrinter({ - preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite'] + preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', + 'previousEvent'] }); // Runs the repl until exit @@ -25,6 +27,13 @@ async function repl({commands}) { const repl = new Command(); let cryptographicEventLog; let didDocument; + let secretKeys = { + authentication: [], + assertionMethod: [], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [] + }; repl.name('command') .usage('[options]') @@ -47,8 +56,9 @@ async function repl({commands}) { .action(async () => { let result = await didcel.create({curve: 'P-256'}); didDocument = result.didDocument; + secretKeys.assertionMethod = [result.keyPair]; cryptographicEventLog = cel.create({data: didDocument}); - console.log('CEL: ', JSON.stringify(cryptographicEventLog, null, 2)); + console.log(`create successful: ${didDocument.id}`); }); repl.command('add') @@ -57,18 +67,14 @@ async function repl({commands}) { .choices(['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service'])) .addArgument(new Argument('', 'the type of property to add') .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) - .action((property, type) => { + .action(async (property, type) => { if(property !== 'service' && type === 'ecdsa') { - const vm = { - id: '#zDnYeGRG5ehnPAmxcf5mDZpvaerx9CtbPJ1q36T5Ln5wYt3MQ', - type: 'Multikey', - controller: didDocument.id, - publicKeyMultibase: 'zDnYeGRG5ehnPAmxcf5mDZpvaerx9CtbPJ1q36T5Ln5wYt3MQ' - }; - didDocument.verificationMethod.push(vm); - didDocument[property].push(vm.id); + let result = await didcel.addVm( + {didDocument, verificationRelationship: property, curve: 'P-256'}); + didDocument = result.didDocument; + secretKeys[property].push(result.keyPair); + console.log(`added new verification method for ${property}`); } - console.log(`add ${property} ${type}: `, JSON.stringify(didDocument, null, 2)); }); repl.command('expire') @@ -85,12 +91,21 @@ async function repl({commands}) { console.error('remove not implemented'); }); + repl.command('update') + .description('Update the cryptographic event log with the latest DID document') + .action(async () => { + didDocument = await didcel.updateProof({ + didDocument, assertionMethod: secretKeys.assertionMethod[0]}); + cryptographicEventLog = + await cel.update({cel: cryptographicEventLog, data: didDocument}); + }); + repl.command('witness') .description( 'Witness the latest set of updates to the DID document.') .action(async () => { const proof = await cel.witness({cel: cryptographicEventLog}) - console.log('Generated witness proofs:', JSON.stringify(proof, null, 2)); + console.log('witness: proofs complete'); }); repl.command('save') diff --git a/lib/cel.js b/lib/cel.js index 3045b09..3065d54 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -61,9 +61,22 @@ export async function witness({cel, options}) { return proofs; } -export function update({cel, data, options}) { - // TODO: Calculate hash of previous event - let previousEvent = 'TODO'; +export async function update({cel, data, options}) { + // calculate the hash of previous event if it exists + let previousEvent = undefined; + if(cel.log.length > 0) { + const lastEvent = cel.log[cel.log.length-1].event; + const utf8Encoder = new TextEncoder(); + const canonicalizedDidDocument = canonicalize(lastEvent); + const sha3256Hasher = mfHasher.from({ + name: 'sha3-256', + code: 0x16, + encode: input => sha3_256(input), + }); + const mfHash = await sha3256Hasher.digest( + utf8Encoder.encode(canonicalizedDidDocument)).bytes; + previousEvent = base58btc.encode(mfHash); + } // push event to end of log cel.log.push({ @@ -76,7 +89,7 @@ export function update({cel, data, options}) { } }); - return log; + return cel; } export default {create, update, witness}; diff --git a/lib/didcel.js b/lib/didcel.js index 1dfe510..2628fda 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -29,7 +29,6 @@ export async function create({options}) { // generate the did:cel identifier const utf8Encoder = new TextEncoder(); const canonicalizedDidDocument = canonicalize(didDocument); - console.log(canonicalizedDidDocument); const sha3256Hasher = mfHasher.from({ name: 'sha3-256', code: 0x16, @@ -67,22 +66,51 @@ export async function create({options}) { return {keyPair, didDocument}; } -export function update({cel, data, options}) { - // TODO: Calculate hash of previous event - let previousEvent = 'TODO'; - - // push event to end of log - cel.log.push({ - event: { - previousEvent, - operation: { - type: 'update', - data - } - } +export async function addVm({didDocument, verificationRelationship, curve}) { + // TODO: replace with modern clone + const newDidDocument = JSON.parse(JSON.stringify(didDocument)); + const keyPair = + await EcdsaMultikey.generate({curve: curve || 'P-256'}); + const publicKey = + await keyPair.export({publicKey: true, includeContext: false}); + publicKey.id = '#' + publicKey.publicKeyMultibase; + publicKey.controller = didDocument.id; + + // add verification method to DID Document + if(!Array.isArray(didDocument[verificationRelationship])) { + newDidDocument[verificationRelationship] = []; + } + newDidDocument[verificationRelationship].push(publicKey); + + // remove old proof and place new proof on didDocument + delete newDidDocument.proof; + + // update document loader + jdl.addStatic(publicKey.id, publicKey); + + return {keyPair, didDocument: newDidDocument}; +} + +export async function updateProof({didDocument, assertionMethod}) { + // TODO: replace with modern clone + const newDidDocument = JSON.parse(JSON.stringify(didDocument)); + if(newDidDocument.proof) { + delete newDidDocument.proof; + } + + // create signed DID document + let documentLoader = jdl.build(); + const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); + const suite = new DataIntegrityProof({ + signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite + }); + const signedDidDocument = await jsigs.sign(newDidDocument, { + suite, + purpose: new AssertionProofPurpose(), + documentLoader }); - return log; + return {didDocument: newDidDocument}; } -export default {create, update}; +export default {create, addVm, updateProof}; diff --git a/lib/witness.js b/lib/witness.js index a083865..3e95a03 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -42,7 +42,6 @@ for(let secretKey of secretKeys) { await keyPair.export({publicKey: true, includeContext: false}); const exportedKeyPair = await keyPair.export({publicKey: true, secretKey: true}); - console.log('WITNESS KEY:', JSON.stringify(exportedKeyPair, null, 2)); // update document loader witnesses[secretKey.controller] = {secretKey, keyPair}; @@ -64,7 +63,6 @@ export async function generateProof({data, options}) { purpose: new AssertionProofPurpose(), documentLoader }); - console.log("WITNESSED", signedData); return signedData.proof; } From 19d6409beed6d15da451990eabddcf46761460d0 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Wed, 26 Nov 2025 06:30:51 -0500 Subject: [PATCH 11/44] Ensure verificationMethod exists in DID Document proofs. --- didcel | 4 ++-- lib/didcel.js | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/didcel b/didcel index 4d2c797..567b8aa 100755 --- a/didcel +++ b/didcel @@ -94,8 +94,8 @@ async function repl({commands}) { repl.command('update') .description('Update the cryptographic event log with the latest DID document') .action(async () => { - didDocument = await didcel.updateProof({ - didDocument, assertionMethod: secretKeys.assertionMethod[0]}); + didDocument = (await didcel.updateProof({didDocument, + assertionMethod: secretKeys.assertionMethod[0]})).didDocument; cryptographicEventLog = await cel.update({cel: cryptographicEventLog, data: didDocument}); }); diff --git a/lib/didcel.js b/lib/didcel.js index 2628fda..033a56b 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -54,6 +54,8 @@ export async function create({options}) { purpose: new AssertionProofPurpose(), documentLoader }); + // TODO: Determine if there is a better way to set the proof VM + signedDidDocument.proof.verificationMethod = controller + publicKey.id; // rewrite DID Document to place the `id` at the top of the document didDocument = { @@ -110,6 +112,10 @@ export async function updateProof({didDocument, assertionMethod}) { documentLoader }); + // TODO: determine if there is a better way to set verificationMethod + newDidDocument.proof.verificationMethod = newDidDocument.id + '#' + + assertionMethod.publicKeyMultibase; + return {didDocument: newDidDocument}; } From 272f8ed2d1bfa7b75396f4bbb8316fe93a901f1d Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 1 Dec 2025 05:30:06 -0500 Subject: [PATCH 12/44] Add `ls` feature - ability to show contents of DID Document. --- didcel | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/didcel b/didcel index 567b8aa..4813455 100755 --- a/didcel +++ b/didcel @@ -20,6 +20,9 @@ const jsonldPretty = createJsonldPrettyPrinter({ 'previousEvent'] }); +// common properties for a DID Document +const COMMON_PROPERTIES = ['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service']; + // Runs the repl until exit async function repl({commands}) { // configure the REPL @@ -64,7 +67,7 @@ async function repl({commands}) { repl.command('add') .description('Add a verification method or service to the current DID document.') .addArgument(new Argument('', 'the name of the property to add to') - .choices(['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service'])) + .choices(COMMON_PROPERTIES)) .addArgument(new Argument('', 'the type of property to add') .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) .action(async (property, type) => { @@ -77,6 +80,43 @@ async function repl({commands}) { } }); + repl.command('ls') + .description('list the contents of all identifiers, or a specific one.') + .addArgument(new Argument('[id]', 'the identifier to list')) + .action(async (id) => { + console.log(didDocument.id); + for(let property of COMMON_PROPERTIES) { + if(didDocument[property]) { + let propertyListing = ''; + for(let entry of didDocument[property]) { + const lastFourOfId = + entry.id.slice(entry.id.length - 4, entry.id.length); + // if an id was supplied, print out the entry and continue + if(id === lastFourOfId) { + propertyListing = + ` ${property}: ` + JSON.stringify(entry, jsonldPretty, 4); + console.log(propertyListing); + return; + } + + // if an id was not supplied, summarize the identifier + if(id === undefined) { + if(propertyListing.length < 1) { + propertyListing = ` ${property}: `; + } + if(entry.type === 'Multikey') { + propertyListing += + entry.id.slice(0, 4) + '...' + lastFourOfId + ' '; + } + } + } + if(propertyListing.length > 0) { + console.log(propertyListing); + } + } + } + }); + repl.command('expire') .description('Expire a verification method from the current DID document.') .argument('', 'the id of the verification method to expire') @@ -138,8 +178,6 @@ async function repl({commands}) { } } }; - - process.exit(0); } // if no command line commands were given, run the repl From 7f40ee77444db4329cb218a0732bb30d84d3049d Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 1 Dec 2025 06:17:17 -0500 Subject: [PATCH 13/44] Clean up `ls` functionality. --- didcel | 61 ++++++++++++++++++++++++++-------------------------- lib/utils.js | 26 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/didcel b/didcel index 4813455..5df6fad 100755 --- a/didcel +++ b/didcel @@ -4,7 +4,7 @@ import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; import promptSync from 'prompt-sync'; import {writeFileSync} from 'fs'; -import {createJsonldPrettyPrinter} from './lib/utils.js'; +import {createJsonldPrettyPrinter, getObjectByIdSuffix} from './lib/utils.js'; // create the CLI and parse the options const program = new Command(); @@ -82,37 +82,38 @@ async function repl({commands}) { repl.command('ls') .description('list the contents of all identifiers, or a specific one.') - .addArgument(new Argument('[id]', 'the identifier to list')) - .action(async (id) => { + .addArgument(new Argument('[suffix]', 'the last several characters of the identifier')) + .action(async (suffix) => { console.log(didDocument.id); - for(let property of COMMON_PROPERTIES) { - if(didDocument[property]) { - let propertyListing = ''; - for(let entry of didDocument[property]) { - const lastFourOfId = - entry.id.slice(entry.id.length - 4, entry.id.length); - // if an id was supplied, print out the entry and continue - if(id === lastFourOfId) { - propertyListing = - ` ${property}: ` + JSON.stringify(entry, jsonldPretty, 4); - console.log(propertyListing); - return; - } - - // if an id was not supplied, summarize the identifier - if(id === undefined) { - if(propertyListing.length < 1) { - propertyListing = ` ${property}: `; - } - if(entry.type === 'Multikey') { - propertyListing += - entry.id.slice(0, 4) + '...' + lastFourOfId + ' '; - } - } - } - if(propertyListing.length > 0) { - console.log(propertyListing); + + // print detailed object if suffix was provided + if(suffix) { + const value = getObjectByIdSuffix({didDocument, suffix}); + if(value) { + console.log(JSON.stringify(value, jsonldPretty, 2)); + return; + } + } + + // summarize DID Document if suffix was not provided + for(let property of Object.keys(didDocument)) { + let numEntries = 0; + if(!Array.isArray(didDocument[property])) { + continue; + } + let propertyListing = ` ${property}: `; + for(let entry of didDocument[property]) { + if(typeof entry !== 'object') { + continue; } + const lastFourOfId = + entry.id.slice(entry.id.length - 4, entry.id.length); + propertyListing += entry.type + + entry.id.slice(0, 4) + '...' + lastFourOfId + ' '; + numEntries++; + } + if(numEntries > 0) { + console.log(propertyListing); } } }); diff --git a/lib/utils.js b/lib/utils.js index 6e7a4a5..7e4582a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -26,4 +26,28 @@ export function createJsonldPrettyPrinter({preferOrder}) { } } -export default {createJsonldPrettyPrinter}; +export function getObjectByIdSuffix({didDocument, suffix}) { + let rval = undefined; + for(let property of Object.keys(didDocument)) { + if(!Array.isArray(didDocument[property])) { + continue; + } + for(let entry of didDocument[property]) { + if(typeof entry !== 'object') { + continue; + } + const idSuffix = + entry.id.slice(entry.id.length - suffix.length, entry.id.length); + if(suffix === idSuffix) { + rval = entry; + } + } + } + + return rval; +} + +export default { + createJsonldPrettyPrinter, + getObjectByIdSuffix +}; From 0f4c0d9a00603ea0a3da8229bfd1047212663dd4 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 1 Dec 2025 06:34:18 -0500 Subject: [PATCH 14/44] Implement `expire` command. --- didcel | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/didcel b/didcel index 5df6fad..058291e 100755 --- a/didcel +++ b/didcel @@ -120,9 +120,22 @@ async function repl({commands}) { repl.command('expire') .description('Expire a verification method from the current DID document.') - .argument('', 'the id of the verification method to expire') - .action(() => { - console.error('expire not implemented'); + .addArgument(new Argument('', 'the last several characters of the identifier to expire')) + .action(async (suffix) => { + // print detailed object if suffix was provided + if(suffix) { + const value = getObjectByIdSuffix({didDocument, suffix}); + if(value) { + let expireDatetime = new Date().toISOString(); + expireDatetime = + expireDatetime.slice(0, expireDatetime.length - 5) + 'Z'; + value.expires = expireDatetime; + console.log(`expire: ${value.id} at ${expireDatetime}.`); + } else { + console.log(`error: Could not find object with suffix "${suffix}".`); + } + } + }); repl.command('remove') From e55cf0583daa58c9e8e571b1e3b88a2c66e9df21 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 1 Dec 2025 07:18:05 -0500 Subject: [PATCH 15/44] Added `remove` feature. --- didcel | 20 +++++++++++++------- lib/utils.js | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/didcel b/didcel index 058291e..6b2b591 100755 --- a/didcel +++ b/didcel @@ -4,7 +4,8 @@ import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; import promptSync from 'prompt-sync'; import {writeFileSync} from 'fs'; -import {createJsonldPrettyPrinter, getObjectByIdSuffix} from './lib/utils.js'; +import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, + getObjectByIdSuffix} from './lib/utils.js'; // create the CLI and parse the options const program = new Command(); @@ -76,7 +77,7 @@ async function repl({commands}) { {didDocument, verificationRelationship: property, curve: 'P-256'}); didDocument = result.didDocument; secretKeys[property].push(result.keyPair); - console.log(`added new verification method for ${property}`); + console.log(`add: new verification method for ${property}`); } }); @@ -122,7 +123,6 @@ async function repl({commands}) { .description('Expire a verification method from the current DID document.') .addArgument(new Argument('', 'the last several characters of the identifier to expire')) .action(async (suffix) => { - // print detailed object if suffix was provided if(suffix) { const value = getObjectByIdSuffix({didDocument, suffix}); if(value) { @@ -135,14 +135,20 @@ async function repl({commands}) { console.log(`error: Could not find object with suffix "${suffix}".`); } } - }); repl.command('remove') .description('Remove an object from the current DID document.') - .argument('', 'the id of the object to remove') - .action(() => { - console.error('remove not implemented'); + .addArgument(new Argument('', 'the last several characters of the identifier to remove')) + .action(async (suffix) => { + if(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}".`); + } + } }); repl.command('update') diff --git a/lib/utils.js b/lib/utils.js index 7e4582a..f6d0609 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -47,7 +47,33 @@ export function getObjectByIdSuffix({didDocument, suffix}) { return rval; } +export function deleteObjectByIdSuffix({didDocument, suffix}) { + let rval = undefined; + for(let property of Object.keys(didDocument)) { + if(!Array.isArray(didDocument[property])) { + continue; + } + + didDocument[property] = didDocument[property].filter((entry) => { + if(typeof entry !== 'object') { + return true; + } + const idSuffix = + entry.id.slice(entry.id.length - suffix.length, entry.id.length); + if(suffix !== idSuffix) { + return true; + } else { + rval = entry; + return false; + } + }) + } + + return rval; +} + export default { createJsonldPrettyPrinter, + deleteObjectByIdSuffix, getObjectByIdSuffix }; From e89a439ea74e514aad4909aeacf12626b17735eb Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 2 Dec 2025 14:35:45 -0500 Subject: [PATCH 16/44] Add stress test for 10 year DID w/ 3 month key cycle times. --- tests/stress.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100755 tests/stress.sh diff --git a/tests/stress.sh b/tests/stress.sh new file mode 100755 index 0000000..a87b3ea --- /dev/null +++ b/tests/stress.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# Create a large DID Document and compress it + +../didcel -c create -c witness \ + -c "add authentication ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add assertionMethod ecdsa" -c update -c witness \ + -c "ls" -c save -c quit From 7815c39e46b1b0d9b1279d1f939fbdca18b17c8e Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 2 Dec 2025 17:24:45 -0500 Subject: [PATCH 17/44] Add LLM-generated documentation to all files. --- didcel | 139 +++++++++++++++++++++++++++++++++++++++++++++---- lib/cel.js | 87 +++++++++++++++++++++++++++---- lib/didcel.js | 101 ++++++++++++++++++++++++++++++----- lib/utils.js | 71 +++++++++++++++++++++++++ lib/witness.js | 38 +++++++++++++- 5 files changed, 399 insertions(+), 37 deletions(-) diff --git a/didcel b/didcel index 6b2b591..c25cca5 100755 --- a/didcel +++ b/didcel @@ -1,4 +1,29 @@ #!/usr/bin/env node +/** + * @fileoverview DID CEL Command Line Interface (CLI) + * + * This is an interactive REPL (Read-Eval-Print Loop) for creating and managing + * DID documents using the Certificate 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 + * quit - Exit the REPL + */ + import { Argument, Command, CommanderError } from 'commander'; import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; @@ -7,7 +32,7 @@ import {writeFileSync} from 'fs'; import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix} from './lib/utils.js'; -// create the CLI and parse the options +// create the CLI and parse command-line options const program = new Command(); program .option('-c, --command ', 'One or more commands to execute') @@ -15,22 +40,36 @@ program .parse(process.argv); const options = program.opts(); -// create the JSON-LD pretty printer +// create the JSON-LD pretty printer for formatted output +// orders keys with @context, id, type first, then alphabetically const jsonldPretty = createJsonldPrettyPrinter({ preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', 'previousEvent'] }); -// common properties for a DID Document +// common verification relationship and service properties in DID documents const COMMON_PROPERTIES = ['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service']; -// Runs the repl until exit +/** + * 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} [options.commands] - Optional array of commands to + * execute before entering interactive mode. + * @returns {Promise} + */ async function repl({commands}) { - // configure the REPL + // configure the REPL environment const prompt = promptSync(); const repl = new Command(); + + // session state variables + // the CEL tracking all DID changes let cryptographicEventLog; + // the current DID document let didDocument; + // secret keys organized by verification relationship let secretKeys = { authentication: [], assertionMethod: [], @@ -39,8 +78,10 @@ async function repl({commands}) { keyAgreement: [] }; + // 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') @@ -55,16 +96,23 @@ async function repl({commands}) { console.error('load not implemented'); }); + // command: create + // creates a new DID document with an initial assertionMethod key repl.command('create') .description('Create a new DID document') .action(async () => { + // generate a new DID document with P-256 elliptic curve key let result = await didcel.create({curve: 'P-256'}); didDocument = result.didDocument; + // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; + // initialize the Certificate Event Log with the create event cryptographicEventLog = cel.create({data: didDocument}); 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 current DID document.') .addArgument(new Argument('', 'the name of the property to add to') @@ -72,22 +120,28 @@ async function repl({commands}) { .addArgument(new Argument('', 'the type of property to add') .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) .action(async (property, type) => { + // TODO: Currently only ECDSA verification methods are supported if(property !== 'service' && type === 'ecdsa') { + // generate a new verification method for the specified relationship let result = await didcel.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); - // print detailed object if suffix was provided + // if suffix provided, print detailed object information if(suffix) { const value = getObjectByIdSuffix({didDocument, suffix}); if(value) { @@ -96,17 +150,20 @@ async function repl({commands}) { } } - // summarize DID Document if suffix was not provided + // if no suffix provided, display a summary of the DID document for(let property of Object.keys(didDocument)) { let numEntries = 0; + // only process array properties (verification relationships, services) if(!Array.isArray(didDocument[property])) { continue; } let propertyListing = ` ${property}: `; + // show abbreviated identifiers for each entry for(let entry of didDocument[property]) { if(typeof entry !== 'object') { continue; } + // display first 4 and last 4 characters of identifier const lastFourOfId = entry.id.slice(entry.id.length - 4, entry.id.length); propertyListing += entry.type + @@ -119,6 +176,8 @@ async function repl({commands}) { } }); + // 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('', 'the last several characters of the identifier to expire')) @@ -126,9 +185,12 @@ async function repl({commands}) { 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 { @@ -137,11 +199,18 @@ async function repl({commands}) { } }); + // 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('', 'the last several characters of the identifier to remove')) .action(async (suffix) => { 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.`); @@ -151,48 +220,89 @@ async function repl({commands}) { } }); + // command: update + // updates the cryptographic proof on the DID document and appends an update + // event to the Certificate Event Log. This creates a new entry in the log + // that is hash-linked to the previous event, forming a verifiable chain. The + // proof is signed using the first assertionMethod key generated during + // create. After running update, you should run 'witness' to get witness + // attestations. repl.command('update') .description('Update the cryptographic event log with the latest DID document') .action(async () => { + // step 1: Regenerate the cryptographic proof on the DID document + // this signs the current state of the DID document didDocument = (await didcel.updateProof({didDocument, assertionMethod: secretKeys.assertionMethod[0]})).didDocument; + + // step 2: Append an update event to the CEL + // this creates a hash-linked chain entry with the modified DID document cryptographicEventLog = await cel.update({cel: cryptographicEventLog, data: didDocument}); }); + // command: witness + // generates cryptographic proofs from external witnesses that attest to the + // validity of the most recent event in the CEL. By default, three witnesses + // (red, green, and blue) each independently sign the event, providing + // decentralized attestation. This is a key feature of the CEL architecture + // that prevents single points of failure and enables auditability. repl.command('witness') .description( 'Witness the latest set of updates to the DID document.') .action(async () => { + // generate witness proofs for the most recent event in the log + // each witness independently validates and signs the event const proof = await cel.witness({cel: cryptographicEventLog}) console.log('witness: proofs complete'); }); + // command: save + // persists the Certificate Event Log to a file. The CEL contains the complete + // history of all operations on the DID document, including create and update + // events, along with witness attestations. The file is saved in JSON format + // with keys ordered for readability (e.g., @context, id, type first). + // this file can later be loaded to reconstruct the DID's history and state. 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) => { + // use default filename 'did.cel' if none provided const celFilename = filename || 'did.cel'; + // write the CEL to file with pretty-printed JSON formatting writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, jsonldPretty, 2)); console.error(`Wrote to ${celFilename}`); }); + // command: quit + // exits the REPL without saving. Any unsaved changes to the DID document + // or CEL will be lost. Make sure to run 'save' before quitting if you want + // to persist your work. repl.command('quit') .description('Exit without saving the cryptographic event log.') .action(async () => { process.exit(0); }); - // if command-line commands were provided, run them and exit + // batch command execution mode + // if commands were provided via the -c flag, execute them sequentially + // before entering interactive mode. This allows for scripting common + // operations, e.g., ./didcel -c "create" "add assertionMethod ecdsa" "update" + // each command is parsed and executed, with errors suppressed to allow + // remaining commands to run. if(commands && commands.length > 0) { for(const cmdLine of commands) { const args = cmdLine.split(' '); const command = args[0]; try { + // parse command arguments and execute + // the command is duplicated in the array for Commander.js parsing 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; } @@ -200,17 +310,23 @@ async function repl({commands}) { }; } - // if no command line commands were given, run the repl + // 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 automatically exit from the Commander CLI + // don't exit the REPL on command errors + // this allows users to correct mistakes without restarting if(!(err instanceof CommanderError)) { throw err; } @@ -218,7 +334,8 @@ async function repl({commands}) { } while(command != 'quit'); } -// Run the repl +// 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 }); diff --git a/lib/cel.js b/lib/cel.js index 3065d54..42d43fa 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -1,3 +1,10 @@ +/** + * @fileoverview Certificate Event Log (CEL) management. + * This module provides functions for creating, updating, and witnessing events + * in a Certificate Event Log, which maintains a cryptographically verifiable + * chain of events for DID document operations. + */ + import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import {JsonLdDocumentLoader} from 'jsonld-document-loader'; @@ -12,13 +19,34 @@ import * as witnessService from './witness.js'; const {purposes: {AssertionProofPurpose}} = jsigs; const jdl = new JsonLdDocumentLoader(); +// default witness DIDs for validating CEL operations let witnesses = [ "did:web:red-witness.example", "did:web:green-witness.example", "did:web:blue-witness.example" ]; +/** + * Creates a new Certificate Event Log (CEL) with an initial 'create' event. + * The log maintains a chain of events that document the history of DID operations. + * + * @param {Object} options - Configuration options. + * @param {Object} options.data - The data for the create operation (typically a DID document). + * @param {Object} [options.options] - Optional configuration. + * @param {string} [options.options.previousLog] - Reference to a previous log if this + * is continuing an existing chain. + * @returns {Object} A new CEL object with the structure: + * - log: Array containing the initial create event + * - previousLog: (optional) Reference to previous log + * + * @example + * const cel = create({ + * data: didDocument, + * options: {previousLog: 'previousLogHash'} + * }); + */ export function create({data, options}) { + // initialize the log with a create operation event let log = { log: [{ event: { @@ -30,7 +58,7 @@ export function create({data, options}) { }] }; - // set a previous log if there is one + // link to a previous log if provided (for log chain continuity) if(options?.previousLog) { log.previousLog = options.previousLog; } @@ -38,20 +66,37 @@ export function create({data, options}) { return log; } +/** + * Generates witness proofs for the most recent event in a CEL. + * Each configured witness creates a cryptographic proof attesting to the event. + * + * @param {Object} options - Configuration options. + * @param {Object} options.cel - The Certificate Event Log containing events to + * witness. + * @param {Object} [options.options] - Optional configuration (currently + * unused). + * @returns {Promise} An array of proof objects, one from each witness. + * + * @example + * const proofs = await witness({cel: myCel}); + * // Returns array of proofs from red, green, and blue witnesses + */ export async function witness({cel, options}) { const proofs = []; + // get the most recent event from the log const event = cel.log[cel.log.length-1]; - // 1. If a previous event exists: + // TODO: Implement previous event hash linking + // 1. If a previous event exists: if(cel.log.length > 1) { - // 1.1. Get the previous event - // 1.2. Calculate hash of previous event - // 1.3. Include the previous event hash in the current event + // 1.1. Get the previous event + // 1.2. Calculate hash of previous event + // 1.3. Include the previous event hash in the current event let previousEvent = 'TODO'; } - // 2. For each witness: - // 2.1. Create a proof for the current event + // generate a cryptographic proof from each witness + // each witness independently attests to the validity of the event for(let witness of witnesses) { const proof = await witnessService.generateProof( {data: event, options: {witness}}); @@ -61,24 +106,46 @@ export async function witness({cel, options}) { return proofs; } +/** + * Adds an update event to an existing CEL, creating a hash-linked chain of + * events. The update event includes a hash of the previous event to ensure log + * integrity. + * + * @param {Object} options - Configuration options. + * @param {Object} options.cel - The Certificate Event Log to update. + * @param {Object} options.data - The data for the update operation (typically + * an updated DID document). + * @param {Object} [options.options] - Optional configuration (currently + * unused). + * @returns {Promise} The updated CEL with the new event appended. + * + * @example + * const updatedCel = await update({ + * cel: existingCel, + * data: modifiedDidDocument + * }); + */ export async function update({cel, data, options}) { - // calculate the hash of previous event if it exists + // calculate the hash of the previous event to create a verifiable chain let previousEvent = undefined; if(cel.log.length > 0) { const lastEvent = cel.log[cel.log.length-1].event; const utf8Encoder = new TextEncoder(); + // canonicalize the event to ensure deterministic hashing const canonicalizedDidDocument = canonicalize(lastEvent); + // create a SHA3-256 hasher with multiformats encoding const sha3256Hasher = mfHasher.from({ name: 'sha3-256', - code: 0x16, + code: 0x16, // Multihash code for SHA3-256 encode: input => sha3_256(input), }); + // compute the hash and encode it in base58btc const mfHash = await sha3256Hasher.digest( utf8Encoder.encode(canonicalizedDidDocument)).bytes; previousEvent = base58btc.encode(mfHash); } - // push event to end of log + // append the new update event to the log, linked to the previous event cel.log.push({ event: { previousEvent, diff --git a/lib/didcel.js b/lib/didcel.js index 033a56b..4f8085a 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -1,3 +1,10 @@ +/** + * @fileoverview DID CEL (Certificate Event Log) DID Document management. + * This module provides functions for creating, updating, and managing DID + * documents using the did:cel method with ECDSA Multikey and Data Integrity + * Proofs. + */ + import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import {JsonLdDocumentLoader} from 'jsonld-document-loader'; @@ -9,55 +16,77 @@ import * as mfHasher from 'multiformats/hashes/hasher'; import {sha3_256} from '@noble/hashes/sha3.js'; const {purposes: {AssertionProofPurpose}} = jsigs; +// jSON-LD document loader for resolving contexts and verification methods const jdl = new JsonLdDocumentLoader(); +/** + * Creates a new DID CEL document with a generated key pair and cryptographic + * proof. The DID identifier is derived from the SHA3-256 hash of the + * canonicalized DID document. + * + * @param {Object} options - Configuration options. + * @param {Object} [options.options] - Optional configuration. + * @param {string} [options.options.curve='P-256'] - The elliptic curve to use + * for key generation (e.g., 'P-256', 'P-384'). + * @returns {Promise} An object containing: + * - keyPair: The generated ECDSA Multikey key pair + * - didDocument: The signed DID document with a did:cel identifier + * + * @example + * const {keyPair, didDocument} = await create({options: {curve: 'P-256'}}); + * console.log(didDocument.id); // did:cel:z... + */ export async function create({options}) { + // generate a new ECDSA key pair using the specified curve (defaults to P-256) const keyPair = await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); const publicKey = await keyPair.export({publicKey: true, includeContext: false}); + // set the key id to the public key multibase encoding publicKey.id = '#' + publicKey.publicKeyMultibase; - // update document loader + // register the public key with the document loader for proof verification jdl.addStatic(publicKey.id, publicKey); + // create initial DID document structure with assertion method let didDocument = { '@context': 'https://www.w3.org/ns/did/v1.1', assertionMethod: [publicKey] } - // generate the did:cel identifier + // generate the did:cel identifier by hashing the canonicalized DID document const utf8Encoder = new TextEncoder(); const canonicalizedDidDocument = canonicalize(didDocument); const sha3256Hasher = mfHasher.from({ name: 'sha3-256', - code: 0x16, + code: 0x16, // Multihash code for SHA3-256 encode: input => sha3_256(input), }); const mfHash = await sha3256Hasher.digest( utf8Encoder.encode(canonicalizedDidDocument)).bytes; const encodedHash = base58btc.encode(mfHash); const controller = 'did:cel:' + encodedHash; + // update the DID document and public key with the generated identifier didDocument.id = controller; publicKey.controller = controller; - // place a proof on the DID Document + // create a cryptographic proof using ECDSA-JCS-2019 const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); - // create signed credential + // sign the DID document let documentLoader = jdl.build(); const signedDidDocument = await jsigs.sign(didDocument, { suite, purpose: new AssertionProofPurpose(), documentLoader }); - // TODO: Determine if there is a better way to set the proof VM + // TODO: Determine if there is a better way to set the proof VM signedDidDocument.proof.verificationMethod = controller + publicKey.id; - // rewrite DID Document to place the `id` at the top of the document + // rewrite DID Document to place the `id` at the top of the document didDocument = { '@context': 'https://www.w3.org/ns/did/v1.1', id: controller, @@ -68,9 +97,33 @@ export async function create({options}) { return {keyPair, didDocument}; } +/** + * Adds a new verification method (VM) to an existing DID document. Generates a + * new key pair and adds it to the specified verification relationship. The + * proof is removed and must be regenerated after this operation. + * + * @param {Object} options - Configuration options. + * @param {Object} options.didDocument - The DID document to modify. + * @param {string} options.verificationRelationship - The verification + * relationship to add the key to (e.g., 'assertionMethod', 'authentication', + * 'keyAgreement'). + * @param {string} [options.curve='P-256'] - The elliptic curve to use for key + * generation (e.g., 'P-256', 'P-384'). + * @returns {Promise} An object containing: + * - keyPair: The newly generated ECDSA Multikey key pair + * - didDocument: The updated DID document (without proof) + * + * @example + * const {keyPair, didDocument} = await addVm({ + * didDocument: existingDoc, + * verificationRelationship: 'authentication', + * curve: 'P-256' + * }); + */ export async function addVm({didDocument, verificationRelationship, curve}) { - // TODO: replace with modern clone + // TODO: replace with modern clone (structuredClone when available) const newDidDocument = JSON.parse(JSON.stringify(didDocument)); + // generate a new key pair for the verification method const keyPair = await EcdsaMultikey.generate({curve: curve || 'P-256'}); const publicKey = @@ -78,29 +131,47 @@ export async function addVm({didDocument, verificationRelationship, curve}) { publicKey.id = '#' + publicKey.publicKeyMultibase; publicKey.controller = didDocument.id; - // add verification method to DID Document + // add verification method to the specified verification relationship if(!Array.isArray(didDocument[verificationRelationship])) { newDidDocument[verificationRelationship] = []; } newDidDocument[verificationRelationship].push(publicKey); - // remove old proof and place new proof on didDocument + // remove old proof (must be regenerated with updateProof function) delete newDidDocument.proof; - // update document loader + // register the new public key with the document loader jdl.addStatic(publicKey.id, publicKey); return {keyPair, didDocument: newDidDocument}; } +/** + * Updates or adds a cryptographic proof to a DID document using the specified + * assertion method key pair. Any existing proof is replaced. + * + * @param {Object} options - Configuration options. + * @param {Object} options.didDocument - The DID document to sign. + * @param {Object} options.assertionMethod - The key pair to use for signing. + * Must have a signer() method and publicKeyMultibase property. + * @returns {Promise} An object containing: + * - didDocument: The DID document with the new proof attached + * + * @example + * const {didDocument} = await updateProof({ + * didDocument: modifiedDoc, + * assertionMethod: keyPair + * }); + */ export async function updateProof({didDocument, assertionMethod}) { - // TODO: replace with modern clone + // TODO: replace with modern clone (structuredClone when available) const newDidDocument = JSON.parse(JSON.stringify(didDocument)); + // remove any existing proof before creating a new one if(newDidDocument.proof) { delete newDidDocument.proof; } - // create signed DID document + // create a new cryptographic proof using ECDSA-JCS-2019 let documentLoader = jdl.build(); const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ @@ -112,7 +183,9 @@ export async function updateProof({didDocument, assertionMethod}) { documentLoader }); - // TODO: determine if there is a better way to set verificationMethod + // set the verification method reference in the proof + // TODO: determine if there is a better way to set verificationMethod + newDidDocument.proof = signedDidDocument.proof; newDidDocument.proof.verificationMethod = newDidDocument.id + '#' + assertionMethod.publicKeyMultibase; diff --git a/lib/utils.js b/lib/utils.js index f6d0609..68a3833 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,21 +1,41 @@ +/** + * Creates a JSON-LD pretty printer function that orders object keys according + * to a preferred order, with remaining keys sorted alphabetically. + * + * @param {Object} options - Configuration options. + * @param {Array} options.preferOrder - Array of keys to appear first + * in the specified order (e.g., ['@context', 'id', 'type']). + * @returns {Function} A replacer function for use with JSON.stringify() that + * orders object properties according to the preferred order. + * + * @example + * const printer = createJsonldPrettyPrinter({ + * preferOrder: ['@context', 'id', 'type'] + * }); + * JSON.stringify(obj, printer, 2); + */ export function createJsonldPrettyPrinter({preferOrder}) { return (key, value) => { let result = value; + // only process objects (not arrays or primitives) if(value instanceof Object && !(value instanceof Array)) { let sortedKeys = Object.keys(value).sort(); let prettyKeys = []; + // first, add keys that are in the preferred order for(let pkey of preferOrder) { if(value[pkey] !== undefined) { prettyKeys.push(pkey); } } + // then, add remaining keys in alphabetical order for(let skey of sortedKeys) { if(!preferOrder.includes(skey)) { prettyKeys.push(skey); } } + // reconstruct the object with the new key order result = prettyKeys.reduce((sorted, key) => { sorted[key] = value[key]; return sorted; @@ -26,18 +46,42 @@ export function createJsonldPrettyPrinter({preferOrder}) { } } +/** + * Retrieves an object from a DID document by matching the suffix of its id + * property. Searches through all array properties in the DID document to find + * an object whose id ends with the specified suffix. + * + * @param {Object} options - Configuration options. + * @param {Object} options.didDocument - The DID document to search. + * @param {string} options.suffix - The suffix to match against object ids + * (e.g., '#key-1' or 'zDnaeRQ...'). + * @returns {Object|undefined} The first object found with a matching id suffix, + * or undefined if no match is found. + * + * @example + * const vm = getObjectByIdSuffix({ + * didDocument: doc, + * suffix: '#key-1' + * }); + */ export function getObjectByIdSuffix({didDocument, suffix}) { let rval = undefined; + // iterate through all properties in the DID document for(let property of Object.keys(didDocument)) { + // only process array properties (e.g., assertionMethod, authentication) if(!Array.isArray(didDocument[property])) { continue; } + // search through each entry in the array for(let entry of didDocument[property]) { + // skip non-object entries if(typeof entry !== 'object') { continue; } + // extract the suffix portion of the entry's id const idSuffix = entry.id.slice(entry.id.length - suffix.length, entry.id.length); + // check if the id suffix matches the target suffix if(suffix === idSuffix) { rval = entry; } @@ -47,22 +91,49 @@ export function getObjectByIdSuffix({didDocument, suffix}) { return rval; } +/** + * Deletes an object from a DID document by matching the suffix of its id + * property. Searches through all array properties in the DID document and + * removes the first object whose id ends with the specified suffix. This + * function mutates the didDocument parameter. + * + * @param {Object} options - Configuration options. + * @param {Object} options.didDocument - The DID document to modify (mutated in + * place). + * @param {string} options.suffix - The suffix to match against object ids + * (e.g., '#key-1' or a multibase encoded key). + * @returns {Object|undefined} The deleted object if found, or undefined if no + * match was found. + * + * @example + * const deleted = deleteObjectByIdSuffix({ + * didDocument: doc, + * suffix: '#key-1' + * }); + */ export function deleteObjectByIdSuffix({didDocument, suffix}) { let rval = undefined; + // iterate through all properties in the DID document for(let property of Object.keys(didDocument)) { + // only process array properties (e.g., assertionMethod, authentication) if(!Array.isArray(didDocument[property])) { continue; } + // filter out the entry with matching id suffix didDocument[property] = didDocument[property].filter((entry) => { + // keep non-object entries if(typeof entry !== 'object') { return true; } + // extract the suffix portion of the entry's id const idSuffix = entry.id.slice(entry.id.length - suffix.length, entry.id.length); + // if suffix doesn't match, keep the entry if(suffix !== idSuffix) { return true; } else { + // if suffix matches, store the entry and remove it from the array rval = entry; return false; } diff --git a/lib/witness.js b/lib/witness.js index 3e95a03..2a2d87a 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -1,3 +1,10 @@ +/** + * @fileoverview Witness service for CEL event attestation. + * This module manages witness key pairs and generates cryptographic proofs + * that attest to the validity of CEL events. Witnesses provide independent + * validation of DID operations. + */ + import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import {JsonLdDocumentLoader} from 'jsonld-document-loader'; @@ -12,6 +19,8 @@ const {purposes: {AssertionProofPurpose}} = jsigs; const jdl = new JsonLdDocumentLoader(); // TODO: move to separate service -- generate all of the witness keys +// hardcoded witness keys for development/testing purposes +// in production, these should be securely managed and not stored in code const secretKeys = [{ "@context": "https://w3id.org/security/multikey/v1", "id": "did:web:red-witness.example#vm-red-1", @@ -34,8 +43,11 @@ const secretKeys = [{ "publicKeyMultibase": "zDnaeo6TCxLGbQ2G1k4jvzv5keBaaADp8v7vgiYLbi2heCFPF", "secretKeyMultibase": "z42ttRq6VGC727Z4F5c8q6zjBvgJ6MTT3t16JoJEWFzujeSq" }]; + +// initialize witness key pairs and register them with the document loader let witnesses = {}; for(let secretKey of secretKeys) { + // import the ECDSA Multikey from the secret key const keyPair = await EcdsaMultikey.from(secretKey); const publicKey = @@ -43,20 +55,41 @@ for(let secretKey of secretKeys) { const exportedKeyPair = await keyPair.export({publicKey: true, secretKey: true}); - // update document loader + // store the witness key pair indexed by controller DID witnesses[secretKey.controller] = {secretKey, keyPair}; + // register the public key with the document loader for verification jdl.addStatic(publicKey.id, publicKey); } +/** + * Generates a cryptographic proof for data using a specified witness key. + * The proof attests that the witness has validated the data. + * + * @param {Object} options - Configuration options. + * @param {Object} options.data - The data to sign (typically a CEL event). + * @param {Object} options.options - Configuration containing witness selection. + * @param {string} options.options.witness - The DID of the witness to use for signing + * (e.g., 'did:web:red-witness.example'). + * @returns {Promise} A Data Integrity Proof object containing the + * cryptographic signature and metadata. + * + * @example + * const proof = await generateProof({ + * data: celEvent, + * options: {witness: 'did:web:red-witness.example'} + * }); + */ export async function generateProof({data, options}) { + // retrieve the key pair for the specified witness const keyPair = witnesses[options.witness].keyPair; + // create ECDSA-JCS-2019 cryptosuite for signing const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); - // create signed credential + // sign the data and generate the proof let documentLoader = jdl.build(); const signedData = await jsigs.sign(data, { suite, @@ -64,6 +97,7 @@ export async function generateProof({data, options}) { documentLoader }); + // return only the proof portion (not the entire signed data) return signedData.proof; } From f6e5baccfd3b6ea0226c439a5fd291f60ae9880d Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Thu, 4 Dec 2025 17:30:39 -0500 Subject: [PATCH 18/44] Update implementation to sign operations and remove @context values. --- didcel | 10 +++--- lib/cel.js | 46 +++++------------------- lib/didcel.js | 97 ++++++++++++++++++++++++++++---------------------- lib/witness.js | 17 +++++---- 4 files changed, 79 insertions(+), 91 deletions(-) diff --git a/didcel b/didcel index c25cca5..e019d85 100755 --- a/didcel +++ b/didcel @@ -107,7 +107,7 @@ async function repl({commands}) { // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; // initialize the Certificate Event Log with the create event - cryptographicEventLog = cel.create({data: didDocument}); + cryptographicEventLog = cel.create({event: result.event}); console.log(`create successful: ${didDocument.id}`); }); @@ -232,13 +232,15 @@ async function repl({commands}) { .action(async () => { // step 1: Regenerate the cryptographic proof on the DID document // this signs the current state of the DID document - didDocument = (await didcel.updateProof({didDocument, - assertionMethod: secretKeys.assertionMethod[0]})).didDocument; + const result = (await didcel.updateProof({didDocument, + assertionMethod: secretKeys.assertionMethod[0]})); + const event = result.event; + didDocument = result.didDocument; // step 2: Append an update event to the CEL // this creates a hash-linked chain entry with the modified DID document cryptographicEventLog = - await cel.update({cel: cryptographicEventLog, data: didDocument}); + await cel.update({cel: cryptographicEventLog, event}); }); // command: witness diff --git a/lib/cel.js b/lib/cel.js index 42d43fa..6b4eaa2 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -31,38 +31,24 @@ let witnesses = [ * The log maintains a chain of events that document the history of DID operations. * * @param {Object} options - Configuration options. - * @param {Object} options.data - The data for the create operation (typically a DID document). + * @param {Object} options.event - The data for the create operation. * @param {Object} [options.options] - Optional configuration. - * @param {string} [options.options.previousLog] - Reference to a previous log if this - * is continuing an existing chain. * @returns {Object} A new CEL object with the structure: * - log: Array containing the initial create event - * - previousLog: (optional) Reference to previous log * * @example * const cel = create({ - * data: didDocument, - * options: {previousLog: 'previousLogHash'} + * event, * }); */ -export function create({data, options}) { +export function create({event, options}) { // initialize the log with a create operation event let log = { log: [{ - event: { - operation: { - type: 'create', - data - } - } + event }] }; - // link to a previous log if provided (for log chain continuity) - if(options?.previousLog) { - log.previousLog = options.previousLog; - } - return log; } @@ -86,15 +72,6 @@ export async function witness({cel, options}) { // get the most recent event from the log const event = cel.log[cel.log.length-1]; - // TODO: Implement previous event hash linking - // 1. If a previous event exists: - if(cel.log.length > 1) { - // 1.1. Get the previous event - // 1.2. Calculate hash of previous event - // 1.3. Include the previous event hash in the current event - let previousEvent = 'TODO'; - } - // generate a cryptographic proof from each witness // each witness independently attests to the validity of the event for(let witness of witnesses) { @@ -113,7 +90,7 @@ export async function witness({cel, options}) { * * @param {Object} options - Configuration options. * @param {Object} options.cel - The Certificate Event Log to update. - * @param {Object} options.data - The data for the update operation (typically + * @param {Object} options.event - The data for the update operation (typically * an updated DID document). * @param {Object} [options.options] - Optional configuration (currently * unused). @@ -125,7 +102,7 @@ export async function witness({cel, options}) { * data: modifiedDidDocument * }); */ -export async function update({cel, data, options}) { +export async function update({cel, event, options}) { // calculate the hash of the previous event to create a verifiable chain let previousEvent = undefined; if(cel.log.length > 0) { @@ -146,15 +123,8 @@ export async function update({cel, data, options}) { } // append the new update event to the log, linked to the previous event - cel.log.push({ - event: { - previousEvent, - operation: { - type: 'update', - data - } - } - }); + event.previousEvent = previousEvent; + cel.log.push({event}); return cel; } diff --git a/lib/didcel.js b/lib/didcel.js index 4f8085a..bcfadcc 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -34,67 +34,80 @@ const jdl = new JsonLdDocumentLoader(); * * @example * const {keyPair, didDocument} = await create({options: {curve: 'P-256'}}); - * console.log(didDocument.id); // did:cel:z... + * console.log(didDocument.id); // did:cel:z... */ export async function create({options}) { - // generate a new ECDSA key pair using the specified curve (defaults to P-256) + // generate a new ECDSA key pair using the specified curve (defaults to P-256) const keyPair = await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); const publicKey = await keyPair.export({publicKey: true, includeContext: false}); - // set the key id to the public key multibase encoding + // set the key id to the public key multibase encoding publicKey.id = '#' + publicKey.publicKeyMultibase; - // register the public key with the document loader for proof verification + // register the public key with the document loader for proof verification jdl.addStatic(publicKey.id, publicKey); - // create initial DID document structure with assertion method + // create initial DID document structure with assertion method let didDocument = { - '@context': 'https://www.w3.org/ns/did/v1.1', - assertionMethod: [publicKey] + '@context': [ + 'https://www.w3.org/ns/did/v1.1', + 'https://w3id.org/didcel/v1' + ], + assertionMethod: [publicKey], + service: { + type: 'CelStorageService', + serviceEndpoint: [ + 'https://storage.gamma.example/v1', + 'https://2001:db8:85a3::8a2e:370:7334/v1', + 'https://celstorageiu7vnjjbwkhpilnemxj7ase3mhbshg7kx5tfydaniltxjqhy.onion/', + ] + } } - // generate the did:cel identifier by hashing the canonicalized DID document + // generate the did:cel identifier by hashing the canonicalized DID document const utf8Encoder = new TextEncoder(); const canonicalizedDidDocument = canonicalize(didDocument); const sha3256Hasher = mfHasher.from({ name: 'sha3-256', - code: 0x16, // Multihash code for SHA3-256 + code: 0x16, // Multihash code for SHA3-256 encode: input => sha3_256(input), }); const mfHash = await sha3256Hasher.digest( utf8Encoder.encode(canonicalizedDidDocument)).bytes; const encodedHash = base58btc.encode(mfHash); const controller = 'did:cel:' + encodedHash; - // update the DID document and public key with the generated identifier + // update the DID document and public key with the generated identifier didDocument.id = controller; publicKey.controller = controller; - // create a cryptographic proof using ECDSA-JCS-2019 + // create a cryptographic proof using ECDSA-JCS-2019 const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); - // sign the DID document + // sign the operation let documentLoader = jdl.build(); - const signedDidDocument = await jsigs.sign(didDocument, { + const event = { + operation: { + type: 'create', + data: didDocument + } + }; + const signedEvent = await jsigs.sign(event, { suite, purpose: new AssertionProofPurpose(), documentLoader }); - // TODO: Determine if there is a better way to set the proof VM - signedDidDocument.proof.verificationMethod = controller + publicKey.id; + // delete the @context in the proof as it's unnecessary + delete signedEvent['@context']; + delete signedEvent.proof['@context']; - // rewrite DID Document to place the `id` at the top of the document - didDocument = { - '@context': 'https://www.w3.org/ns/did/v1.1', - id: controller, - assertionMethod: [publicKey], - proof: signedDidDocument.proof - } + // TODO: Determine if there is a better way to set the proof VM + signedEvent.proof.verificationMethod = controller + publicKey.id; - return {keyPair, didDocument}; + return {keyPair, event: signedEvent, didDocument}; } /** @@ -121,9 +134,9 @@ export async function create({options}) { * }); */ export async function addVm({didDocument, verificationRelationship, curve}) { - // TODO: replace with modern clone (structuredClone when available) + // TODO: replace with modern clone (structuredClone when available) const newDidDocument = JSON.parse(JSON.stringify(didDocument)); - // generate a new key pair for the verification method + // generate a new key pair for the verification method const keyPair = await EcdsaMultikey.generate({curve: curve || 'P-256'}); const publicKey = @@ -131,16 +144,16 @@ export async function addVm({didDocument, verificationRelationship, curve}) { publicKey.id = '#' + publicKey.publicKeyMultibase; publicKey.controller = didDocument.id; - // add verification method to the specified verification relationship + // add verification method to the specified verification relationship if(!Array.isArray(didDocument[verificationRelationship])) { newDidDocument[verificationRelationship] = []; } newDidDocument[verificationRelationship].push(publicKey); - // remove old proof (must be regenerated with updateProof function) + // remove old proof (must be regenerated with updateProof function) delete newDidDocument.proof; - // register the new public key with the document loader + // register the new public key with the document loader jdl.addStatic(publicKey.id, publicKey); return {keyPair, didDocument: newDidDocument}; @@ -164,32 +177,32 @@ export async function addVm({didDocument, verificationRelationship, curve}) { * }); */ export async function updateProof({didDocument, assertionMethod}) { - // TODO: replace with modern clone (structuredClone when available) - const newDidDocument = JSON.parse(JSON.stringify(didDocument)); - // remove any existing proof before creating a new one - if(newDidDocument.proof) { - delete newDidDocument.proof; - } - - // create a new cryptographic proof using ECDSA-JCS-2019 + // create a new cryptographic proof using ECDSA-JCS-2019 let documentLoader = jdl.build(); const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); - const signedDidDocument = await jsigs.sign(newDidDocument, { + const event = { + operation: { + type: 'update', + data: didDocument + } + } + const signedEvent = await jsigs.sign(event, { suite, purpose: new AssertionProofPurpose(), documentLoader }); + // delete the @context in the proof as it's unnecessary + delete signedEvent.proof['@context']; - // set the verification method reference in the proof - // TODO: determine if there is a better way to set verificationMethod - newDidDocument.proof = signedDidDocument.proof; - newDidDocument.proof.verificationMethod = newDidDocument.id + '#' + + // set the verification method reference in the proof + // TODO: determine if there is a better way to set verificationMethod + signedEvent.proof.verificationMethod = didDocument.id + '#' + assertionMethod.publicKeyMultibase; - return {didDocument: newDidDocument}; + return {event: signedEvent, didDocument}; } export default {create, addVm, updateProof}; diff --git a/lib/witness.js b/lib/witness.js index 2a2d87a..f5a6bd1 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -47,7 +47,7 @@ const secretKeys = [{ // initialize witness key pairs and register them with the document loader let witnesses = {}; for(let secretKey of secretKeys) { - // import the ECDSA Multikey from the secret key + // import the ECDSA Multikey from the secret key const keyPair = await EcdsaMultikey.from(secretKey); const publicKey = @@ -55,9 +55,9 @@ for(let secretKey of secretKeys) { const exportedKeyPair = await keyPair.export({publicKey: true, secretKey: true}); - // store the witness key pair indexed by controller DID + // store the witness key pair indexed by controller DID witnesses[secretKey.controller] = {secretKey, keyPair}; - // register the public key with the document loader for verification + // register the public key with the document loader for verification jdl.addStatic(publicKey.id, publicKey); } @@ -81,15 +81,15 @@ for(let secretKey of secretKeys) { * }); */ export async function generateProof({data, options}) { - // retrieve the key pair for the specified witness + // retrieve the key pair for the specified witness const keyPair = witnesses[options.witness].keyPair; - // create ECDSA-JCS-2019 cryptosuite for signing + // create ECDSA-JCS-2019 cryptosuite for signing const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); - // sign the data and generate the proof + // sign the data and generate the proof let documentLoader = jdl.build(); const signedData = await jsigs.sign(data, { suite, @@ -97,7 +97,10 @@ export async function generateProof({data, options}) { documentLoader }); - // return only the proof portion (not the entire signed data) + // remove the context as it's unnecessary + delete signedData.proof['@context']; + + // return only the proof portion (not the entire signed data) return signedData.proof; } From 2429b0f4f12ae503845662ecde3e304fa9820e3b Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Fri, 5 Dec 2025 14:44:49 -0500 Subject: [PATCH 19/44] Add initial README.md. --- README.md | 356 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc36e61 --- /dev/null +++ b/README.md @@ -0,0 +1,356 @@ +# DID CEL Tools + +A command-line tool for creating and managing Decentralized Identifiers (DIDs) using the Certificate Event Log (CEL) method. This tool provides an interactive REPL (Read-Eval-Print Loop) for working with `did:cel` identifiers, which use a witness-based architecture to maintain a cryptographically verifiable history of DID document operations. + +The `did:cel` method is a fully decentralized DID method that doesn't depend on blockchains, centralized registries, or any single point of control. Instead, it uses cryptographic event logs with independent witness attestations to create tamper-evident audit trails for DID operations. + +## Installation + +### Prerequisites + +- Node.js (v18 or higher recommended) +- npm (comes with Node.js) + +### Install Dependencies + +```bash +npm install +``` + +## Usage + +### Starting the REPL + +To start the interactive REPL: + +```bash +./didcel +``` + +### Non-Interactive Mode + +You can execute one or more commands and then enter interactive mode: + +```bash +./didcel -c "create" -c "witness" -c "save" -c "quit" +``` + +## REPL Interactive Mode + +To run in interactive mode, do the following: + +```bash +./didcel +``` + +Once in the REPL, you'll see a `did:cel>` prompt. The following commands are available: + +### `help` + +Displays help information about available commands. + +**Usage:** +``` +did:cel> help +``` + +**Description:** Shows a list of all available commands with brief descriptions. + +--- + +### `create` + +Creates a new DID document with an initial verification method. + +**Usage:** +``` +did:cel> create +``` + +**Description:** Generates a new `did:cel` DID document with a self-certifying identifier derived from the document's cryptographic hash. Creates an initial assertion method using a P-256 elliptic curve key pair and initializes a Cryptographic Event Log (CEL) to track the DID's history. The DID identifier is generated by hashing the canonicalized DID document using SHA3-256. + +**Output:** Displays the created DID identifier (e.g., `did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE`) + +--- + +### `add ` + +Adds a new verification method or service to the current DID document. + +**Usage:** +``` +did:cel> add +``` + +**Parameters:** +- ``: The verification relationship to add to. Choices: + - `authentication` - For authentication purposes + - `assertionMethod` - For making assertions + - `capabilityDelegation` - For delegating capabilities + - `capabilityInvocation` - For invoking capabilities + - `keyAgreement` - For key agreement protocols + - `service` - For service endpoints + +- ``: The type of verification method or service. Choices: + - `eddsa` - EdDSA signature scheme (not yet implemented) + - `ecdsa` - ECDSA signature scheme with P-256 curve + - `bbs` - BBS+ signatures (not yet implemented) + - `FileService` - File service endpoint (not yet implemented) + +**Description:** Generates a new cryptographic key pair and adds it to the specified verification relationship in the DID document. Currently, only ECDSA verification methods are supported. The DID document is modified in-place but changes are not committed to the Cryptographic Event Log until you run the `update` command. + +**Example:** +``` +did:cel> add authentication ecdsa +``` + +**Output:** Confirmation message indicating the verification method was added. + +--- + +### `ls [suffix]` + +Lists the contents of the DID document. + +**Usage:** +``` +did:cel> ls [suffix] +``` + +**Parameters:** +- `[suffix]` (optional): The last several characters of an identifier to display details for. + +**Description:** +- Without arguments: Displays a summary of the DID document, showing the DID identifier and abbreviated listings of all verification methods and services. +- With a suffix: Shows detailed JSON representation of the specific object whose identifier ends with the provided suffix. + +**Examples:** +``` +did:cel> ls +did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE + assertionMethod: Multikey#zDn...T9UV + authentication: MultikeyDid:...8j4K + +did:cel> ls T9UV +{ + "id": "#zDnaei5odivPwAt8q8QFF1cKCtz6gMkVpb9PBacKBzUNcT9UV", + "type": "Multikey", + "controller": "did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE", + "publicKeyMultibase": "zDnaei5odivPwAt8q8QFF1cKCtz6gMkVpb9PBacKBzUNcT9UV" +} +``` + +--- + +### `expire ` + +Sets an expiration timestamp on a verification method. + +**Usage:** +``` +did:cel> expire +``` + +**Parameters:** +- ``: The last several characters of the verification method identifier to expire. + +**Description:** Adds an `expires` property to the specified verification method with the current timestamp in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). This marks the verification method as expired, though it remains in the DID document. Changes take effect immediately in the local DID document but are not committed to the Cryptographic Event Log until you run the `update` command. + +**Example:** +``` +did:cel> expire T9UV +``` + +**Output:** Confirmation message with the expiration timestamp. + +--- + +### `remove ` + +Removes a verification method or service from the DID document. + +**Usage:** +``` +did:cel> remove +``` + +**Parameters:** +- ``: The last several characters of the identifier to remove. + +**Description:** Searches through all arrays in the DID document (verification relationships and services) and removes the object whose identifier ends with the specified suffix. The object is immediately removed from the local DID document, but the change is not committed to the Cryptographic Event Log until you run the `update` and `witness` commands. + +**Example:** +``` +did:cel> remove T9UV +``` + +**Output:** Confirmation message showing the removed object's full identifier. + +--- + +### `update` + +Updates the cryptographic event log with the latest DID document changes. + +**Usage:** +``` +did:cel> update +``` + +**Description:** Performs a two-phase operation to record changes to the Cryptographic Event Log: + +1. **Update Proof:** Regenerates the cryptographic proof on the DID document using the first assertion method key (created during the `create` command). This signs the current state of the DID document. + +2. **Append Event:** Creates a new update event in the Cryptographic Event Log that is hash-linked to the previous event. The hash of the previous event is computed using SHA3-256 and encoded in base58-btc format, then stored in the `previousEvent` property of the new event. + +This creates an immutable, verifiable chain of events. After running `update`, you should run the `witness` command to obtain independent attestations from witness services. + +**Note:** This command does not display output but prepares the event log for witnessing. + +--- + +### `witness` + +Obtains cryptographic attestations from witness services for the latest event. + +**Usage:** +``` +did:cel> witness +``` + +**Description:** Generates witness proofs for the most recent event in the Cryptographic Event Log. By default, the tool contacts three independent witness services (red, green, and blue witnesses), each of which: + +1. Validates the event +2. Creates a cryptographic proof (data integrity proof using ecdsa-jcs-2019) +3. Returns the proof as an attestation + +These witness attestations provide: +- **Temporal anchoring:** Proof of when the event occurred +- **Independent validation:** Third-party verification of the event +- **Distributed trust:** No single witness can compromise the system + +The witness proofs are attached to the event structure in the Cryptographic Event Log, creating a fully attested and verifiable history of DID operations. + +**Output:** Confirmation message when witness proofs are complete. + +--- + +### `save [filename]` + +Saves the Cryptographic Event Log to a file. + +**Usage:** +``` +did:cel> save [filename] +``` + +**Parameters:** +- `[filename]` (optional): The name of the file to save to. Defaults to `did.cel` if not specified. + +**Description:** Writes the complete Cryptographic Event Log (CEL) to a JSON file. The file contains the entire history of DID operations, including: +- Create and update events +- All witness attestations +- Hash-linked event chain +- Complete DID document state at each event + +The JSON is formatted with keys ordered for readability (@context, id, type, cryptosuite, previousEvent first, then alphabetically). This file can later be loaded to reconstruct the DID's complete history and verify the integrity of the event chain. + +**Example:** +``` +did:cel> save my-did.cel +Wrote to my-did.cel +``` + +--- + +### `load` + +Loads a DID from a cryptographic event log file. + +**Usage:** +``` +did:cel> load +``` + +**Description:** ⚠️ **Not yet implemented.** This command will eventually load a previously saved Cryptographic Event Log from a file, reconstruct the DID document state, and verify the integrity of the event chain and witness attestations. + +--- + +### `quit` + +Exits the REPL without saving. + +**Usage:** +``` +did:cel> quit +``` + +**Description:** Terminates the interactive session immediately. Any unsaved changes to the DID document or Cryptographic Event Log will be lost. Make sure to run the `save` command before quitting if you want to persist your work. + +## Typical Workflow + +Here's a common workflow for creating and managing a DID: + +```bash +# 1. Start the REPL +./didcel + +# 2. Create a new DID +did:cel> create + +# 3. Add additional verification methods +did:cel> add authentication ecdsa +did:cel> add assertionMethod ecdsa + +# 4. View the current state +did:cel> ls + +# 5. Update the event log with changes +did:cel> update + +# 6. Get witness attestations +did:cel> witness + +# 7. Save the complete event log +did:cel> save + +# 8. Exit +did:cel> quit +``` + +## Architecture + +The DID CEL tools implement the `did:cel` DID method, which consists of: + +- **Self-certifying identifiers:** DID identifiers derived from cryptographic hashes of the initial DID document +- **Cryptographic Event Log (CEL):** A hash-linked chain of events recording all DID operations +- **Witness attestations:** Independent cryptographic proofs from witness services providing temporal evidence and distributed validation +- **Data Integrity Proofs:** ecdsa-jcs-2019 cryptographic signatures on both DID documents and events + +## File Structure + +- `didcel` - Main executable script and REPL implementation +- `lib/cel.js` - Cryptographic Event Log management (create, update, witness) +- `lib/didcel.js` - DID document operations (create, add verification methods, update proofs) +- `lib/witness.js` - Witness service for generating attestation proofs +- `lib/utils.js` - Utility functions for JSON-LD formatting and object manipulation + +## Security Considerations + +- **Secret Keys:** The tool stores secret keys in memory during the session. Keys are lost when you exit the REPL unless you implement your own key management. +- **Witness Keys:** Currently uses hardcoded witness keys for development/testing. In production, witnesses should be independent services with securely managed keys. +- **File Storage:** Saved CEL files contain only public information (DID documents and proofs), not secret keys. + +## License + +BSD-3-Clause + +## Contributing + +This is an experimental implementation of the `did:cel` DID method. Contributions and feedback are welcome. + +## Related Specifications + +- [DID CEL Specification](https://digitalbazaar.github.io/did-cel-spec/) - Technical specification for the `did:cel` method +- [W3C Decentralized Identifiers (DIDs) v1.0](https://www.w3.org/TR/did-core/) - Core DID specification +- [Verifiable Credential Data Integrity](https://www.w3.org/TR/vc-data-integrity/) - Data Integrity Proofs specification From 2eb4909b41341d66b8a4218e4b04e92d2c6b6b9c Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Dec 2025 15:04:46 -0500 Subject: [PATCH 20/44] Fix "Certificate" -> "Cryptographic". --- README.md | 2 +- didcel | 8 ++++---- lib/cel.js | 10 +++++----- lib/didcel.js | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fc36e61..d25de5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DID CEL Tools -A command-line tool for creating and managing Decentralized Identifiers (DIDs) using the Certificate Event Log (CEL) method. This tool provides an interactive REPL (Read-Eval-Print Loop) for working with `did:cel` identifiers, which use a witness-based architecture to maintain a cryptographically verifiable history of DID document operations. +A command-line tool for creating and managing Decentralized Identifiers (DIDs) using the Cryptographic Event Log (CEL) method. This tool provides an interactive REPL (Read-Eval-Print Loop) for working with `did:cel` identifiers, which use a witness-based architecture to maintain a cryptographically verifiable history of DID document operations. The `did:cel` method is a fully decentralized DID method that doesn't depend on blockchains, centralized registries, or any single point of control. Instead, it uses cryptographic event logs with independent witness attestations to create tamper-evident audit trails for DID operations. diff --git a/didcel b/didcel index e019d85..dd7a73e 100755 --- a/didcel +++ b/didcel @@ -3,7 +3,7 @@ * @fileoverview DID CEL Command Line Interface (CLI) * * This is an interactive REPL (Read-Eval-Print Loop) for creating and managing - * DID documents using the Certificate Event Log (CEL) method. The tool allows + * 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. * @@ -106,7 +106,7 @@ async function repl({commands}) { didDocument = result.didDocument; // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; - // initialize the Certificate Event Log with the create event + // initialize the Cryptographic Event Log with the create event cryptographicEventLog = cel.create({event: result.event}); console.log(`create successful: ${didDocument.id}`); }); @@ -222,7 +222,7 @@ async function repl({commands}) { // command: update // updates the cryptographic proof on the DID document and appends an update - // event to the Certificate Event Log. This creates a new entry in the log + // event to the Cryptographic Event Log. This creates a new entry in the log // that is hash-linked to the previous event, forming a verifiable chain. The // proof is signed using the first assertionMethod key generated during // create. After running update, you should run 'witness' to get witness @@ -260,7 +260,7 @@ async function repl({commands}) { }); // command: save - // persists the Certificate Event Log to a file. The CEL contains the complete + // persists the Cryptographic Event Log to a file. The CEL contains the complete // history of all operations on the DID document, including create and update // events, along with witness attestations. The file is saved in JSON format // with keys ordered for readability (e.g., @context, id, type first). diff --git a/lib/cel.js b/lib/cel.js index 6b4eaa2..77ca118 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -1,7 +1,7 @@ /** - * @fileoverview Certificate Event Log (CEL) management. + * @fileoverview Cryptographic Event Log (CEL) management. * This module provides functions for creating, updating, and witnessing events - * in a Certificate Event Log, which maintains a cryptographically verifiable + * in a Cryptographic Event Log, which maintains a cryptographically verifiable * chain of events for DID document operations. */ @@ -27,7 +27,7 @@ let witnesses = [ ]; /** - * Creates a new Certificate Event Log (CEL) with an initial 'create' event. + * Creates a new Cryptographic Event Log (CEL) with an initial 'create' event. * The log maintains a chain of events that document the history of DID operations. * * @param {Object} options - Configuration options. @@ -57,7 +57,7 @@ export function create({event, options}) { * Each configured witness creates a cryptographic proof attesting to the event. * * @param {Object} options - Configuration options. - * @param {Object} options.cel - The Certificate Event Log containing events to + * @param {Object} options.cel - The Cryptographic Event Log containing events to * witness. * @param {Object} [options.options] - Optional configuration (currently * unused). @@ -89,7 +89,7 @@ export async function witness({cel, options}) { * integrity. * * @param {Object} options - Configuration options. - * @param {Object} options.cel - The Certificate Event Log to update. + * @param {Object} options.cel - The Cryptographic Event Log to update. * @param {Object} options.event - The data for the update operation (typically * an updated DID document). * @param {Object} [options.options] - Optional configuration (currently diff --git a/lib/didcel.js b/lib/didcel.js index bcfadcc..d5fe560 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -1,5 +1,5 @@ /** - * @fileoverview DID CEL (Certificate Event Log) DID Document management. + * @fileoverview DID CEL (Cryptographic Event Log) DID Document management. * This module provides functions for creating, updating, and managing DID * documents using the did:cel method with ECDSA Multikey and Data Integrity * Proofs. From 94a2582c0ac80d978c252f7632c2cc37b5136799 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Dec 2025 15:47:15 -0500 Subject: [PATCH 21/44] Clean up how signatures are generated. --- didcel | 11 ++++----- lib/cel.js | 64 +++++++++++++++++++++++++++++---------------------- lib/didcel.js | 42 ++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/didcel b/didcel index dd7a73e..c7e9dd0 100755 --- a/didcel +++ b/didcel @@ -44,7 +44,7 @@ const options = program.opts(); // orders keys with @context, id, type first, then alphabetically const jsonldPretty = createJsonldPrettyPrinter({ preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', - 'previousEvent'] + 'heartbeatFrequency', 'previousEventHash'] }); // common verification relationship and service properties in DID documents @@ -232,15 +232,14 @@ async function repl({commands}) { .action(async () => { // step 1: Regenerate the cryptographic proof on the DID document // this signs the current state of the DID document - const result = (await didcel.updateProof({didDocument, - assertionMethod: secretKeys.assertionMethod[0]})); + const result = await didcel.createEvent({data: didDocument, + assertionMethod: secretKeys.assertionMethod[0]}); const event = result.event; - didDocument = result.didDocument; // step 2: Append an update event to the CEL // this creates a hash-linked chain entry with the modified DID document cryptographicEventLog = - await cel.update({cel: cryptographicEventLog, event}); + await cel.addEvent({cel: cryptographicEventLog, event}); }); // command: witness @@ -255,7 +254,7 @@ async function repl({commands}) { .action(async () => { // generate witness proofs for the most recent event in the log // each witness independently validates and signs the event - const proof = await cel.witness({cel: cryptographicEventLog}) + const proofs = await cel.witness({cel: cryptographicEventLog}); console.log('witness: proofs complete'); }); diff --git a/lib/cel.js b/lib/cel.js index 77ca118..f78a999 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -68,7 +68,6 @@ export function create({event, options}) { * // Returns array of proofs from red, green, and blue witnesses */ export async function witness({cel, options}) { - const proofs = []; // get the most recent event from the log const event = cel.log[cel.log.length-1]; @@ -77,34 +76,20 @@ export async function witness({cel, options}) { for(let witness of witnesses) { const proof = await witnessService.generateProof( {data: event, options: {witness}}); - proofs.push(proof); } - return proofs; + // TODO: Explore better way to remove auto-injected @context + delete event['@context']; + for(let proof of event.proof) { + delete proof['@context']; + } + + return event.proof; } -/** - * Adds an update event to an existing CEL, creating a hash-linked chain of - * events. The update event includes a hash of the previous event to ensure log - * integrity. - * - * @param {Object} options - Configuration options. - * @param {Object} options.cel - The Cryptographic Event Log to update. - * @param {Object} options.event - The data for the update operation (typically - * an updated DID document). - * @param {Object} [options.options] - Optional configuration (currently - * unused). - * @returns {Promise} The updated CEL with the new event appended. - * - * @example - * const updatedCel = await update({ - * cel: existingCel, - * data: modifiedDidDocument - * }); - */ -export async function update({cel, event, options}) { - // calculate the hash of the previous event to create a verifiable chain - let previousEvent = undefined; +async function _calculatePreviousEventHash({cel}) { + // calculate the hash of the previous event to create a verifiable chain + let previousEventHash = undefined; if(cel.log.length > 0) { const lastEvent = cel.log[cel.log.length-1].event; const utf8Encoder = new TextEncoder(); @@ -119,14 +104,37 @@ export async function update({cel, event, options}) { // compute the hash and encode it in base58btc const mfHash = await sha3256Hasher.digest( utf8Encoder.encode(canonicalizedDidDocument)).bytes; - previousEvent = base58btc.encode(mfHash); + previousEventHash = base58btc.encode(mfHash); } + return previousEventHash; +} + +/** + * Adds an event to an existing CEL, creating a hash-linked chain of + * events. The update event includes a hash of the previous event to ensure log + * integrity. + * + * @param {Object} options - Configuration options. + * @param {Object} options.cel - The Certificate Event Log to add the event to. + * @param {Object} options.event - The data for the update operation (typically + * an updated DID document). + * @param {Object} [options.options] - Optional configuration (currently + * unused). + * @returns {Promise} The updated CEL with the new event appended. + * + * @example + * const updatedCel = await addEvent({ + * cel: existingCel, + * data: modifiedDidDocument + * }); + */ +export async function addEvent({cel, event, options}) { // append the new update event to the log, linked to the previous event - event.previousEvent = previousEvent; + event.previousEventHash = await _calculatePreviousEventHash({cel}); cel.log.push({event}); return cel; } -export default {create, update, witness}; +export default {create, addEvent, witness}; diff --git a/lib/didcel.js b/lib/didcel.js index d5fe560..c765dfb 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -30,10 +30,12 @@ const jdl = new JsonLdDocumentLoader(); * for key generation (e.g., 'P-256', 'P-384'). * @returns {Promise} An object containing: * - keyPair: The generated ECDSA Multikey key pair + * - recoveryKeyPair: The generated ECDSA Multikey recovery key pair * - didDocument: The signed DID document with a did:cel identifier * * @example - * const {keyPair, didDocument} = await create({options: {curve: 'P-256'}}); + * const {keyPair, recoveryKeyPair, didDocument} = + * await create({options: {curve: 'P-256'}}); * console.log(didDocument.id); // did:cel:z... */ export async function create({options}) { @@ -45,8 +47,17 @@ export async function create({options}) { // set the key id to the public key multibase encoding publicKey.id = '#' + publicKey.publicKeyMultibase; + // generate a new recovery key pair using the specified curve (defaults to P-256) + const recoveryKeyPair = + await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); + const recoveryPublicKey = + await recoveryKeyPair.export({publicKey: true, includeContext: false}); + // set the key id to the public key multibase encoding + recoveryPublicKey.id = '#' + recoveryPublicKey.publicKeyMultibase; + // register the public key with the document loader for proof verification jdl.addStatic(publicKey.id, publicKey); + jdl.addStatic(recoveryPublicKey.id, recoveryPublicKey); // create initial DID document structure with assertion method let didDocument = { @@ -54,7 +65,9 @@ export async function create({options}) { 'https://www.w3.org/ns/did/v1.1', 'https://w3id.org/didcel/v1' ], + heartbeatFrequency: options?.heartbeatFrequency || 'P3M', assertionMethod: [publicKey], + recovery: [recoveryPublicKey], service: { type: 'CelStorageService', serviceEndpoint: [ @@ -80,6 +93,7 @@ export async function create({options}) { // update the DID document and public key with the generated identifier didDocument.id = controller; publicKey.controller = controller; + recoveryPublicKey.controller = controller; // create a cryptographic proof using ECDSA-JCS-2019 const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); @@ -107,7 +121,7 @@ export async function create({options}) { // TODO: Determine if there is a better way to set the proof VM signedEvent.proof.verificationMethod = controller + publicKey.id; - return {keyPair, event: signedEvent, didDocument}; + return {keyPair, recoveryKeyPair, event: signedEvent, didDocument}; } /** @@ -160,24 +174,23 @@ export async function addVm({didDocument, verificationRelationship, curve}) { } /** - * Updates or adds a cryptographic proof to a DID document using the specified - * assertion method key pair. Any existing proof is replaced. + * Creates a signed event given event data and an assertion method keypair. * * @param {Object} options - Configuration options. - * @param {Object} options.didDocument - The DID document to sign. + * @param {Object} options.data - The data to place into the event. * @param {Object} options.assertionMethod - The key pair to use for signing. * Must have a signer() method and publicKeyMultibase property. * @returns {Promise} An object containing: * - didDocument: The DID document with the new proof attached * * @example - * const {didDocument} = await updateProof({ - * didDocument: modifiedDoc, + * const {didDocument} = await createEvent({ + * data: didDocument, * assertionMethod: keyPair * }); */ -export async function updateProof({didDocument, assertionMethod}) { - // create a new cryptographic proof using ECDSA-JCS-2019 +export async function createEvent({data, assertionMethod}) { + // create a new cryptographic proof using ecdsa-jcs-2019 let documentLoader = jdl.build(); const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ @@ -186,23 +199,24 @@ export async function updateProof({didDocument, assertionMethod}) { const event = { operation: { type: 'update', - data: didDocument + data } - } + }; const signedEvent = await jsigs.sign(event, { suite, purpose: new AssertionProofPurpose(), documentLoader }); // delete the @context in the proof as it's unnecessary + delete signedEvent['@context']; delete signedEvent.proof['@context']; // set the verification method reference in the proof // TODO: determine if there is a better way to set verificationMethod - signedEvent.proof.verificationMethod = didDocument.id + '#' + + signedEvent.proof.verificationMethod = assertionMethod.controller + '#' + assertionMethod.publicKeyMultibase; - return {event: signedEvent, didDocument}; + return {event: signedEvent}; } -export default {create, addVm, updateProof}; +export default {create, addVm, createEvent}; From bde4d64aaa0a08d3e568553011cdc4fcca2eed7a Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Dec 2025 16:47:39 -0500 Subject: [PATCH 22/44] Add heartbeat and deactivate functionality. --- didcel | 39 ++++++++++++++++++++++++++++++++++++++- lib/didcel.js | 8 +++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/didcel b/didcel index c7e9dd0..837719e 100755 --- a/didcel +++ b/didcel @@ -232,7 +232,8 @@ async function repl({commands}) { .action(async () => { // step 1: Regenerate the cryptographic proof on the DID document // this signs the current state of the DID document - const result = await didcel.createEvent({data: didDocument, + const result = await didcel.createEvent({ + data: didDocument, type: 'update', assertionMethod: secretKeys.assertionMethod[0]}); const event = result.event; @@ -242,6 +243,42 @@ async function repl({commands}) { await cel.addEvent({cel: cryptographicEventLog, event}); }); + // 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 () => { + // step 1: Create a heartbeat event + const result = await didcel.createEvent({ + data: undefined, type: 'heartbeat', + assertionMethod: secretKeys.assertionMethod[0]}); + const event = result.event; + + // step 2: Append an heatbeat event to the CEL + // this creates a hash-linked chain entry with the heatbeat event + cryptographicEventLog = + await cel.addEvent({cel: cryptographicEventLog, event}); + console.log('heartbeat: generated'); + }); + + // command: deactivate + // Deactivates the DID + repl.command('deactivate') + .description('Deactivate the DID') + .action(async () => { + // step 1: Create the deactivation event + const result = await didcel.createEvent({ + data: undefined, type: 'deactivate', + assertionMethod: secretKeys.assertionMethod[0]}); + const event = result.event; + + // step 2: Append the deactivation event to the CEL + // this creates a hash-linked chain entry with the deactivation event + cryptographicEventLog = + await cel.addEvent({cel: cryptographicEventLog, event}); + console.log('deactivation: complete'); + }); + // command: witness // generates cryptographic proofs from external witnesses that attest to the // validity of the most recent event in the CEL. By default, three witnesses diff --git a/lib/didcel.js b/lib/didcel.js index c765dfb..ef2256b 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -186,10 +186,11 @@ export async function addVm({didDocument, verificationRelationship, curve}) { * @example * const {didDocument} = await createEvent({ * data: didDocument, + * type: 'update', * assertionMethod: keyPair * }); */ -export async function createEvent({data, assertionMethod}) { +export async function createEvent({type, data, assertionMethod}) { // create a new cryptographic proof using ecdsa-jcs-2019 let documentLoader = jdl.build(); const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); @@ -197,10 +198,7 @@ export async function createEvent({data, assertionMethod}) { signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); const event = { - operation: { - type: 'update', - data - } + operation: { type, data } }; const signedEvent = await jsigs.sign(event, { suite, From 9558c932da66762ad4cdd8404a06172bfe6dacb3 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Dec 2025 17:27:35 -0500 Subject: [PATCH 23/44] Update test files. --- tests/run-tests.sh | 274 +++++++++++++++++++++++++++++++++++++++++++++ tests/stress.sh | 47 -------- 2 files changed, 274 insertions(+), 47 deletions(-) create mode 100755 tests/run-tests.sh delete mode 100755 tests/stress.sh diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..4d1e150 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,274 @@ +#!/bin/bash +# +# Create a large DID Document and compress it + +# Create example +../didcel -c create -c save -c quit +mv did.cel create.cel + +# Witness example +../didcel -c create -c witness -c save -c quit +mv did.cel witness.cel + +# Update example +../didcel -c create -c witness -c "add authentication ecdsa" -c update -c witness -c save -c quit +mv did.cel update.cel + +# Heartbeat example +../didcel -c create -c witness -c heartbeat -c witness -c save -c quit +mv did.cel heartbeat.cel + +# Deactivate example +../didcel -c create -c witness -c "add authentication ecdsa" -c update -c witness -c deactivate -c witness -c save -c quit +mv did.cel deactivate.cel + +# 30 year personal did:cel +../didcel -c create -c witness \ + -c "add authentication ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c heartbeat -c witness \ + -c ls -c save -c quit +mv did.cel 30-year-personal.cel + +# 30 year organization did:cel +../didcel -c create -c witness \ + -c "add authentication ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c "add authentication ecdsa" -c "add assertionMethod ecdsa" -c update -c witness \ + -c ls -c save -c quit +mv did.cel 30-year-organization.cel diff --git a/tests/stress.sh b/tests/stress.sh deleted file mode 100755 index a87b3ea..0000000 --- a/tests/stress.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# -# Create a large DID Document and compress it - -../didcel -c create -c witness \ - -c "add authentication ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "add assertionMethod ecdsa" -c update -c witness \ - -c "ls" -c save -c quit From cceb6d820c55deec3974098154a8bfa72025abc5 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 13:07:06 -0400 Subject: [PATCH 24/44] Add support for calling hmbd witness service. --- lib/cel.js | 41 +++++++++-------- lib/witness.js | 122 +++++++++++-------------------------------------- 2 files changed, 47 insertions(+), 116 deletions(-) diff --git a/lib/cel.js b/lib/cel.js index f78a999..040bb6b 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -14,17 +14,15 @@ import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; import jsigs from 'jsonld-signatures'; import * as mfHasher from 'multiformats/hashes/hasher'; import {sha3_256} from '@noble/hashes/sha3.js'; -import * as witnessService from './witness.js'; +import {sha256} from '@noble/hashes/sha2.js'; +import {callWitness} from './witness.js'; const {purposes: {AssertionProofPurpose}} = jsigs; const jdl = new JsonLdDocumentLoader(); -// default witness DIDs for validating CEL operations -let witnesses = [ - "did:web:red-witness.example", - "did:web:green-witness.example", - "did:web:blue-witness.example" -]; +const HMBD_WITNESS_URL = 'https://localhost:22443/witnesses/test/witness'; +// SHA2-256 multihash header: function code 0x12, digest size 32 (0x20) +const SHA2_256_HEADER = new Uint8Array([0x12, 0x20]); /** * Creates a new Cryptographic Event Log (CEL) with an initial 'create' event. @@ -68,21 +66,24 @@ export function create({event, options}) { * // Returns array of proofs from red, green, and blue witnesses */ export async function witness({cel, options}) { - // get the most recent event from the log - const event = cel.log[cel.log.length-1]; - - // generate a cryptographic proof from each witness - // each witness independently attests to the validity of the event - for(let witness of witnesses) { - const proof = await witnessService.generateProof( - {data: event, options: {witness}}); - } + const event = cel.log[cel.log.length - 1]; + + // canonicalize and SHA2-256 hash the event to produce the digestMultibase + const utf8Encoder = new TextEncoder(); + const canonicalized = canonicalize(event); + const rawHash = sha256(utf8Encoder.encode(canonicalized)); + + // build SHA2-256 multihash and encode as base58btc with 'z' multibase prefix + const mhBytes = new Uint8Array(SHA2_256_HEADER.length + rawHash.length); + mhBytes.set(SHA2_256_HEADER, 0); + mhBytes.set(rawHash, SHA2_256_HEADER.length); + const digestMultibase = base58btc.encode(mhBytes); - // TODO: Explore better way to remove auto-injected @context + const proof = await callWitness({digestMultibase, witnessUrl: HMBD_WITNESS_URL}); + + event.proof = [proof]; delete event['@context']; - for(let proof of event.proof) { - delete proof['@context']; - } + delete proof['@context']; return event.proof; } diff --git a/lib/witness.js b/lib/witness.js index f5a6bd1..883cbe6 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -1,107 +1,37 @@ /** - * @fileoverview Witness service for CEL event attestation. - * This module manages witness key pairs and generates cryptographic proofs - * that attest to the validity of CEL events. Witnesses provide independent - * validation of DID operations. + * @fileoverview Witness service HTTP client. + * Calls a real blind witness service to obtain a DataIntegrityProof attesting + * to a cryptographic event hash. */ -import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; -import {JsonLdDocumentLoader} from 'jsonld-document-loader'; -import {base58btc} from 'multiformats/bases/base58'; -import canonicalize from 'canonicalize'; -import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; -import jsigs from 'jsonld-signatures'; -import * as mfHasher from 'multiformats/hashes/hasher'; -import {sha3_256} from '@noble/hashes/sha3.js'; - -const {purposes: {AssertionProofPurpose}} = jsigs; -const jdl = new JsonLdDocumentLoader(); - -// TODO: move to separate service -- generate all of the witness keys -// hardcoded witness keys for development/testing purposes -// in production, these should be securely managed and not stored in code -const secretKeys = [{ - "@context": "https://w3id.org/security/multikey/v1", - "id": "did:web:red-witness.example#vm-red-1", - "type": "Multikey", - "controller": "did:web:red-witness.example", - "publicKeyMultibase": "zDnaeRQKUJYxFwB1zgHisFGeHXYhgoDkXQ3cgzTJHVPfxtfxY", - "secretKeyMultibase": "z42twzpeKSKsX7NNH5v4CGREKhmcEKGu5RXXAVQQCqjDMnPg" -}, { - "@context": "https://w3id.org/security/multikey/v1", - "id": "did:web:green-witness.example#vm-green-1", - "type": "Multikey", - "controller": "did:web:green-witness.example", - "publicKeyMultibase": "zDnaecDuyWKVKwfHEZrh6bNtLDK46Y88nGLEEEjqcTbCYwWYW", - "secretKeyMultibase": "z42tp2TDou6md8m7oq78f52mdYCDdUwSqhuvYEPsdG6cXGHo" -}, { - "@context": "https://w3id.org/security/multikey/v1", - "id": "did:web:blue-witness.example#vm-blue-1", - "type": "Multikey", - "controller": "did:web:blue-witness.example", - "publicKeyMultibase": "zDnaeo6TCxLGbQ2G1k4jvzv5keBaaADp8v7vgiYLbi2heCFPF", - "secretKeyMultibase": "z42ttRq6VGC727Z4F5c8q6zjBvgJ6MTT3t16JoJEWFzujeSq" -}]; - -// initialize witness key pairs and register them with the document loader -let witnesses = {}; -for(let secretKey of secretKeys) { - // import the ECDSA Multikey from the secret key - const keyPair = - await EcdsaMultikey.from(secretKey); - const publicKey = - await keyPair.export({publicKey: true, includeContext: false}); - const exportedKeyPair = - await keyPair.export({publicKey: true, secretKey: true}); - - // store the witness key pair indexed by controller DID - witnesses[secretKey.controller] = {secretKey, keyPair}; - // register the public key with the document loader for verification - jdl.addStatic(publicKey.id, publicKey); -} +import fetch from 'node-fetch'; +import https from 'node:https'; +// allow self-signed certs on localhost witness services +const httpsAgent = new https.Agent({rejectUnauthorized: false}); /** - * Generates a cryptographic proof for data using a specified witness key. - * The proof attests that the witness has validated the data. + * Sends a digestMultibase to a witness service and returns the proof. * - * @param {Object} options - Configuration options. - * @param {Object} options.data - The data to sign (typically a CEL event). - * @param {Object} options.options - Configuration containing witness selection. - * @param {string} options.options.witness - The DID of the witness to use for signing - * (e.g., 'did:web:red-witness.example'). - * @returns {Promise} A Data Integrity Proof object containing the - * cryptographic signature and metadata. - * - * @example - * const proof = await generateProof({ - * data: celEvent, - * options: {witness: 'did:web:red-witness.example'} - * }); + * @param {Object} options + * @param {string} options.digestMultibase - base58btc-encoded SHA2-256 + * multihash of the event to attest (z prefix). + * @param {string} options.witnessUrl - Full URL of the witness endpoint. + * @returns {Promise} DataIntegrityProof returned by the witness. */ -export async function generateProof({data, options}) { - // retrieve the key pair for the specified witness - const keyPair = witnesses[options.witness].keyPair; - // create ECDSA-JCS-2019 cryptosuite for signing - const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); - const suite = new DataIntegrityProof({ - signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite - }); - - // sign the data and generate the proof - let documentLoader = jdl.build(); - const signedData = await jsigs.sign(data, { - suite, - purpose: new AssertionProofPurpose(), - documentLoader +export async function callWitness({digestMultibase, witnessUrl}) { + const response = await fetch(witnessUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({digestMultibase}), + agent: httpsAgent }); - - // remove the context as it's unnecessary - delete signedData.proof['@context']; - - // return only the proof portion (not the entire signed data) - return signedData.proof; + if(!response.ok) { + const body = await response.text(); + throw new Error(`Witness request failed (${response.status}): ${body}`); + } + const {proof} = await response.json(); + return proof; } -export default {generateProof}; +export default {callWitness}; From 4d220bd6cc06ca25f2449c86817b19c15a3bbcde Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 13:32:09 -0400 Subject: [PATCH 25/44] Add eslint. --- package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/package.json b/package.json index a2974aa..72ff8f9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "main": "./lib/index.js", "scripts": { + "lint": "eslint .", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], @@ -26,5 +27,13 @@ "jsonld-document-loader": "^2.3.0", "multiformats": "^13.4.1", "prompt-sync": "^4.2.0" + }, + "devDependencies": { + "@bedrock/test": "^8.2.0", + "cross-env": "^7.0.3", + "eslint": "^8.57.1", + "eslint-config-digitalbazaar": "^5.2.0", + "eslint-plugin-jsdoc": "^50.6.8", + "eslint-plugin-unicorn": "^56.0.1" } } From 625e6b5743e6d001650014d68d3adf2026a6b5d5 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 13:32:15 -0400 Subject: [PATCH 26/44] Add config file support and log storage. --- didcel | 26 ++++++++++++++++++++------ lib/cel.js | 18 +++++++++++++----- lib/witness.js | 4 ++-- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/didcel b/didcel index 837719e..4ed4a07 100755 --- a/didcel +++ b/didcel @@ -26,9 +26,11 @@ import { Argument, Command, CommanderError } from 'commander'; import cel from './lib/cel.js'; +import {config} from './lib/config.js'; import didcel from './lib/didcel.js'; import promptSync from 'prompt-sync'; -import {writeFileSync} from 'fs'; +import {mkdirSync, writeFileSync} from 'fs'; +import {join} from 'node:path'; import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix} from './lib/utils.js'; @@ -306,11 +308,23 @@ async function repl({commands}) { '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) => { - // use default filename 'did.cel' if none provided - const celFilename = filename || 'did.cel'; - // write the CEL to file with pretty-printed JSON formatting - writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, jsonldPretty, 2)); - console.error(`Wrote to ${celFilename}`); + const celJson = JSON.stringify(cryptographicEventLog, jsonldPretty, 2); + + // always write to configured logs directory using DID identifier as filename + if(config.logs) { + mkdirSync(config.logs, {recursive: true}); + // use the method-specific identifier (part after did:cel:) as filename + const didIdentifier = didDocument.id.split(':').pop(); + const logsPath = join(config.logs, `${didIdentifier}.cel`); + writeFileSync(logsPath, celJson); + console.error(`Wrote to ${logsPath}`); + } + + // also write to explicit filename if provided + if(filename) { + writeFileSync(filename, celJson); + console.error(`Wrote to ${filename}`); + } }); // command: quit diff --git a/lib/cel.js b/lib/cel.js index 040bb6b..f9574d9 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -15,12 +15,12 @@ import jsigs from 'jsonld-signatures'; import * as mfHasher from 'multiformats/hashes/hasher'; import {sha3_256} from '@noble/hashes/sha3.js'; import {sha256} from '@noble/hashes/sha2.js'; -import {callWitness} from './witness.js'; +import * as witnessService from './witness.js'; +import {config} from './config.js'; const {purposes: {AssertionProofPurpose}} = jsigs; const jdl = new JsonLdDocumentLoader(); -const HMBD_WITNESS_URL = 'https://localhost:22443/witnesses/test/witness'; // SHA2-256 multihash header: function code 0x12, digest size 32 (0x20) const SHA2_256_HEADER = new Uint8Array([0x12, 0x20]); @@ -79,11 +79,19 @@ export async function witness({cel, options}) { mhBytes.set(rawHash, SHA2_256_HEADER.length); const digestMultibase = base58btc.encode(mhBytes); - const proof = await callWitness({digestMultibase, witnessUrl: HMBD_WITNESS_URL}); + const witnessUrls = config.witnesses; + if(!Array.isArray(witnessUrls) || witnessUrls.length === 0) { + throw new Error('No witnesses configured. Add a "witnesses" array to config.yaml.'); + } + + const proofs = await Promise.all(witnessUrls.map( + witnessUrl => witnessService.witness({digestMultibase, witnessUrl}))); - event.proof = [proof]; + event.proof = proofs; delete event['@context']; - delete proof['@context']; + for(const proof of proofs) { + delete proof['@context']; + } return event.proof; } diff --git a/lib/witness.js b/lib/witness.js index 883cbe6..15aa13e 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -19,7 +19,7 @@ const httpsAgent = new https.Agent({rejectUnauthorized: false}); * @param {string} options.witnessUrl - Full URL of the witness endpoint. * @returns {Promise} DataIntegrityProof returned by the witness. */ -export async function callWitness({digestMultibase, witnessUrl}) { +export async function witness({digestMultibase, witnessUrl}) { const response = await fetch(witnessUrl, { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -34,4 +34,4 @@ export async function callWitness({digestMultibase, witnessUrl}) { return proof; } -export default {callWitness}; +export default {witness}; From 826da38d66b7bb0d3823a3ff5a8636da5cb2cf32 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 13:49:56 -0400 Subject: [PATCH 27/44] Add secrets management to tool. --- .eslintrc.cjs | 12 ++++++++ didcel | 21 ++++++++++--- lib/config.js | 31 +++++++++++++++++++ lib/secrets.js | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 .eslintrc.cjs create mode 100644 lib/config.js create mode 100644 lib/secrets.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..fdd7a87 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + root: true, + env: { + node: true + }, + extends: [ + 'digitalbazaar', + 'digitalbazaar/jsdoc', + 'digitalbazaar/module' + ], + ignorePatterns: ['node_modules/'] +}; diff --git a/didcel b/didcel index 4ed4a07..1c8eaf2 100755 --- a/didcel +++ b/didcel @@ -31,6 +31,7 @@ import didcel from './lib/didcel.js'; import promptSync from 'prompt-sync'; import {mkdirSync, writeFileSync} from 'fs'; import {join} from 'node:path'; +import {saveSecrets} from './lib/secrets.js'; import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix} from './lib/utils.js'; @@ -38,6 +39,7 @@ import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, const program = new Command(); program .option('-c, --command ', 'One or more commands to execute') + .option('-p, --password ', 'Password for encrypting private keys') .option('-v, --verbose', 'Provide verbose output') .parse(process.argv); const options = program.opts(); @@ -61,11 +63,16 @@ const COMMON_PROPERTIES = ['authentication', 'assertionMethod', 'capabilityDeleg * execute before entering interactive mode. * @returns {Promise} */ -async function repl({commands}) { +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 + if(!password) { + password = prompt('Encryption password: ', {echo: ''}); + } + // session state variables // the CEL tracking all DID changes let cryptographicEventLog; @@ -308,19 +315,22 @@ async function repl({commands}) { '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) => { + const didIdentifier = didDocument.id.split(':').pop(); const celJson = JSON.stringify(cryptographicEventLog, jsonldPretty, 2); // always write to configured logs directory using DID identifier as filename if(config.logs) { mkdirSync(config.logs, {recursive: true}); - // use the method-specific identifier (part after did:cel:) as filename - const didIdentifier = didDocument.id.split(':').pop(); const logsPath = join(config.logs, `${didIdentifier}.cel`); writeFileSync(logsPath, celJson); console.error(`Wrote to ${logsPath}`); } - // also write to explicit filename if provided + // save encrypted private keys to secrets directory + await saveSecrets({didIdentifier, secretKeys, password}); + console.error(`Wrote secrets to ${join(config.secrets, `${didIdentifier}.yaml`)}`); + + // also write CEL to explicit filename if provided if(filename) { writeFileSync(filename, celJson); console.error(`Wrote to ${filename}`); @@ -389,5 +399,6 @@ async function repl({commands}) { // 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 + commands: options.command, + password: options.password }); diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..ed5ff5e --- /dev/null +++ b/lib/config.js @@ -0,0 +1,31 @@ +/** + * @fileoverview Configuration loader. + * Reads config.yaml from ~/.config/didcel/. + */ + +import {existsSync, readFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import yaml from 'js-yaml'; + +const configPath = join(homedir(), '.config', 'didcel', 'config.yaml'); + +if(!existsSync(configPath)) { + throw new Error(`Configuration file not found: ${configPath}`); +} + +const raw = yaml.load(readFileSync(configPath, 'utf8')) ?? {}; + +// resolve leading ~/ in path values to the user's home directory +function _resolvePath(value) { + if(typeof value === 'string' && value.startsWith('~/')) { + return join(homedir(), value.slice(2)); + } + return value; +} + +export const config = { + ...raw, + logs: _resolvePath(raw.logs), + secrets: _resolvePath(raw.secrets) +}; diff --git a/lib/secrets.js b/lib/secrets.js new file mode 100644 index 0000000..f04616f --- /dev/null +++ b/lib/secrets.js @@ -0,0 +1,83 @@ +/** + * @fileoverview Encrypted private key storage. + * Saves and loads private keys to ~/.config/didcel/secrets/.yaml. + * Each secretKeyMultibase is encrypted with AES-256-GCM, with the encryption + * key derived from a user-supplied password via scrypt. + */ + +import crypto from 'node:crypto'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import yaml from 'js-yaml'; +import {config} from './config.js'; + +// scrypt parameters: N=2^14, r=8, p=1 +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const KEY_LEN = 32; + +/** + * Encrypts and saves all secret key pairs to the secrets file for a DID. + * + * @param {Object} options + * @param {string} options.didIdentifier - Method-specific ID (part after did:cel:). + * @param {Object} options.secretKeys - Session secretKeys object keyed by + * verification relationship, each an array of keyPair objects. + * @param {string} options.password - Password used to encrypt each secret key. + */ +export async function saveSecrets({didIdentifier, secretKeys, password}) { + const keys = []; + for(const [relationship, keyPairs] of Object.entries(secretKeys)) { + for(const keyPair of keyPairs) { + const exported = await keyPair.export( + {publicKey: true, secretKey: true, includeContext: true}); + const {secretKeyMultibase, ...publicFields} = exported; + if(!secretKeyMultibase) { + continue; + } + const encryptedSecretKeyMultibase = + await _encrypt(secretKeyMultibase, password); + keys.push({...publicFields, relationship, encryptedSecretKeyMultibase}); + } + } + + mkdirSync(config.secrets, {recursive: true}); + writeFileSync(_secretsPath(didIdentifier), yaml.dump({keys})); +} + + +function _secretsPath(didIdentifier) { + return join(config.secrets, `${didIdentifier}.yaml`); +} + +function _deriveKey(password, salt) { + return new Promise((resolve, reject) => { + crypto.scrypt(password, salt, KEY_LEN, + {N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P}, + (err, key) => err ? reject(err) : resolve(key)); + }); +} + +async function _encrypt(plaintext, password) { + const salt = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const key = await _deriveKey(password, salt); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // pack: salt(32) || iv(12) || tag(16) || ciphertext, encode as base64 + return Buffer.concat([salt, iv, tag, enc]).toString('base64'); +} + +async function _decrypt(encoded, password) { + const buf = Buffer.from(encoded, 'base64'); + const salt = buf.subarray(0, 32); + const iv = buf.subarray(32, 44); + const tag = buf.subarray(44, 60); + const ciphertext = buf.subarray(60); + const key = await _deriveKey(password, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); +} From e0b7a18cb9dab8ffb18fb6fe96079deb9d3f5b7b Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 20:09:48 -0400 Subject: [PATCH 28/44] Fix eslint errors. --- lib/cel.js | 65 ++++++++++++++++++++------------------------------ lib/config.js | 2 +- lib/didcel.js | 62 +++++++++++++++++++++++------------------------ lib/secrets.js | 28 +++++++--------------- lib/utils.js | 36 ++++++++++++++-------------- lib/witness.js | 8 +++---- 6 files changed, 88 insertions(+), 113 deletions(-) diff --git a/lib/cel.js b/lib/cel.js index f9574d9..2ae3d6e 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -1,47 +1,38 @@ /** - * @fileoverview Cryptographic Event Log (CEL) management. + * @file Cryptographic Event Log (CEL) management. * This module provides functions for creating, updating, and witnessing events * in a Cryptographic Event Log, which maintains a cryptographically verifiable * chain of events for DID document operations. */ -import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; -import {JsonLdDocumentLoader} from 'jsonld-document-loader'; -import {base58btc} from 'multiformats/bases/base58'; -import canonicalize from 'canonicalize'; -import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; -import jsigs from 'jsonld-signatures'; import * as mfHasher from 'multiformats/hashes/hasher'; -import {sha3_256} from '@noble/hashes/sha3.js'; -import {sha256} from '@noble/hashes/sha2.js'; import * as witnessService from './witness.js'; +import {base58btc} from 'multiformats/bases/base58'; +import canonicalize from 'canonicalize'; import {config} from './config.js'; - -const {purposes: {AssertionProofPurpose}} = jsigs; -const jdl = new JsonLdDocumentLoader(); +import {sha256} from '@noble/hashes/sha2.js'; +import {sha3_256} from '@noble/hashes/sha3.js'; // SHA2-256 multihash header: function code 0x12, digest size 32 (0x20) const SHA2_256_HEADER = new Uint8Array([0x12, 0x20]); /** * Creates a new Cryptographic Event Log (CEL) with an initial 'create' event. - * The log maintains a chain of events that document the history of DID operations. + * The log maintains a chain of events that document the history of DID ops. * - * @param {Object} options - Configuration options. - * @param {Object} options.event - The data for the create operation. - * @param {Object} [options.options] - Optional configuration. - * @returns {Object} A new CEL object with the structure: - * - log: Array containing the initial create event + * @param {object} options - Configuration options. + * @param {object} options.event - The data for the create operation. + * @returns {object} A new CEL object with the structure: + * - log: Array containing the initial create event. * * @example * const cel = create({ * event, * }); */ -export function create({event, options}) { +export function create({event}) { // initialize the log with a create operation event - let log = { + const log = { log: [{ event }] @@ -54,18 +45,15 @@ export function create({event, options}) { * Generates witness proofs for the most recent event in a CEL. * Each configured witness creates a cryptographic proof attesting to the event. * - * @param {Object} options - Configuration options. - * @param {Object} options.cel - The Cryptographic Event Log containing events to - * witness. - * @param {Object} [options.options] - Optional configuration (currently - * unused). + * @param {object} options - Configuration options. + * @param {object} options.cel - The Cryptographic Event Log containing events + * to witness. * @returns {Promise} An array of proof objects, one from each witness. * * @example * const proofs = await witness({cel: myCel}); - * // Returns array of proofs from red, green, and blue witnesses */ -export async function witness({cel, options}) { +export async function witness({cel}) { const event = cel.log[cel.log.length - 1]; // canonicalize and SHA2-256 hash the event to produce the digestMultibase @@ -81,7 +69,8 @@ export async function witness({cel, options}) { const witnessUrls = config.witnesses; if(!Array.isArray(witnessUrls) || witnessUrls.length === 0) { - throw new Error('No witnesses configured. Add a "witnesses" array to config.yaml.'); + throw new Error( + 'No witnesses configured. Add a "witnesses" array to config.yaml.'); } const proofs = await Promise.all(witnessUrls.map( @@ -97,17 +86,17 @@ export async function witness({cel, options}) { } async function _calculatePreviousEventHash({cel}) { - // calculate the hash of the previous event to create a verifiable chain + // calculate the hash of the previous event to create a verifiable chain let previousEventHash = undefined; if(cel.log.length > 0) { - const lastEvent = cel.log[cel.log.length-1].event; + const lastEvent = cel.log[cel.log.length - 1].event; const utf8Encoder = new TextEncoder(); // canonicalize the event to ensure deterministic hashing const canonicalizedDidDocument = canonicalize(lastEvent); // create a SHA3-256 hasher with multiformats encoding const sha3256Hasher = mfHasher.from({ name: 'sha3-256', - code: 0x16, // Multihash code for SHA3-256 + code: 0x16, // Multihash code for SHA3-256 encode: input => sha3_256(input), }); // compute the hash and encode it in base58btc @@ -124,13 +113,11 @@ async function _calculatePreviousEventHash({cel}) { * events. The update event includes a hash of the previous event to ensure log * integrity. * - * @param {Object} options - Configuration options. - * @param {Object} options.cel - The Certificate Event Log to add the event to. - * @param {Object} options.event - The data for the update operation (typically + * @param {object} options - Configuration options. + * @param {object} options.cel - The Certificate Event Log to add the event to. + * @param {object} options.event - The data for the update operation (typically * an updated DID document). - * @param {Object} [options.options] - Optional configuration (currently - * unused). - * @returns {Promise} The updated CEL with the new event appended. + * @returns {Promise} The updated CEL with the new event appended. * * @example * const updatedCel = await addEvent({ @@ -138,7 +125,7 @@ async function _calculatePreviousEventHash({cel}) { * data: modifiedDidDocument * }); */ -export async function addEvent({cel, event, options}) { +export async function addEvent({cel, event}) { // append the new update event to the log, linked to the previous event event.previousEventHash = await _calculatePreviousEventHash({cel}); cel.log.push({event}); diff --git a/lib/config.js b/lib/config.js index ed5ff5e..56f403a 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,5 +1,5 @@ /** - * @fileoverview Configuration loader. + * @file Configuration loader. * Reads config.yaml from ~/.config/didcel/. */ diff --git a/lib/didcel.js b/lib/didcel.js index ef2256b..a12fc93 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -1,18 +1,18 @@ /** - * @fileoverview DID CEL (Cryptographic Event Log) DID Document management. + * @file DID CEL (Cryptographic Event Log) DID Document management. * This module provides functions for creating, updating, and managing DID * documents using the did:cel method with ECDSA Multikey and Data Integrity * Proofs. */ import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; -import {JsonLdDocumentLoader} from 'jsonld-document-loader'; +import * as mfHasher from 'multiformats/hashes/hasher'; import {base58btc} from 'multiformats/bases/base58'; import canonicalize from 'canonicalize'; import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; +import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; import jsigs from 'jsonld-signatures'; -import * as mfHasher from 'multiformats/hashes/hasher'; +import {JsonLdDocumentLoader} from 'jsonld-document-loader'; import {sha3_256} from '@noble/hashes/sha3.js'; const {purposes: {AssertionProofPurpose}} = jsigs; @@ -24,32 +24,29 @@ const jdl = new JsonLdDocumentLoader(); * proof. The DID identifier is derived from the SHA3-256 hash of the * canonicalized DID document. * - * @param {Object} options - Configuration options. - * @param {Object} [options.options] - Optional configuration. - * @param {string} [options.options.curve='P-256'] - The elliptic curve to use - * for key generation (e.g., 'P-256', 'P-384'). - * @returns {Promise} An object containing: + * @param {object} options - Configuration options. + * @param {string} [options.curve='P-256'] - The elliptic curve to use for + * key generation (e.g., 'P-256', 'P-384'). + * @returns {Promise} An object containing: * - keyPair: The generated ECDSA Multikey key pair * - recoveryKeyPair: The generated ECDSA Multikey recovery key pair - * - didDocument: The signed DID document with a did:cel identifier + * - didDocument: The signed DID document with a did:cel identifier. * * @example * const {keyPair, recoveryKeyPair, didDocument} = * await create({options: {curve: 'P-256'}}); * console.log(didDocument.id); // did:cel:z... */ -export async function create({options}) { +export async function create({curve = 'P-256'} = {}) { // generate a new ECDSA key pair using the specified curve (defaults to P-256) - const keyPair = - await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); + const keyPair = await EcdsaMultikey.generate({curve}); const publicKey = await keyPair.export({publicKey: true, includeContext: false}); // set the key id to the public key multibase encoding publicKey.id = '#' + publicKey.publicKeyMultibase; - // generate a new recovery key pair using the specified curve (defaults to P-256) - const recoveryKeyPair = - await EcdsaMultikey.generate({curve: options?.curve || 'P-256'}); + // generate a new recovery key pair using the specified curve + const recoveryKeyPair = await EcdsaMultikey.generate({curve}); const recoveryPublicKey = await recoveryKeyPair.export({publicKey: true, includeContext: false}); // set the key id to the public key multibase encoding @@ -60,12 +57,12 @@ export async function create({options}) { jdl.addStatic(recoveryPublicKey.id, recoveryPublicKey); // create initial DID document structure with assertion method - let didDocument = { + const didDocument = { '@context': [ 'https://www.w3.org/ns/did/v1.1', 'https://w3id.org/didcel/v1' ], - heartbeatFrequency: options?.heartbeatFrequency || 'P3M', + heartbeatFrequency: 'P3M', assertionMethod: [publicKey], recovery: [recoveryPublicKey], service: { @@ -76,14 +73,14 @@ export async function create({options}) { 'https://celstorageiu7vnjjbwkhpilnemxj7ase3mhbshg7kx5tfydaniltxjqhy.onion/', ] } - } + }; // generate the did:cel identifier by hashing the canonicalized DID document const utf8Encoder = new TextEncoder(); const canonicalizedDidDocument = canonicalize(didDocument); const sha3256Hasher = mfHasher.from({ name: 'sha3-256', - code: 0x16, // Multihash code for SHA3-256 + code: 0x16, // Multihash code for SHA3-256 encode: input => sha3_256(input), }); const mfHash = await sha3256Hasher.digest( @@ -102,7 +99,7 @@ export async function create({options}) { }); // sign the operation - let documentLoader = jdl.build(); + const documentLoader = jdl.build(); const event = { operation: { type: 'create', @@ -129,16 +126,16 @@ export async function create({options}) { * new key pair and adds it to the specified verification relationship. The * proof is removed and must be regenerated after this operation. * - * @param {Object} options - Configuration options. - * @param {Object} options.didDocument - The DID document to modify. + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to modify. * @param {string} options.verificationRelationship - The verification * relationship to add the key to (e.g., 'assertionMethod', 'authentication', * 'keyAgreement'). * @param {string} [options.curve='P-256'] - The elliptic curve to use for key * generation (e.g., 'P-256', 'P-384'). - * @returns {Promise} An object containing: + * @returns {Promise} An object containing: * - keyPair: The newly generated ECDSA Multikey key pair - * - didDocument: The updated DID document (without proof) + * - didDocument: The updated DID document (without proof). * * @example * const {keyPair, didDocument} = await addVm({ @@ -176,12 +173,13 @@ export async function addVm({didDocument, verificationRelationship, curve}) { /** * Creates a signed event given event data and an assertion method keypair. * - * @param {Object} options - Configuration options. - * @param {Object} options.data - The data to place into the event. - * @param {Object} options.assertionMethod - The key pair to use for signing. + * @param {object} options - Configuration options. + * @param {string} options.type - The event type (e.g., 'create', 'update'). + * @param {object} options.data - The data to place into the event. + * @param {object} options.assertionMethod - The key pair to use for signing. * Must have a signer() method and publicKeyMultibase property. - * @returns {Promise} An object containing: - * - didDocument: The DID document with the new proof attached + * @returns {Promise} An object containing: + * - didDocument: The DID document with the new proof attached. * * @example * const {didDocument} = await createEvent({ @@ -192,13 +190,13 @@ export async function addVm({didDocument, verificationRelationship, curve}) { */ export async function createEvent({type, data, assertionMethod}) { // create a new cryptographic proof using ecdsa-jcs-2019 - let documentLoader = jdl.build(); + const documentLoader = jdl.build(); const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite }); const event = { - operation: { type, data } + operation: {type, data} }; const signedEvent = await jsigs.sign(event, { suite, diff --git a/lib/secrets.js b/lib/secrets.js index f04616f..3178677 100644 --- a/lib/secrets.js +++ b/lib/secrets.js @@ -1,15 +1,15 @@ /** - * @fileoverview Encrypted private key storage. + * @file Encrypted private key storage. * Saves and loads private keys to ~/.config/didcel/secrets/.yaml. * Each secretKeyMultibase is encrypted with AES-256-GCM, with the encryption * key derived from a user-supplied password via scrypt. */ +import {mkdirSync, writeFileSync} from 'node:fs'; +import {config} from './config.js'; import crypto from 'node:crypto'; -import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; import yaml from 'js-yaml'; -import {config} from './config.js'; // scrypt parameters: N=2^14, r=8, p=1 const SCRYPT_N = 16384; @@ -20,9 +20,10 @@ const KEY_LEN = 32; /** * Encrypts and saves all secret key pairs to the secrets file for a DID. * - * @param {Object} options - * @param {string} options.didIdentifier - Method-specific ID (part after did:cel:). - * @param {Object} options.secretKeys - Session secretKeys object keyed by + * @param {object} options - Configuration options. + * @param {string} options.didIdentifier - Method-specific ID (part after + * did:cel:). + * @param {object} options.secretKeys - Session secretKeys object keyed by * verification relationship, each an array of keyPair objects. * @param {string} options.password - Password used to encrypt each secret key. */ @@ -46,7 +47,6 @@ export async function saveSecrets({didIdentifier, secretKeys, password}) { writeFileSync(_secretsPath(didIdentifier), yaml.dump({keys})); } - function _secretsPath(didIdentifier) { return join(config.secrets, `${didIdentifier}.yaml`); } @@ -64,20 +64,10 @@ async function _encrypt(plaintext, password) { const iv = crypto.randomBytes(12); const key = await _deriveKey(password, salt); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const enc = Buffer.concat( + [cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); // pack: salt(32) || iv(12) || tag(16) || ciphertext, encode as base64 return Buffer.concat([salt, iv, tag, enc]).toString('base64'); } -async function _decrypt(encoded, password) { - const buf = Buffer.from(encoded, 'base64'); - const salt = buf.subarray(0, 32); - const iv = buf.subarray(32, 44); - const tag = buf.subarray(44, 60); - const ciphertext = buf.subarray(60); - const key = await _deriveKey(password, salt); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); -} diff --git a/lib/utils.js b/lib/utils.js index 68a3833..f8e858c 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,7 +2,7 @@ * Creates a JSON-LD pretty printer function that orders object keys according * to a preferred order, with remaining keys sorted alphabetically. * - * @param {Object} options - Configuration options. + * @param {object} options - Configuration options. * @param {Array} options.preferOrder - Array of keys to appear first * in the specified order (e.g., ['@context', 'id', 'type']). * @returns {Function} A replacer function for use with JSON.stringify() that @@ -19,17 +19,17 @@ export function createJsonldPrettyPrinter({preferOrder}) { let result = value; // only process objects (not arrays or primitives) if(value instanceof Object && !(value instanceof Array)) { - let sortedKeys = Object.keys(value).sort(); - let prettyKeys = []; + const sortedKeys = Object.keys(value).sort(); + const prettyKeys = []; // first, add keys that are in the preferred order - for(let pkey of preferOrder) { + for(const pkey of preferOrder) { if(value[pkey] !== undefined) { prettyKeys.push(pkey); } } // then, add remaining keys in alphabetical order - for(let skey of sortedKeys) { + for(const skey of sortedKeys) { if(!preferOrder.includes(skey)) { prettyKeys.push(skey); } @@ -43,7 +43,7 @@ export function createJsonldPrettyPrinter({preferOrder}) { } return result; - } + }; } /** @@ -51,12 +51,12 @@ export function createJsonldPrettyPrinter({preferOrder}) { * property. Searches through all array properties in the DID document to find * an object whose id ends with the specified suffix. * - * @param {Object} options - Configuration options. - * @param {Object} options.didDocument - The DID document to search. + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to search. * @param {string} options.suffix - The suffix to match against object ids * (e.g., '#key-1' or 'zDnaeRQ...'). - * @returns {Object|undefined} The first object found with a matching id suffix, - * or undefined if no match is found. + * @returns {object | undefined} The first object found with a matching id + * suffix, or undefined if no match is found. * * @example * const vm = getObjectByIdSuffix({ @@ -67,13 +67,13 @@ export function createJsonldPrettyPrinter({preferOrder}) { export function getObjectByIdSuffix({didDocument, suffix}) { let rval = undefined; // iterate through all properties in the DID document - for(let property of Object.keys(didDocument)) { + for(const property of Object.keys(didDocument)) { // only process array properties (e.g., assertionMethod, authentication) if(!Array.isArray(didDocument[property])) { continue; } // search through each entry in the array - for(let entry of didDocument[property]) { + for(const entry of didDocument[property]) { // skip non-object entries if(typeof entry !== 'object') { continue; @@ -97,12 +97,12 @@ export function getObjectByIdSuffix({didDocument, suffix}) { * removes the first object whose id ends with the specified suffix. This * function mutates the didDocument parameter. * - * @param {Object} options - Configuration options. - * @param {Object} options.didDocument - The DID document to modify (mutated in + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to modify (mutated in * place). * @param {string} options.suffix - The suffix to match against object ids * (e.g., '#key-1' or a multibase encoded key). - * @returns {Object|undefined} The deleted object if found, or undefined if no + * @returns {object | undefined} The deleted object if found, or undefined if no * match was found. * * @example @@ -114,14 +114,14 @@ export function getObjectByIdSuffix({didDocument, suffix}) { export function deleteObjectByIdSuffix({didDocument, suffix}) { let rval = undefined; // iterate through all properties in the DID document - for(let property of Object.keys(didDocument)) { + for(const property of Object.keys(didDocument)) { // only process array properties (e.g., assertionMethod, authentication) if(!Array.isArray(didDocument[property])) { continue; } // filter out the entry with matching id suffix - didDocument[property] = didDocument[property].filter((entry) => { + didDocument[property] = didDocument[property].filter(entry => { // keep non-object entries if(typeof entry !== 'object') { return true; @@ -137,7 +137,7 @@ export function deleteObjectByIdSuffix({didDocument, suffix}) { rval = entry; return false; } - }) + }); } return rval; diff --git a/lib/witness.js b/lib/witness.js index 15aa13e..9c2c891 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -1,5 +1,5 @@ /** - * @fileoverview Witness service HTTP client. + * @file Witness service HTTP client. * Calls a real blind witness service to obtain a DataIntegrityProof attesting * to a cryptographic event hash. */ @@ -13,11 +13,11 @@ const httpsAgent = new https.Agent({rejectUnauthorized: false}); /** * Sends a digestMultibase to a witness service and returns the proof. * - * @param {Object} options - * @param {string} options.digestMultibase - base58btc-encoded SHA2-256 + * @param {object} options - Configuration options. + * @param {string} options.digestMultibase - Base58btc-encoded SHA2-256 * multihash of the event to attest (z prefix). * @param {string} options.witnessUrl - Full URL of the witness endpoint. - * @returns {Promise} DataIntegrityProof returned by the witness. + * @returns {Promise} DataIntegrityProof returned by the witness. */ export async function witness({digestMultibase, witnessUrl}) { const response = await fetch(witnessUrl, { From d9f84a212fe0801ff305ff0418659e0fbbd94ce2 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 20:15:51 -0400 Subject: [PATCH 29/44] Fix eslint errors in didcel CLI. --- didcel | 82 ++++++++++++++++++++++++++++++++-------------------------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/didcel b/didcel index 1c8eaf2..df9f722 100755 --- a/didcel +++ b/didcel @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * @fileoverview DID CEL Command Line Interface (CLI) + * @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 @@ -10,7 +10,7 @@ * Usage: * ./didcel # Start interactive REPL * ./didcel -c "create" "add ..." # Execute commands and continue in REPL - * ./didcel -v # Verbose output mode + * ./didcel -v # Verbose output mode. * * Available commands: * create - Create a new DID document @@ -21,19 +21,19 @@ * update - Update the cryptographic event log * witness - Generate witness proofs * save - Save CEL to file - * quit - Exit the REPL + * quit - Exit the REPL. */ -import { Argument, Command, CommanderError } from 'commander'; +import {Argument, Command, CommanderError} from 'commander'; +import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, + getObjectByIdSuffix} from './lib/utils.js'; +import {mkdirSync, writeFileSync} from 'fs'; import cel from './lib/cel.js'; import {config} from './lib/config.js'; import didcel from './lib/didcel.js'; -import promptSync from 'prompt-sync'; -import {mkdirSync, writeFileSync} from 'fs'; import {join} from 'node:path'; +import promptSync from 'prompt-sync'; import {saveSecrets} from './lib/secrets.js'; -import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, - getObjectByIdSuffix} from './lib/utils.js'; // create the CLI and parse command-line options const program = new Command(); @@ -47,20 +47,24 @@ const options = program.opts(); // create the JSON-LD pretty printer for formatted output // orders keys with @context, id, type first, then alphabetically const jsonldPretty = createJsonldPrettyPrinter({ - preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', + preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', 'heartbeatFrequency', 'previousEventHash'] }); // common verification relationship and service properties in DID documents -const COMMON_PROPERTIES = ['authentication', 'assertionMethod', 'capabilityDelegation', 'capabilityInvocation', 'keyAgreement', 'service']; +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 {object} options - Configuration options. * @param {Array} [options.commands] - Optional array of commands to * execute before entering interactive mode. + * @param {string} [options.password] - Password for encrypting private keys. * @returns {Promise} */ async function repl({commands, password}) { @@ -79,7 +83,7 @@ async function repl({commands, password}) { // the current DID document let didDocument; // secret keys organized by verification relationship - let secretKeys = { + const secretKeys = { authentication: [], assertionMethod: [], capabilityInvocation: [], @@ -111,7 +115,7 @@ async function repl({commands, password}) { .description('Create a new DID document') .action(async () => { // generate a new DID document with P-256 elliptic curve key - let result = await didcel.create({curve: 'P-256'}); + const result = await didcel.create({curve: 'P-256'}); didDocument = result.didDocument; // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; @@ -123,16 +127,16 @@ async function repl({commands, password}) { // command: add // adds verification methods or services to the DID document repl.command('add') - .description('Add a verification method or service to the current DID document.') - .addArgument(new Argument('', 'the name of the property to add to') - .choices(COMMON_PROPERTIES)) + .description('Add a verification method or service to the DID document.') + .addArgument(new Argument('', + 'the name of the property to add to').choices(COMMON_PROPERTIES)) .addArgument(new Argument('', 'the type of property to add') .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) .action(async (property, type) => { // TODO: Currently only ECDSA verification methods are supported if(property !== 'service' && type === 'ecdsa') { // generate a new verification method for the specified relationship - let result = await didcel.addVm( + const result = await didcel.addVm( {didDocument, verificationRelationship: property, curve: 'P-256'}); didDocument = result.didDocument; // store the secret key for this verification relationship @@ -145,8 +149,9 @@ async function repl({commands, password}) { // 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) => { + .addArgument( + new Argument('[suffix]', 'the last several characters of the identifier')) + .action(async suffix => { // always display the DID identifier console.log(didDocument.id); @@ -160,7 +165,7 @@ async function repl({commands, password}) { } // if no suffix provided, display a summary of the DID document - for(let property of Object.keys(didDocument)) { + for(const property of Object.keys(didDocument)) { let numEntries = 0; // only process array properties (verification relationships, services) if(!Array.isArray(didDocument[property])) { @@ -168,14 +173,14 @@ async function repl({commands, password}) { } let propertyListing = ` ${property}: `; // show abbreviated identifiers for each entry - for(let entry of didDocument[property]) { + for(const entry of didDocument[property]) { if(typeof entry !== 'object') { continue; } // display first 4 and last 4 characters of identifier const lastFourOfId = entry.id.slice(entry.id.length - 4, entry.id.length); - propertyListing += entry.type + + propertyListing += entry.type + entry.id.slice(0, 4) + '...' + lastFourOfId + ' '; numEntries++; } @@ -189,8 +194,9 @@ async function repl({commands, password}) { // sets an expiration timestamp on a verification method repl.command('expire') .description('Expire a verification method from the current DID document.') - .addArgument(new Argument('', 'the last several characters of the identifier to expire')) - .action(async (suffix) => { + .addArgument(new Argument('', + 'the last several characters of the identifier to expire')) + .action(async suffix => { if(suffix) { const value = getObjectByIdSuffix({didDocument, suffix}); if(value) { @@ -216,8 +222,9 @@ async function repl({commands, password}) { // 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('', 'the last several characters of the identifier to remove')) - .action(async (suffix) => { + .addArgument(new Argument('', + 'the last several characters of the identifier to remove')) + .action(async suffix => { if(suffix) { // search for and delete the object matching the ID suffix const value = deleteObjectByIdSuffix({didDocument, suffix}); @@ -237,7 +244,7 @@ async function repl({commands, password}) { // create. After running update, you should run 'witness' to get witness // attestations. repl.command('update') - .description('Update the cryptographic event log with the latest DID document') + .description('Update the cryptographic event log with the latest DID doc') .action(async () => { // step 1: Regenerate the cryptographic proof on the DID document // this signs the current state of the DID document @@ -300,25 +307,25 @@ async function repl({commands, password}) { .action(async () => { // generate witness proofs for the most recent event in the log // each witness independently validates and signs the event - const proofs = await cel.witness({cel: cryptographicEventLog}); + await cel.witness({cel: cryptographicEventLog}); console.log('witness: proofs complete'); }); // command: save - // persists the Cryptographic Event Log to a file. The CEL contains the complete - // history of all operations on the DID document, including create and update - // events, along with witness attestations. The file is saved in JSON format - // with keys ordered for readability (e.g., @context, id, type first). - // this file can later be loaded to reconstruct the DID's history and state. + // persists the Cryptographic Event Log to a file. The CEL contains the + // complete history of all operations on the DID document, including create + // and update events, along with witness attestations. The file is saved in + // JSON format with keys ordered for readability (e.g., @context, id, type + // first). This file can later be loaded to reconstruct the DID's 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) => { + .action(async filename => { const didIdentifier = didDocument.id.split(':').pop(); const celJson = JSON.stringify(cryptographicEventLog, jsonldPretty, 2); - // always write to configured logs directory using DID identifier as filename + // always write to configured logs directory using DID identifier if(config.logs) { mkdirSync(config.logs, {recursive: true}); const logsPath = join(config.logs, `${didIdentifier}.cel`); @@ -328,7 +335,8 @@ async function repl({commands, password}) { // save encrypted private keys to secrets directory await saveSecrets({didIdentifier, secretKeys, password}); - console.error(`Wrote secrets to ${join(config.secrets, `${didIdentifier}.yaml`)}`); + const secretsPath = join(config.secrets, `${didIdentifier}.yaml`); + console.error(`Wrote secrets to ${secretsPath}`); // also write CEL to explicit filename if provided if(filename) { @@ -369,7 +377,7 @@ async function repl({commands, password}) { throw err; } } - }; + } } // interactive REPL loop From 7ce631785344fea9c7f038196ec5af42d2fc9448 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Mon, 25 May 2026 20:22:05 -0400 Subject: [PATCH 30/44] Add CLI parameter to load config from specified file. --- didcel | 5 ++++- lib/config.js | 38 +++++++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/didcel b/didcel index df9f722..cdcebe6 100755 --- a/didcel +++ b/didcel @@ -25,11 +25,11 @@ */ import {Argument, Command, CommanderError} from 'commander'; +import {config, loadConfig} from './lib/config.js'; import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix} from './lib/utils.js'; import {mkdirSync, writeFileSync} from 'fs'; import cel from './lib/cel.js'; -import {config} from './lib/config.js'; import didcel from './lib/didcel.js'; import {join} from 'node:path'; import promptSync from 'prompt-sync'; @@ -39,11 +39,14 @@ import {saveSecrets} from './lib/secrets.js'; const program = new Command(); program .option('-c, --command ', 'One or more commands to execute') + .option('-g, --config ', 'Path to config.yaml') .option('-p, --password ', 'Password for encrypting private keys') .option('-v, --verbose', 'Provide verbose output') .parse(process.argv); const options = program.opts(); +loadConfig({configPath: options.config}); + // create the JSON-LD pretty printer for formatted output // orders keys with @context, id, type first, then alphabetically const jsonldPretty = createJsonldPrettyPrinter({ diff --git a/lib/config.js b/lib/config.js index 56f403a..4b8bd59 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,6 +1,7 @@ /** * @file Configuration loader. - * Reads config.yaml from ~/.config/didcel/. + * Reads config.yaml from a given path, defaulting to ~/.config/didcel/. + * Call loadConfig() before accessing config properties. */ import {existsSync, readFileSync} from 'node:fs'; @@ -8,13 +9,8 @@ import {homedir} from 'node:os'; import {join} from 'node:path'; import yaml from 'js-yaml'; -const configPath = join(homedir(), '.config', 'didcel', 'config.yaml'); - -if(!existsSync(configPath)) { - throw new Error(`Configuration file not found: ${configPath}`); -} - -const raw = yaml.load(readFileSync(configPath, 'utf8')) ?? {}; +export const DEFAULT_CONFIG_PATH = + join(homedir(), '.config', 'didcel', 'config.yaml'); // resolve leading ~/ in path values to the user's home directory function _resolvePath(value) { @@ -24,8 +20,24 @@ function _resolvePath(value) { return value; } -export const config = { - ...raw, - logs: _resolvePath(raw.logs), - secrets: _resolvePath(raw.secrets) -}; +// mutable config object populated by loadConfig() +export const config = {}; + +/** + * Loads and validates the configuration file. + * + * @param {object} [options={}] - Configuration options. + * @param {string} [options.configPath] - Path to config.yaml; defaults to + * ~/.config/didcel/config.yaml. + */ +export function loadConfig({configPath = DEFAULT_CONFIG_PATH} = {}) { + if(!existsSync(configPath)) { + throw new Error(`Configuration file not found: ${configPath}`); + } + const raw = yaml.load(readFileSync(configPath, 'utf8')) ?? {}; + Object.assign(config, { + ...raw, + logs: _resolvePath(raw.logs), + secrets: _resolvePath(raw.secrets) + }); +} From 780bc130dc4a435b1ee0d8ea3dff1c4b0d4e5a32 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Tue, 26 May 2026 09:50:56 -0400 Subject: [PATCH 31/44] Add initial set of tests. --- .mocharc.cjs | 7 +++ package.json | 2 +- tests/.eslintrc.cjs | 7 +++ tests/config.yaml | 4 ++ tests/mocha/00-setup.js | 6 +++ tests/mocha/10-create.js | 43 +++++++++++++++ tests/mocha/20-witness.js | 78 +++++++++++++++++++++++++++ tests/mocha/30-update.js | 102 +++++++++++++++++++++++++++++++++++ tests/mocha/40-heartbeat.js | 92 +++++++++++++++++++++++++++++++ tests/mocha/50-deactivate.js | 101 ++++++++++++++++++++++++++++++++++ tests/mocha/helpers.js | 92 +++++++++++++++++++++++++++++++ 11 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 .mocharc.cjs create mode 100644 tests/.eslintrc.cjs create mode 100644 tests/config.yaml create mode 100644 tests/mocha/00-setup.js create mode 100644 tests/mocha/10-create.js create mode 100644 tests/mocha/20-witness.js create mode 100644 tests/mocha/30-update.js create mode 100644 tests/mocha/40-heartbeat.js create mode 100644 tests/mocha/50-deactivate.js create mode 100644 tests/mocha/helpers.js diff --git a/.mocharc.cjs b/.mocharc.cjs new file mode 100644 index 0000000..02a813e --- /dev/null +++ b/.mocharc.cjs @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + spec: 'tests/mocha/*.js', + timeout: 120000, + reporter: 'spec' +}; diff --git a/package.json b/package.json index 72ff8f9..9d598d0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./lib/index.js", "scripts": { "lint": "eslint .", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha" }, "keywords": [], "author": { diff --git a/tests/.eslintrc.cjs b/tests/.eslintrc.cjs new file mode 100644 index 0000000..428b349 --- /dev/null +++ b/tests/.eslintrc.cjs @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + env: { + mocha: true + } +}; diff --git a/tests/config.yaml b/tests/config.yaml new file mode 100644 index 0000000..c0b1fe2 --- /dev/null +++ b/tests/config.yaml @@ -0,0 +1,4 @@ +witnesses: + - https://localhost:22443/witnesses/test/witness +logs: ./tests/tmp/logs +secrets: ./tests/tmp/secrets diff --git a/tests/mocha/00-setup.js b/tests/mocha/00-setup.js new file mode 100644 index 0000000..6b86886 --- /dev/null +++ b/tests/mocha/00-setup.js @@ -0,0 +1,6 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import {clearTmpDir} from './helpers.js'; + +before(() => clearTmpDir()); diff --git a/tests/mocha/10-create.js b/tests/mocha/10-create.js new file mode 100644 index 0000000..a9dbbc1 --- /dev/null +++ b/tests/mocha/10-create.js @@ -0,0 +1,43 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, listSecretFiles, runDidcel +} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +describe('create', function() { + this.timeout(30000); + + it('should create a new DID document and save', async () => { + const {stdout, stderr, exitCode} = await runDidcel({ + commands: ['create', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('create successful: did:cel:'); + + const celFiles = listCelFiles(); + expect(celFiles).to.have.length(1); + + const secretFiles = listSecretFiles(); + expect(secretFiles).to.have.length(1); + }); + + it('should create multiple DIDs independently', async () => { + const before = listCelFiles().length; + + const result1 = await runDidcel({commands: ['create', 'save', 'quit']}); + expect(result1.exitCode, `stderr: ${result1.stderr}`).to.equal(0); + expect(result1.stdout).to.include('create successful: did:cel:'); + + const result2 = await runDidcel({commands: ['create', 'save', 'quit']}); + expect(result2.exitCode, `stderr: ${result2.stderr}`).to.equal(0); + expect(result2.stdout).to.include('create successful: did:cel:'); + + const celFiles = listCelFiles(); + expect(celFiles).to.have.length(before + 2); + }); +}); diff --git a/tests/mocha/20-witness.js b/tests/mocha/20-witness.js new file mode 100644 index 0000000..87088a8 --- /dev/null +++ b/tests/mocha/20-witness.js @@ -0,0 +1,78 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, listSecretFiles, runDidcel, TMP_DIR +} from './helpers.js'; +import chai from 'chai'; +import {join} from 'node:path'; +import {readFileSync} from 'node:fs'; + +const {expect} = chai; + +describe('witness', function() { + this.timeout(60000); + + it('should create, witness, and save a DID', async () => { + const before = listCelFiles().length; + const secretsBefore = listSecretFiles().length; + + const {stdout, stderr, exitCode} = await runDidcel({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('create successful: did:cel:'); + expect(stdout).to.include('witness: proofs complete'); + + expect(listCelFiles()).to.have.length(before + 1); + expect(listSecretFiles()).to.have.length(secretsBefore + 1); + }); + + it('should produce a CEL with a witness proof on the create event', + async () => { + const before = listCelFiles(); + + const {stderr, exitCode} = await runDidcel({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + expect(celContent).to.have.property('log'); + expect(celContent.log).to.have.length(1); + + const createEntry = celContent.log[0]; + expect(createEntry).to.have.property('proof'); + expect(createEntry.proof).to.be.an('array'); + expect(createEntry.proof.length).to.be.at.least(1); + + const proof = createEntry.proof[0]; + expect(proof).to.have.property('type', 'DataIntegrityProof'); + expect(proof).to.have.property('verificationMethod'); + }); + + it('should have witness proof with a real verificationMethod', async () => { + const before = listCelFiles(); + + const {exitCode, stderr} = await runDidcel({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const proof = celContent.log[0].proof[0]; + // verificationMethod should reference a real did:key (not a placeholder) + expect(proof.verificationMethod).to.match(/^did:key:/); + }); +}); diff --git a/tests/mocha/30-update.js b/tests/mocha/30-update.js new file mode 100644 index 0000000..d0b864f --- /dev/null +++ b/tests/mocha/30-update.js @@ -0,0 +1,102 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, runDidcel, TMP_DIR +} from './helpers.js'; +import chai from 'chai'; +import {join} from 'node:path'; +import {readFileSync} from 'node:fs'; + +const {expect} = chai; + +const UPDATE_COMMANDS = [ + 'create', 'witness', + 'add authentication ecdsa', + 'update', 'witness', + 'save', 'quit' +]; + +async function runUpdate() { + const before = listCelFiles(); + const result = await runDidcel({commands: UPDATE_COMMANDS}); + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + return {...result, newFile}; +} + +describe('update', function() { + this.timeout(120000); + + it('should create, witness, add auth key, update, witness, and save', + async () => { + const before = listCelFiles().length; + + const {stdout, stderr, exitCode} = await runDidcel({ + commands: UPDATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('create successful: did:cel:'); + expect(stdout).to.include( + 'add: new verification method for authentication'); + expect(listCelFiles()).to.have.length(before + 1); + }); + + it('should produce a CEL with 2 events (create + update)', async () => { + const {exitCode, stderr, newFile} = await runUpdate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + expect(celContent).to.have.property('log'); + expect(celContent.log).to.have.length(2); + }); + + it('should hashlink events via previousEventHash', async () => { + const {exitCode, stderr, newFile} = await runUpdate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const updateEntry = celContent.log[1]; + expect(updateEntry.event).to.have.property('previousEventHash'); + expect(updateEntry.event.previousEventHash).to.be.a('string'); + expect(updateEntry.event.previousEventHash).to.match(/^z/); + }); + + it('should include the new authentication key in the update event', + async () => { + const {exitCode, stderr, newFile} = await runUpdate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const updateEntry = celContent.log[1]; + const didDoc = updateEntry.event.operation.data; + expect(didDoc).to.have.property('authentication'); + expect(didDoc.authentication).to.be.an('array'); + expect(didDoc.authentication.length).to.be.at.least(1); + }); + + it('should witness proofs on both events', async () => { + const {exitCode, stderr, newFile} = await runUpdate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + for(const entry of celContent.log) { + expect(entry).to.have.property('proof'); + expect(entry.proof).to.be.an('array'); + expect(entry.proof.length).to.be.at.least(1); + } + }); +}); diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js new file mode 100644 index 0000000..cab7349 --- /dev/null +++ b/tests/mocha/40-heartbeat.js @@ -0,0 +1,92 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, runDidcel, TMP_DIR +} from './helpers.js'; +import chai from 'chai'; +import {join} from 'node:path'; +import {readFileSync} from 'node:fs'; + +const {expect} = chai; + +const HB_COMMANDS = [ + 'create', 'witness', 'heartbeat', 'witness', 'save', 'quit' +]; + +async function runHeartbeat() { + const before = listCelFiles(); + const result = await runDidcel({commands: HB_COMMANDS}); + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + return {...result, newFile}; +} + +describe('heartbeat', function() { + this.timeout(120000); + + it('should create, witness, heartbeat, witness, and save', async () => { + const before = listCelFiles().length; + + const {stdout, stderr, exitCode} = await runDidcel({commands: HB_COMMANDS}); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('create successful: did:cel:'); + expect(stdout).to.include('heartbeat: generated'); + expect(listCelFiles()).to.have.length(before + 1); + }); + + it('should produce a CEL with 2 events (create + heartbeat)', async () => { + const {exitCode, stderr, newFile} = await runHeartbeat(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + expect(celContent).to.have.property('log'); + expect(celContent.log).to.have.length(2); + }); + + it('should have heartbeat event with correct operation type', async () => { + const {exitCode, stderr, newFile} = await runHeartbeat(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const heartbeatEntry = celContent.log[1]; + expect(heartbeatEntry.event.operation).to.have.property( + 'type', 'heartbeat'); + expect(heartbeatEntry.event.operation.data).to.be.undefined; + }); + + it('should hash-link heartbeat event to the witnessed create event', + async () => { + const {exitCode, stderr, newFile} = await runHeartbeat(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const heartbeatEntry = celContent.log[1]; + expect(heartbeatEntry.event).to.have.property('previousEventHash'); + expect(heartbeatEntry.event.previousEventHash).to.match(/^z/); + }); + + it('should witness the heartbeat event', async () => { + const {exitCode, stderr, newFile} = await runHeartbeat(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const heartbeatEntry = celContent.log[1]; + expect(heartbeatEntry).to.have.property('proof'); + expect(heartbeatEntry.proof).to.be.an('array'); + expect(heartbeatEntry.proof.length).to.be.at.least(1); + }); +}); diff --git a/tests/mocha/50-deactivate.js b/tests/mocha/50-deactivate.js new file mode 100644 index 0000000..e1c6a7d --- /dev/null +++ b/tests/mocha/50-deactivate.js @@ -0,0 +1,101 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, runDidcel, TMP_DIR +} from './helpers.js'; +import chai from 'chai'; +import {join} from 'node:path'; +import {readFileSync} from 'node:fs'; + +const {expect} = chai; + +const DEACTIVATE_COMMANDS = [ + 'create', 'witness', + 'add authentication ecdsa', 'update', 'witness', + 'deactivate', 'witness', + 'save', 'quit' +]; + +async function runDeactivate() { + const before = listCelFiles(); + const result = await runDidcel({commands: DEACTIVATE_COMMANDS}); + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + return {...result, newFile}; +} + +describe('deactivate', function() { + this.timeout(120000); + + it('should create, witness, add key, update, witness, deactivate, witness, ' + + 'and save', async () => { + const before = listCelFiles().length; + + const {stdout, stderr, exitCode} = await runDidcel({ + commands: DEACTIVATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('create successful: did:cel:'); + expect(stdout).to.include('deactivation: complete'); + expect(listCelFiles()).to.have.length(before + 1); + }); + + it('should produce a CEL with 3 events (create + update + deactivate)', + async () => { + const {exitCode, stderr, newFile} = await runDeactivate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + expect(celContent).to.have.property('log'); + expect(celContent.log).to.have.length(3); + }); + + it('should have deactivate event with correct operation type', async () => { + const {exitCode, stderr, newFile} = await runDeactivate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + const deactivateEntry = celContent.log[2]; + expect(deactivateEntry.event.operation).to.have.property( + 'type', 'deactivate'); + expect(deactivateEntry.event.operation.data).to.be.undefined; + }); + + it('should hash-link all events in the chain', async () => { + const {exitCode, stderr, newFile} = await runDeactivate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + for(let i = 1; i < celContent.log.length; i++) { + const entry = celContent.log[i]; + expect(entry.event).to.have.property('previousEventHash'); + expect(entry.event.previousEventHash).to.match(/^z/); + } + }); + + it('should have witness proofs on all events', async () => { + const {exitCode, stderr, newFile} = await runDeactivate(); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = JSON.parse( + readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + + for(const entry of celContent.log) { + expect(entry).to.have.property('proof'); + expect(entry.proof).to.be.an('array'); + expect(entry.proof.length).to.be.at.least(1); + } + }); +}); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js new file mode 100644 index 0000000..8e665c8 --- /dev/null +++ b/tests/mocha/helpers.js @@ -0,0 +1,92 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import {existsSync, mkdirSync, readdirSync, rmSync} from 'node:fs'; +import {execFile} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {join} from 'node:path'; +import path from 'node:path'; +import {promisify} from 'node:util'; + +const execFileAsync = promisify(execFile); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const TESTS_DIR = path.resolve(__dirname, '..'); +export const ROOT_DIR = path.resolve(TESTS_DIR, '..'); +export const TMP_DIR = join(TESTS_DIR, 'tmp'); +export const CONFIG_PATH = join(TESTS_DIR, 'config.yaml'); +export const DIDCEL_PATH = join(ROOT_DIR, 'didcel'); + +export const TEST_PASSWORD = 'test-password-for-automated-tests'; + +export function clearTmpDir() { + if(existsSync(TMP_DIR)) { + for(const entry of readdirSync(TMP_DIR)) { + rmSync(join(TMP_DIR, entry), {recursive: true, force: true}); + } + } + mkdirSync(join(TMP_DIR, 'logs'), {recursive: true}); + mkdirSync(join(TMP_DIR, 'secrets'), {recursive: true}); +} + +/** + * Runs the didcel CLI with the given commands and returns stdout/stderr. + * + * @param {object} options - Options. + * @param {Array} options.commands - Commands to pass via -c flags. + * @param {string} [options.password] - Encryption password (-p flag). + * @param {number} [options.timeout] - Timeout in ms (default 120000). + * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} - + * the output of the command. + */ +export async function runDidcel({ + commands, + password = TEST_PASSWORD, + timeout = 120000 +} = {}) { + const args = ['-g', CONFIG_PATH, '-p', password]; + for(const cmd of commands) { + args.push('-c', cmd); + } + + try { + const {stdout, stderr} = await execFileAsync( + DIDCEL_PATH, args, + {cwd: ROOT_DIR, timeout} + ); + return {stdout, stderr, exitCode: 0}; + } catch(err) { + return { + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + exitCode: err.code ?? 1, + error: err + }; + } +} + +/** + * Lists .cel files in the test tmp/logs directory. + * + * @returns {Array} Array of filenames. + */ +export function listCelFiles() { + const logsDir = join(TMP_DIR, 'logs'); + if(!existsSync(logsDir)) { + return []; + } + return readdirSync(logsDir).filter(f => f.endsWith('.cel')); +} + +/** + * Lists .yaml files in the test tmp/secrets directory. + * + * @returns {Array} Array of filenames. + */ +export function listSecretFiles() { + const secretsDir = join(TMP_DIR, 'secrets'); + if(!existsSync(secretsDir)) { + return []; + } + return readdirSync(secretsDir).filter(f => f.endsWith('.yaml')); +} From f61fa7fdcc317e0b08586caf43596ca95532fe2f Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Fri, 29 May 2026 17:50:36 -0400 Subject: [PATCH 32/44] Add ability to load and verify cryptographic event log. --- didcel | 27 +++++- lib/cel.js | 219 +++++++++++++++++++++++++++++++++++++++++++++++-- lib/didcel.js | 31 +++---- lib/secrets.js | 58 ++++++++++++- 4 files changed, 309 insertions(+), 26 deletions(-) diff --git a/didcel b/didcel index cdcebe6..abd9b71 100755 --- a/didcel +++ b/didcel @@ -33,7 +33,7 @@ import cel from './lib/cel.js'; import didcel from './lib/didcel.js'; import {join} from 'node:path'; import promptSync from 'prompt-sync'; -import {saveSecrets} from './lib/secrets.js'; +import {loadSecrets, saveSecrets} from './lib/secrets.js'; // create the CLI and parse command-line options const program = new Command(); @@ -107,9 +107,28 @@ async function repl({commands, password}) { }); repl.command('load') - .description('Load a DID from a cryptographic event log.') - .action(() => { - console.error('load not implemented'); + .description('Load and validate a DID from a cryptographic event log.') + .argument('', 'path to the .cel file to load') + .action(async filename => { + try { + const result = await cel.load({filename}); + if(result.valid) { + didDocument = result.didDocument; + cryptographicEventLog = result.cel; + const didIdentifier = didDocument.id.split(':').pop(); + const loaded = await loadSecrets({didIdentifier, password}); + Object.assign(secretKeys, loaded); + 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: create diff --git a/lib/cel.js b/lib/cel.js index 2ae3d6e..025af92 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -5,11 +5,15 @@ * chain of events for DID document operations. */ +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; import * as mfHasher from 'multiformats/hashes/hasher'; import * as witnessService from './witness.js'; import {base58btc} from 'multiformats/bases/base58'; +import {decode as base58Decode} from 'base58-universal'; import canonicalize from 'canonicalize'; import {config} from './config.js'; +import crypto from 'node:crypto'; +import {readFileSync} from 'node:fs'; import {sha256} from '@noble/hashes/sha2.js'; import {sha3_256} from '@noble/hashes/sha3.js'; @@ -77,10 +81,6 @@ export async function witness({cel}) { witnessUrl => witnessService.witness({digestMultibase, witnessUrl}))); event.proof = proofs; - delete event['@context']; - for(const proof of proofs) { - delete proof['@context']; - } return event.proof; } @@ -133,4 +133,213 @@ export async function addEvent({cel, event}) { return cel; } -export default {create, addEvent, witness}; +/** + * Loads and fully validates a Cryptographic Event Log from a file. Checks: + * - Hash chain integrity (previousEventHash on each non-create entry) + * - Operation proof signatures (ecdsa-jcs-2019 via manual JCS verification) + * - Witness proof signatures (blind-witness manual JCS verification) + * - Timestamp deviation between operation proof and witness proofs (≤ 5 min). + * + * @param {object} options - Configuration options. + * @param {string} options.filename - Path to the .cel file to load. + * @returns {Promise} An object with: + * - cel: The parsed CEL object. + * - errors: Array of error strings (empty if valid). + * - valid: Boolean, true if no errors. + * - didDocument: The most recent DID document state (or null). + */ +export async function load({filename}) { + const cel = JSON.parse(readFileSync(filename, 'utf8')); + const errors = []; + let currentDidDocument = null; + + for(let i = 0; i < cel.log.length; i++) { + const logEntry = cel.log[i]; + const event = logEntry.event; + const opProof = event.proof; + const witnessProofs = logEntry.proof ?? []; + + // 1. Verify previousEventHash for all entries after the first + if(i > 0) { + const computed = await _calculatePreviousEventHash( + {cel: {log: cel.log.slice(0, i)}}); + if(computed !== event.previousEventHash) { + errors.push( + `entry ${i}: previousEventHash mismatch ` + + `(expected ${computed}, got ${event.previousEventHash})`); + } + } + + // Track the current DID document for key lookup on stateless events + if(event.operation?.data) { + currentDidDocument = event.operation.data; + } + + // 2. Verify the operation proof + if(opProof) { + try { + const verified = await _verifyOperationProof( + {event, opProof, currentDidDocument}); + if(!verified) { + errors.push(`entry ${i}: operation proof invalid`); + } + } catch(e) { + errors.push(`entry ${i}: operation proof error: ${e.message}`); + } + } + + // 3. Verify each witness proof and check timestamp deviation + const opTime = opProof?.created ? + new Date(opProof.created).getTime() : null; + for(let j = 0; j < witnessProofs.length; j++) { + const witnessProof = witnessProofs[j]; + + try { + const verified = await _verifyWitnessProof({logEntry, witnessProof}); + if(!verified) { + errors.push(`entry ${i} witness ${j}: invalid signature`); + } + } catch(e) { + errors.push(`entry ${i} witness ${j}: error: ${e.message}`); + } + + // 4. Check timestamp deviation ≤ 5 minutes + if(opTime !== null && witnessProof.created) { + const wTime = new Date(witnessProof.created).getTime(); + const diffMinutes = Math.abs(opTime - wTime) / 60000; + if(diffMinutes > 5) { + errors.push( + `entry ${i} witness ${j}: timestamp deviation ` + + `${diffMinutes.toFixed(1)}min exceeds 5min limit`); + } + } + } + } + + return { + cel, errors, valid: errors.length === 0, didDocument: currentDidDocument + }; +} + +/** + * Verifies an operation proof using the ecdsa-jcs-2019 manual JCS approach. + * VerifyData = SHA256(JCS(proofOptions_without_proofValue)) || + * SHA256(JCS(event_without_proof)). + * + * @param {object} options - Options. + * @param {object} options.event - The event object. + * @param {object} options.opProof - The operation proof. + * @param {object} options.currentDidDocument - The current DID document state. + * @returns {Promise} True if the proof is valid. + */ +async function _verifyOperationProof({event, opProof, currentDidDocument}) { + // find the assertionMethod key matching the verificationMethod in the proof + const vmRef = opProof.verificationMethod; + const assertionKey = _findAssertionKey( + {vmRef, didDocument: currentDidDocument}); + if(!assertionKey) { + throw new Error(`verification method not found in DID document: ${vmRef}`); + } + + // previousEventHash is appended after signing; exclude from doc hash + const doc = {...event}; + delete doc.proof; + delete doc.previousEventHash; + const proofOptions = {...opProof}; + delete proofOptions.proofValue; + + const c14nDoc = canonicalize(doc); + const c14nProof = canonicalize(proofOptions); + const proofHash = new Uint8Array( + crypto.createHash('sha256').update(c14nProof).digest()); + const docHash = new Uint8Array( + crypto.createHash('sha256').update(c14nDoc).digest()); + + const verifyData = new Uint8Array(proofHash.length + docHash.length); + verifyData.set(proofHash, 0); + verifyData.set(docHash, proofHash.length); + + const keyPair = await EcdsaMultikey.from({ + type: 'Multikey', + id: vmRef, + controller: currentDidDocument.id, + publicKeyMultibase: assertionKey.publicKeyMultibase + }); + const verifier = keyPair.verifier(); + const sigBytes = base58Decode(opProof.proofValue.slice(1)); + return verifier.verify({data: verifyData, signature: sigBytes}); +} + +/** + * Verifies a witness proof using hmbd's blind-witness signing scheme. + * VerifyData = SHA256(JCS(proofOptions_without_proofValue)) || rawHash + * where rawHash = SHA256 bytes from digestMultibase of the log entry. + * + * @param {object} options - Options. + * @param {object} options.logEntry - The full log entry {event, proof[]}. + * @param {object} options.witnessProof - The witness proof to verify. + * @returns {Promise} True if the proof is valid. + */ +async function _verifyWitnessProof({logEntry, witnessProof}) { + const utf8Encoder = new TextEncoder(); + + // reconstruct the digestMultibase from the log entry's event + // (same as what was sent to the witness service — sans witness proofs) + const entryForDigest = {event: logEntry.event}; + const canonicalized = canonicalize(entryForDigest); + const rawHashFull = sha256(utf8Encoder.encode(canonicalized)); + + // build proofHash from the witness proof options (without proofValue) + const proofOptions = {...witnessProof}; + delete proofOptions.proofValue; + const c14nProof = canonicalize(proofOptions); + const proofHash = new Uint8Array( + crypto.createHash('sha256').update(c14nProof).digest()); + + // verifyData = SHA256(c14n(proofOptions)) || rawHash + const verifyData = new Uint8Array(proofHash.length + rawHashFull.length); + verifyData.set(proofHash, 0); + verifyData.set(rawHashFull, proofHash.length); + + // extract public key from did:key: verificationMethod + const vmId = witnessProof.verificationMethod; + const didKeyId = vmId.split('#')[0]; + const publicKeyMultibase = didKeyId.replace('did:key:', ''); + + const keyPair = await EcdsaMultikey.from({ + type: 'Multikey', + id: vmId, + controller: didKeyId, + publicKeyMultibase + }); + const verifier = keyPair.verifier(); + const sigBytes = base58Decode(witnessProof.proofValue.slice(1)); + return verifier.verify({data: verifyData, signature: sigBytes}); +} + +/** + * Finds the assertionMethod key in a DID document that matches a VM reference. + * + * @param {object} options - Options. + * @param {string} options.vmRef - The verificationMethod reference to find. + * @param {object} options.didDocument - The DID document to search. + * @returns {object|null} The matching key object, or null if not found. + */ +function _findAssertionKey({vmRef, didDocument}) { + if(!didDocument?.assertionMethod) { + return null; + } + for(const key of didDocument.assertionMethod) { + if(typeof key !== 'object') { + continue; + } + // match by full id or by fragment suffix + const fullId = didDocument.id + key.id; + if(fullId === vmRef || key.id === vmRef) { + return key; + } + } + return null; +} + +export default {addEvent, create, load, witness}; diff --git a/lib/didcel.js b/lib/didcel.js index a12fc93..239b1b9 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -92,6 +92,16 @@ export async function create({curve = 'P-256'} = {}) { publicKey.controller = controller; recoveryPublicKey.controller = controller; + // set key id and controller so jsigs uses the correct verificationMethod + keyPair.id = controller + publicKey.id; + keyPair.controller = controller; + + // register the full VM id for document loader resolution during verification + jdl.addStatic(keyPair.id, { + ...publicKey, id: keyPair.id, + '@context': 'https://w3id.org/security/multikey/v1' + }); + // create a cryptographic proof using ECDSA-JCS-2019 const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); const suite = new DataIntegrityProof({ @@ -111,12 +121,6 @@ export async function create({curve = 'P-256'} = {}) { purpose: new AssertionProofPurpose(), documentLoader }); - // delete the @context in the proof as it's unnecessary - delete signedEvent['@context']; - delete signedEvent.proof['@context']; - - // TODO: Determine if there is a better way to set the proof VM - signedEvent.proof.verificationMethod = controller + publicKey.id; return {keyPair, recoveryKeyPair, event: signedEvent, didDocument}; } @@ -164,8 +168,13 @@ export async function addVm({didDocument, verificationRelationship, curve}) { // remove old proof (must be regenerated with updateProof function) delete newDidDocument.proof; - // register the new public key with the document loader + // register the new public key with the document loader (short and full ids) jdl.addStatic(publicKey.id, publicKey); + const fullId = publicKey.controller + publicKey.id; + jdl.addStatic(fullId, { + ...publicKey, id: fullId, + '@context': 'https://w3id.org/security/multikey/v1' + }); return {keyPair, didDocument: newDidDocument}; } @@ -203,14 +212,6 @@ export async function createEvent({type, data, assertionMethod}) { purpose: new AssertionProofPurpose(), documentLoader }); - // delete the @context in the proof as it's unnecessary - delete signedEvent['@context']; - delete signedEvent.proof['@context']; - - // set the verification method reference in the proof - // TODO: determine if there is a better way to set verificationMethod - signedEvent.proof.verificationMethod = assertionMethod.controller + '#' + - assertionMethod.publicKeyMultibase; return {event: signedEvent}; } diff --git a/lib/secrets.js b/lib/secrets.js index 3178677..7429807 100644 --- a/lib/secrets.js +++ b/lib/secrets.js @@ -5,7 +5,8 @@ * key derived from a user-supplied password via scrypt. */ -import {mkdirSync, writeFileSync} from 'node:fs'; +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {config} from './config.js'; import crypto from 'node:crypto'; import {join} from 'node:path'; @@ -59,6 +60,60 @@ function _deriveKey(password, salt) { }); } +/** + * Loads and decrypts private keys from the secrets file for a DID, returning + * a secretKeys object keyed by verification relationship. + * + * @param {object} options - Configuration options. + * @param {string} options.didIdentifier - Method-specific ID (part after + * did:cel:). + * @param {string} options.password - Password used to decrypt each secret key. + * @returns {Promise} SecretKeys object keyed by relationship, each an + * array of reconstructed EcdsaMultikey key pair objects. + */ +export async function loadSecrets({didIdentifier, password}) { + const secretsPath = _secretsPath(didIdentifier); + if(!existsSync(secretsPath)) { + throw new Error(`Secrets file not found: ${secretsPath}`); + } + const {keys} = yaml.load(readFileSync(secretsPath, 'utf8')) ?? {keys: []}; + + const secretKeys = { + authentication: [], + assertionMethod: [], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [] + }; + + for(const entry of keys) { + const { + relationship, encryptedSecretKeyMultibase, ...publicFields + } = entry; + const secretKeyMultibase = + await _decrypt(encryptedSecretKeyMultibase, password); + const keyPair = await EcdsaMultikey.from( + {...publicFields, secretKeyMultibase}); + if(secretKeys[relationship]) { + secretKeys[relationship].push(keyPair); + } + } + + return secretKeys; +} + +async function _decrypt(ciphertext, password) { + const buf = Buffer.from(ciphertext, 'base64'); + const salt = buf.subarray(0, 32); + const iv = buf.subarray(32, 44); + const tag = buf.subarray(44, 60); + const enc = buf.subarray(60); + const key = await _deriveKey(password, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return decipher.update(enc, undefined, 'utf8') + decipher.final('utf8'); +} + async function _encrypt(plaintext, password) { const salt = crypto.randomBytes(32); const iv = crypto.randomBytes(12); @@ -70,4 +125,3 @@ async function _encrypt(plaintext, password) { // pack: salt(32) || iv(12) || tag(16) || ciphertext, encode as base64 return Buffer.concat([salt, iv, tag, enc]).toString('base64'); } - From 07f0f5714e6c26b89dd9a845fe050dc8a9d4f89e Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Fri, 29 May 2026 21:31:31 -0400 Subject: [PATCH 33/44] Add node-fetch to package.json. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 9d598d0..7f867aa 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.5", "jsonld-document-loader": "^2.3.0", "multiformats": "^13.4.1", + "node-fetch": "^3.3.2", "prompt-sync": "^4.2.0" }, "devDependencies": { From eee552d9a282b29fe9ddb8dcfc0806391226f8a5 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 30 May 2026 16:51:44 -0400 Subject: [PATCH 34/44] Refactor tools to use didcel library. --- didcel | 39 ++--- lib/cel.js | 345 ---------------------------------------- lib/didcel.js | 219 ------------------------- lib/secrets.js | 127 --------------- lib/utils.js | 150 ----------------- lib/witness.js | 37 ----- package.json | 12 +- tests/mocha/00-setup.js | 22 ++- tests/mocha/helpers.js | 3 +- 9 files changed, 46 insertions(+), 908 deletions(-) delete mode 100644 lib/cel.js delete mode 100644 lib/didcel.js delete mode 100644 lib/secrets.js delete mode 100644 lib/utils.js delete mode 100644 lib/witness.js diff --git a/didcel b/didcel index abd9b71..49efe37 100755 --- a/didcel +++ b/didcel @@ -26,14 +26,15 @@ import {Argument, Command, CommanderError} from 'commander'; import {config, loadConfig} from './lib/config.js'; -import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, - getObjectByIdSuffix} from './lib/utils.js'; +import { + addEvent, createCel, load, witness, + addVm, create, createEvent, + loadSecrets, saveSecrets, + createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix +} from 'didcel'; import {mkdirSync, writeFileSync} from 'fs'; -import cel from './lib/cel.js'; -import didcel from './lib/didcel.js'; import {join} from 'node:path'; import promptSync from 'prompt-sync'; -import {loadSecrets, saveSecrets} from './lib/secrets.js'; // create the CLI and parse command-line options const program = new Command(); @@ -111,12 +112,13 @@ async function repl({commands, password}) { .argument('', 'path to the .cel file to load') .action(async filename => { try { - const result = await cel.load({filename}); + const result = await load({filename}); if(result.valid) { didDocument = result.didDocument; cryptographicEventLog = result.cel; const didIdentifier = didDocument.id.split(':').pop(); - const loaded = await loadSecrets({didIdentifier, password}); + const loaded = await loadSecrets( + {didIdentifier, password, secretsDir: config.secrets}); Object.assign(secretKeys, loaded); const {log} = result.cel; console.log( @@ -137,12 +139,12 @@ async function repl({commands, password}) { .description('Create a new DID document') .action(async () => { // generate a new DID document with P-256 elliptic curve key - const result = await didcel.create({curve: 'P-256'}); + const result = await create({curve: 'P-256'}); didDocument = result.didDocument; // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; // initialize the Cryptographic Event Log with the create event - cryptographicEventLog = cel.create({event: result.event}); + cryptographicEventLog = createCel({event: result.event}); console.log(`create successful: ${didDocument.id}`); }); @@ -158,7 +160,7 @@ async function repl({commands, password}) { // 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 didcel.addVm( + const result = await addVm( {didDocument, verificationRelationship: property, curve: 'P-256'}); didDocument = result.didDocument; // store the secret key for this verification relationship @@ -270,7 +272,7 @@ async function repl({commands, password}) { .action(async () => { // step 1: Regenerate the cryptographic proof on the DID document // this signs the current state of the DID document - const result = await didcel.createEvent({ + const result = await createEvent({ data: didDocument, type: 'update', assertionMethod: secretKeys.assertionMethod[0]}); const event = result.event; @@ -278,7 +280,7 @@ async function repl({commands, password}) { // step 2: Append an update event to the CEL // this creates a hash-linked chain entry with the modified DID document cryptographicEventLog = - await cel.addEvent({cel: cryptographicEventLog, event}); + await addEvent({cel: cryptographicEventLog, event}); }); // command: heartbeat @@ -287,7 +289,7 @@ async function repl({commands, password}) { .description('Update the cryptographic event log with a heartbeat') .action(async () => { // step 1: Create a heartbeat event - const result = await didcel.createEvent({ + const result = await createEvent({ data: undefined, type: 'heartbeat', assertionMethod: secretKeys.assertionMethod[0]}); const event = result.event; @@ -295,7 +297,7 @@ async function repl({commands, password}) { // step 2: Append an heatbeat event to the CEL // this creates a hash-linked chain entry with the heatbeat event cryptographicEventLog = - await cel.addEvent({cel: cryptographicEventLog, event}); + await addEvent({cel: cryptographicEventLog, event}); console.log('heartbeat: generated'); }); @@ -305,7 +307,7 @@ async function repl({commands, password}) { .description('Deactivate the DID') .action(async () => { // step 1: Create the deactivation event - const result = await didcel.createEvent({ + const result = await createEvent({ data: undefined, type: 'deactivate', assertionMethod: secretKeys.assertionMethod[0]}); const event = result.event; @@ -313,7 +315,7 @@ async function repl({commands, password}) { // step 2: Append the deactivation event to the CEL // this creates a hash-linked chain entry with the deactivation event cryptographicEventLog = - await cel.addEvent({cel: cryptographicEventLog, event}); + await addEvent({cel: cryptographicEventLog, event}); console.log('deactivation: complete'); }); @@ -329,7 +331,7 @@ async function repl({commands, password}) { .action(async () => { // generate witness proofs for the most recent event in the log // each witness independently validates and signs the event - await cel.witness({cel: cryptographicEventLog}); + await witness({cel: cryptographicEventLog, witnesses: config.witnesses}); console.log('witness: proofs complete'); }); @@ -356,7 +358,8 @@ async function repl({commands, password}) { } // save encrypted private keys to secrets directory - await saveSecrets({didIdentifier, secretKeys, password}); + await saveSecrets( + {didIdentifier, secretKeys, password, secretsDir: config.secrets}); const secretsPath = join(config.secrets, `${didIdentifier}.yaml`); console.error(`Wrote secrets to ${secretsPath}`); diff --git a/lib/cel.js b/lib/cel.js deleted file mode 100644 index 025af92..0000000 --- a/lib/cel.js +++ /dev/null @@ -1,345 +0,0 @@ -/** - * @file Cryptographic Event Log (CEL) management. - * This module provides functions for creating, updating, and witnessing events - * in a Cryptographic Event Log, which maintains a cryptographically verifiable - * chain of events for DID document operations. - */ - -import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import * as mfHasher from 'multiformats/hashes/hasher'; -import * as witnessService from './witness.js'; -import {base58btc} from 'multiformats/bases/base58'; -import {decode as base58Decode} from 'base58-universal'; -import canonicalize from 'canonicalize'; -import {config} from './config.js'; -import crypto from 'node:crypto'; -import {readFileSync} from 'node:fs'; -import {sha256} from '@noble/hashes/sha2.js'; -import {sha3_256} from '@noble/hashes/sha3.js'; - -// SHA2-256 multihash header: function code 0x12, digest size 32 (0x20) -const SHA2_256_HEADER = new Uint8Array([0x12, 0x20]); - -/** - * Creates a new Cryptographic Event Log (CEL) with an initial 'create' event. - * The log maintains a chain of events that document the history of DID ops. - * - * @param {object} options - Configuration options. - * @param {object} options.event - The data for the create operation. - * @returns {object} A new CEL object with the structure: - * - log: Array containing the initial create event. - * - * @example - * const cel = create({ - * event, - * }); - */ -export function create({event}) { - // initialize the log with a create operation event - const log = { - log: [{ - event - }] - }; - - return log; -} - -/** - * Generates witness proofs for the most recent event in a CEL. - * Each configured witness creates a cryptographic proof attesting to the event. - * - * @param {object} options - Configuration options. - * @param {object} options.cel - The Cryptographic Event Log containing events - * to witness. - * @returns {Promise} An array of proof objects, one from each witness. - * - * @example - * const proofs = await witness({cel: myCel}); - */ -export async function witness({cel}) { - const event = cel.log[cel.log.length - 1]; - - // canonicalize and SHA2-256 hash the event to produce the digestMultibase - const utf8Encoder = new TextEncoder(); - const canonicalized = canonicalize(event); - const rawHash = sha256(utf8Encoder.encode(canonicalized)); - - // build SHA2-256 multihash and encode as base58btc with 'z' multibase prefix - const mhBytes = new Uint8Array(SHA2_256_HEADER.length + rawHash.length); - mhBytes.set(SHA2_256_HEADER, 0); - mhBytes.set(rawHash, SHA2_256_HEADER.length); - const digestMultibase = base58btc.encode(mhBytes); - - const witnessUrls = config.witnesses; - if(!Array.isArray(witnessUrls) || witnessUrls.length === 0) { - throw new Error( - 'No witnesses configured. Add a "witnesses" array to config.yaml.'); - } - - const proofs = await Promise.all(witnessUrls.map( - witnessUrl => witnessService.witness({digestMultibase, witnessUrl}))); - - event.proof = proofs; - - return event.proof; -} - -async function _calculatePreviousEventHash({cel}) { - // calculate the hash of the previous event to create a verifiable chain - let previousEventHash = undefined; - if(cel.log.length > 0) { - const lastEvent = cel.log[cel.log.length - 1].event; - const utf8Encoder = new TextEncoder(); - // canonicalize the event to ensure deterministic hashing - const canonicalizedDidDocument = canonicalize(lastEvent); - // create a SHA3-256 hasher with multiformats encoding - const sha3256Hasher = mfHasher.from({ - name: 'sha3-256', - code: 0x16, // Multihash code for SHA3-256 - encode: input => sha3_256(input), - }); - // compute the hash and encode it in base58btc - const mfHash = await sha3256Hasher.digest( - utf8Encoder.encode(canonicalizedDidDocument)).bytes; - previousEventHash = base58btc.encode(mfHash); - } - - return previousEventHash; -} - -/** - * Adds an event to an existing CEL, creating a hash-linked chain of - * events. The update event includes a hash of the previous event to ensure log - * integrity. - * - * @param {object} options - Configuration options. - * @param {object} options.cel - The Certificate Event Log to add the event to. - * @param {object} options.event - The data for the update operation (typically - * an updated DID document). - * @returns {Promise} The updated CEL with the new event appended. - * - * @example - * const updatedCel = await addEvent({ - * cel: existingCel, - * data: modifiedDidDocument - * }); - */ -export async function addEvent({cel, event}) { - // append the new update event to the log, linked to the previous event - event.previousEventHash = await _calculatePreviousEventHash({cel}); - cel.log.push({event}); - - return cel; -} - -/** - * Loads and fully validates a Cryptographic Event Log from a file. Checks: - * - Hash chain integrity (previousEventHash on each non-create entry) - * - Operation proof signatures (ecdsa-jcs-2019 via manual JCS verification) - * - Witness proof signatures (blind-witness manual JCS verification) - * - Timestamp deviation between operation proof and witness proofs (≤ 5 min). - * - * @param {object} options - Configuration options. - * @param {string} options.filename - Path to the .cel file to load. - * @returns {Promise} An object with: - * - cel: The parsed CEL object. - * - errors: Array of error strings (empty if valid). - * - valid: Boolean, true if no errors. - * - didDocument: The most recent DID document state (or null). - */ -export async function load({filename}) { - const cel = JSON.parse(readFileSync(filename, 'utf8')); - const errors = []; - let currentDidDocument = null; - - for(let i = 0; i < cel.log.length; i++) { - const logEntry = cel.log[i]; - const event = logEntry.event; - const opProof = event.proof; - const witnessProofs = logEntry.proof ?? []; - - // 1. Verify previousEventHash for all entries after the first - if(i > 0) { - const computed = await _calculatePreviousEventHash( - {cel: {log: cel.log.slice(0, i)}}); - if(computed !== event.previousEventHash) { - errors.push( - `entry ${i}: previousEventHash mismatch ` + - `(expected ${computed}, got ${event.previousEventHash})`); - } - } - - // Track the current DID document for key lookup on stateless events - if(event.operation?.data) { - currentDidDocument = event.operation.data; - } - - // 2. Verify the operation proof - if(opProof) { - try { - const verified = await _verifyOperationProof( - {event, opProof, currentDidDocument}); - if(!verified) { - errors.push(`entry ${i}: operation proof invalid`); - } - } catch(e) { - errors.push(`entry ${i}: operation proof error: ${e.message}`); - } - } - - // 3. Verify each witness proof and check timestamp deviation - const opTime = opProof?.created ? - new Date(opProof.created).getTime() : null; - for(let j = 0; j < witnessProofs.length; j++) { - const witnessProof = witnessProofs[j]; - - try { - const verified = await _verifyWitnessProof({logEntry, witnessProof}); - if(!verified) { - errors.push(`entry ${i} witness ${j}: invalid signature`); - } - } catch(e) { - errors.push(`entry ${i} witness ${j}: error: ${e.message}`); - } - - // 4. Check timestamp deviation ≤ 5 minutes - if(opTime !== null && witnessProof.created) { - const wTime = new Date(witnessProof.created).getTime(); - const diffMinutes = Math.abs(opTime - wTime) / 60000; - if(diffMinutes > 5) { - errors.push( - `entry ${i} witness ${j}: timestamp deviation ` + - `${diffMinutes.toFixed(1)}min exceeds 5min limit`); - } - } - } - } - - return { - cel, errors, valid: errors.length === 0, didDocument: currentDidDocument - }; -} - -/** - * Verifies an operation proof using the ecdsa-jcs-2019 manual JCS approach. - * VerifyData = SHA256(JCS(proofOptions_without_proofValue)) || - * SHA256(JCS(event_without_proof)). - * - * @param {object} options - Options. - * @param {object} options.event - The event object. - * @param {object} options.opProof - The operation proof. - * @param {object} options.currentDidDocument - The current DID document state. - * @returns {Promise} True if the proof is valid. - */ -async function _verifyOperationProof({event, opProof, currentDidDocument}) { - // find the assertionMethod key matching the verificationMethod in the proof - const vmRef = opProof.verificationMethod; - const assertionKey = _findAssertionKey( - {vmRef, didDocument: currentDidDocument}); - if(!assertionKey) { - throw new Error(`verification method not found in DID document: ${vmRef}`); - } - - // previousEventHash is appended after signing; exclude from doc hash - const doc = {...event}; - delete doc.proof; - delete doc.previousEventHash; - const proofOptions = {...opProof}; - delete proofOptions.proofValue; - - const c14nDoc = canonicalize(doc); - const c14nProof = canonicalize(proofOptions); - const proofHash = new Uint8Array( - crypto.createHash('sha256').update(c14nProof).digest()); - const docHash = new Uint8Array( - crypto.createHash('sha256').update(c14nDoc).digest()); - - const verifyData = new Uint8Array(proofHash.length + docHash.length); - verifyData.set(proofHash, 0); - verifyData.set(docHash, proofHash.length); - - const keyPair = await EcdsaMultikey.from({ - type: 'Multikey', - id: vmRef, - controller: currentDidDocument.id, - publicKeyMultibase: assertionKey.publicKeyMultibase - }); - const verifier = keyPair.verifier(); - const sigBytes = base58Decode(opProof.proofValue.slice(1)); - return verifier.verify({data: verifyData, signature: sigBytes}); -} - -/** - * Verifies a witness proof using hmbd's blind-witness signing scheme. - * VerifyData = SHA256(JCS(proofOptions_without_proofValue)) || rawHash - * where rawHash = SHA256 bytes from digestMultibase of the log entry. - * - * @param {object} options - Options. - * @param {object} options.logEntry - The full log entry {event, proof[]}. - * @param {object} options.witnessProof - The witness proof to verify. - * @returns {Promise} True if the proof is valid. - */ -async function _verifyWitnessProof({logEntry, witnessProof}) { - const utf8Encoder = new TextEncoder(); - - // reconstruct the digestMultibase from the log entry's event - // (same as what was sent to the witness service — sans witness proofs) - const entryForDigest = {event: logEntry.event}; - const canonicalized = canonicalize(entryForDigest); - const rawHashFull = sha256(utf8Encoder.encode(canonicalized)); - - // build proofHash from the witness proof options (without proofValue) - const proofOptions = {...witnessProof}; - delete proofOptions.proofValue; - const c14nProof = canonicalize(proofOptions); - const proofHash = new Uint8Array( - crypto.createHash('sha256').update(c14nProof).digest()); - - // verifyData = SHA256(c14n(proofOptions)) || rawHash - const verifyData = new Uint8Array(proofHash.length + rawHashFull.length); - verifyData.set(proofHash, 0); - verifyData.set(rawHashFull, proofHash.length); - - // extract public key from did:key: verificationMethod - const vmId = witnessProof.verificationMethod; - const didKeyId = vmId.split('#')[0]; - const publicKeyMultibase = didKeyId.replace('did:key:', ''); - - const keyPair = await EcdsaMultikey.from({ - type: 'Multikey', - id: vmId, - controller: didKeyId, - publicKeyMultibase - }); - const verifier = keyPair.verifier(); - const sigBytes = base58Decode(witnessProof.proofValue.slice(1)); - return verifier.verify({data: verifyData, signature: sigBytes}); -} - -/** - * Finds the assertionMethod key in a DID document that matches a VM reference. - * - * @param {object} options - Options. - * @param {string} options.vmRef - The verificationMethod reference to find. - * @param {object} options.didDocument - The DID document to search. - * @returns {object|null} The matching key object, or null if not found. - */ -function _findAssertionKey({vmRef, didDocument}) { - if(!didDocument?.assertionMethod) { - return null; - } - for(const key of didDocument.assertionMethod) { - if(typeof key !== 'object') { - continue; - } - // match by full id or by fragment suffix - const fullId = didDocument.id + key.id; - if(fullId === vmRef || key.id === vmRef) { - return key; - } - } - return null; -} - -export default {addEvent, create, load, witness}; diff --git a/lib/didcel.js b/lib/didcel.js deleted file mode 100644 index 239b1b9..0000000 --- a/lib/didcel.js +++ /dev/null @@ -1,219 +0,0 @@ -/** - * @file DID CEL (Cryptographic Event Log) DID Document management. - * This module provides functions for creating, updating, and managing DID - * documents using the did:cel method with ECDSA Multikey and Data Integrity - * Proofs. - */ - -import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import * as mfHasher from 'multiformats/hashes/hasher'; -import {base58btc} from 'multiformats/bases/base58'; -import canonicalize from 'canonicalize'; -import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; -import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; -import jsigs from 'jsonld-signatures'; -import {JsonLdDocumentLoader} from 'jsonld-document-loader'; -import {sha3_256} from '@noble/hashes/sha3.js'; - -const {purposes: {AssertionProofPurpose}} = jsigs; -// jSON-LD document loader for resolving contexts and verification methods -const jdl = new JsonLdDocumentLoader(); - -/** - * Creates a new DID CEL document with a generated key pair and cryptographic - * proof. The DID identifier is derived from the SHA3-256 hash of the - * canonicalized DID document. - * - * @param {object} options - Configuration options. - * @param {string} [options.curve='P-256'] - The elliptic curve to use for - * key generation (e.g., 'P-256', 'P-384'). - * @returns {Promise} An object containing: - * - keyPair: The generated ECDSA Multikey key pair - * - recoveryKeyPair: The generated ECDSA Multikey recovery key pair - * - didDocument: The signed DID document with a did:cel identifier. - * - * @example - * const {keyPair, recoveryKeyPair, didDocument} = - * await create({options: {curve: 'P-256'}}); - * console.log(didDocument.id); // did:cel:z... - */ -export async function create({curve = 'P-256'} = {}) { - // generate a new ECDSA key pair using the specified curve (defaults to P-256) - const keyPair = await EcdsaMultikey.generate({curve}); - const publicKey = - await keyPair.export({publicKey: true, includeContext: false}); - // set the key id to the public key multibase encoding - publicKey.id = '#' + publicKey.publicKeyMultibase; - - // generate a new recovery key pair using the specified curve - const recoveryKeyPair = await EcdsaMultikey.generate({curve}); - const recoveryPublicKey = - await recoveryKeyPair.export({publicKey: true, includeContext: false}); - // set the key id to the public key multibase encoding - recoveryPublicKey.id = '#' + recoveryPublicKey.publicKeyMultibase; - - // register the public key with the document loader for proof verification - jdl.addStatic(publicKey.id, publicKey); - jdl.addStatic(recoveryPublicKey.id, recoveryPublicKey); - - // create initial DID document structure with assertion method - const didDocument = { - '@context': [ - 'https://www.w3.org/ns/did/v1.1', - 'https://w3id.org/didcel/v1' - ], - heartbeatFrequency: 'P3M', - assertionMethod: [publicKey], - recovery: [recoveryPublicKey], - service: { - type: 'CelStorageService', - serviceEndpoint: [ - 'https://storage.gamma.example/v1', - 'https://2001:db8:85a3::8a2e:370:7334/v1', - 'https://celstorageiu7vnjjbwkhpilnemxj7ase3mhbshg7kx5tfydaniltxjqhy.onion/', - ] - } - }; - - // generate the did:cel identifier by hashing the canonicalized DID document - const utf8Encoder = new TextEncoder(); - const canonicalizedDidDocument = canonicalize(didDocument); - const sha3256Hasher = mfHasher.from({ - name: 'sha3-256', - code: 0x16, // Multihash code for SHA3-256 - encode: input => sha3_256(input), - }); - const mfHash = await sha3256Hasher.digest( - utf8Encoder.encode(canonicalizedDidDocument)).bytes; - const encodedHash = base58btc.encode(mfHash); - const controller = 'did:cel:' + encodedHash; - // update the DID document and public key with the generated identifier - didDocument.id = controller; - publicKey.controller = controller; - recoveryPublicKey.controller = controller; - - // set key id and controller so jsigs uses the correct verificationMethod - keyPair.id = controller + publicKey.id; - keyPair.controller = controller; - - // register the full VM id for document loader resolution during verification - jdl.addStatic(keyPair.id, { - ...publicKey, id: keyPair.id, - '@context': 'https://w3id.org/security/multikey/v1' - }); - - // create a cryptographic proof using ECDSA-JCS-2019 - const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); - const suite = new DataIntegrityProof({ - signer: keyPair.signer(), cryptosuite: ecdsaJcs2019Cryptosuite - }); - - // sign the operation - const documentLoader = jdl.build(); - const event = { - operation: { - type: 'create', - data: didDocument - } - }; - const signedEvent = await jsigs.sign(event, { - suite, - purpose: new AssertionProofPurpose(), - documentLoader - }); - - return {keyPair, recoveryKeyPair, event: signedEvent, didDocument}; -} - -/** - * Adds a new verification method (VM) to an existing DID document. Generates a - * new key pair and adds it to the specified verification relationship. The - * proof is removed and must be regenerated after this operation. - * - * @param {object} options - Configuration options. - * @param {object} options.didDocument - The DID document to modify. - * @param {string} options.verificationRelationship - The verification - * relationship to add the key to (e.g., 'assertionMethod', 'authentication', - * 'keyAgreement'). - * @param {string} [options.curve='P-256'] - The elliptic curve to use for key - * generation (e.g., 'P-256', 'P-384'). - * @returns {Promise} An object containing: - * - keyPair: The newly generated ECDSA Multikey key pair - * - didDocument: The updated DID document (without proof). - * - * @example - * const {keyPair, didDocument} = await addVm({ - * didDocument: existingDoc, - * verificationRelationship: 'authentication', - * curve: 'P-256' - * }); - */ -export async function addVm({didDocument, verificationRelationship, curve}) { - // TODO: replace with modern clone (structuredClone when available) - const newDidDocument = JSON.parse(JSON.stringify(didDocument)); - // generate a new key pair for the verification method - const keyPair = - await EcdsaMultikey.generate({curve: curve || 'P-256'}); - const publicKey = - await keyPair.export({publicKey: true, includeContext: false}); - publicKey.id = '#' + publicKey.publicKeyMultibase; - publicKey.controller = didDocument.id; - - // add verification method to the specified verification relationship - if(!Array.isArray(didDocument[verificationRelationship])) { - newDidDocument[verificationRelationship] = []; - } - newDidDocument[verificationRelationship].push(publicKey); - - // remove old proof (must be regenerated with updateProof function) - delete newDidDocument.proof; - - // register the new public key with the document loader (short and full ids) - jdl.addStatic(publicKey.id, publicKey); - const fullId = publicKey.controller + publicKey.id; - jdl.addStatic(fullId, { - ...publicKey, id: fullId, - '@context': 'https://w3id.org/security/multikey/v1' - }); - - return {keyPair, didDocument: newDidDocument}; -} - -/** - * Creates a signed event given event data and an assertion method keypair. - * - * @param {object} options - Configuration options. - * @param {string} options.type - The event type (e.g., 'create', 'update'). - * @param {object} options.data - The data to place into the event. - * @param {object} options.assertionMethod - The key pair to use for signing. - * Must have a signer() method and publicKeyMultibase property. - * @returns {Promise} An object containing: - * - didDocument: The DID document with the new proof attached. - * - * @example - * const {didDocument} = await createEvent({ - * data: didDocument, - * type: 'update', - * assertionMethod: keyPair - * }); - */ -export async function createEvent({type, data, assertionMethod}) { - // create a new cryptographic proof using ecdsa-jcs-2019 - const documentLoader = jdl.build(); - const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); - const suite = new DataIntegrityProof({ - signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite - }); - const event = { - operation: {type, data} - }; - const signedEvent = await jsigs.sign(event, { - suite, - purpose: new AssertionProofPurpose(), - documentLoader - }); - - return {event: signedEvent}; -} - -export default {create, addVm, createEvent}; diff --git a/lib/secrets.js b/lib/secrets.js deleted file mode 100644 index 7429807..0000000 --- a/lib/secrets.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Encrypted private key storage. - * Saves and loads private keys to ~/.config/didcel/secrets/.yaml. - * Each secretKeyMultibase is encrypted with AES-256-GCM, with the encryption - * key derived from a user-supplied password via scrypt. - */ - -import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; -import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; -import {config} from './config.js'; -import crypto from 'node:crypto'; -import {join} from 'node:path'; -import yaml from 'js-yaml'; - -// scrypt parameters: N=2^14, r=8, p=1 -const SCRYPT_N = 16384; -const SCRYPT_R = 8; -const SCRYPT_P = 1; -const KEY_LEN = 32; - -/** - * Encrypts and saves all secret key pairs to the secrets file for a DID. - * - * @param {object} options - Configuration options. - * @param {string} options.didIdentifier - Method-specific ID (part after - * did:cel:). - * @param {object} options.secretKeys - Session secretKeys object keyed by - * verification relationship, each an array of keyPair objects. - * @param {string} options.password - Password used to encrypt each secret key. - */ -export async function saveSecrets({didIdentifier, secretKeys, password}) { - const keys = []; - for(const [relationship, keyPairs] of Object.entries(secretKeys)) { - for(const keyPair of keyPairs) { - const exported = await keyPair.export( - {publicKey: true, secretKey: true, includeContext: true}); - const {secretKeyMultibase, ...publicFields} = exported; - if(!secretKeyMultibase) { - continue; - } - const encryptedSecretKeyMultibase = - await _encrypt(secretKeyMultibase, password); - keys.push({...publicFields, relationship, encryptedSecretKeyMultibase}); - } - } - - mkdirSync(config.secrets, {recursive: true}); - writeFileSync(_secretsPath(didIdentifier), yaml.dump({keys})); -} - -function _secretsPath(didIdentifier) { - return join(config.secrets, `${didIdentifier}.yaml`); -} - -function _deriveKey(password, salt) { - return new Promise((resolve, reject) => { - crypto.scrypt(password, salt, KEY_LEN, - {N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P}, - (err, key) => err ? reject(err) : resolve(key)); - }); -} - -/** - * Loads and decrypts private keys from the secrets file for a DID, returning - * a secretKeys object keyed by verification relationship. - * - * @param {object} options - Configuration options. - * @param {string} options.didIdentifier - Method-specific ID (part after - * did:cel:). - * @param {string} options.password - Password used to decrypt each secret key. - * @returns {Promise} SecretKeys object keyed by relationship, each an - * array of reconstructed EcdsaMultikey key pair objects. - */ -export async function loadSecrets({didIdentifier, password}) { - const secretsPath = _secretsPath(didIdentifier); - if(!existsSync(secretsPath)) { - throw new Error(`Secrets file not found: ${secretsPath}`); - } - const {keys} = yaml.load(readFileSync(secretsPath, 'utf8')) ?? {keys: []}; - - const secretKeys = { - authentication: [], - assertionMethod: [], - capabilityInvocation: [], - capabilityDelegation: [], - keyAgreement: [] - }; - - for(const entry of keys) { - const { - relationship, encryptedSecretKeyMultibase, ...publicFields - } = entry; - const secretKeyMultibase = - await _decrypt(encryptedSecretKeyMultibase, password); - const keyPair = await EcdsaMultikey.from( - {...publicFields, secretKeyMultibase}); - if(secretKeys[relationship]) { - secretKeys[relationship].push(keyPair); - } - } - - return secretKeys; -} - -async function _decrypt(ciphertext, password) { - const buf = Buffer.from(ciphertext, 'base64'); - const salt = buf.subarray(0, 32); - const iv = buf.subarray(32, 44); - const tag = buf.subarray(44, 60); - const enc = buf.subarray(60); - const key = await _deriveKey(password, salt); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - return decipher.update(enc, undefined, 'utf8') + decipher.final('utf8'); -} - -async function _encrypt(plaintext, password) { - const salt = crypto.randomBytes(32); - const iv = crypto.randomBytes(12); - const key = await _deriveKey(password, salt); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const enc = Buffer.concat( - [cipher.update(plaintext, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - // pack: salt(32) || iv(12) || tag(16) || ciphertext, encode as base64 - return Buffer.concat([salt, iv, tag, enc]).toString('base64'); -} diff --git a/lib/utils.js b/lib/utils.js deleted file mode 100644 index f8e858c..0000000 --- a/lib/utils.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Creates a JSON-LD pretty printer function that orders object keys according - * to a preferred order, with remaining keys sorted alphabetically. - * - * @param {object} options - Configuration options. - * @param {Array} options.preferOrder - Array of keys to appear first - * in the specified order (e.g., ['@context', 'id', 'type']). - * @returns {Function} A replacer function for use with JSON.stringify() that - * orders object properties according to the preferred order. - * - * @example - * const printer = createJsonldPrettyPrinter({ - * preferOrder: ['@context', 'id', 'type'] - * }); - * JSON.stringify(obj, printer, 2); - */ -export function createJsonldPrettyPrinter({preferOrder}) { - return (key, value) => { - let result = value; - // only process objects (not arrays or primitives) - if(value instanceof Object && !(value instanceof Array)) { - const sortedKeys = Object.keys(value).sort(); - const prettyKeys = []; - - // first, add keys that are in the preferred order - for(const pkey of preferOrder) { - if(value[pkey] !== undefined) { - prettyKeys.push(pkey); - } - } - // then, add remaining keys in alphabetical order - for(const skey of sortedKeys) { - if(!preferOrder.includes(skey)) { - prettyKeys.push(skey); - } - } - - // reconstruct the object with the new key order - result = prettyKeys.reduce((sorted, key) => { - sorted[key] = value[key]; - return sorted; - }, {}); - } - - return result; - }; -} - -/** - * Retrieves an object from a DID document by matching the suffix of its id - * property. Searches through all array properties in the DID document to find - * an object whose id ends with the specified suffix. - * - * @param {object} options - Configuration options. - * @param {object} options.didDocument - The DID document to search. - * @param {string} options.suffix - The suffix to match against object ids - * (e.g., '#key-1' or 'zDnaeRQ...'). - * @returns {object | undefined} The first object found with a matching id - * suffix, or undefined if no match is found. - * - * @example - * const vm = getObjectByIdSuffix({ - * didDocument: doc, - * suffix: '#key-1' - * }); - */ -export function getObjectByIdSuffix({didDocument, suffix}) { - let rval = undefined; - // iterate through all properties in the DID document - for(const property of Object.keys(didDocument)) { - // only process array properties (e.g., assertionMethod, authentication) - if(!Array.isArray(didDocument[property])) { - continue; - } - // search through each entry in the array - for(const entry of didDocument[property]) { - // skip non-object entries - if(typeof entry !== 'object') { - continue; - } - // extract the suffix portion of the entry's id - const idSuffix = - entry.id.slice(entry.id.length - suffix.length, entry.id.length); - // check if the id suffix matches the target suffix - if(suffix === idSuffix) { - rval = entry; - } - } - } - - return rval; -} - -/** - * Deletes an object from a DID document by matching the suffix of its id - * property. Searches through all array properties in the DID document and - * removes the first object whose id ends with the specified suffix. This - * function mutates the didDocument parameter. - * - * @param {object} options - Configuration options. - * @param {object} options.didDocument - The DID document to modify (mutated in - * place). - * @param {string} options.suffix - The suffix to match against object ids - * (e.g., '#key-1' or a multibase encoded key). - * @returns {object | undefined} The deleted object if found, or undefined if no - * match was found. - * - * @example - * const deleted = deleteObjectByIdSuffix({ - * didDocument: doc, - * suffix: '#key-1' - * }); - */ -export function deleteObjectByIdSuffix({didDocument, suffix}) { - let rval = undefined; - // iterate through all properties in the DID document - for(const property of Object.keys(didDocument)) { - // only process array properties (e.g., assertionMethod, authentication) - if(!Array.isArray(didDocument[property])) { - continue; - } - - // filter out the entry with matching id suffix - didDocument[property] = didDocument[property].filter(entry => { - // keep non-object entries - if(typeof entry !== 'object') { - return true; - } - // extract the suffix portion of the entry's id - const idSuffix = - entry.id.slice(entry.id.length - suffix.length, entry.id.length); - // if suffix doesn't match, keep the entry - if(suffix !== idSuffix) { - return true; - } else { - // if suffix matches, store the entry and remove it from the array - rval = entry; - return false; - } - }); - } - - return rval; -} - -export default { - createJsonldPrettyPrinter, - deleteObjectByIdSuffix, - getObjectByIdSuffix -}; diff --git a/lib/witness.js b/lib/witness.js deleted file mode 100644 index 9c2c891..0000000 --- a/lib/witness.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file Witness service HTTP client. - * Calls a real blind witness service to obtain a DataIntegrityProof attesting - * to a cryptographic event hash. - */ - -import fetch from 'node-fetch'; -import https from 'node:https'; - -// allow self-signed certs on localhost witness services -const httpsAgent = new https.Agent({rejectUnauthorized: false}); - -/** - * Sends a digestMultibase to a witness service and returns the proof. - * - * @param {object} options - Configuration options. - * @param {string} options.digestMultibase - Base58btc-encoded SHA2-256 - * multihash of the event to attest (z prefix). - * @param {string} options.witnessUrl - Full URL of the witness endpoint. - * @returns {Promise} DataIntegrityProof returned by the witness. - */ -export async function witness({digestMultibase, witnessUrl}) { - const response = await fetch(witnessUrl, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({digestMultibase}), - agent: httpsAgent - }); - if(!response.ok) { - const body = await response.text(); - throw new Error(`Witness request failed (${response.status}): ${body}`); - } - const {proof} = await response.json(); - return proof; -} - -export default {witness}; diff --git a/package.json b/package.json index 7f867aa..e95d7d7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { - "name": "didcel", + "name": "did-cel-tools", "version": "0.0.1", "type": "module", - "main": "./lib/index.js", "scripts": { "lint": "eslint .", "test": "mocha" @@ -18,15 +17,10 @@ "license": "BSD", "description": "", "dependencies": { - "@digitalbazaar/data-integrity": "^2.5.0", - "@digitalbazaar/ecdsa-jcs-2019-cryptosuite": "^1.0.0", - "@noble/hashes": "^2.0.1", - "canonicalize": "^2.1.0", "commander": "^12.1.0", + "didcel": "github:digitalbazaar/didcel#initial", "dotenv": "^16.4.5", - "jsonld-document-loader": "^2.3.0", - "multiformats": "^13.4.1", - "node-fetch": "^3.3.2", + "js-yaml": "^4.1.0", "prompt-sync": "^4.2.0" }, "devDependencies": { diff --git a/tests/mocha/00-setup.js b/tests/mocha/00-setup.js index 6b86886..6aad08d 100644 --- a/tests/mocha/00-setup.js +++ b/tests/mocha/00-setup.js @@ -1,6 +1,24 @@ /*! * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ -import {clearTmpDir} from './helpers.js'; +import {CONFIG_PATH, TMP_DIR, clearTmpDir} from './helpers.js'; +import {TEST_WITNESSES, start, stop} from './mock-witness.js'; +import {writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import yaml from 'js-yaml'; -before(() => clearTmpDir()); +before(async () => { + clearTmpDir(); + await start(); + // write a config.yaml into the tmp dir with the mock witness URL and + // absolute paths to the tmp logs/secrets dirs so the CLI subprocess can + // find them regardless of its working directory + const config = { + witnesses: TEST_WITNESSES.slice(), + logs: join(TMP_DIR, 'logs'), + secrets: join(TMP_DIR, 'secrets') + }; + writeFileSync(CONFIG_PATH, yaml.dump(config)); +}); + +after(() => stop()); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js index 8e665c8..d2d460c 100644 --- a/tests/mocha/helpers.js +++ b/tests/mocha/helpers.js @@ -14,7 +14,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const TESTS_DIR = path.resolve(__dirname, '..'); export const ROOT_DIR = path.resolve(TESTS_DIR, '..'); export const TMP_DIR = join(TESTS_DIR, 'tmp'); -export const CONFIG_PATH = join(TESTS_DIR, 'config.yaml'); +// config.yaml is written dynamically by 00-setup.js with the mock witness URL +export const CONFIG_PATH = join(TMP_DIR, 'config.yaml'); export const DIDCEL_PATH = join(ROOT_DIR, 'didcel'); export const TEST_PASSWORD = 'test-password-for-automated-tests'; From ddabc422c86ff002f7ec0be128631690a93718ac Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Jun 2026 12:21:10 -0400 Subject: [PATCH 35/44] Remove dependency on dotenv package. --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index e95d7d7..f668718 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "dependencies": { "commander": "^12.1.0", "didcel": "github:digitalbazaar/didcel#initial", - "dotenv": "^16.4.5", "js-yaml": "^4.1.0", "prompt-sync": "^4.2.0" }, From eaffa12b881739f14e26bc6dd2aff01d139a447d Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Jun 2026 13:32:46 -0400 Subject: [PATCH 36/44] Add mock witness to test suite. --- tests/mocha/mock-witness.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/mocha/mock-witness.js diff --git a/tests/mocha/mock-witness.js b/tests/mocha/mock-witness.js new file mode 100644 index 0000000..c26ae40 --- /dev/null +++ b/tests/mocha/mock-witness.js @@ -0,0 +1,13 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ + +// Re-export start/stop from the didcel library's mock witness server. +// start() spins up an in-process HTTP witness and pushes its URL into +// TEST_WITNESSES. stop() shuts the server down after the suite finishes. +export {start, stop} from 'didcel/tests/mocha/mock-witness.js'; + +// TEST_WITNESSES is the shared mutable array that start() populates. +// Because Node.js caches ES module instances, the array that mock-witness.js +// writes into is the same object we read here. +export {TEST_WITNESSES} from 'didcel/tests/mocha/helpers.js'; From b19b50bf3aed3f186a69785887fd330774c7e214 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sat, 6 Jun 2026 14:03:18 -0400 Subject: [PATCH 37/44] Ensure previousEventHash is covered by operation signature. --- didcel | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/didcel b/didcel index 49efe37..acc0527 100755 --- a/didcel +++ b/didcel @@ -27,7 +27,7 @@ import {Argument, Command, CommanderError} from 'commander'; import {config, loadConfig} from './lib/config.js'; import { - addEvent, createCel, load, witness, + addEvent, createCel, getPreviousEventHash, load, witness, addVm, create, createEvent, loadSecrets, saveSecrets, createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix @@ -270,15 +270,19 @@ async function repl({commands, password}) { repl.command('update') .description('Update the cryptographic event log with the latest DID doc') .action(async () => { - // step 1: Regenerate the cryptographic proof on the DID document - // this signs the current state of the DID document + // step 1: Get the hash of the current last event so it can be covered + // by the new operation proof before signing + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + + // step 2: Sign the current DID document state; previousEventHash is + // included in the event before signing so it is part of the proof const result = await createEvent({ data: didDocument, type: 'update', - assertionMethod: secretKeys.assertionMethod[0]}); + assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); const event = result.event; - // step 2: Append an update event to the CEL - // this creates a hash-linked chain entry with the modified DID document + // step 3: Append the signed, hash-linked event to the CEL cryptographicEventLog = await addEvent({cel: cryptographicEventLog, event}); }); @@ -288,14 +292,18 @@ async function repl({commands, password}) { repl.command('heartbeat') .description('Update the cryptographic event log with a heartbeat') .action(async () => { - // step 1: Create a heartbeat event + // step 1: Get the hash of the current last event so it can be covered + // by the new operation proof before signing + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + + // step 2: Create a heartbeat event with previousEventHash covered by proof const result = await createEvent({ data: undefined, type: 'heartbeat', - assertionMethod: secretKeys.assertionMethod[0]}); + assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); const event = result.event; - // step 2: Append an heatbeat event to the CEL - // this creates a hash-linked chain entry with the heatbeat event + // step 3: Append the signed, hash-linked event to the CEL cryptographicEventLog = await addEvent({cel: cryptographicEventLog, event}); console.log('heartbeat: generated'); @@ -306,14 +314,19 @@ async function repl({commands, password}) { repl.command('deactivate') .description('Deactivate the DID') .action(async () => { - // step 1: Create the deactivation event + // step 1: Get the hash of the current last event so it can be covered + // by the new operation proof before signing + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + + // step 2: Create the deactivation event with previousEventHash covered + // by the operation proof const result = await createEvent({ data: undefined, type: 'deactivate', - assertionMethod: secretKeys.assertionMethod[0]}); + assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); const event = result.event; - // step 2: Append the deactivation event to the CEL - // this creates a hash-linked chain entry with the deactivation event + // step 3: Append the signed, hash-linked event to the CEL cryptographicEventLog = await addEvent({cel: cryptographicEventLog, event}); console.log('deactivation: complete'); From f8699e4cf4a5b988dddee731afc249057d19178c Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 7 Jun 2026 13:18:38 -0400 Subject: [PATCH 38/44] Update to use new didcel library interface changes. --- didcel | 17 ++++++++--------- tests/mocha/20-witness.js | 5 +++-- tests/mocha/30-update.js | 9 +++++---- tests/mocha/40-heartbeat.js | 9 +++++---- tests/mocha/50-deactivate.js | 9 +++++---- tests/mocha/helpers.js | 2 +- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/didcel b/didcel index acc0527..38dab79 100755 --- a/didcel +++ b/didcel @@ -27,12 +27,12 @@ import {Argument, Command, CommanderError} from 'commander'; import {config, loadConfig} from './lib/config.js'; import { - addEvent, createCel, getPreviousEventHash, load, witness, + addEvent, createCel, getPreviousEventHash, loadFromFile, saveToFile, witness, addVm, create, createEvent, loadSecrets, saveSecrets, createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix } from 'didcel'; -import {mkdirSync, writeFileSync} from 'fs'; +import {mkdirSync} from 'fs'; import {join} from 'node:path'; import promptSync from 'prompt-sync'; @@ -112,7 +112,7 @@ async function repl({commands, password}) { .argument('', 'path to the .cel file to load') .action(async filename => { try { - const result = await load({filename}); + const result = await loadFromFile({filename}); if(result.valid) { didDocument = result.didDocument; cryptographicEventLog = result.cel; @@ -143,8 +143,8 @@ async function repl({commands, password}) { didDocument = result.didDocument; // store the secret key for future signing operations secretKeys.assertionMethod = [result.keyPair]; - // initialize the Cryptographic Event Log with the create event - cryptographicEventLog = createCel({event: result.event}); + // use the CEL returned directly by create() + cryptographicEventLog = result.cryptographicEventLog; console.log(`create successful: ${didDocument.id}`); }); @@ -360,13 +360,12 @@ async function repl({commands, password}) { .argument('[filename]', 'the name of the file to save the event log to') .action(async filename => { const didIdentifier = didDocument.id.split(':').pop(); - const celJson = JSON.stringify(cryptographicEventLog, jsonldPretty, 2); // always write to configured logs directory using DID identifier if(config.logs) { mkdirSync(config.logs, {recursive: true}); - const logsPath = join(config.logs, `${didIdentifier}.cel`); - writeFileSync(logsPath, celJson); + const logsPath = join(config.logs, `${didIdentifier}.cel.gz`); + saveToFile({filename: logsPath, cel: cryptographicEventLog}); console.error(`Wrote to ${logsPath}`); } @@ -378,7 +377,7 @@ async function repl({commands, password}) { // also write CEL to explicit filename if provided if(filename) { - writeFileSync(filename, celJson); + saveToFile({filename, cel: cryptographicEventLog}); console.error(`Wrote to ${filename}`); } }); diff --git a/tests/mocha/20-witness.js b/tests/mocha/20-witness.js index 87088a8..8de8b4e 100644 --- a/tests/mocha/20-witness.js +++ b/tests/mocha/20-witness.js @@ -7,6 +7,7 @@ import { import chai from 'chai'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; +import {gunzipSync} from 'node:zlib'; const {expect} = chai; @@ -42,7 +43,7 @@ describe('witness', function() { const after = listCelFiles(); const newFile = after.find(f => !before.includes(f)); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(1); @@ -69,7 +70,7 @@ describe('witness', function() { const after = listCelFiles(); const newFile = after.find(f => !before.includes(f)); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const proof = celContent.log[0].proof[0]; // verificationMethod should reference a real did:key (not a placeholder) diff --git a/tests/mocha/30-update.js b/tests/mocha/30-update.js index d0b864f..a96f321 100644 --- a/tests/mocha/30-update.js +++ b/tests/mocha/30-update.js @@ -7,6 +7,7 @@ import { import chai from 'chai'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; +import {gunzipSync} from 'node:zlib'; const {expect} = chai; @@ -49,7 +50,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); @@ -61,7 +62,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const updateEntry = celContent.log[1]; expect(updateEntry.event).to.have.property('previousEventHash'); @@ -76,7 +77,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const updateEntry = celContent.log[1]; const didDoc = updateEntry.event.operation.data; @@ -91,7 +92,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js index cab7349..e1255ba 100644 --- a/tests/mocha/40-heartbeat.js +++ b/tests/mocha/40-heartbeat.js @@ -7,6 +7,7 @@ import { import chai from 'chai'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; +import {gunzipSync} from 'node:zlib'; const {expect} = chai; @@ -42,7 +43,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); @@ -54,7 +55,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event.operation).to.have.property( @@ -69,7 +70,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event).to.have.property('previousEventHash'); @@ -82,7 +83,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry).to.have.property('proof'); diff --git a/tests/mocha/50-deactivate.js b/tests/mocha/50-deactivate.js index e1c6a7d..3c1db01 100644 --- a/tests/mocha/50-deactivate.js +++ b/tests/mocha/50-deactivate.js @@ -7,6 +7,7 @@ import { import chai from 'chai'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; +import {gunzipSync} from 'node:zlib'; const {expect} = chai; @@ -49,7 +50,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(3); @@ -61,7 +62,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); const deactivateEntry = celContent.log[2]; expect(deactivateEntry.event.operation).to.have.property( @@ -75,7 +76,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); for(let i = 1; i < celContent.log.length; i++) { const entry = celContent.log[i]; @@ -90,7 +91,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); const celContent = JSON.parse( - readFileSync(join(TMP_DIR, 'logs', newFile), 'utf8')); + gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js index d2d460c..75524ce 100644 --- a/tests/mocha/helpers.js +++ b/tests/mocha/helpers.js @@ -76,7 +76,7 @@ export function listCelFiles() { if(!existsSync(logsDir)) { return []; } - return readdirSync(logsDir).filter(f => f.endsWith('.cel')); + return readdirSync(logsDir).filter(f => f.endsWith('.cel.gz')); } /** From 72441944e5c9353ddbed9c7b02355fe0d26ee2fa Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 7 Jun 2026 13:52:31 -0400 Subject: [PATCH 39/44] Fix lint errors and refactor reading from CEL file. --- tests/mocha/00-setup.js | 6 +++--- tests/mocha/20-witness.js | 13 ++++++++----- tests/mocha/30-update.js | 19 ++++++++++--------- tests/mocha/40-heartbeat.js | 19 ++++++++++--------- tests/mocha/50-deactivate.js | 19 ++++++++++--------- 5 files changed, 41 insertions(+), 35 deletions(-) diff --git a/tests/mocha/00-setup.js b/tests/mocha/00-setup.js index 6aad08d..c119cde 100644 --- a/tests/mocha/00-setup.js +++ b/tests/mocha/00-setup.js @@ -1,10 +1,10 @@ /*! * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ -import {CONFIG_PATH, TMP_DIR, clearTmpDir} from './helpers.js'; -import {TEST_WITNESSES, start, stop} from './mock-witness.js'; -import {writeFileSync} from 'node:fs'; +import {clearTmpDir, CONFIG_PATH, TMP_DIR} from './helpers.js'; +import {start, stop, TEST_WITNESSES} from './mock-witness.js'; import {join} from 'node:path'; +import {writeFileSync} from 'node:fs'; import yaml from 'js-yaml'; before(async () => { diff --git a/tests/mocha/20-witness.js b/tests/mocha/20-witness.js index 8de8b4e..827cc98 100644 --- a/tests/mocha/20-witness.js +++ b/tests/mocha/20-witness.js @@ -5,12 +5,17 @@ import { listCelFiles, listSecretFiles, runDidcel, TMP_DIR } from './helpers.js'; import chai from 'chai'; +import {gunzipSync} from 'node:zlib'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; -import {gunzipSync} from 'node:zlib'; const {expect} = chai; +function readCel(filename) { + return JSON.parse( + gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); +} + describe('witness', function() { this.timeout(60000); @@ -42,8 +47,7 @@ describe('witness', function() { const after = listCelFiles(); const newFile = after.find(f => !before.includes(f)); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(1); @@ -69,8 +73,7 @@ describe('witness', function() { const after = listCelFiles(); const newFile = after.find(f => !before.includes(f)); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const proof = celContent.log[0].proof[0]; // verificationMethod should reference a real did:key (not a placeholder) diff --git a/tests/mocha/30-update.js b/tests/mocha/30-update.js index a96f321..de3465b 100644 --- a/tests/mocha/30-update.js +++ b/tests/mocha/30-update.js @@ -5,12 +5,17 @@ import { listCelFiles, runDidcel, TMP_DIR } from './helpers.js'; import chai from 'chai'; +import {gunzipSync} from 'node:zlib'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; -import {gunzipSync} from 'node:zlib'; const {expect} = chai; +function readCel(filename) { + return JSON.parse( + gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); +} + const UPDATE_COMMANDS = [ 'create', 'witness', 'add authentication ecdsa', @@ -49,8 +54,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); @@ -61,8 +65,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const updateEntry = celContent.log[1]; expect(updateEntry.event).to.have.property('previousEventHash'); @@ -76,8 +79,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const updateEntry = celContent.log[1]; const didDoc = updateEntry.event.operation.data; @@ -91,8 +93,7 @@ describe('update', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js index e1255ba..602cff1 100644 --- a/tests/mocha/40-heartbeat.js +++ b/tests/mocha/40-heartbeat.js @@ -5,12 +5,17 @@ import { listCelFiles, runDidcel, TMP_DIR } from './helpers.js'; import chai from 'chai'; +import {gunzipSync} from 'node:zlib'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; -import {gunzipSync} from 'node:zlib'; const {expect} = chai; +function readCel(filename) { + return JSON.parse( + gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); +} + const HB_COMMANDS = [ 'create', 'witness', 'heartbeat', 'witness', 'save', 'quit' ]; @@ -42,8 +47,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); @@ -54,8 +58,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event.operation).to.have.property( @@ -69,8 +72,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event).to.have.property('previousEventHash'); @@ -82,8 +84,7 @@ describe('heartbeat', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry).to.have.property('proof'); diff --git a/tests/mocha/50-deactivate.js b/tests/mocha/50-deactivate.js index 3c1db01..cc733ff 100644 --- a/tests/mocha/50-deactivate.js +++ b/tests/mocha/50-deactivate.js @@ -5,12 +5,17 @@ import { listCelFiles, runDidcel, TMP_DIR } from './helpers.js'; import chai from 'chai'; +import {gunzipSync} from 'node:zlib'; import {join} from 'node:path'; import {readFileSync} from 'node:fs'; -import {gunzipSync} from 'node:zlib'; const {expect} = chai; +function readCel(filename) { + return JSON.parse( + gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); +} + const DEACTIVATE_COMMANDS = [ 'create', 'witness', 'add authentication ecdsa', 'update', 'witness', @@ -49,8 +54,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(3); @@ -61,8 +65,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); const deactivateEntry = celContent.log[2]; expect(deactivateEntry.event.operation).to.have.property( @@ -75,8 +78,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); for(let i = 1; i < celContent.log.length; i++) { const entry = celContent.log[i]; @@ -90,8 +92,7 @@ describe('deactivate', function() { expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', newFile))).toString('utf8')); + const celContent = readCel(newFile); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); From 254bad36567dec39ae9821f6875db07654f4a00c Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 7 Jun 2026 14:08:24 -0400 Subject: [PATCH 40/44] Refactor test code into utility functions to avoid repetition. --- didcel | 73 +++++++++++------------------------- tests/mocha/20-witness.js | 26 +++---------- tests/mocha/30-update.js | 42 +++++++++------------ tests/mocha/40-heartbeat.js | 42 +++++++++------------ tests/mocha/50-deactivate.js | 42 +++++++++------------ tests/mocha/helpers.js | 40 ++++++++++++++++++-- 6 files changed, 113 insertions(+), 152 deletions(-) diff --git a/didcel b/didcel index 38dab79..b7fe09a 100755 --- a/didcel +++ b/didcel @@ -260,75 +260,44 @@ async function repl({commands, password}) { } }); + // signs an event with the current assertionMethod key and appends it to + // the CEL; previousEventHash is set before signing so it is covered by proof + async function _appendEvent({type, data}) { + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const {event} = await createEvent({ + type, data, + assertionMethod: secretKeys.assertionMethod[0], + previousEventHash + }); + cryptographicEventLog = + await addEvent({cel: cryptographicEventLog, event}); + } + // command: update - // updates the cryptographic proof on the DID document and appends an update - // event to the Cryptographic Event Log. This creates a new entry in the log - // that is hash-linked to the previous event, forming a verifiable chain. The - // proof is signed using the first assertionMethod key generated during - // create. After running update, you should run 'witness' to get witness - // attestations. + // appends a signed update event to the CEL with the current DID document + // state. hash-linked to the previous event. Run 'witness' afterward. repl.command('update') .description('Update the cryptographic event log with the latest DID doc') .action(async () => { - // step 1: Get the hash of the current last event so it can be covered - // by the new operation proof before signing - const previousEventHash = - await getPreviousEventHash({cel: cryptographicEventLog}); - - // step 2: Sign the current DID document state; previousEventHash is - // included in the event before signing so it is part of the proof - const result = await createEvent({ - data: didDocument, type: 'update', - assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); - const event = result.event; - - // step 3: Append the signed, hash-linked event to the CEL - cryptographicEventLog = - await addEvent({cel: cryptographicEventLog, event}); + await _appendEvent({type: 'update', data: didDocument}); }); // command: heartbeat - // generates a heartbeat to ensure the DID Document does not deactivate + // 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 () => { - // step 1: Get the hash of the current last event so it can be covered - // by the new operation proof before signing - const previousEventHash = - await getPreviousEventHash({cel: cryptographicEventLog}); - - // step 2: Create a heartbeat event with previousEventHash covered by proof - const result = await createEvent({ - data: undefined, type: 'heartbeat', - assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); - const event = result.event; - - // step 3: Append the signed, hash-linked event to the CEL - cryptographicEventLog = - await addEvent({cel: cryptographicEventLog, event}); + await _appendEvent({type: 'heartbeat', data: undefined}); console.log('heartbeat: generated'); }); // command: deactivate - // Deactivates the DID + // deactivates the DID; terminal operation, no further events permitted repl.command('deactivate') .description('Deactivate the DID') .action(async () => { - // step 1: Get the hash of the current last event so it can be covered - // by the new operation proof before signing - const previousEventHash = - await getPreviousEventHash({cel: cryptographicEventLog}); - - // step 2: Create the deactivation event with previousEventHash covered - // by the operation proof - const result = await createEvent({ - data: undefined, type: 'deactivate', - assertionMethod: secretKeys.assertionMethod[0], previousEventHash}); - const event = result.event; - - // step 3: Append the signed, hash-linked event to the CEL - cryptographicEventLog = - await addEvent({cel: cryptographicEventLog, event}); + await _appendEvent({type: 'deactivate', data: undefined}); console.log('deactivation: complete'); }); diff --git a/tests/mocha/20-witness.js b/tests/mocha/20-witness.js index 827cc98..54d8642 100644 --- a/tests/mocha/20-witness.js +++ b/tests/mocha/20-witness.js @@ -2,20 +2,12 @@ * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ import { - listCelFiles, listSecretFiles, runDidcel, TMP_DIR + listCelFiles, listSecretFiles, readCelFile, runAndCapture, runDidcel } from './helpers.js'; import chai from 'chai'; -import {gunzipSync} from 'node:zlib'; -import {join} from 'node:path'; -import {readFileSync} from 'node:fs'; const {expect} = chai; -function readCel(filename) { - return JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); -} - describe('witness', function() { this.timeout(60000); @@ -37,17 +29,13 @@ describe('witness', function() { it('should produce a CEL with a witness proof on the create event', async () => { - const before = listCelFiles(); - - const {stderr, exitCode} = await runDidcel({ + const {exitCode, stderr, newFile} = await runAndCapture({ commands: ['create', 'witness', 'save', 'quit'] }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const after = listCelFiles(); - const newFile = after.find(f => !before.includes(f)); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(1); @@ -63,17 +51,13 @@ describe('witness', function() { }); it('should have witness proof with a real verificationMethod', async () => { - const before = listCelFiles(); - - const {exitCode, stderr} = await runDidcel({ + const {exitCode, stderr, newFile} = await runAndCapture({ commands: ['create', 'witness', 'save', 'quit'] }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const after = listCelFiles(); - const newFile = after.find(f => !before.includes(f)); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const proof = celContent.log[0].proof[0]; // verificationMethod should reference a real did:key (not a placeholder) diff --git a/tests/mocha/30-update.js b/tests/mocha/30-update.js index de3465b..3036d2b 100644 --- a/tests/mocha/30-update.js +++ b/tests/mocha/30-update.js @@ -2,20 +2,12 @@ * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ import { - listCelFiles, runDidcel, TMP_DIR + listCelFiles, readCelFile, runAndCapture, runDidcel } from './helpers.js'; import chai from 'chai'; -import {gunzipSync} from 'node:zlib'; -import {join} from 'node:path'; -import {readFileSync} from 'node:fs'; const {expect} = chai; -function readCel(filename) { - return JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); -} - const UPDATE_COMMANDS = [ 'create', 'witness', 'add authentication ecdsa', @@ -23,14 +15,6 @@ const UPDATE_COMMANDS = [ 'save', 'quit' ]; -async function runUpdate() { - const before = listCelFiles(); - const result = await runDidcel({commands: UPDATE_COMMANDS}); - const after = listCelFiles(); - const newFile = after.find(f => !before.includes(f)); - return {...result, newFile}; -} - describe('update', function() { this.timeout(120000); @@ -50,22 +34,26 @@ describe('update', function() { }); it('should produce a CEL with 2 events (create + update)', async () => { - const {exitCode, stderr, newFile} = await runUpdate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: UPDATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); }); it('should hashlink events via previousEventHash', async () => { - const {exitCode, stderr, newFile} = await runUpdate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: UPDATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const updateEntry = celContent.log[1]; expect(updateEntry.event).to.have.property('previousEventHash'); @@ -75,11 +63,13 @@ describe('update', function() { it('should include the new authentication key in the update event', async () => { - const {exitCode, stderr, newFile} = await runUpdate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: UPDATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const updateEntry = celContent.log[1]; const didDoc = updateEntry.event.operation.data; @@ -89,11 +79,13 @@ describe('update', function() { }); it('should witness proofs on both events', async () => { - const {exitCode, stderr, newFile} = await runUpdate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: UPDATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js index 602cff1..cc2b024 100644 --- a/tests/mocha/40-heartbeat.js +++ b/tests/mocha/40-heartbeat.js @@ -2,32 +2,16 @@ * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ import { - listCelFiles, runDidcel, TMP_DIR + listCelFiles, readCelFile, runAndCapture, runDidcel } from './helpers.js'; import chai from 'chai'; -import {gunzipSync} from 'node:zlib'; -import {join} from 'node:path'; -import {readFileSync} from 'node:fs'; const {expect} = chai; -function readCel(filename) { - return JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); -} - const HB_COMMANDS = [ 'create', 'witness', 'heartbeat', 'witness', 'save', 'quit' ]; -async function runHeartbeat() { - const before = listCelFiles(); - const result = await runDidcel({commands: HB_COMMANDS}); - const after = listCelFiles(); - const newFile = after.find(f => !before.includes(f)); - return {...result, newFile}; -} - describe('heartbeat', function() { this.timeout(120000); @@ -43,22 +27,26 @@ describe('heartbeat', function() { }); it('should produce a CEL with 2 events (create + heartbeat)', async () => { - const {exitCode, stderr, newFile} = await runHeartbeat(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: HB_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(2); }); it('should have heartbeat event with correct operation type', async () => { - const {exitCode, stderr, newFile} = await runHeartbeat(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: HB_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event.operation).to.have.property( @@ -68,11 +56,13 @@ describe('heartbeat', function() { it('should hash-link heartbeat event to the witnessed create event', async () => { - const {exitCode, stderr, newFile} = await runHeartbeat(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: HB_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event).to.have.property('previousEventHash'); @@ -80,11 +70,13 @@ describe('heartbeat', function() { }); it('should witness the heartbeat event', async () => { - const {exitCode, stderr, newFile} = await runHeartbeat(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: HB_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry).to.have.property('proof'); diff --git a/tests/mocha/50-deactivate.js b/tests/mocha/50-deactivate.js index cc733ff..36d9caa 100644 --- a/tests/mocha/50-deactivate.js +++ b/tests/mocha/50-deactivate.js @@ -2,20 +2,12 @@ * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ import { - listCelFiles, runDidcel, TMP_DIR + listCelFiles, readCelFile, runAndCapture, runDidcel } from './helpers.js'; import chai from 'chai'; -import {gunzipSync} from 'node:zlib'; -import {join} from 'node:path'; -import {readFileSync} from 'node:fs'; const {expect} = chai; -function readCel(filename) { - return JSON.parse( - gunzipSync(readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); -} - const DEACTIVATE_COMMANDS = [ 'create', 'witness', 'add authentication ecdsa', 'update', 'witness', @@ -23,14 +15,6 @@ const DEACTIVATE_COMMANDS = [ 'save', 'quit' ]; -async function runDeactivate() { - const before = listCelFiles(); - const result = await runDidcel({commands: DEACTIVATE_COMMANDS}); - const after = listCelFiles(); - const newFile = after.find(f => !before.includes(f)); - return {...result, newFile}; -} - describe('deactivate', function() { this.timeout(120000); @@ -50,22 +34,26 @@ describe('deactivate', function() { it('should produce a CEL with 3 events (create + update + deactivate)', async () => { - const {exitCode, stderr, newFile} = await runDeactivate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); expect(celContent).to.have.property('log'); expect(celContent.log).to.have.length(3); }); it('should have deactivate event with correct operation type', async () => { - const {exitCode, stderr, newFile} = await runDeactivate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); const deactivateEntry = celContent.log[2]; expect(deactivateEntry.event.operation).to.have.property( @@ -74,11 +62,13 @@ describe('deactivate', function() { }); it('should hash-link all events in the chain', async () => { - const {exitCode, stderr, newFile} = await runDeactivate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); for(let i = 1; i < celContent.log.length; i++) { const entry = celContent.log[i]; @@ -88,11 +78,13 @@ describe('deactivate', function() { }); it('should have witness proofs on all events', async () => { - const {exitCode, stderr, newFile} = await runDeactivate(); + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); expect(exitCode, `stderr: ${stderr}`).to.equal(0); - const celContent = readCel(newFile); + const celContent = readCelFile(newFile); for(const entry of celContent.log) { expect(entry).to.have.property('proof'); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js index 75524ce..24b0205 100644 --- a/tests/mocha/helpers.js +++ b/tests/mocha/helpers.js @@ -1,9 +1,12 @@ /*! * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ -import {existsSync, mkdirSync, readdirSync, rmSync} from 'node:fs'; +import { + existsSync, mkdirSync, readdirSync, readFileSync, rmSync +} from 'node:fs'; import {execFile} from 'node:child_process'; import {fileURLToPath} from 'node:url'; +import {gunzipSync} from 'node:zlib'; import {join} from 'node:path'; import path from 'node:path'; import {promisify} from 'node:util'; @@ -30,6 +33,18 @@ export function clearTmpDir() { mkdirSync(join(TMP_DIR, 'secrets'), {recursive: true}); } +/** + * Reads and decompresses a gzipped CEL file from the test tmp/logs directory. + * + * @param {string} filename - The filename within tmp/logs to read. + * @returns {object} The parsed CEL object. + */ +export function readCelFile(filename) { + return JSON.parse( + gunzipSync( + readFileSync(join(TMP_DIR, 'logs', filename))).toString('utf8')); +} + /** * Runs the didcel CLI with the given commands and returns stdout/stderr. * @@ -37,8 +52,8 @@ export function clearTmpDir() { * @param {Array} options.commands - Commands to pass via -c flags. * @param {string} [options.password] - Encryption password (-p flag). * @param {number} [options.timeout] - Timeout in ms (default 120000). - * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} - - * the output of the command. + * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} + * The output of the command. */ export async function runDidcel({ commands, @@ -67,7 +82,24 @@ export async function runDidcel({ } /** - * Lists .cel files in the test tmp/logs directory. + * Runs the didcel CLI with the given commands and returns the result along + * with the new .cel.gz file that was created during the run. + * + * @param {object} options - Options. + * @param {Array} options.commands - Commands to pass via -c flags. + * @param {string} [options.password] - Encryption password (-p flag). + * @returns {Promise} Result from runDidcel extended with {newFile}. + */ +export async function runAndCapture({commands, password}) { + const before = listCelFiles(); + const result = await runDidcel({commands, password}); + const after = listCelFiles(); + const newFile = after.find(f => !before.includes(f)); + return {...result, newFile}; +} + +/** + * Lists .cel.gz files in the test tmp/logs directory. * * @returns {Array} Array of filenames. */ From bd92b3c38e262e2084589fd185c9bc5fb7d0cc70 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 7 Jun 2026 14:11:52 -0400 Subject: [PATCH 41/44] Fix import ordering in didcel CLI. --- didcel | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/didcel b/didcel index b7fe09a..7007fa2 100755 --- a/didcel +++ b/didcel @@ -24,16 +24,15 @@ * quit - Exit the REPL. */ -import {Argument, Command, CommanderError} from 'commander'; -import {config, loadConfig} from './lib/config.js'; import { - addEvent, createCel, getPreviousEventHash, loadFromFile, saveToFile, witness, - addVm, create, createEvent, - loadSecrets, saveSecrets, - createJsonldPrettyPrinter, deleteObjectByIdSuffix, getObjectByIdSuffix + addEvent, addVm, create, createEvent, createJsonldPrettyPrinter, + deleteObjectByIdSuffix, getObjectByIdSuffix, getPreviousEventHash, + loadFromFile, loadSecrets, saveSecrets, saveToFile, witness } from 'didcel'; -import {mkdirSync} from 'fs'; +import {Argument, Command, CommanderError} from 'commander'; +import {config, loadConfig} from './lib/config.js'; import {join} from 'node:path'; +import {mkdirSync} from 'fs'; import promptSync from 'prompt-sync'; // create the CLI and parse command-line options From d0c91f0b411d7adaba73966ba0f9cc61c372fda9 Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 7 Jun 2026 14:28:56 -0400 Subject: [PATCH 42/44] Update documentation and README.md. --- README.md | 180 +++++++++++++++++++++++++++++++++++------------------- didcel | 43 ++++++------- 2 files changed, 134 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index d25de5e..4599db6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ The `did:cel` method is a fully decentralized DID method that doesn't depend on npm install ``` +## Configuration + +The tool reads a YAML configuration file. By default it looks for +`~/.config/didcel/config.yaml`. Pass `-g ` to use a different file. + +```yaml +# URL(s) of witness services to contact during the witness command +witnesses: + - https://red-witness.example/witnesses/v1/witness + - https://green-witness.example/witnesses/v1/witness + - https://blue-witness.example/witnesses/v1/witness + +# directory where .cel.gz event log files are written by the save command +logs: ~/.config/didcel/logs + +# directory where encrypted secret key files are written by the save command +secrets: ~/.config/didcel/secrets +``` + ## Usage ### Starting the REPL @@ -27,9 +46,13 @@ To start the interactive REPL: ./didcel ``` -### Non-Interactive Mode +You will be prompted for an encryption password used to protect secret keys on +disk. Pass `-p ` to supply it non-interactively. -You can execute one or more commands and then enter interactive mode: +### Non-Interactive (Batch) Mode + +Pass one or more `-c` flags to execute commands before entering the interactive +loop: ```bash ./didcel -c "create" -c "witness" -c "save" -c "quit" @@ -37,12 +60,6 @@ You can execute one or more commands and then enter interactive mode: ## REPL Interactive Mode -To run in interactive mode, do the following: - -```bash -./didcel -``` - Once in the REPL, you'll see a `did:cel>` prompt. The following commands are available: ### `help` @@ -67,9 +84,9 @@ Creates a new DID document with an initial verification method. did:cel> create ``` -**Description:** Generates a new `did:cel` DID document with a self-certifying identifier derived from the document's cryptographic hash. Creates an initial assertion method using a P-256 elliptic curve key pair and initializes a Cryptographic Event Log (CEL) to track the DID's history. The DID identifier is generated by hashing the canonicalized DID document using SHA3-256. +**Description:** Generates a new `did:cel` DID document with a self-certifying identifier derived from the document's cryptographic hash. Creates an initial assertion method using a P-256 elliptic curve key pair and a recovery key pair, and initializes a Cryptographic Event Log (CEL) to track the DID's history. The DID identifier is generated by computing `did:cel:` + base58btc(SHA3-256(JCS(initial-DID-document-without-id))). -**Output:** Displays the created DID identifier (e.g., `did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE`) +**Output:** Displays the created DID identifier (e.g., `create successful: did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE`) --- @@ -197,15 +214,43 @@ Updates the cryptographic event log with the latest DID document changes. did:cel> update ``` -**Description:** Performs a two-phase operation to record changes to the Cryptographic Event Log: +**Description:** Creates a new signed update event containing the current DID document and appends it to the Cryptographic Event Log. The event includes a `previousEventHash` field — a base58btc-encoded SHA3-256 hash of the previous event — that hash-links it to the preceding entry, making the chain tamper-evident. -1. **Update Proof:** Regenerates the cryptographic proof on the DID document using the first assertion method key (created during the `create` command). This signs the current state of the DID document. +After running `update`, you should run the `witness` command to obtain independent attestations from witness services. -2. **Append Event:** Creates a new update event in the Cryptographic Event Log that is hash-linked to the previous event. The hash of the previous event is computed using SHA3-256 and encoded in base58-btc format, then stored in the `previousEvent` property of the new event. +**Note:** This command does not display output but prepares the event log for witnessing. + +--- -This creates an immutable, verifiable chain of events. After running `update`, you should run the `witness` command to obtain independent attestations from witness services. +### `heartbeat` -**Note:** This command does not display output but prepares the event log for witnessing. +Appends a heartbeat event to the cryptographic event log. + +**Usage:** +``` +did:cel> heartbeat +``` + +**Description:** Creates a signed heartbeat event and appends it to the Cryptographic Event Log. A heartbeat carries no DID document data — it simply proves the DID is still active. DIDs with a `heartbeatFrequency` field are considered automatically deactivated if no witnessed heartbeat (or other event) is recorded within the specified ISO 8601 duration (e.g., `P3M` = 3 months). + +After running `heartbeat`, run the `witness` command to obtain witness attestations. + +**Output:** `heartbeat: generated` + +--- + +### `deactivate` + +Permanently deactivates the DID. + +**Usage:** +``` +did:cel> deactivate +``` + +**Description:** Creates a signed deactivate event and appends it to the Cryptographic Event Log. Deactivation is a terminal operation — no further events (create, update, heartbeat) may be appended after it. After running `deactivate`, run the `witness` command and then `save` to persist the final state. + +**Output:** `deactivation: complete` --- @@ -218,61 +263,57 @@ Obtains cryptographic attestations from witness services for the latest event. did:cel> witness ``` -**Description:** Generates witness proofs for the most recent event in the Cryptographic Event Log. By default, the tool contacts three independent witness services (red, green, and blue witnesses), each of which: - -1. Validates the event -2. Creates a cryptographic proof (data integrity proof using ecdsa-jcs-2019) -3. Returns the proof as an attestation +**Description:** Sends a SHA3-256 digest of the most recent event in the Cryptographic Event Log to each configured witness service. Witnesses are *blind* — they never see the DID document, only the hash — and each returns a DataIntegrityProof (ecdsa-jcs-2019) that is attached to the log entry. These witness attestations provide: -- **Temporal anchoring:** Proof of when the event occurred -- **Independent validation:** Third-party verification of the event +- **Temporal anchoring:** Proof of when the event was witnessed +- **Independent validation:** Third-party attestation of the event hash - **Distributed trust:** No single witness can compromise the system -The witness proofs are attached to the event structure in the Cryptographic Event Log, creating a fully attested and verifiable history of DID operations. +The witness proofs are attached to the log entry in the Cryptographic Event Log, creating a fully attested and verifiable history of DID operations. -**Output:** Confirmation message when witness proofs are complete. +**Output:** `witness: proofs complete` --- -### `save [filename]` +### `load ` -Saves the Cryptographic Event Log to a file. +Loads and validates a DID from a cryptographic event log file. **Usage:** ``` -did:cel> save [filename] +did:cel> load ``` **Parameters:** -- `[filename]` (optional): The name of the file to save to. Defaults to `did.cel` if not specified. +- ``: Path to the `.cel.gz` file to load. -**Description:** Writes the complete Cryptographic Event Log (CEL) to a JSON file. The file contains the entire history of DID operations, including: -- Create and update events -- All witness attestations -- Hash-linked event chain -- Complete DID document state at each event +**Description:** Reads a gzip-compressed CEL file, fully validates the event chain (DID self-certification, hash chain, operation proofs, and trusted witness proofs if configured), and restores the current DID document and session state. Also loads the corresponding encrypted secret keys from `config.secrets` so subsequent commands can sign new events. -The JSON is formatted with keys ordered for readability (@context, id, type, cryptosuite, previousEvent first, then alphabetically). This file can later be loaded to reconstruct the DID's complete history and verify the integrity of the event chain. - -**Example:** -``` -did:cel> save my-did.cel -Wrote to my-did.cel -``` +**Output:** `load: valid CEL with N event(s): did:cel:z...` or one or more `error:` lines describing validation failures. --- -### `load` +### `save [filename]` -Loads a DID from a cryptographic event log file. +Saves the Cryptographic Event Log to a file. **Usage:** ``` -did:cel> load +did:cel> save [filename] ``` -**Description:** ⚠️ **Not yet implemented.** This command will eventually load a previously saved Cryptographic Event Log from a file, reconstruct the DID document state, and verify the integrity of the event chain and witness attestations. +**Parameters:** +- `[filename]` (optional): An additional path to write the event log to. + +**Description:** Writes the complete Cryptographic Event Log (CEL) to a gzip-compressed JSON file (`.cel.gz`) in the `logs` directory from config. Also encrypts all secret keys with AES-256-GCM (key derived via scrypt) and saves them to the `secrets` directory. If `filename` is supplied, the CEL is also written to that path. The CEL file contains the entire history of DID operations and can later be loaded with the `load` command. + +**Example:** +``` +did:cel> save my-did.cel.gz +Wrote to /home/user/.config/didcel/logs/z1abc...xyz.cel.gz +Wrote secrets to /home/user/.config/didcel/secrets/z1abc...xyz.yaml +``` --- @@ -298,48 +339,59 @@ Here's a common workflow for creating and managing a DID: # 2. Create a new DID did:cel> create -# 3. Add additional verification methods -did:cel> add authentication ecdsa -did:cel> add assertionMethod ecdsa +# 3. Witness the create event +did:cel> witness -# 4. View the current state -did:cel> ls +# 4. Add additional verification methods +did:cel> add authentication ecdsa -# 5. Update the event log with changes +# 5. Record the changes as an update event did:cel> update -# 6. Get witness attestations +# 6. Get witness attestations for the update did:cel> witness -# 7. Save the complete event log +# 7. Save the complete event log and encrypted keys did:cel> save # 8. Exit did:cel> quit ``` +### Periodic heartbeat + +If your DID document has a `heartbeatFrequency`, you must record witnessed +heartbeats within that interval to keep the DID active: + +```bash +did:cel> load z1abc...xyz.cel.gz +did:cel> heartbeat +did:cel> witness +did:cel> save +did:cel> quit +``` + ## Architecture The DID CEL tools implement the `did:cel` DID method, which consists of: -- **Self-certifying identifiers:** DID identifiers derived from cryptographic hashes of the initial DID document -- **Cryptographic Event Log (CEL):** A hash-linked chain of events recording all DID operations -- **Witness attestations:** Independent cryptographic proofs from witness services providing temporal evidence and distributed validation -- **Data Integrity Proofs:** ecdsa-jcs-2019 cryptographic signatures on both DID documents and events +- **Self-certifying identifiers:** DID identifiers derived from `did:cel:` + base58btc(SHA3-256(JCS(initial-DID-document-without-id))) +- **Cryptographic Event Log (CEL):** A hash-linked chain of events recording all DID operations, stored as gzip-compressed JSON +- **Blind witness attestations:** Witness services receive only a SHA3-256 digest of each event and return DataIntegrityProofs, providing temporal anchoring and distributed trust without ever seeing DID document contents +- **Data Integrity Proofs:** ecdsa-jcs-2019 cryptographic signatures on events (operation proofs) and on witness attestations (witness proofs) +- **Recovery keys:** Each DID document stores SHA3-256 hashes of recovery `did:key:` URIs; recovery operations require rotating these hashes ## File Structure - `didcel` - Main executable script and REPL implementation -- `lib/cel.js` - Cryptographic Event Log management (create, update, witness) -- `lib/didcel.js` - DID document operations (create, add verification methods, update proofs) -- `lib/witness.js` - Witness service for generating attestation proofs -- `lib/utils.js` - Utility functions for JSON-LD formatting and object manipulation +- `lib/config.js` - Configuration loader (reads `config.yaml`, resolves `~/` paths) ## Security Considerations -- **Secret Keys:** The tool stores secret keys in memory during the session. Keys are lost when you exit the REPL unless you implement your own key management. -- **Witness Keys:** Currently uses hardcoded witness keys for development/testing. In production, witnesses should be independent services with securely managed keys. -- **File Storage:** Saved CEL files contain only public information (DID documents and proofs), not secret keys. +- **Secret Keys:** Secret keys are stored in memory during the session and encrypted to disk when you run `save`. Keys use AES-256-GCM with scrypt key derivation. +- **Blind Witnesses:** Witness services never see the DID document — they only sign a SHA3-256 hash of the event. This prevents witnesses from learning private information about DID controllers. +- **File Storage:** CEL files (`.cel.gz`) contain only public information (DID documents and proofs), not secret keys. Secret keys are stored separately in encrypted YAML files. +- **Recovery Keys:** Recovery key hashes are stored in the DID document. A recovery operation requires proving possession of a recovery key and rotating the hash, preventing replay attacks. ## License @@ -351,6 +403,6 @@ This is an experimental implementation of the `did:cel` DID method. Contribution ## Related Specifications -- [DID CEL Specification](https://digitalbazaar.github.io/did-cel-spec/) - Technical specification for the `did:cel` method +- [DID CEL Specification](https://w3c-ccg.github.io/did-cel-spec/) - Technical specification for the `did:cel` method - [W3C Decentralized Identifiers (DIDs) v1.0](https://www.w3.org/TR/did-core/) - Core DID specification - [Verifiable Credential Data Integrity](https://www.w3.org/TR/vc-data-integrity/) - Data Integrity Proofs specification diff --git a/didcel b/didcel index 7007fa2..79a762c 100755 --- a/didcel +++ b/didcel @@ -259,8 +259,8 @@ async function repl({commands, password}) { } }); - // signs an event with the current assertionMethod key and appends it to - // the CEL; previousEventHash is set before signing so it is covered by proof + // computes previousEventHash from the last CEL entry, creates a signed event + // with that hash (so it is covered by the proof), and appends it to the CEL async function _appendEvent({type, data}) { const previousEventHash = await getPreviousEventHash({cel: cryptographicEventLog}); @@ -274,8 +274,8 @@ async function repl({commands, password}) { } // command: update - // appends a signed update event to the CEL with the current DID document - // state. hash-linked to the previous event. Run 'witness' afterward. + // 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 () => { @@ -301,11 +301,10 @@ async function repl({commands, password}) { }); // command: witness - // generates cryptographic proofs from external witnesses that attest to the - // validity of the most recent event in the CEL. By default, three witnesses - // (red, green, and blue) each independently sign the event, providing - // decentralized attestation. This is a key feature of the CEL architecture - // that prevents single points of failure and enables auditability. + // sends a SHA3-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.') @@ -317,11 +316,10 @@ async function repl({commands, password}) { }); // command: save - // persists the Cryptographic Event Log to a file. The CEL contains the - // complete history of all operations on the DID document, including create - // and update events, along with witness attestations. The file is saved in - // JSON format with keys ordered for readability (e.g., @context, id, type - // first). This file can later be loaded to reconstruct the DID's history. + // 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.') @@ -351,28 +349,23 @@ async function repl({commands, password}) { }); // command: quit - // exits the REPL without saving. Any unsaved changes to the DID document - // or CEL will be lost. Make sure to run 'save' before quitting if you want - // to persist your work. + // 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 - // before entering interactive mode. This allows for scripting common - // operations, e.g., ./didcel -c "create" "add assertionMethod ecdsa" "update" - // each command is parsed and executed, with errors suppressed to allow - // remaining commands to run. + // 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 { - // parse command arguments and execute - // the command is duplicated in the array for Commander.js parsing + // prepend two dummy argv entries so Commander.js sees the right offset const commanderArgs = [command, command].concat(args); await repl.parseAsync(commanderArgs); } catch(err) { From 0704d2923514541075a2e74318f3a50446a0028f Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Thu, 11 Jun 2026 20:56:10 -0400 Subject: [PATCH 43/44] Add CEL loading tests. --- tests/mocha/60-load.js | 96 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/mocha/60-load.js diff --git a/tests/mocha/60-load.js b/tests/mocha/60-load.js new file mode 100644 index 0000000..c0fa19e --- /dev/null +++ b/tests/mocha/60-load.js @@ -0,0 +1,96 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + readCelFile, runAndCapture, runDidcel, TMP_DIR +} from './helpers.js'; +import chai from 'chai'; +import {gzipSync} from 'node:zlib'; +import {join} from 'node:path'; +import {writeFileSync} from 'node:fs'; + +const {expect} = chai; + +async function createAndLoad({commands}) { + const {newFile} = await runAndCapture({commands}); + const filename = join(TMP_DIR, 'logs', newFile); + return runDidcel({commands: [`load ${filename}`, 'quit']}); +} + +describe('load', function() { + this.timeout(120000); + + it('should load and validate a create-only CEL', async () => { + const {stdout, stderr, exitCode} = await createAndLoad({ + commands: ['create', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('load: valid CEL with 1 event(s): did:cel:'); + }); + + it('should load and validate a witnessed CEL', async () => { + const {stdout, stderr, exitCode} = await createAndLoad({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('load: valid CEL with 1 event(s): did:cel:'); + }); + + it('should load and validate a CEL with an update', async () => { + const {stdout, stderr, exitCode} = await createAndLoad({ + commands: [ + 'create', 'witness', + 'add authentication ecdsa', 'update', 'witness', + 'save', 'quit' + ] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('load: valid CEL with 2 event(s): did:cel:'); + }); + + it('should load and validate a CEL with a heartbeat', async () => { + const {stdout, stderr, exitCode} = await createAndLoad({ + commands: ['create', 'witness', 'heartbeat', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('load: valid CEL with 2 event(s): did:cel:'); + }); + + it('should load and validate a deactivated CEL', async () => { + const {stdout, stderr, exitCode} = await createAndLoad({ + commands: [ + 'create', 'witness', + 'add authentication ecdsa', 'update', 'witness', + 'deactivate', 'witness', + 'save', 'quit' + ] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + expect(stdout).to.include('load: valid CEL with 3 event(s): did:cel:'); + }); + + it('should error on a CEL with a tampered hash chain', async () => { + const {newFile} = await runAndCapture({ + commands: ['create', 'witness', 'heartbeat', 'witness', 'save', 'quit'] + }); + const filename = join(TMP_DIR, 'logs', newFile); + + // tamper with the previousEventHash in entry 1 + const cel = readCelFile(newFile); + cel.log[1].event.previousEventHash = 'zTAMPEREDHASH'; + writeFileSync(filename, + gzipSync(Buffer.from(JSON.stringify(cel), 'utf8'))); + + const {stdout, exitCode} = await runDidcel({ + commands: [`load ${filename}`, 'quit'] + }); + + expect(exitCode).to.equal(0); + expect(stdout).to.include('error: entry 1: previousEventHash mismatch'); + }); +}); From 02c0507832b94564629ce846cbf4eb7b497cc9bc Mon Sep 17 00:00:00 2001 From: Manu Sporny Date: Sun, 28 Jun 2026 07:20:25 -0400 Subject: [PATCH 44/44] Update calls to didcel to match new interface. --- README.md | 8 +-- didcel | 79 +++++++++++++++++---------- tests/mocha/40-heartbeat.js | 4 +- tests/mocha/mock-witness.js | 106 +++++++++++++++++++++++++++++++++--- 4 files changed, 153 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4599db6..02caebd 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,14 @@ did:cel> help ### `create` -Creates a new DID document with an initial verification method. +Creates a new DID document with a heartbeat commitment. **Usage:** ``` did:cel> create ``` -**Description:** Generates a new `did:cel` DID document with a self-certifying identifier derived from the document's cryptographic hash. Creates an initial assertion method using a P-256 elliptic curve key pair and a recovery key pair, and initializes a Cryptographic Event Log (CEL) to track the DID's history. The DID identifier is generated by computing `did:cel:` + base58btc(SHA3-256(JCS(initial-DID-document-without-id))). +**Description:** Generates a new `did:cel` DID document with a self-certifying identifier derived from the document's cryptographic hash, and initializes a Cryptographic Event Log (CEL) to track the DID's history. The DID identifier is generated by computing `did:cel:` + base58btc(SHA3-256(JCS(initial-DID-document-without-id))). No verification methods are added at creation time — use the `add` command afterwards to add keys for authentication, assertionMethod, and other relationships. **Output:** Displays the created DID identifier (e.g., `create successful: did:cel:zW1jPC3ViLfgPJX6KaPMhymin3LpATUgYTS7N58FLHtQ4HE`) @@ -379,7 +379,7 @@ The DID CEL tools implement the `did:cel` DID method, which consists of: - **Cryptographic Event Log (CEL):** A hash-linked chain of events recording all DID operations, stored as gzip-compressed JSON - **Blind witness attestations:** Witness services receive only a SHA3-256 digest of each event and return DataIntegrityProofs, providing temporal anchoring and distributed trust without ever seeing DID document contents - **Data Integrity Proofs:** ecdsa-jcs-2019 cryptographic signatures on events (operation proofs) and on witness attestations (witness proofs) -- **Recovery keys:** Each DID document stores SHA3-256 hashes of recovery `did:key:` URIs; recovery operations require rotating these hashes +- **Heartbeat keys:** Each DID document stores SHA3-256 hashes of heartbeat `did:key:` URIs derived from a master secret via HKDF-SHA256; each signed event rotates the hash to the next derived key, preventing key reuse ## File Structure @@ -391,7 +391,7 @@ The DID CEL tools implement the `did:cel` DID method, which consists of: - **Secret Keys:** Secret keys are stored in memory during the session and encrypted to disk when you run `save`. Keys use AES-256-GCM with scrypt key derivation. - **Blind Witnesses:** Witness services never see the DID document — they only sign a SHA3-256 hash of the event. This prevents witnesses from learning private information about DID controllers. - **File Storage:** CEL files (`.cel.gz`) contain only public information (DID documents and proofs), not secret keys. Secret keys are stored separately in encrypted YAML files. -- **Recovery Keys:** Recovery key hashes are stored in the DID document. A recovery operation requires proving possession of a recovery key and rotating the hash, preventing replay attacks. +- **Heartbeat Keys:** Heartbeat key hashes are stored in the DID document. Each signed event must include the next heartbeat key's hash, rotating the commitment forward and preventing key reuse or replay attacks. ## License diff --git a/didcel b/didcel index 79a762c..0c49b09 100755 --- a/didcel +++ b/didcel @@ -25,9 +25,10 @@ */ import { - addEvent, addVm, create, createEvent, createJsonldPrettyPrinter, + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, deleteObjectByIdSuffix, getObjectByIdSuffix, getPreviousEventHash, - loadFromFile, loadSecrets, saveSecrets, saveToFile, witness + loadFromFile, loadSecrets, prettyPrintCel, saveSecrets, saveToFile, + sha3256Multibase, witness } from 'didcel'; import {Argument, Command, CommanderError} from 'commander'; import {config, loadConfig} from './lib/config.js'; @@ -47,13 +48,6 @@ const options = program.opts(); loadConfig({configPath: options.config}); -// create the JSON-LD pretty printer for formatted output -// orders keys with @context, id, type first, then alphabetically -const jsonldPretty = createJsonldPrettyPrinter({ - preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', - 'heartbeatFrequency', 'previousEventHash'] -}); - // common verification relationship and service properties in DID documents const COMMON_PROPERTIES = [ 'authentication', 'assertionMethod', 'capabilityDelegation', @@ -85,7 +79,7 @@ async function repl({commands, password}) { let cryptographicEventLog; // the current DID document let didDocument; - // secret keys organized by verification relationship + // secret keys organized by verification relationship; heartbeat is a Buffer const secretKeys = { authentication: [], assertionMethod: [], @@ -93,6 +87,8 @@ async function repl({commands, password}) { capabilityDelegation: [], keyAgreement: [] }; + // index of the next heartbeat key to use for signing (0-based) + let heartbeatKeyIndex = 0; // configure the Commander.js REPL with custom error handling repl.name('command') @@ -113,12 +109,14 @@ async function repl({commands, password}) { try { const result = await loadFromFile({filename}); if(result.valid) { - didDocument = result.didDocument; + // clone to decouple the session document from the CEL's entries + didDocument = structuredClone(result.didDocument); cryptographicEventLog = result.cel; 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}`); @@ -133,17 +131,18 @@ async function repl({commands, password}) { }); // command: create - // creates a new DID document with an initial assertionMethod key + // 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 () => { - // generate a new DID document with P-256 elliptic curve key - const result = await create({curve: 'P-256'}); - didDocument = result.didDocument; - // store the secret key for future signing operations - secretKeys.assertionMethod = [result.keyPair]; - // use the CEL returned directly by create() + 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; console.log(`create successful: ${didDocument.id}`); }); @@ -182,7 +181,7 @@ async function repl({commands, password}) { if(suffix) { const value = getObjectByIdSuffix({didDocument, suffix}); if(value) { - console.log(JSON.stringify(value, jsonldPretty, 2)); + console.log(prettyPrintCel(value)); return; } } @@ -259,18 +258,38 @@ async function repl({commands, password}) { } }); - // computes previousEventHash from the last CEL entry, creates a signed event - // with that hash (so it is covered by the proof), and appends it to the CEL + // 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 = - await getPreviousEventHash({cel: cryptographicEventLog}); - const {event} = await createEvent({ - type, data, - assertionMethod: secretKeys.assertionMethod[0], - previousEventHash - }); - cryptographicEventLog = - await addEvent({cel: cryptographicEventLog, event}); + 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 diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js index cc2b024..d77d26b 100644 --- a/tests/mocha/40-heartbeat.js +++ b/tests/mocha/40-heartbeat.js @@ -51,7 +51,9 @@ describe('heartbeat', function() { const heartbeatEntry = celContent.log[1]; expect(heartbeatEntry.event.operation).to.have.property( 'type', 'heartbeat'); - expect(heartbeatEntry.event.operation.data).to.be.undefined; + expect(heartbeatEntry.event.operation.data).to.have.property('heartbeat'); + expect(heartbeatEntry.event.operation.data.heartbeat).to.be.an('array'); + expect(heartbeatEntry.event.operation.data.heartbeat[0]).to.match(/^z/); }); it('should hash-link heartbeat event to the witnessed create event', diff --git a/tests/mocha/mock-witness.js b/tests/mocha/mock-witness.js index c26ae40..79ddd18 100644 --- a/tests/mocha/mock-witness.js +++ b/tests/mocha/mock-witness.js @@ -2,12 +2,100 @@ * Copyright (c) 2024-2026 Digital Bazaar, Inc. */ -// Re-export start/stop from the didcel library's mock witness server. -// start() spins up an in-process HTTP witness and pushes its URL into -// TEST_WITNESSES. stop() shuts the server down after the suite finishes. -export {start, stop} from 'didcel/tests/mocha/mock-witness.js'; - -// TEST_WITNESSES is the shared mutable array that start() populates. -// Because Node.js caches ES module instances, the array that mock-witness.js -// writes into is the same object we read here. -export {TEST_WITNESSES} from 'didcel/tests/mocha/helpers.js'; +/** + * Minimal mock HTTP server implementing the did:cel blind-witness endpoint. + * Accepts POST {digestMultibase} and returns {proof: DataIntegrityProof}. + * + * VerifyData = SHA256(JCS(proofOptions)) || rawHash, where rawHash is the + * 32-byte SHA3-256 digest extracted from the received multihash. This matches + * exactly what `_verifyWitnessProof()` in cel.js reconstructs. + */ +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {base58btc} from 'multiformats/bases/base58'; +import canonicalize from 'canonicalize'; +import crypto from 'node:crypto'; +import http from 'node:http'; + +// SHA3-256 multihash header is 2 bytes: [0x16, 0x20] +const MULTIHASH_HEADER_LENGTH = 2; + +// populated by start(); consumed by 00-setup.js to configure the CLI +export const TEST_WITNESSES = []; + +let _server = null; +let _keyPair = null; +let _verificationMethod = null; + +export async function start() { + _keyPair = await EcdsaMultikey.generate({curve: 'P-256'}); + const exported = + await _keyPair.export({publicKey: true, includeContext: false}); + const {publicKeyMultibase} = exported; + const didKeyId = `did:key:${publicKeyMultibase}`; + _verificationMethod = `${didKeyId}#${publicKeyMultibase}`; + + _server = http.createServer(_handleRequest); + await new Promise(resolve => _server.listen(0, '127.0.0.1', resolve)); + + const {port} = _server.address(); + TEST_WITNESSES.push(`http://127.0.0.1:${port}/witness`); +} + +export function stop() { + return new Promise(resolve => { + if(_server) { + _server.close(resolve); + _server = null; + } else { + resolve(); + } + }); +} + +async function _handleRequest(req, res) { + if(req.method !== 'POST') { + res.writeHead(405); + res.end(); + return; + } + + try { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const {digestMultibase} = JSON.parse(Buffer.concat(chunks).toString()); + + // strip the 2-byte multihash header to get the raw 32-byte digest + const mhBytes = base58btc.decode(digestMultibase); + const rawHash = mhBytes.slice(MULTIHASH_HEADER_LENGTH); + + const proofOptions = { + '@context': 'https://w3id.org/security/data-integrity/v2', + created: new Date().toISOString(), + cryptosuite: 'ecdsa-jcs-2019', + proofPurpose: 'assertionMethod', + type: 'DataIntegrityProof', + verificationMethod: _verificationMethod + }; + + // verifyData = SHA256(JCS(proofOptions)) || rawHash + const c14nProof = canonicalize(proofOptions); + const proofHash = new Uint8Array( + crypto.createHash('sha256').update(c14nProof).digest()); + const verifyData = new Uint8Array(proofHash.length + rawHash.length); + verifyData.set(proofHash, 0); + verifyData.set(rawHash, proofHash.length); + + const signer = _keyPair.signer(); + const signatureBytes = await signer.sign({data: verifyData}); + const proofValue = base58btc.encode(signatureBytes); + + const proof = {...proofOptions, proofValue}; + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end(JSON.stringify({proof})); + } catch(e) { + res.writeHead(500); + res.end(JSON.stringify({error: e.message})); + } +}