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/.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/README.md b/README.md new file mode 100644 index 0000000..02caebd --- /dev/null +++ b/README.md @@ -0,0 +1,408 @@ +# DID CEL Tools + +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. + +## Installation + +### Prerequisites + +- Node.js (v18 or higher recommended) +- npm (comes with Node.js) + +### Install Dependencies + +```bash +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 + +To start the interactive REPL: + +```bash +./didcel +``` + +You will be prompted for an encryption password used to protect secret keys on +disk. Pass `-p ` to supply it non-interactively. + +### 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" +``` + +## REPL Interactive Mode + +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 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, 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`) + +--- + +### `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:** 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. + +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. + +--- + +### `heartbeat` + +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` + +--- + +### `witness` + +Obtains cryptographic attestations from witness services for the latest event. + +**Usage:** +``` +did:cel> witness +``` + +**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 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 log entry in the Cryptographic Event Log, creating a fully attested and verifiable history of DID operations. + +**Output:** `witness: proofs complete` + +--- + +### `load ` + +Loads and validates a DID from a cryptographic event log file. + +**Usage:** +``` +did:cel> load +``` + +**Parameters:** +- ``: Path to the `.cel.gz` file to load. + +**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. + +**Output:** `load: valid CEL with N event(s): did:cel:z...` or one or more `error:` lines describing validation failures. + +--- + +### `save [filename]` + +Saves the Cryptographic Event Log to a file. + +**Usage:** +``` +did:cel> save [filename] +``` + +**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 +``` + +--- + +### `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. Witness the create event +did:cel> witness + +# 4. Add additional verification methods +did:cel> add authentication ecdsa + +# 5. Record the changes as an update event +did:cel> update + +# 6. Get witness attestations for the update +did:cel> witness + +# 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 `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) +- **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 + +- `didcel` - Main executable script and REPL implementation +- `lib/config.js` - Configuration loader (reads `config.yaml`, resolves `~/` paths) + +## Security Considerations + +- **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. +- **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 + +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://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 223a387..0c49b09 100755 --- a/didcel +++ b/didcel @@ -1,103 +1,419 @@ #!/usr/bin/env node -import { Argument, Command, CommanderError } from 'commander'; -import path from 'path'; +/** + * @file DID CEL Command Line Interface (CLI). + * + * This is an interactive REPL (Read-Eval-Print Loop) for creating and managing + * DID documents using the Cryptographic Event Log (CEL) method. The tool allows + * users to create DIDs, add/remove verification methods, update DID documents, + * and maintain a cryptographic event log of all changes. + * + * Usage: + * ./didcel # Start interactive REPL + * ./didcel -c "create" "add ..." # Execute commands and continue in REPL + * ./didcel -v # Verbose output mode. + * + * Available commands: + * create - Create a new DID document + * add - Add verification methods or services + * ls - List DID contents + * expire - Set expiration on verification methods + * remove - Remove objects from DID document + * update - Update the cryptographic event log + * witness - Generate witness proofs + * save - Save CEL to file + * quit - Exit the REPL. + */ + +import { + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, + deleteObjectByIdSuffix, getObjectByIdSuffix, getPreviousEventHash, + loadFromFile, loadSecrets, prettyPrintCel, saveSecrets, saveToFile, + sha3256Multibase, witness +} from 'didcel'; +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'; -// import { -// createLog, addEvent, witnessEvent, -// } from './lib/cel.js'; -// import { -// create, read, update, deactivate -// } from './lib/did-cel-driver.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') + .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(); -// Run the repl -async function repl({commands}) { - // configure the REPL +loadConfig({configPath: options.config}); + +// common verification relationship and service properties in DID documents +const COMMON_PROPERTIES = [ + 'authentication', 'assertionMethod', 'capabilityDelegation', + 'capabilityInvocation', 'keyAgreement', 'service' +]; + +/** + * Runs the interactive REPL for DID CEL management. Maintains session state + * including the current DID document, CEL, and secret keys. + * + * @param {object} options - Configuration options. + * @param {Array} [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}) { + // 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; + // the current DID document + let didDocument; + // secret keys organized by verification relationship; heartbeat is a Buffer + const secretKeys = { + authentication: [], + assertionMethod: [], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [] + }; + // index of the next heartbeat key to use for signing (0-based) + let heartbeatKeyIndex = 0; + + // configure the Commander.js REPL with custom error handling repl.name('command') .usage('[options]') + // don't exit process on command errors .exitOverride(); repl.command('help') .description('Show help') .action(() => { repl.help(); - }); + }); + repl.command('load') + .description('Load and validate a DID from a cryptographic event log.') + .argument('', 'path to the .cel file to load') + .action(async filename => { + try { + const result = await loadFromFile({filename}); + if(result.valid) { + // clone to decouple the session document from the CEL's entries + didDocument = structuredClone(result.didDocument); + cryptographicEventLog = result.cel; + const didIdentifier = didDocument.id.split(':').pop(); + const loaded = await loadSecrets( + {didIdentifier, password, secretsDir: config.secrets}); + Object.assign(secretKeys, loaded); + heartbeatKeyIndex = result.cel.log.length - 1; + const {log} = result.cel; + console.log( + `load: valid CEL with ${log.length} event(s): ${didDocument.id}`); + } else { + for(const err of result.errors) { + console.log(`error: ${err}`); + } + } + } catch(err) { + console.log(`error: ${err.message}`); + } + }); + + // command: create + // creates a new self-certifying DID document with a heartbeat commitment. + // no assertionMethod key is generated at creation time; use 'add' afterwards. repl.command('create') .description('Create a new DID document') - .action(() => { - console.error('create not implemented'); - }); + .action(async () => { + const result = await create(); + // clone to decouple the session document from the CEL's create entry + didDocument = structuredClone(result.didDocument); + // store the heartbeat master secret for deriving per-event signing keys + secretKeys.heartbeat = result.heartbeatSecret; + cryptographicEventLog = result.cryptographicEventLog; + heartbeatKeyIndex = 0; + 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 to the current DID document.') - .action(() => { - console.error('add not implemented'); - }); + .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 + const result = await addVm( + {didDocument, verificationRelationship: property, curve: 'P-256'}); + didDocument = result.didDocument; + // store the secret key for this verification relationship + secretKeys[property].push(result.keyPair); + console.log(`add: new verification method for ${property}`); + } + }); + + // command: ls + // lists DID contents - either a summary or details of a specific object + repl.command('ls') + .description('list the contents of all identifiers, or a specific one.') + .addArgument( + new Argument('[suffix]', 'the last several characters of the identifier')) + .action(async suffix => { + // always display the DID identifier + console.log(didDocument.id); + // if suffix provided, print detailed object information + if(suffix) { + const value = getObjectByIdSuffix({didDocument, suffix}); + if(value) { + console.log(prettyPrintCel(value)); + return; + } + } + + // if no suffix provided, display a summary of the DID document + for(const property 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(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 + + entry.id.slice(0, 4) + '...' + lastFourOfId + ' '; + numEntries++; + } + if(numEntries > 0) { + console.log(propertyListing); + } + } + }); + + // command: expire + // sets an expiration timestamp on a verification method repl.command('expire') .description('Expire a verification method from the current DID document.') - .action(() => { - console.error('expire not implemented'); - }); + .addArgument(new Argument('', + 'the last several characters of the identifier to expire')) + .action(async suffix => { + if(suffix) { + const value = getObjectByIdSuffix({didDocument, suffix}); + if(value) { + // generate ISO 8601 timestamp for expiration + let expireDatetime = new Date().toISOString(); + // format as YYYY-MM-DDTHH:MM:SSZ (remove milliseconds) + expireDatetime = + expireDatetime.slice(0, expireDatetime.length - 5) + 'Z'; + // add expires property to the verification method + value.expires = expireDatetime; + console.log(`expire: ${value.id} at ${expireDatetime}.`); + } else { + console.log(`error: Could not find object with suffix "${suffix}".`); + } + } + }); + // command: remove + // removes a verification method or service from the DID document. + // the object is identified by the last few characters of its ID (suffix). + // this is useful for removing keys without typing the full identifier. note: + // The DID document is modified but not automatically committed to the CEL. + // you must run 'update' and 'witness' commands to persist the change. repl.command('remove') - .description('Expire a verification method from the current DID document.') - .action(() => { - console.error('remove not implemented'); - }); + .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.`); + } else { + console.log(`error: Could not find object with suffix "${suffix}".`); + } + } + }); + + // derives the current heartbeat signing key, rotates the heartbeat commitment + // in the event data, creates a signed event hash-linked via previousEventHash, + // and appends it to the CEL. deactivate events skip heartbeat rotation. + // + // eventData is always a fresh clone so that mutations to didDocument after + // this call cannot retroactively corrupt the committed event payload. + async function _appendEvent({type, data}) { + const previousEventHash = getPreviousEventHash({cel: cryptographicEventLog}); + const signingKeyPair = await deriveHeartbeatKeyPair( + secretKeys.heartbeat, heartbeatKeyIndex); + let eventData = data; + if(type !== 'deactivate') { + const nextKeyPair = await deriveHeartbeatKeyPair( + secretKeys.heartbeat, heartbeatKeyIndex + 1); + const nextHash = sha3256Multibase(nextKeyPair.id); + if(type === 'update') { + // clone before mutating so the CEL's prior events are not affected + eventData = structuredClone(data); + eventData.heartbeat = [nextHash]; + // update the session DID document to match committed state + didDocument.heartbeat = [nextHash]; + } else if(type === 'heartbeat') { + eventData = {heartbeat: [nextHash]}; + didDocument.heartbeat = [nextHash]; + } + } + const event = await createEvent({ + type, data: eventData, signingKeyPair, previousEventHash}); + cryptographicEventLog = await addEvent({cel: cryptographicEventLog, event}); + if(type !== 'deactivate') { + heartbeatKeyIndex++; + } + } + + // command: update + // creates a signed update event containing the current DID document and + // appends it to the CEL, hash-linked via previousEventHash. Run 'witness'. + repl.command('update') + .description('Update the cryptographic event log with the latest DID doc') + .action(async () => { + await _appendEvent({type: 'update', data: didDocument}); + }); + + // command: heartbeat + // generates a heartbeat to ensure the DID document does not deactivate + repl.command('heartbeat') + .description('Update the cryptographic event log with a heartbeat') + .action(async () => { + await _appendEvent({type: 'heartbeat', data: undefined}); + console.log('heartbeat: generated'); + }); + // command: deactivate + // deactivates the DID; terminal operation, no further events permitted + repl.command('deactivate') + .description('Deactivate the DID') + .action(async () => { + await _appendEvent({type: 'deactivate', data: undefined}); + console.log('deactivation: complete'); + }); + + // command: witness + // 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.') + .action(async () => { + // generate witness proofs for the most recent event in the log + // each witness independently validates and signs the event + await witness({cel: cryptographicEventLog, witnesses: config.witnesses}); + console.log('witness: proofs complete'); + }); + + // command: save + // persists the CEL to a gzip-compressed JSON file in config.logs, named + // by the DID identifier (e.g. z1abc....cel.gz). Also encrypts and saves + // all secret keys to config.secrets. Optionally writes to an explicit + // filename as well. CEL files can later be loaded to reconstruct history. repl.command('save') .description( 'Saves the current DID to a cryptographic event log.') - .action(async () => { - console.error('save not implemented'); + .argument('[filename]', 'the name of the file to save the event log to') + .action(async filename => { + const didIdentifier = didDocument.id.split(':').pop(); + + // always write to configured logs directory using DID identifier + if(config.logs) { + mkdirSync(config.logs, {recursive: true}); + const logsPath = join(config.logs, `${didIdentifier}.cel.gz`); + saveToFile({filename: logsPath, cel: cryptographicEventLog}); + console.error(`Wrote to ${logsPath}`); + } + + // save encrypted private keys to secrets directory + await saveSecrets( + {didIdentifier, secretKeys, password, secretsDir: config.secrets}); + const secretsPath = join(config.secrets, `${didIdentifier}.yaml`); + console.error(`Wrote secrets to ${secretsPath}`); + + // also write CEL to explicit filename if provided + if(filename) { + saveToFile({filename, cel: cryptographicEventLog}); + console.error(`Wrote to ${filename}`); + } }); + // command: quit + // exits the REPL. Unsaved changes to the DID document or CEL will be lost. + // Run 'save' before quitting to persist secret keys and the event log. repl.command('quit') .description('Exit without saving the cryptographic event log.') .action(async () => { process.exit(0); }); - // 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. Commander.js errors (unknown command, bad args) + // are suppressed so remaining commands still run; other errors propagate. 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 { + // prepend two dummy argv entries so Commander.js sees the right offset const commanderArgs = [command, command].concat(args); await repl.parseAsync(commanderArgs); } catch(err) { + // suppress Commander errors (e.g., unknown command, validation + // failures) to continue executing remaining commands in the batch if(!(err instanceof CommanderError)) { throw err; } } - }); - - process.exit(0); + } } - // 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; } @@ -105,7 +421,9 @@ 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 + commands: options.command, + password: options.password }); diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..4b8bd59 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,43 @@ +/** + * @file Configuration loader. + * Reads config.yaml from a given path, defaulting to ~/.config/didcel/. + * Call loadConfig() before accessing config properties. + */ + +import {existsSync, readFileSync} from 'node:fs'; +import {homedir} from 'node:os'; +import {join} from 'node:path'; +import yaml from 'js-yaml'; + +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) { + if(typeof value === 'string' && value.startsWith('~/')) { + return join(homedir(), value.slice(2)); + } + return value; +} + +// 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) + }); +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index be5ab22..f668718 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "didcel", + "name": "did-cel-tools", "version": "0.0.1", "type": "module", - "main": "./lib/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "lint": "eslint .", + "test": "mocha" }, "keywords": [], "author": { @@ -18,7 +18,16 @@ "description": "", "dependencies": { "commander": "^12.1.0", - "dotenv": "^16.4.5", + "didcel": "github:digitalbazaar/didcel#initial", + "js-yaml": "^4.1.0", "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" } } 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..c119cde --- /dev/null +++ b/tests/mocha/00-setup.js @@ -0,0 +1,24 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +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 () => { + 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/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..54d8642 --- /dev/null +++ b/tests/mocha/20-witness.js @@ -0,0 +1,66 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, listSecretFiles, readCelFile, runAndCapture, runDidcel +} from './helpers.js'; +import chai from 'chai'; + +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 {exitCode, stderr, newFile} = await runAndCapture({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 {exitCode, stderr, newFile} = await runAndCapture({ + commands: ['create', 'witness', 'save', 'quit'] + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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..3036d2b --- /dev/null +++ b/tests/mocha/30-update.js @@ -0,0 +1,96 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, readCelFile, runAndCapture, runDidcel +} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +const UPDATE_COMMANDS = [ + 'create', 'witness', + 'add authentication ecdsa', + 'update', 'witness', + 'save', 'quit' +]; + +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 runAndCapture({ + commands: UPDATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + 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 runAndCapture({ + commands: UPDATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 runAndCapture({ + commands: UPDATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 runAndCapture({ + commands: UPDATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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..d77d26b --- /dev/null +++ b/tests/mocha/40-heartbeat.js @@ -0,0 +1,88 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, readCelFile, runAndCapture, runDidcel +} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +const HB_COMMANDS = [ + 'create', 'witness', 'heartbeat', 'witness', 'save', 'quit' +]; + +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 runAndCapture({ + commands: HB_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + 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 runAndCapture({ + commands: HB_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + const heartbeatEntry = celContent.log[1]; + expect(heartbeatEntry.event.operation).to.have.property( + 'type', 'heartbeat'); + 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', + async () => { + const {exitCode, stderr, newFile} = await runAndCapture({ + commands: HB_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 runAndCapture({ + commands: HB_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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..36d9caa --- /dev/null +++ b/tests/mocha/50-deactivate.js @@ -0,0 +1,95 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + listCelFiles, readCelFile, runAndCapture, runDidcel +} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +const DEACTIVATE_COMMANDS = [ + 'create', 'witness', + 'add authentication ecdsa', 'update', 'witness', + 'deactivate', 'witness', + 'save', 'quit' +]; + +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 runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + 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 runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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 runAndCapture({ + commands: DEACTIVATE_COMMANDS + }); + + expect(exitCode, `stderr: ${stderr}`).to.equal(0); + + const celContent = readCelFile(newFile); + + 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/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'); + }); +}); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js new file mode 100644 index 0000000..24b0205 --- /dev/null +++ b/tests/mocha/helpers.js @@ -0,0 +1,125 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +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'; + +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'); +// 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'; + +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}); +} + +/** + * 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. + * + * @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 + }; + } +} + +/** + * 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. + */ +export function listCelFiles() { + const logsDir = join(TMP_DIR, 'logs'); + if(!existsSync(logsDir)) { + return []; + } + return readdirSync(logsDir).filter(f => f.endsWith('.cel.gz')); +} + +/** + * 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')); +} diff --git a/tests/mocha/mock-witness.js b/tests/mocha/mock-witness.js new file mode 100644 index 0000000..79ddd18 --- /dev/null +++ b/tests/mocha/mock-witness.js @@ -0,0 +1,101 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ + +/** + * 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})); + } +} 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